visual testing

This commit is contained in:
2026-04-06 03:17:30 +03:00
parent 889b66978a
commit f1357b6dbe
31 changed files with 2575 additions and 76 deletions

View File

@@ -0,0 +1,166 @@
/**
* Cross-renderer visual test: compares HTML render (browser) vs PDF render (Rust).
*
* Both renderers consume the same LayoutResult from the same layout engine.
* This test verifies that the visual output is consistent between:
* - LayoutRenderer.vue (HTML divs in browser)
* - pdf_render.rs → pdftoppm (PDF rasterized to PNG)
*
* Prerequisites:
* cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored
* (or: just visual-refs)
*/
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { PNG } from 'pngjs'
import pixelmatch from 'pixelmatch'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, '../../../layout-engine/tests/fixtures')
const REFS_DIR = path.resolve(__dirname, 'cross-renderer-refs')
const DIFFS_DIR = path.resolve(__dirname, 'cross-renderer-diffs')
interface TestFixture {
name: string
templateFile: string
dataFile: string
/** Max allowed pixel diff ratio (0.0 1.0) */
maxDiffRatio: number
}
const FIXTURES: TestFixture[] = [
{
name: 'visual_test',
templateFile: 'visual_test_template.json',
dataFile: 'visual_test_data.json',
// Text rendering differences between browser and PDF are expected.
// Font hinting, anti-aliasing, and line-height handling differ.
maxDiffRatio: 0.05,
},
{
name: 'chart_test',
templateFile: 'chart_test_template.json',
dataFile: 'chart_test_data.json',
// Charts have more visual complexity (SVG vs PDF primitives)
maxDiffRatio: 0.08,
},
{
name: 'comprehensive_test',
templateFile: 'comprehensive_test_template.json',
dataFile: 'comprehensive_test_data.json',
// All element types: text, rich_text, table, charts, barcodes, shapes,
// checkboxes, calculated_text, current_date, page_number, lines.
// Barcodes render differently (canvas vs PDF) so higher tolerance.
maxDiffRatio: 0.08,
},
]
test.describe('Cross-renderer: HTML vs PDF', () => {
test.beforeAll(() => {
fs.mkdirSync(DIFFS_DIR, { recursive: true })
})
for (const fixture of FIXTURES) {
test(`${fixture.name}: HTML render matches PDF render`, async ({ page }) => {
const refPath = path.join(REFS_DIR, `${fixture.name}.png`)
if (!fs.existsSync(refPath)) {
test.skip(true, `PDF reference not found: ${refPath}. Run: just visual-refs`)
return
}
// Read fixture files
const templateJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.templateFile), 'utf-8')
const dataJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.dataFile), 'utf-8')
// Inject fixture data before page loads
await page.addInitScript((fixtureData: { template: string; data: string }) => {
;(window as any).__DREPORT_FIXTURE__ = fixtureData
}, { template: templateJson, data: dataJson })
// Navigate to render test page
await page.goto('/render-test.html')
// Wait for layout to compute and render
await page.waitForSelector('[data-render-ready]', { timeout: 20000 })
// Small delay for font rendering and any async canvas operations
await page.waitForTimeout(500)
// Screenshot the rendered page (first page only)
const pageEl = page.locator('.layout-page').first()
const htmlScreenshot = await pageEl.screenshot({ type: 'png' })
// Load PDF reference PNG
const refBuffer = fs.readFileSync(refPath)
const refPng = PNG.sync.read(refBuffer)
const htmlPng = PNG.sync.read(htmlScreenshot)
// Handle dimension mismatch by resizing to the smaller of the two
const width = Math.min(htmlPng.width, refPng.width)
const height = Math.min(htmlPng.height, refPng.height)
// Crop both images to common size
const croppedHtml = cropPng(htmlPng, width, height)
const croppedRef = cropPng(refPng, width, height)
// Pixel comparison
const diffPng = new PNG({ width, height })
const numDiffPixels = pixelmatch(
croppedHtml.data,
croppedRef.data,
diffPng.data,
width,
height,
{
threshold: 0.15, // Per-pixel color distance threshold
alpha: 0.3,
includeAA: false, // Ignore anti-aliasing differences
},
)
const totalPixels = width * height
const diffRatio = numDiffPixels / totalPixels
// Save diff image for debugging
const diffPath = path.join(DIFFS_DIR, `${fixture.name}_diff.png`)
const htmlPath = path.join(DIFFS_DIR, `${fixture.name}_html.png`)
fs.writeFileSync(diffPath, PNG.sync.write(diffPng))
fs.writeFileSync(htmlPath, htmlScreenshot)
// Dimension info
const dimInfo = htmlPng.width !== refPng.width || htmlPng.height !== refPng.height
? ` (HTML: ${htmlPng.width}x${htmlPng.height}, PDF: ${refPng.width}x${refPng.height}, compared: ${width}x${height})`
: ` (${width}x${height})`
console.log(
`[${fixture.name}] diff: ${(diffRatio * 100).toFixed(2)}% pixels${dimInfo}`,
)
expect(
diffRatio,
`Visual diff too large for ${fixture.name}: ${(diffRatio * 100).toFixed(2)}% pixels differ (max: ${(fixture.maxDiffRatio * 100).toFixed(0)}%). Check diff at: ${diffPath}`,
).toBeLessThanOrEqual(fixture.maxDiffRatio)
})
}
})
/** Crop a PNG to the given width and height (top-left origin) */
function cropPng(src: PNG, width: number, height: number): PNG {
if (src.width === width && src.height === height) return src
const cropped = new PNG({ width, height })
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * src.width + x) * 4
const dstIdx = (y * width + x) * 4
cropped.data[dstIdx] = src.data[srcIdx]
cropped.data[dstIdx + 1] = src.data[srcIdx + 1]
cropped.data[dstIdx + 2] = src.data[srcIdx + 2]
cropped.data[dstIdx + 3] = src.data[srcIdx + 3]
}
}
return cropped
}