visual testing

This commit is contained in:
2026-04-06 03:17:30 +03:00
parent 53ba44e2f9
commit f04c39cb69
29 changed files with 2575 additions and 76 deletions

View File

@@ -19,10 +19,13 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^25.5.0",
"@types/pngjs": "^6.0.5",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",
"happy-dom": "^20.8.9",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0",
"typescript": "~6.0.2",
"vite": "^8.0.1",
"vitest": "^4.1.2",
@@ -235,6 +238,8 @@
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -471,12 +476,16 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],

View File

@@ -9,7 +9,8 @@
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:visual": "playwright test"
"test:visual": "playwright test",
"test:visual:cross": "playwright test --project=cross-renderer"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -26,10 +27,13 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^25.5.0",
"@types/pngjs": "^6.0.5",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",
"happy-dom": "^20.8.9",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0",
"typescript": "~6.0.2",
"vite": "^8.0.1",
"vitest": "^4.1.2",

View File

@@ -6,6 +6,8 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:5173',
viewport: { width: 1400, height: 900 },
// Disable HiDPI scaling for pixel-exact comparison
deviceScaleFactor: 1,
},
webServer: {
command: 'bun run dev',
@@ -18,4 +20,19 @@ export default defineConfig({
maxDiffPixelRatio: 0.01,
},
},
projects: [
{
name: 'editor',
testMatch: 'editor.spec.ts',
},
{
name: 'cross-renderer',
testMatch: 'cross-renderer.spec.ts',
use: {
// Render test page needs larger viewport for A4 at 150 DPI
// A4 = 210mm x 297mm → 1240 x 1754 px at 150 DPI
viewport: { width: 1300, height: 1800 },
},
},
],
})

48
frontend/render-test.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dreport - Render Test</title>
<style>
/* @font-face declarations — must match the fonts used by PDF renderer */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Noto Sans Mono';
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: white; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/render-test/main.ts"></script>
</body>
</html>

View File

@@ -604,11 +604,7 @@ async function downloadPdf() {
const blob = await editorRef.value?.exportPdf()
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${template.value.name || 'belge'}.pdf`
a.click()
URL.revokeObjectURL(url)
window.open(url, '_blank')
} catch (err) {
alert(err instanceof Error ? err.message : 'PDF olusturulamadi')
} finally {
@@ -668,7 +664,7 @@ function resetTemplate() {
<!-- Output -->
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="1" width="10" height="14" rx="1.5" /><path d="M6 5h4M6 8h4M6 11h2" /></svg>
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Indir' }}
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Onizle' }}
</button>
</header>
<DreportEditor

View File

@@ -3,12 +3,15 @@ import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { isContainer } from '../../core/types'
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle } from '../../core/types'
import type { ElementLayout } from '../../core/layout-types'
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle, ChartElement, ChartType } from '../../core/types'
import type { LayoutMapEntry } from '../../core/layout-types'
const PAGE_GAP_PX = 24
const props = defineProps<{
scale: number
layoutMap: Record<string, ElementLayout>
layoutMap: Record<string, LayoutMapEntry>
pageHeightPx?: number
}>()
const templateStore = useTemplateStore()
@@ -36,6 +39,15 @@ const isTable = computed(() => selected.value?.type === 'repeating_table')
const tableEl = computed(() => isTable.value ? selected.value as RepeatingTableElement : null)
const tableStyle = computed(() => tableEl.value?.style as TableStyle | undefined)
const isChart = computed(() => selected.value?.type === 'chart')
const chartEl = computed(() => isChart.value ? selected.value as ChartElement : null)
function pageYOffset(pageIndex: number): number {
if (pageIndex <= 0) return 0
const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale)
return pageIndex * (pageH + PAGE_GAP_PX)
}
const toolbarStyle = computed(() => {
const el = selected.value
if (!el) return { display: 'none' }
@@ -43,10 +55,11 @@ const toolbarStyle = computed(() => {
if (!l) return { display: 'none' }
const s = props.scale
const pYOff = pageYOffset(l.pageIndex)
return {
position: 'absolute' as const,
left: `${l.x_mm * s}px`,
top: `${l.y_mm * s - 30}px`,
top: `${l.y_mm * s - 30 + pYOff}px`,
zIndex: 1100,
}
})
@@ -76,6 +89,13 @@ function updateTableStyle(key: string, value: unknown) {
if (!selected.value) return
update({ style: { ...selected.value.style, [key]: value } })
}
// Chart
function setChartType(t: ChartType) { update({ chartType: t }) }
function updateChartStyle(key: string, value: unknown) {
if (!selected.value) return
update({ style: { ...selected.value.style, [key]: value } })
}
</script>
<template>
@@ -300,6 +320,73 @@ function updateTableStyle(key: string, value: unknown) {
</div>
</template>
<!-- ===== Chart ===== -->
<template v-if="isChart && chartEl">
<!-- Chart type -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'bar' }" data-tip="Cubuk" @click="setChartType('bar')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor"/>
<rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor"/>
<rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor"/>
</svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'line' }" data-tip="Cizgi" @click="setChartType('line')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="2" cy="10" r="1.2" fill="currentColor"/><circle cx="5" cy="5" r="1.2" fill="currentColor"/><circle cx="8" cy="7" r="1.2" fill="currentColor"/><circle cx="12" cy="3" r="1.2" fill="currentColor"/>
</svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'pie' }" data-tip="Pasta" @click="setChartType('pie')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none"/>
<path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor"/>
</svg>
</button>
</div>
<div class="et__sep" />
<!-- Show labels -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chartEl.labels?.show !== false }" data-tip="Etiketler" @click="update({ labels: { ...chartEl.labels, show: chartEl.labels?.show === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4"/>
<rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4"/>
<rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4"/>
<text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text>
<text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text>
<text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text>
</svg>
</button>
</div>
<div class="et__sep" />
<!-- Show grid -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chartEl.axis?.showGrid !== false }" data-tip="Izgara" @click="update({ axis: { ...chartEl.axis, showGrid: chartEl.axis?.showGrid === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
<line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
</svg>
</button>
</div>
<div class="et__sep" />
<!-- Background color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Arka Plan">
<input type="color" class="et__color" :value="chartEl.style.backgroundColor ?? '#ffffff'" @input="(e) => updateChartStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chartEl.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8"/>
</svg>
</label>
</div>
</template>
<!-- ===== Line ===== -->
<template v-if="isLine">
<!-- Stroke width -->

View File

@@ -713,6 +713,7 @@ const isAnyDragActive = computed(() =>
v-if="!isDragging && !isResizing"
:scale="scale"
:layout-map="layoutMap"
:page-height-px="pageHeightPx"
/>
</div>
</template>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import LayoutRenderer from '../components/editor/LayoutRenderer.vue'
import { useLayoutEngine } from '../composables/useLayoutEngine'
import type { Template } from '../core/types'
// Fixture data is injected by Playwright via window.__DREPORT_FIXTURE__
declare global {
interface Window {
__DREPORT_FIXTURE__?: { template: string; data: string }
}
}
// DPI matching: 150 DPI = 150/25.4 px/mm (must match pdftoppm -r 150)
const DPI = 150
const SCALE = DPI / 25.4
const ready = ref(false)
const errorMsg = ref<string | null>(null)
const template = ref<Template>({
id: 'empty',
name: 'empty',
page: { width: 210, height: 297 },
fonts: ['Noto Sans'],
root: {
id: 'root', type: 'container',
position: { type: 'flow' },
size: { width: { type: 'auto' }, height: { type: 'auto' } },
direction: 'column', gap: 0,
padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'stretch', justify: 'start',
style: {}, children: [],
},
})
const data = ref<Record<string, unknown>>({})
const { layout, generateBarcode, error } = useLayoutEngine(template, data)
// Provide barcode generator for LayoutRenderer
provide('generateBarcode', generateBarcode)
// Watch for layout computation to complete
const checkReady = setInterval(() => {
if (layout.value && layout.value.pages.length > 0) {
ready.value = true
clearInterval(checkReady)
}
if (error.value) {
errorMsg.value = error.value
clearInterval(checkReady)
}
}, 50)
// Timeout after 20 seconds
setTimeout(() => {
clearInterval(checkReady)
if (!ready.value) {
errorMsg.value = 'Layout computation timed out'
}
}, 20000)
onMounted(() => {
const fixture = window.__DREPORT_FIXTURE__
if (!fixture) {
errorMsg.value = 'No fixture data found. Set window.__DREPORT_FIXTURE__ = { template, data }'
return
}
try {
template.value = JSON.parse(fixture.template)
data.value = JSON.parse(fixture.data)
} catch (e) {
errorMsg.value = `Failed to parse fixture data: ${e}`
}
})
</script>
<template>
<div
class="render-test-root"
:data-render-ready="ready || undefined"
:data-render-error="errorMsg || undefined"
>
<LayoutRenderer
v-if="layout"
:layout="layout"
:scale="SCALE"
/>
<div v-else-if="errorMsg" class="error">{{ errorMsg }}</div>
<div v-else class="loading">Computing layout...</div>
</div>
</template>
<style>
.render-test-root {
/* No padding/margin — pixel-exact rendering */
background: white;
}
.error {
color: red;
padding: 20px;
font-family: monospace;
}
.loading {
color: #999;
padding: 20px;
font-family: monospace;
}
/* Override layout-page styles for test — no shadow, no margin */
.layout-page {
box-shadow: none !important;
margin: 0 !important;
}
.layout-page + .layout-page {
margin-top: 0 !important;
}
</style>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import RenderTestPage from './RenderTestPage.vue'
createApp(RenderTestPage).mount('#app')

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
}

View File

@@ -1,8 +1,17 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
'render-test': resolve(__dirname, 'render-test.html'),
},
},
},
resolve: {
dedupe: [
'@codemirror/state',