From c5ee6d0ec6e8389f737474dadb79c947ec003dd1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:56:43 +0000 Subject: [PATCH 1/2] Initial plan From e3054bc57b06200198a8e4cf0bd9dc12a16d1040 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:09:46 +0000 Subject: [PATCH 2/2] feat: add measurement validator with divergence classifier and multi-language support Agent-Logs-Url: https://github.com/Himaan1998Y/pretext/sessions/ca665dce-f115-4eb3-87c1-8ca621c70083 Co-authored-by: Himaan1998Y <210527591+Himaan1998Y@users.noreply.github.com> --- docs/classifier-guide.md | 198 ++++++++++++ docs/language-matrix.md | 165 ++++++++++ docs/measurement-validator.md | 167 ++++++++++ src/measurement-validator/classifier.ts | 301 ++++++++++++++++++ src/measurement-validator/comparator.ts | 107 +++++++ src/measurement-validator/dom-adapter.ts | 133 ++++++++ src/measurement-validator/index.ts | 51 +++ src/measurement-validator/report-generator.ts | 134 ++++++++ src/measurement-validator/test-suite.ts | 106 ++++++ src/measurement-validator/types.ts | 114 +++++++ test/classifier.test.ts | 230 +++++++++++++ test/fixtures/cjk-samples.json | 103 ++++++ test/fixtures/complex-script-samples.json | 72 +++++ test/fixtures/english-samples.json | 163 ++++++++++ test/fixtures/mixed-bidi-samples.json | 72 +++++ test/fixtures/rtl-samples.json | 82 +++++ test/measurement-validator.test.ts | 182 +++++++++++ 17 files changed, 2380 insertions(+) create mode 100644 docs/classifier-guide.md create mode 100644 docs/language-matrix.md create mode 100644 docs/measurement-validator.md create mode 100644 src/measurement-validator/classifier.ts create mode 100644 src/measurement-validator/comparator.ts create mode 100644 src/measurement-validator/dom-adapter.ts create mode 100644 src/measurement-validator/index.ts create mode 100644 src/measurement-validator/report-generator.ts create mode 100644 src/measurement-validator/test-suite.ts create mode 100644 src/measurement-validator/types.ts create mode 100644 test/classifier.test.ts create mode 100644 test/fixtures/cjk-samples.json create mode 100644 test/fixtures/complex-script-samples.json create mode 100644 test/fixtures/english-samples.json create mode 100644 test/fixtures/mixed-bidi-samples.json create mode 100644 test/fixtures/rtl-samples.json create mode 100644 test/measurement-validator.test.ts diff --git a/docs/classifier-guide.md b/docs/classifier-guide.md new file mode 100644 index 00000000..00c982bc --- /dev/null +++ b/docs/classifier-guide.md @@ -0,0 +1,198 @@ +# Divergence Classifier Guide + +The divergence classifier (`src/measurement-validator/classifier.ts`) identifies +**why** Pretext canvas measurements diverge from DOM measurements. It runs a +priority-ordered chain of detection strategies and returns a `DivergenceAnalysis`. + +## Quick Start + +```typescript +import { + createComparator, + createDOMAdapter, + classifyDivergence, + classifyDivergenceSync, +} from './src/measurement-validator/index.js' + +const adapter = createDOMAdapter() +const comparator = createComparator(adapter) + +const result = comparator.compare({ + text: 'مرحباً بالعالم', + font: '16px Arial', + maxWidth: 300, + lineHeight: 20, +}) + +// Async (includes font-fallback detection via DOM) +const analysis = await classifyDivergence(result, adapter) +console.log(analysis.rootCause) // 'bidi_shaping' +console.log(analysis.confidence) // 0.85 +console.log(analysis.recommendation) +``` + +## `DivergenceAnalysis` shape + +```typescript +type DivergenceAnalysis = { + detected: boolean + severity: 'minor' | 'major' | 'critical' + rootCause?: + | 'font_fallback' + | 'bidi_shaping' + | 'emoji_rendering' + | 'browser_quirk' + | 'variable_font' + | 'unknown' + confidence: number // 0–1 + recommendation: string + details: Record +} +``` + +## Detection Strategies + +The classifier tests strategies in priority order. The **first** matching strategy +wins and determines the `rootCause`. + +### 1. Font Fallback (`font_fallback`) — async only + +**When:** The requested font is not loaded and the browser silently falls back to +a system font. + +**How:** Re-measures with the `serif` fallback; if the total line widths are +within 1 % of the specified-font widths, the font was likely never loaded. + +**Confidence:** 0.90 + +**Fix:** Preload fonts with `` or use a guaranteed system font. + +--- + +### 2. Bidi Shaping (`bidi_shaping`) + +**When:** The text contains Arabic, Hebrew, Urdu, or other RTL characters +(`U+0590–U+08FF`, `U+FB1D–U+FDFF`, `U+FE70–U+FEFF`). + +**How:** Regexp check on the text string; no DOM access required. + +**Confidence:** 0.85 + +**Fix:** Verify that `PreparedTextWithSegments.segLevels` are populated and used +for RTL rendering; check that `canvas.measureText` and DOM agree on shaped glyphs. + +--- + +### 3. Emoji Rendering (`emoji_rendering`) + +**When:** The text contains one or more emoji presentation codepoints +(`\p{Emoji_Presentation}`). + +**How:** Unicode regex check; no DOM access required. + +**Confidence:** 0.75 + +**Note:** Pretext auto-corrects Chrome/Firefox canvas emoji metrics at small font +sizes; Safari canvas and DOM agree natively. Divergence here usually means a +font-size-specific correction is off. + +**Fix:** Test emoji-heavy strings across Chrome, Firefox, and Safari independently. + +--- + +### 4. Browser Quirk (`browser_quirk`) + +**When:** Heuristics suggest a known browser/OS rendering difference: +- `system-ui` in the font string → `os_rendering` (macOS vs Windows resolution) +- `variation` in the font string → `variable_font` (canvas axis support) +- Safari user-agent detected → `safari_kerning` + +**Confidence:** 0.60 + +**Fix:** Use named fonts instead of `system-ui`; test variable fonts manually. + +--- + +### 5. Unknown (`unknown`) + +**When:** A divergence exists but none of the above strategies fired. + +**Confidence:** 0.30 + +**Action:** File a bug with a minimal reproduction — text string, font, width, and +the two measurements. + +--- + +## Sync vs Async + +| Function | Font-fallback | Use when | +|---|---|---| +| `classifyDivergence(result, adapter)` | ✅ async DOM | Production validation | +| `classifyDivergenceSync(result)` | ❌ skipped | Unit tests, scripts without live DOM | + +--- + +## Batch Classification + +```typescript +import { classifyAll } from './src/measurement-validator/index.js' + +const analyses = await classifyAll(results, adapter) +``` + +--- + +## Interpreting Confidence + +| Confidence | Interpretation | +|---|---| +| ≥ 0.90 | Very likely the root cause | +| 0.75–0.89 | Probable cause, worth investigating | +| 0.60–0.74 | Possible cause, check manually | +| < 0.60 | Speculative — use as a starting point | + +--- + +## Examples + +### Font not loaded + +```typescript +const analysis = await classifyDivergence(result, adapter) +// { +// detected: true, +// rootCause: 'font_fallback', +// severity: 'critical', +// confidence: 0.90, +// recommendation: 'Font "16px Roboto" may not be loaded ...', +// details: { fontSpecified: '16px Roboto', fontDetected: 'serif (system fallback)' } +// } +``` + +### RTL text + +```typescript +// sample.text = 'مرحباً بالعالم' +// { +// detected: true, +// rootCause: 'bidi_shaping', +// severity: 'major', +// confidence: 0.85, +// recommendation: 'RTL text detected ...', +// details: { hasRTL: true, isMixedBidi: false } +// } +``` + +### No divergence + +```typescript +// result.overallSeverity === 'pass' +// { +// detected: false, +// severity: 'minor', +// confidence: 1, +// recommendation: 'No divergence detected.', +// details: {} +// } +``` diff --git a/docs/language-matrix.md b/docs/language-matrix.md new file mode 100644 index 00000000..f3e15752 --- /dev/null +++ b/docs/language-matrix.md @@ -0,0 +1,165 @@ +# Language Support Matrix + +This document describes the measurement validator's coverage for each language +group, known divergence patterns, and recommended workarounds. + +## Language Groups + +| Group | Languages | Fixture file | Status | +|-------|-----------|--------------|--------| +| `ltr-simple` | English, Spanish, French, German | `english-samples.json` | ✅ Phase 1 | +| `rtl` | Arabic, Hebrew, Urdu | `rtl-samples.json` | ✅ Phase 2 | +| `cjk` | Chinese (Simplified/Traditional), Japanese, Korean | `cjk-samples.json` | ✅ Phase 2 | +| `complex-script` | Thai, Myanmar, Khmer | `complex-script-samples.json` | ✅ Phase 2 | +| `mixed-bidi` | English + Arabic/Hebrew in same text | `mixed-bidi-samples.json` | ✅ Phase 2 | + +--- + +## LTR Simple (`ltr-simple`) + +**Languages:** English, Spanish, French, German (and other Latin-script languages) + +**Accuracy target:** ≥ 99 % of lines within 0.5 px + +**Known divergences:** + +| Divergence | Cause | Impact | Workaround | +|---|---|---|---| +| System font resolution | `system-ui` resolves differently in canvas vs DOM on macOS | Moderate | Use named font (e.g. `Arial`, `Helvetica`) | +| Soft-hyphen visibility | SHY (`\u00AD`) is invisible until chosen as break point | Minor | Expected; Pretext exposes trailing `-` in `line.text` | +| Non-breaking space | NBSP prevents word breaks but may add visual width | Minor | Expected behaviour | + +--- + +## RTL (`rtl`) + +**Languages:** Arabic, Hebrew, Urdu, Persian + +**Accuracy target:** ≥ 85 % of lines within 1.0 px + +**Key considerations:** + +- Arabic is a **connected script** — glyphs change shape depending on position in a word + (initial, medial, final, isolated forms). `canvas.measureText` uses the shaped + glyph widths when the font is loaded; divergence usually means the font is missing. +- The classifier flags RTL text with `rootCause: 'bidi_shaping'` at confidence 0.85. +- Pretext's `prepareWithSegments()` exposes `segLevels` (bidi embedding levels) for + custom RTL rendering; `layout()` itself does not read bidi levels. + +**Known divergences:** + +| Divergence | Cause | Impact | +|---|---|---| +| Arabic ligature width | Some fonts collapse two glyphs into one ligature | Minor | +| Diacritic (harakat) stacking | Zero-width combining marks may add canvas overhead | Minor | +| RTL line direction | DOM aligns text to the right; Pretext reports pixel widths only | N/A | + +**Workarounds:** + +1. Always preload the target Arabic/Hebrew font — falling back to a system font will + cause significant divergence. +2. Use `prepareWithSegments()` and inspect `segLevels` to confirm bidi levels. +3. For Urdu (Nastaliq style), use a font that supports the Nastaliq layout engine. + +--- + +## CJK (`cjk`) + +**Languages:** Chinese (Simplified), Chinese (Traditional), Japanese, Korean + +**Accuracy target:** ≥ 90 % of lines within 0.5 px + +**Key considerations:** + +- CJK ideographs are normally one grapheme cluster per character and break at every + character boundary (Pretext uses `Intl.Segmenter` for this). +- Japanese kinsoku rules prohibit certain punctuation at line start/end; Pretext merges + these into adjacent graphemes. +- `word-break: keep-all` prevents mid-word breaks in CJK; pass `wordBreak: 'keep-all'` + to `MeasurementSample` to test this mode. +- Full-width punctuation and iteration marks have specific break prohibition rules. + +**Known divergences:** + +| Divergence | Cause | Impact | +|---|---|---| +| Kinsoku edge cases | Browser may differ from Pretext on rare punctuation combinations | Minor | +| Mixed CJK + Latin kerning | Latin kerning near CJK glyphs varies by font | Minor | +| `word-break: keep-all` | Hangul syllable block break policy varies | Minor | + +--- + +## Complex Scripts (`complex-script`) + +**Languages:** Thai, Myanmar, Khmer + +**Accuracy target:** ≥ 80 % of lines within 1.0 px + +**Key considerations:** + +- These scripts do **not** use spaces as word boundaries; line breaking is + cluster/syllable-based and requires ICU dictionary data. +- Pretext relies on `Intl.Segmenter` which uses the browser's ICU data — this varies + between Chrome, Firefox, and Safari. +- **Use `Range`-based DOM extraction** for these scripts. Span-based extraction + can perturb line breaking around cluster boundaries. +- Divergences here are often extractor-sensitive, not algorithm bugs. + +**Known divergences:** + +| Language | Divergence | Notes | +|---|---|---| +| Thai | Cluster boundary differences between browsers | Chrome ICU ≥ Firefox | +| Myanmar | Medial/glue glyph stacking | Font-dependent | +| Khmer | Zero-width spaces from clean source text | Can be explicit break hints | + +**Workarounds:** + +1. Use the exact corpus font for measurements. +2. For diagnosis, prefer Range-based extraction over span-based. +3. Accept higher tolerance (1.0 px) for these scripts. + +--- + +## Mixed Bidi (`mixed-bidi`) + +**Languages:** Any combination of LTR + RTL in the same string + +**Accuracy target:** ≥ 80 % of lines within 1.0 px + +**Key considerations:** + +- Mixed bidi text requires the Unicode Bidi Algorithm (UBA) to determine visual order. +- Pretext handles bidi at the segment level via `prepareWithSegments().segLevels`. +- The classifier returns `rootCause: 'bidi_shaping'` for any text containing RTL ranges. +- Line-break opportunities in mixed bidi text depend on the resolved bidi levels. + +**Known divergences:** + +| Divergence | Cause | Impact | +|---|---|---| +| Visual order of neutral characters | Punctuation between LTR and RTL runs | Minor | +| URL / brand names in RTL context | Latin embedded in Arabic paragraph | Minor | +| Number direction | Arabic-Indic vs Western Arabic numerals | Minor | + +--- + +## Browser Compatibility + +| Browser | LTR Simple | RTL | CJK | Complex Script | Mixed Bidi | +|---|---|---|---|---|---| +| Chrome (Chromium) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Safari (WebKit) | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | +| Firefox (Gecko) | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | + +⚠️ = higher tolerance required; see the accuracy checker pages for current per-browser data. + +--- + +## Adding New Languages + +1. Add fixture samples to the appropriate JSON file under `test/fixtures/`. +2. Give each sample an `id`, `description`, `languageGroup`, and `language` field. +3. Run the test suite with `bun test test/` to confirm the new samples load. +4. If a new language group is needed, add it to the `LanguageGroup` union in + `src/measurement-validator/types.ts` and add a corresponding fixture file. diff --git a/docs/measurement-validator.md b/docs/measurement-validator.md new file mode 100644 index 00000000..975b313f --- /dev/null +++ b/docs/measurement-validator.md @@ -0,0 +1,167 @@ +# Measurement Validator + +Validates that Pretext's canvas-based text measurements match DOM measurements. + +## Overview + +Pretext uses `canvas.measureText` for fast, reflow-free text layout. The measurement +validator compares Pretext's line widths and counts against the browser's native DOM +rendering (via the `Range` API) to detect and surface divergences. + +## Installation + +The measurement validator lives inside the Pretext repository as a sub-package: + +``` +src/measurement-validator/ +├── types.ts — Core interfaces +├── dom-adapter.ts — DOM Range-API measurement +├── comparator.ts — Pretext vs DOM comparison engine +├── report-generator.ts — JSON + console output +├── classifier.ts — Divergence root-cause detection (Phase 2) +├── test-suite.ts — Multi-language test runner (Phase 2) +└── index.ts — Public API +``` + +## Quick Start + +```typescript +import { + createDOMAdapter, + createComparator, + formatResultConsole, +} from './src/measurement-validator/index.js' + +// 1. Create a DOM adapter (requires a browser environment) +const adapter = createDOMAdapter() + +// 2. Create a comparator backed by the adapter +const comparator = createComparator(adapter) + +// 3. Compare a sample +const result = comparator.compare({ + text: 'The quick brown fox jumps over the lazy dog.', + font: '16px Arial', + maxWidth: 400, + lineHeight: 20, +}) + +// 4. Print a human-readable summary +console.log(formatResultConsole(result)) + +// 5. Clean up +comparator.dispose() +``` + +## API Reference + +### `MeasurementSample` + +Input descriptor for a single comparison: + +```typescript +type MeasurementSample = { + text: string // Text to measure + font: string // CSS font, e.g. '16px Arial' + maxWidth: number // Container width (px) + lineHeight: number // Line height (px) + language?: string // BCP 47 tag, e.g. 'en', 'ar', 'zh-Hans' + wordBreak?: 'normal' | 'keep-all' + whiteSpace?: 'normal' | 'pre-wrap' +} +``` + +### `MeasurementResult` + +Output of a single comparison: + +```typescript +type MeasurementResult = { + sample: MeasurementSample + pretextLineCount: number + domLineCount: number + lineCountMatch: boolean + lines: MeasurementLinePair[] + overallSeverity: 'pass' | 'minor' | 'major' | 'critical' + passRate: number // 0-1 + maxDelta: number // largest |delta| in px + durationMs: number +} +``` + +### `compareMeasurements(sample, domLines, options?)` + +Core comparison function. Does not require a DOM adapter — inject pre-measured +`DOMLineMetrics[]` directly (useful for testing and scripts that measure separately). + +```typescript +import { compareMeasurements } from './src/measurement-validator/comparator.js' +const result = compareMeasurements(sample, myDOMLines) +``` + +### `createComparator(adapter)` + +Creates a stateful comparator that manages a shared DOM adapter: + +```typescript +const comparator = createComparator(adapter) +const result = comparator.compare(sample) +comparator.dispose() // removes DOM elements +``` + +### `createDOMAdapter()` + +Returns an `DOMAdapter` that measures text via the browser Range API. +Must be called in a browser (or jsdom) environment. + +### Tolerance + +Default tolerance thresholds (`DEFAULT_TOLERANCE`): + +| Severity | Condition | +|----------|-----------| +| `pass` | `|delta| ≤ 0.5px` | +| `minor` | `0.5px < |delta| ≤ 1.0px` | +| `major` | `1.0px < |delta| ≤ 2.0px` | +| `critical` | `|delta| > 2.0px` | + +Pass a custom `ToleranceConfig` to override: + +```typescript +comparator.compare(sample, { + tolerance: { passDelta: 1.0, minorDelta: 2.0, majorDelta: 4.0 }, +}) +``` + +## Report Formats + +### Console (human-readable) + +```typescript +import { formatResultConsole } from './src/measurement-validator/report-generator.js' +console.log(formatResultConsole(result)) +// ✅ PASS — "Hello, world!" +// font=16px Arial width=400px passRate=100.0% maxDelta=0.000px 1ms +``` + +### JSON (machine-readable) + +```typescript +import { formatResultJSON } from './src/measurement-validator/report-generator.js' +const json = formatResultJSON(result) // full MeasurementResult as JSON +``` + +## Known Limitations + +- Requires a real browser environment (or a DOM emulator like `jsdom`). +- `system-ui` font may resolve differently in canvas vs DOM on macOS — use named fonts. +- Complex script line-breaking (Thai, Myanmar, Khmer) depends on browser ICU data. +- Variable fonts are not fully supported by `canvas.measureText` in all browsers. + +## Phase 2: Divergence Classifier + +See [classifier-guide.md](./classifier-guide.md) for root-cause detection. + +## Language Support + +See [language-matrix.md](./language-matrix.md) for per-language test coverage and known issues. diff --git a/src/measurement-validator/classifier.ts b/src/measurement-validator/classifier.ts new file mode 100644 index 00000000..34338d0a --- /dev/null +++ b/src/measurement-validator/classifier.ts @@ -0,0 +1,301 @@ +// Divergence Classifier — Phase 2. +// +// Identifies WHY measurements diverge between Pretext and DOM by running +// a priority-ordered chain of detection strategies: +// +// 1. Font fallback (highest confidence — font not loaded) +// 2. Bidi shaping (RTL/mixed text) +// 3. Emoji rendering (emoji codepoints with browser-specific metrics) +// 4. Browser quirk (Safari kerning, variable fonts, OS rendering) +// 5. Unknown (divergence found but cause unclear) +// +// Each detector returns a small analysis object; the first "detected" hit +// wins and its DivergenceAnalysis is returned. + +import type { DOMAdapter } from './dom-adapter.js' +import type { + DivergenceAnalysis, + DivergenceRootCause, + MeasurementResult, + MeasurementSample, +} from './types.js' + +// --- Internal sub-analysis types --- + +type SubAnalysis = { + detected: boolean + severity: 'minor' | 'major' | 'critical' + confidence: number + details: Record + recommendation: string +} + +function noDetection(): SubAnalysis { + return { detected: false, severity: 'minor', confidence: 0, details: {}, recommendation: '' } +} + +// --- 1. Font fallback detection --- +// +// Re-measures with the system fallback font (serif) and checks whether +// the fallback matches DOM better than the requested font. If so, the +// specified font was likely not loaded. + +async function detectFontFallback( + sample: MeasurementSample, + adapter: DOMAdapter, +): Promise { + const fallbackSample: MeasurementSample = { ...sample, font: replaceFontFamily(sample.font, 'serif') } + const specifiedDOMLines = adapter.measureLines(sample) + const fallbackDOMLines = adapter.measureLines(fallbackSample) + + // Compare total widths + const specifiedTotal = specifiedDOMLines.reduce((s, l) => s + l.width, 0) + const fallbackTotal = fallbackDOMLines.reduce((s, l) => s + l.width, 0) + + // If the two totals are within 1% of each other the font is likely not loaded + // (browser is already silently falling back to the same face for both requests). + if (specifiedTotal > 0 && Math.abs(specifiedTotal - fallbackTotal) / specifiedTotal < 0.01) { + return { + detected: true, + severity: 'critical', + confidence: 0.9, + details: { + fontSpecified: sample.font, + fontDetected: 'serif (system fallback)', + specifiedTotal, + fallbackTotal, + }, + recommendation: + `Font "${sample.font}" may not be loaded. ` + + 'Consider preloading fonts or using a system font (serif/sans-serif).', + } + } + + return noDetection() +} + +// Replace the family portion of a CSS font string, preserving size/weight/style. +function replaceFontFamily(font: string, newFamily: string): string { + // CSS font shorthand: [style] [variant] [weight] [size/line-height] family + // The family is always last — replace the last whitespace-delimited token(s). + const parts = font.trim().split(/\s+/) + // The size token contains a digit (e.g. "16px") + const sizeIdx = parts.findIndex((p) => /\d/.test(p)) + if (sizeIdx !== -1) { + return [...parts.slice(0, sizeIdx + 1), newFamily].join(' ') + } + return `${font} ${newFamily}` +} + +// --- 2. Bidi detection --- +// +// Checks for RTL Unicode ranges. Does not require DOM access. + +const RTL_CHAR_RE = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/u + +function detectBidi(sample: MeasurementSample): SubAnalysis { + const hasRTL = RTL_CHAR_RE.test(sample.text) + if (!hasRTL) return noDetection() + + const allRTL = [...sample.text].every( + (ch) => RTL_CHAR_RE.test(ch) || /\s/.test(ch) || /\p{P}/u.test(ch), + ) + + return { + detected: true, + severity: 'major', + confidence: 0.85, + details: { hasRTL: true, isMixedBidi: !allRTL }, + recommendation: + 'RTL text detected. Verify that segLevels are available in PreparedTextWithSegments ' + + 'and that canvas.measureText matches DOM for this script.', + } +} + +// --- 3. Emoji detection --- +// +// Checks for emoji presentation codepoints. + +const EMOJI_RE = /\p{Emoji_Presentation}/u + +function detectEmoji(sample: MeasurementSample): SubAnalysis { + const hasEmoji = EMOJI_RE.test(sample.text) + if (!hasEmoji) return noDetection() + + return { + detected: true, + severity: 'minor', + confidence: 0.75, + details: { hasEmoji: true }, + recommendation: + 'Emoji detected. Different browsers render emoji with different metrics. ' + + 'Pretext applies automatic emoji-correction for Chrome/Firefox canvas at small sizes. ' + + 'Consider testing emoji-heavy text separately.', + } +} + +// --- 4. Browser-specific quirk detection --- +// +// Heuristics for known rendering differences that do not fit the other buckets. +// Currently detects: Safari kerning (detected from userAgent), variable fonts, +// and OS-specific rendering (macOS vs Windows). + +type BrowserQuirkType = 'safari_kerning' | 'variable_font' | 'os_rendering' | 'none' + +function detectBrowserQuirk(sample: MeasurementSample): SubAnalysis { + const quirk = identifyBrowserQuirk(sample) + if (quirk === 'none') return noDetection() + + const quirkMessages: Record = { + safari_kerning: + 'Safari applies different kerning metrics than Chrome/Firefox. ' + + 'Pretext uses a per-browser tolerance adjustment to account for this.', + variable_font: + 'Variable font detected via font-variation-settings. ' + + 'canvas.measureText may not honour all variation axes, leading to divergence.', + os_rendering: + 'OS-specific font rendering detected (macOS vs Windows). ' + + 'system-ui resolves to different optical variants; use a named font instead.', + none: '', + } + + return { + detected: true, + severity: 'minor', + confidence: 0.6, + details: { quirkType: quirk }, + recommendation: quirkMessages[quirk], + } +} + +function identifyBrowserQuirk(sample: MeasurementSample): BrowserQuirkType { + // Variable font: font string contains 'variation-settings' style property + if (/variation/i.test(sample.font)) return 'variable_font' + + // system-ui warning — different resolution on macOS vs Windows + if (/system-ui/i.test(sample.font)) return 'os_rendering' + + // Safari user-agent heuristic (browser environment only) + if ( + typeof navigator !== 'undefined' && + /Safari/.test(navigator.userAgent) && + !/Chrome/.test(navigator.userAgent) + ) { + return 'safari_kerning' + } + + return 'none' +} + +// --- Public classifier --- + +export async function classifyDivergence( + result: MeasurementResult, + adapter: DOMAdapter, +): Promise { + // No divergence — return immediately + if (result.overallSeverity === 'pass') { + return { + detected: false, + severity: 'minor', + confidence: 1, + recommendation: 'No divergence detected.', + details: {}, + } + } + + const sample = result.sample + + // 1. Font fallback (highest priority, requires async DOM access) + const fontFallback = await detectFontFallback(sample, adapter) + if (fontFallback.detected) { + return buildAnalysis('font_fallback', fontFallback) + } + + // 2. Bidi shaping + const bidi = detectBidi(sample) + if (bidi.detected) return buildAnalysis('bidi_shaping', bidi) + + // 3. Emoji rendering + const emoji = detectEmoji(sample) + if (emoji.detected) return buildAnalysis('emoji_rendering', emoji) + + // 4. Browser quirks + const quirk = detectBrowserQuirk(sample) + if (quirk.detected) return buildAnalysis('browser_quirk', quirk) + + // 5. Unknown + return { + detected: true, + severity: 'major', + rootCause: 'unknown', + confidence: 0.3, + recommendation: + 'Divergence detected but root cause is unknown. ' + + 'Please report this to the Pretext maintainers with a minimal reproduction.', + details: { maxDelta: result.maxDelta }, + } +} + +function buildAnalysis(rootCause: DivergenceRootCause, sub: SubAnalysis): DivergenceAnalysis { + return { + detected: true, + severity: sub.severity, + rootCause, + confidence: sub.confidence, + recommendation: sub.recommendation, + details: sub.details, + } +} + +// --- Batch classification helper --- + +export async function classifyAll( + results: MeasurementResult[], + adapter: DOMAdapter, +): Promise { + const analyses: DivergenceAnalysis[] = [] + for (const result of results) { + analyses.push(await classifyDivergence(result, adapter)) + } + return analyses +} + +// --- Sync classification (no font-fallback check) --- +// +// Use this when you do not have access to a live DOM (e.g. in unit tests that +// inject fake results). Font-fallback detection is skipped. + +export function classifyDivergenceSync(result: MeasurementResult): DivergenceAnalysis { + if (result.overallSeverity === 'pass') { + return { + detected: false, + severity: 'minor', + confidence: 1, + recommendation: 'No divergence detected.', + details: {}, + } + } + + const sample = result.sample + + const bidi = detectBidi(sample) + if (bidi.detected) return buildAnalysis('bidi_shaping', bidi) + + const emoji = detectEmoji(sample) + if (emoji.detected) return buildAnalysis('emoji_rendering', emoji) + + const quirk = detectBrowserQuirk(sample) + if (quirk.detected) return buildAnalysis('browser_quirk', quirk) + + return { + detected: true, + severity: 'major', + rootCause: 'unknown', + confidence: 0.3, + recommendation: + 'Divergence detected but root cause is unknown. ' + + 'Please report to the Pretext maintainers with a minimal reproduction.', + details: { maxDelta: result.maxDelta }, + } +} diff --git a/src/measurement-validator/comparator.ts b/src/measurement-validator/comparator.ts new file mode 100644 index 00000000..0501a2b7 --- /dev/null +++ b/src/measurement-validator/comparator.ts @@ -0,0 +1,107 @@ +// Comparator: Pretext canvas measurement vs DOM measurement. +// +// Takes a MeasurementSample, runs Pretext layoutWithLines + a DOM adapter, +// and produces a MeasurementResult with per-line deltas and severity ratings. + +import { layoutWithLines, prepareWithSegments } from '../layout.js' +import type { DOMAdapter, DOMLineMetrics } from './dom-adapter.js' +import { + DEFAULT_TOLERANCE, + type LineSeverity, + type MeasurementLinePair, + type MeasurementResult, + type MeasurementSample, + type ToleranceConfig, +} from './types.js' + +function classifyDelta(delta: number, tol: ToleranceConfig): LineSeverity { + const abs = Math.abs(delta) + if (abs <= tol.passDelta) return 'pass' + if (abs <= tol.minorDelta) return 'minor' + if (abs <= tol.majorDelta) return 'major' + return 'critical' +} + +function worstSeverity(severities: LineSeverity[]): LineSeverity { + if (severities.includes('critical')) return 'critical' + if (severities.includes('major')) return 'major' + if (severities.includes('minor')) return 'minor' + return 'pass' +} + +export type ComparatorOptions = { + tolerance?: ToleranceConfig | undefined +} + +export function compareMeasurements( + sample: MeasurementSample, + domLines: DOMLineMetrics[], + options: ComparatorOptions = {}, +): MeasurementResult { + const startMs = Date.now() + const tol = options.tolerance ?? DEFAULT_TOLERANCE + + const prepareOptions: import('../layout.js').PrepareOptions = {} + if (sample.wordBreak !== undefined) prepareOptions.wordBreak = sample.wordBreak + if (sample.whiteSpace !== undefined) prepareOptions.whiteSpace = sample.whiteSpace + + const prepared = prepareWithSegments(sample.text, sample.font, prepareOptions) + const pretextResult = layoutWithLines(prepared, sample.maxWidth, sample.lineHeight) + const pretextLines = pretextResult.lines + + const lineCount = Math.max(pretextLines.length, domLines.length) + const pairs: MeasurementLinePair[] = [] + + for (let i = 0; i < lineCount; i++) { + const pretextLine = pretextLines[i] + const domLine = domLines[i] + const pretextWidth = pretextLine?.width ?? 0 + const domWidth = domLine?.width ?? 0 + const delta = pretextWidth - domWidth + const severity = classifyDelta(delta, tol) + + pairs.push({ + lineIndex: i, + pretextText: pretextLine?.text ?? '', + pretextWidth, + domText: domLine?.text ?? '', + domWidth, + delta, + severity, + }) + } + + const severities = pairs.map((p) => p.severity) + const passCount = severities.filter((s) => s === 'pass').length + const maxDelta = pairs.reduce((acc, p) => Math.max(acc, Math.abs(p.delta)), 0) + + return { + sample, + pretextLineCount: pretextLines.length, + domLineCount: domLines.length, + lineCountMatch: pretextLines.length === domLines.length, + lines: pairs, + overallSeverity: worstSeverity(severities), + passRate: lineCount > 0 ? passCount / lineCount : 1, + maxDelta, + durationMs: Date.now() - startMs, + } +} + +export type Comparator = { + compare(sample: MeasurementSample, options?: ComparatorOptions): MeasurementResult + dispose(): void +} + +export function createComparator(adapter: DOMAdapter): Comparator { + return { + compare(sample: MeasurementSample, options: ComparatorOptions = {}): MeasurementResult { + const domLines = adapter.measureLines(sample) + return compareMeasurements(sample, domLines, options) + }, + + dispose(): void { + adapter.cleanup() + }, + } +} diff --git a/src/measurement-validator/dom-adapter.ts b/src/measurement-validator/dom-adapter.ts new file mode 100644 index 00000000..74841f3a --- /dev/null +++ b/src/measurement-validator/dom-adapter.ts @@ -0,0 +1,133 @@ +// DOM measurement adapter. +// +// Measures text width and line layout using the browser's Range API. +// This is the ground-truth counterpart to Pretext's canvas-based measurement. +// +// Only valid in a browser environment that provides document, Range, and +// getBoundingClientRect. The adapter is deliberately thin so it can be +// replaced with a stub in unit tests. + +import type { MeasurementSample } from './types.js' + +export type DOMLineMetrics = { + text: string + width: number +} + +export type DOMAdapter = { + measureLines(sample: MeasurementSample): DOMLineMetrics[] + cleanup(): void +} + +// Sentinel element created once per adapter instance and reused across calls. +type AdapterState = { + container: HTMLDivElement + textNode: Text +} + +function applyStyles(container: HTMLDivElement, sample: MeasurementSample): void { + const style = container.style + style.position = 'absolute' + style.visibility = 'hidden' + style.pointerEvents = 'none' + style.whiteSpace = sample.whiteSpace === 'pre-wrap' ? 'pre-wrap' : 'normal' + style.wordBreak = sample.wordBreak ?? 'normal' + style.overflowWrap = 'break-word' + style.font = sample.font + style.lineHeight = `${sample.lineHeight}px` + style.width = `${sample.maxWidth}px` +} + +function createState(): AdapterState { + const container = document.createElement('div') + const textNode = document.createTextNode('') + container.appendChild(textNode) + document.body.appendChild(container) + return { container, textNode } +} + +// Extract per-line text and widths using client Range rectangles. +// Each line is identified by a distinct vertical band of DOMRect tops. +function extractLinesFromRange(container: HTMLDivElement, text: string): DOMLineMetrics[] { + const range = document.createRange() + const textNode = container.firstChild + if (textNode === null || textNode.nodeType !== Node.TEXT_NODE) return [] + + const lineHeight = parseFloat(container.style.lineHeight) || 0 + const lines: DOMLineMetrics[] = [] + + // Walk character-by-character and group by vertical band. + // Rects at the same `top` (within 1px) belong to the same line. + let lineStartChar = 0 + let currentTop: number | null = null + let currentWidth = 0 + + for (let i = 0; i <= text.length; i++) { + if (i < text.length) { + range.setStart(textNode, i) + range.setEnd(textNode, i + 1) + const rects = range.getClientRects() + if (rects.length === 0) continue + const rect = rects[0]! + + if (currentTop === null) { + currentTop = rect.top + currentWidth = rect.right + } else if (Math.abs(rect.top - currentTop) > lineHeight * 0.5) { + // New line detected — capture previous line + const lineText = text.slice(lineStartChar, i) + lines.push({ text: lineText, width: currentWidth - container.getBoundingClientRect().left }) + lineStartChar = i + currentTop = rect.top + currentWidth = rect.right + } else { + currentWidth = Math.max(currentWidth, rect.right) + } + } else if (currentTop !== null) { + // Flush the last line + const lineText = text.slice(lineStartChar) + lines.push({ text: lineText, width: currentWidth - container.getBoundingClientRect().left }) + } + } + + return lines +} + +export function createDOMAdapter(): DOMAdapter { + let state: AdapterState | null = null + + function getState(): AdapterState { + if (state === null) { + state = createState() + } + return state + } + + return { + measureLines(sample: MeasurementSample): DOMLineMetrics[] { + const { container, textNode } = getState() + applyStyles(container, sample) + textNode.nodeValue = sample.text + return extractLinesFromRange(container, sample.text) + }, + + cleanup(): void { + if (state !== null) { + state.container.remove() + state = null + } + }, + } +} + +// Pure-function helper: given a measured container element and raw text, +// extract line metrics without maintaining any persistent state. +// Useful for one-off measurements in scripts and tests. +export function measureDOMLines(sample: MeasurementSample): DOMLineMetrics[] { + const adapter = createDOMAdapter() + try { + return adapter.measureLines(sample) + } finally { + adapter.cleanup() + } +} diff --git a/src/measurement-validator/index.ts b/src/measurement-validator/index.ts new file mode 100644 index 00000000..2eca469b --- /dev/null +++ b/src/measurement-validator/index.ts @@ -0,0 +1,51 @@ +// Public API for the measurement validator. +// +// Phase 1: core comparison (comparator, DOM adapter, report generator, types) +// Phase 2: divergence classifier and multi-language test suite + +export type { + DivergenceAnalysis, + DivergenceRootCause, + FixtureSample, + LanguageGroup, + LanguageGroupStats, + LineSeverity, + MeasurementLinePair, + MeasurementResult, + MeasurementSample, + TestSuiteReport, + ToleranceConfig, +} from './types.js' + +export { DEFAULT_TOLERANCE } from './types.js' + +export type { DOMAdapter, DOMLineMetrics } from './dom-adapter.js' +export { createDOMAdapter, measureDOMLines } from './dom-adapter.js' + +export type { Comparator, ComparatorOptions } from './comparator.js' +export { compareMeasurements, createComparator } from './comparator.js' + +export { + buildGroupStats, + formatDivergenceConsole, + formatResultConsole, + formatResultJSON, + formatSuiteConsole, + formatSuiteJSON, +} from './report-generator.js' + +// Phase 2: classifier +export { + classifyAll, + classifyDivergence, + classifyDivergenceSync, +} from './classifier.js' + +// Phase 2: test suite +export type { FixtureSet, TestSuiteOptions } from './test-suite.js' +export { + filterByGroup, + groupSamplesByLanguage, + runTestSuite, + validateFixtureSamples, +} from './test-suite.js' diff --git a/src/measurement-validator/report-generator.ts b/src/measurement-validator/report-generator.ts new file mode 100644 index 00000000..01b7b2ab --- /dev/null +++ b/src/measurement-validator/report-generator.ts @@ -0,0 +1,134 @@ +// Report generator: formats MeasurementResult and TestSuiteReport output. +// +// Supports two output modes: +// - JSON: structured machine-readable output for CI/tooling integration +// - console: human-readable summary for interactive inspection + +import type { + DivergenceAnalysis, + LanguageGroupStats, + MeasurementResult, + TestSuiteReport, +} from './types.js' + +// --- Severity formatting helpers --- + +const SEVERITY_ICONS: Record = { + pass: '✅', + minor: '⚠️', + major: '🟠', + critical: '🔴', +} + +function severityIcon(severity: string): string { + return SEVERITY_ICONS[severity] ?? '❓' +} + +// --- Single result report --- + +export function formatResultJSON(result: MeasurementResult): string { + return JSON.stringify(result, null, 2) +} + +export function formatResultConsole(result: MeasurementResult): string { + const lines: string[] = [] + const icon = severityIcon(result.overallSeverity) + + lines.push(`${icon} ${result.overallSeverity.toUpperCase()} — "${result.sample.text.slice(0, 40)}"`) + lines.push( + ` font=${result.sample.font} width=${result.sample.maxWidth}px ` + + `passRate=${(result.passRate * 100).toFixed(1)}% maxDelta=${result.maxDelta.toFixed(3)}px ` + + `${result.durationMs}ms`, + ) + + if (!result.lineCountMatch) { + lines.push( + ` ⚠ line count mismatch: pretext=${result.pretextLineCount} dom=${result.domLineCount}`, + ) + } + + for (const pair of result.lines) { + if (pair.severity !== 'pass') { + lines.push( + ` line ${pair.lineIndex}: delta=${pair.delta.toFixed(3)}px ` + + `pretext="${pair.pretextText.slice(0, 30)}" dom="${pair.domText.slice(0, 30)}"`, + ) + } + } + + return lines.join('\n') +} + +// --- Divergence analysis report --- + +export function formatDivergenceConsole(analysis: DivergenceAnalysis): string { + if (!analysis.detected) return '✅ No divergence detected.' + const icon = severityIcon(analysis.severity) + return [ + `${icon} Divergence: ${analysis.rootCause ?? 'unknown'} (confidence=${(analysis.confidence * 100).toFixed(0)}%)`, + ` → ${analysis.recommendation}`, + ].join('\n') +} + +// --- Test suite report --- + +export function formatSuiteJSON(report: TestSuiteReport): string { + return JSON.stringify(report, null, 2) +} + +export function formatSuiteConsole(report: TestSuiteReport): string { + const lines: string[] = [] + const icon = report.overallPassRate >= 0.99 ? '✅' : report.overallPassRate >= 0.8 ? '⚠️' : '🔴' + + lines.push( + `${icon} Test Suite — ${report.totalPassed}/${report.totalSamples} passed ` + + `(${(report.overallPassRate * 100).toFixed(1)}%) ${report.durationMs}ms`, + ) + + for (const grp of report.byLanguageGroup) { + lines.push(formatGroupStatsConsole(grp)) + } + + const critical = report.results.filter((r) => r.overallSeverity === 'critical') + if (critical.length > 0) { + lines.push(`\n🔴 Critical failures (${critical.length}):`) + for (const r of critical) { + lines.push(` • "${r.sample.text.slice(0, 50)}" (${r.sample.font}, ${r.sample.maxWidth}px)`) + } + } + + return lines.join('\n') +} + +function formatGroupStatsConsole(stats: LanguageGroupStats): string { + const icon = stats.passRate >= 0.99 ? '✅' : stats.passRate >= 0.8 ? '⚠️' : '🔴' + return ( + ` ${icon} ${stats.group.padEnd(16)} ` + + `${stats.passed}/${stats.total} ` + + `passRate=${(stats.passRate * 100).toFixed(1)}% ` + + `avgDelta=${stats.averageDelta.toFixed(3)}px ` + + `maxDelta=${stats.maxDelta.toFixed(3)}px` + ) +} + +// --- Aggregate helpers used by test-suite.ts --- + +export function buildGroupStats( + group: string, + results: MeasurementResult[], +): LanguageGroupStats { + const total = results.length + const passed = results.filter((r) => r.overallSeverity === 'pass').length + const deltas = results.flatMap((r) => r.lines.map((l) => Math.abs(l.delta))) + const maxDelta = deltas.length > 0 ? Math.max(...deltas) : 0 + const averageDelta = deltas.length > 0 ? deltas.reduce((a, b) => a + b, 0) / deltas.length : 0 + + return { + group: group as import('./types.js').LanguageGroup, + total, + passed, + passRate: total > 0 ? passed / total : 1, + averageDelta, + maxDelta, + } +} diff --git a/src/measurement-validator/test-suite.ts b/src/measurement-validator/test-suite.ts new file mode 100644 index 00000000..1b334f56 --- /dev/null +++ b/src/measurement-validator/test-suite.ts @@ -0,0 +1,106 @@ +// Multi-language test suite runner — Phase 2. +// +// Loads fixture files for multiple language groups, runs the comparator and +// classifier against each sample, and produces a TestSuiteReport with +// per-language-group statistics. + +import type { DOMAdapter } from './dom-adapter.js' +import { compareMeasurements } from './comparator.js' +import { classifyDivergenceSync } from './classifier.js' +import { buildGroupStats } from './report-generator.js' +import type { + DivergenceAnalysis, + FixtureSample, + LanguageGroup, + MeasurementResult, + TestSuiteReport, +} from './types.js' + +// --- Fixture loading --- + +export type FixtureSet = { + group: LanguageGroup + samples: FixtureSample[] +} + +export function validateFixtureSamples(samples: unknown): FixtureSample[] { + if (!Array.isArray(samples)) { + throw new TypeError('Fixture file must contain a JSON array of samples.') + } + return samples as FixtureSample[] +} + +// --- Suite runner --- + +export type TestSuiteOptions = { + adapter: DOMAdapter + fixtureSets: FixtureSet[] + tolerance?: import('./types.js').ToleranceConfig +} + +export async function runTestSuite(options: TestSuiteOptions): Promise { + const startMs = Date.now() + const { adapter, fixtureSets, tolerance } = options + + const allResults: MeasurementResult[] = [] + const allDivergences: DivergenceAnalysis[] = [] + + for (const fixtureSet of fixtureSets) { + for (const sample of fixtureSet.samples) { + const domLines = adapter.measureLines(sample) + const result = compareMeasurements(sample, domLines, { tolerance }) + allResults.push(result) + + const analysis = classifyDivergenceSync(result) + allDivergences.push(analysis) + } + } + + const totalSamples = allResults.length + const totalPassed = allResults.filter((r) => r.overallSeverity === 'pass').length + + // Build per-group stats + const groupMap = new Map() + for (const fixtureSet of fixtureSets) { + const groupResults = allResults.filter( + (r) => (r.sample as FixtureSample).languageGroup === fixtureSet.group, + ) + groupMap.set(fixtureSet.group, groupResults) + } + + const byLanguageGroup = [...groupMap.entries()].map(([group, results]) => + buildGroupStats(group, results), + ) + + return { + totalSamples, + totalPassed, + overallPassRate: totalSamples > 0 ? totalPassed / totalSamples : 1, + byLanguageGroup, + results: allResults, + divergences: allDivergences, + generatedAt: new Date().toISOString(), + durationMs: Date.now() - startMs, + } +} + +// --- Subset helpers --- + +export function filterByGroup( + samples: FixtureSample[], + group: LanguageGroup, +): FixtureSample[] { + return samples.filter((s) => s.languageGroup === group) +} + +export function groupSamplesByLanguage( + samples: FixtureSample[], +): Map { + const map = new Map() + for (const sample of samples) { + const list = map.get(sample.languageGroup) ?? [] + list.push(sample) + map.set(sample.languageGroup, list) + } + return map +} diff --git a/src/measurement-validator/types.ts b/src/measurement-validator/types.ts new file mode 100644 index 00000000..b8945d6a --- /dev/null +++ b/src/measurement-validator/types.ts @@ -0,0 +1,114 @@ +// Core types for the measurement validator. +// +// The validator compares Pretext canvas-based line measurements against DOM +// measurements to surface divergences and classify their root causes. + +// --- Inputs --- + +export type MeasurementSample = { + text: string // The text to measure + font: string // CSS font string, e.g. '16px Arial' + maxWidth: number // Container width in pixels + lineHeight: number // Line height in pixels + language?: string // BCP 47 language tag, e.g. 'en', 'ar', 'zh-Hans' + wordBreak?: 'normal' | 'keep-all' + whiteSpace?: 'normal' | 'pre-wrap' +} + +// --- Line-level results --- + +export type MeasurementLinePair = { + lineIndex: number // 0-based line index + pretextText: string // Text as returned by Pretext layoutWithLines + pretextWidth: number // Width measured by Pretext + domText: string // Text extracted from DOM Range + domWidth: number // Width measured by DOM Range.getBoundingClientRect + delta: number // pretextWidth - domWidth + severity: LineSeverity +} + +export type LineSeverity = 'pass' | 'minor' | 'major' | 'critical' + +// --- Overall comparison result --- + +export type MeasurementResult = { + sample: MeasurementSample + pretextLineCount: number + domLineCount: number + lineCountMatch: boolean + lines: MeasurementLinePair[] + overallSeverity: LineSeverity + passRate: number // 0-1, fraction of lines within tolerance + maxDelta: number // largest absolute delta across all lines + durationMs: number // wall-clock time for the full comparison +} + +// --- Divergence classifier output --- + +export type DivergenceRootCause = + | 'font_fallback' + | 'bidi_shaping' + | 'emoji_rendering' + | 'browser_quirk' + | 'variable_font' + | 'unknown' + +export type DivergenceAnalysis = { + detected: boolean + severity: 'minor' | 'major' | 'critical' + rootCause?: DivergenceRootCause + confidence: number // 0-1 + recommendation: string + details: Record +} + +// --- Multi-language test suite --- + +export type LanguageGroup = + | 'ltr-simple' // English, Spanish, French + | 'rtl' // Arabic, Hebrew, Urdu + | 'cjk' // Chinese, Japanese, Korean + | 'complex-script' // Thai, Myanmar, Khmer + | 'mixed-bidi' // Mixed LTR + RTL + +export type FixtureSample = MeasurementSample & { + id: string + description: string + languageGroup: LanguageGroup + expectedSeverity?: LineSeverity +} + +export type LanguageGroupStats = { + group: LanguageGroup + total: number + passed: number + passRate: number + averageDelta: number + maxDelta: number +} + +export type TestSuiteReport = { + totalSamples: number + totalPassed: number + overallPassRate: number + byLanguageGroup: LanguageGroupStats[] + results: MeasurementResult[] + divergences: DivergenceAnalysis[] + generatedAt: string // ISO 8601 + durationMs: number +} + +// --- Thresholds --- + +export type ToleranceConfig = { + passDelta: number // px — pass if |delta| <= this + minorDelta: number // px — minor if passDelta < |delta| <= this + majorDelta: number // px — major if minorDelta < |delta| <= this + // critical: |delta| > majorDelta +} + +export const DEFAULT_TOLERANCE: ToleranceConfig = { + passDelta: 0.5, + minorDelta: 1.0, + majorDelta: 2.0, +} diff --git a/test/classifier.test.ts b/test/classifier.test.ts new file mode 100644 index 00000000..d3f8ad8a --- /dev/null +++ b/test/classifier.test.ts @@ -0,0 +1,230 @@ +// Unit tests for the divergence classifier (Phase 2). +// +// Tests each detection strategy in isolation using fake MeasurementResults +// (no real browser or DOM access required). + +import { describe, expect, test } from 'bun:test' +import { classifyDivergenceSync } from '../src/measurement-validator/classifier.js' +import type { MeasurementLinePair, MeasurementResult, MeasurementSample } from '../src/measurement-validator/types.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fakeSample(overrides: Partial = {}): MeasurementSample { + return { + text: 'Hello world', + font: '16px Arial', + maxWidth: 400, + lineHeight: 20, + ...overrides, + } +} + +function fakeLinePair(delta: number): MeasurementLinePair { + return { + lineIndex: 0, + pretextText: 'Hello world', + pretextWidth: 80 + delta, + domText: 'Hello world', + domWidth: 80, + delta, + severity: Math.abs(delta) <= 0.5 ? 'pass' : Math.abs(delta) <= 1 ? 'minor' : Math.abs(delta) <= 2 ? 'major' : 'critical', + } +} + +function fakeResult( + sample: MeasurementSample, + delta: number, + overallSeverity: MeasurementResult['overallSeverity'] = 'major', +): MeasurementResult { + return { + sample, + pretextLineCount: 1, + domLineCount: 1, + lineCountMatch: true, + lines: [fakeLinePair(delta)], + overallSeverity, + passRate: overallSeverity === 'pass' ? 1 : 0, + maxDelta: Math.abs(delta), + durationMs: 1, + } +} + +// --------------------------------------------------------------------------- +// Pass-through: no divergence +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — no divergence', () => { + test('returns detected=false when result is pass', () => { + const result = fakeResult(fakeSample(), 0, 'pass') + const analysis = classifyDivergenceSync(result) + expect(analysis.detected).toBe(false) + expect(analysis.confidence).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Bidi detection +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — bidi detection', () => { + test('detects RTL Arabic text', () => { + const sample = fakeSample({ text: 'مرحباً بالعالم' }) + const result = fakeResult(sample, 5) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('bidi_shaping') + expect(analysis.confidence).toBeGreaterThan(0.5) + expect(analysis.recommendation).toContain('RTL') + }) + + test('detects RTL Hebrew text', () => { + const sample = fakeSample({ text: 'שלום עולם' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('bidi_shaping') + }) + + test('does not flag LTR English as bidi', () => { + const sample = fakeSample({ text: 'Hello world', font: '16px Arial' }) + const result = fakeResult(sample, 3) // delta high enough to trigger "something" + const analysis = classifyDivergenceSync(result) + + expect(analysis.rootCause).not.toBe('bidi_shaping') + }) +}) + +// --------------------------------------------------------------------------- +// Emoji detection +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — emoji detection', () => { + test('detects emoji in text', () => { + const sample = fakeSample({ text: 'Hello 🌍 world' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('emoji_rendering') + expect(analysis.recommendation).toContain('emoji') + }) + + test('detects emoji sequence', () => { + const sample = fakeSample({ text: '👨‍👩‍👧‍👦 family' }) + const result = fakeResult(sample, 2) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('emoji_rendering') + }) + + test('does not flag plain ASCII as emoji', () => { + const sample = fakeSample({ text: 'no emoji here' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.rootCause).not.toBe('emoji_rendering') + }) +}) + +// --------------------------------------------------------------------------- +// Browser quirk detection +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — browser quirk detection', () => { + test('detects system-ui font', () => { + const sample = fakeSample({ font: '16px system-ui' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('browser_quirk') + expect(analysis.details['quirkType']).toBe('os_rendering') + }) + + test('detects variable font', () => { + const sample = fakeSample({ font: '16px Inter variation-settings' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('browser_quirk') + expect(analysis.details['quirkType']).toBe('variable_font') + }) +}) + +// --------------------------------------------------------------------------- +// Unknown divergence fallback +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — unknown divergence', () => { + test('returns unknown when no specific cause matches', () => { + const sample = fakeSample({ text: 'simple text', font: '16px Arial' }) + const result = fakeResult(sample, 5) + const analysis = classifyDivergenceSync(result) + + expect(analysis.detected).toBe(true) + expect(analysis.rootCause).toBe('unknown') + expect(analysis.confidence).toBeLessThan(0.5) + expect(analysis.recommendation).toContain('Pretext') + }) +}) + +// --------------------------------------------------------------------------- +// Priority ordering +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — priority ordering', () => { + test('bidi takes priority over emoji when both present', () => { + // Arabic text with emoji — bidi should win (higher priority) + const sample = fakeSample({ text: 'مرحبا 🌍' }) + const result = fakeResult(sample, 5) + const analysis = classifyDivergenceSync(result) + + expect(analysis.rootCause).toBe('bidi_shaping') + }) + + test('emoji takes priority over unknown for plain text with emoji', () => { + const sample = fakeSample({ text: 'Hello 😊 world' }) + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(analysis.rootCause).toBe('emoji_rendering') + }) +}) + +// --------------------------------------------------------------------------- +// DivergenceAnalysis shape +// --------------------------------------------------------------------------- + +describe('classifyDivergenceSync — output shape', () => { + test('always returns required fields', () => { + const sample = fakeSample() + const result = fakeResult(sample, 3) + const analysis = classifyDivergenceSync(result) + + expect(typeof analysis.detected).toBe('boolean') + expect(typeof analysis.severity).toBe('string') + expect(typeof analysis.confidence).toBe('number') + expect(typeof analysis.recommendation).toBe('string') + expect(typeof analysis.details).toBe('object') + }) + + test('confidence is between 0 and 1', () => { + const cases: Array<[string, number]> = [ + ['مرحبا', 5], + ['Hello 😊', 3], + ['simple text', 5], + ] + for (const [text, delta] of cases) { + const result = fakeResult(fakeSample({ text }), delta) + const analysis = classifyDivergenceSync(result) + expect(analysis.confidence).toBeGreaterThanOrEqual(0) + expect(analysis.confidence).toBeLessThanOrEqual(1) + } + }) +}) diff --git a/test/fixtures/cjk-samples.json b/test/fixtures/cjk-samples.json new file mode 100644 index 00000000..f51f18a4 --- /dev/null +++ b/test/fixtures/cjk-samples.json @@ -0,0 +1,103 @@ +[ + { + "id": "zh-001", + "description": "Short Chinese phrase", + "text": "\u4E16\u754C\u4F60\u597D", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "zh-Hans", + "languageGroup": "cjk" + }, + { + "id": "zh-002", + "description": "Chinese sentence", + "text": "\u8FD9\u662F\u4E00\u4E2A\u7528\u4E8E\u6D4B\u8BD5\u6587\u672C\u6D4B\u91CF\u7684\u53E5\u5B50\u3002", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "zh-Hans", + "languageGroup": "cjk" + }, + { + "id": "zh-003", + "description": "Chinese text at narrow width — per-character breaking", + "text": "\u6C49\u5B57\u6BCF\u4E2A\u5B57\u90FD\u53EF\u4EE5\u5355\u72EC\u6210\u4E3A\u4E00\u4E2A\u65AD\u884C\u70B9", + "font": "16px Arial", + "maxWidth": 50, + "lineHeight": 20, + "language": "zh-Hans", + "languageGroup": "cjk" + }, + { + "id": "ja-001", + "description": "Short Japanese phrase", + "text": "\u3053\u3093\u306B\u3061\u306F\u3001\u4E16\u754C\uFF01", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ja", + "languageGroup": "cjk" + }, + { + "id": "ja-002", + "description": "Japanese sentence with kana and kanji", + "text": "\u65E5\u672C\u8A9E\u306E\u30C6\u30AD\u30B9\u30C8\u6D4B\u5B9A\u306E\u30C6\u30B9\u30C8\u6587\u3067\u3059\u3002", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "ja", + "languageGroup": "cjk" + }, + { + "id": "ja-003", + "description": "Japanese kinsoku: prohibited at line start", + "text": "\u3053\u308C\u306F\u3001\u884C\u982D\u7981\u5247\u306E\u30C6\u30B9\u30C8\u3067\u3059\u3002\u3053\u306E\u6587\u306F\u3001\u884C\u982D\u306B\u8A18\u53F7\u304C\u6765\u306A\u3044\u3088\u3046\u306B\u3059\u308B\u3002", + "font": "16px Arial", + "maxWidth": 150, + "lineHeight": 20, + "language": "ja", + "languageGroup": "cjk" + }, + { + "id": "ko-001", + "description": "Short Korean phrase", + "text": "\uc548\ub155\ud558\uc138\uc694", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ko", + "languageGroup": "cjk" + }, + { + "id": "ko-002", + "description": "Korean sentence", + "text": "\ud55c\uad6d\uc5b4 \ud14d\uc2a4\ud2b8 \uce21\uc815 \ud14c\uc2a4\ud2b8 \ubb38\uc7a5\uc785\ub2c8\ub2e4.", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "ko", + "languageGroup": "cjk" + }, + { + "id": "zh-004", + "description": "Chinese with keep-all word break", + "text": "\u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5 keep-all \u65AD\u884C\u6A21\u5F0F\u7684\u53E5\u5B50\u3002", + "font": "16px Arial", + "maxWidth": 150, + "lineHeight": 20, + "wordBreak": "keep-all", + "language": "zh-Hans", + "languageGroup": "cjk" + }, + { + "id": "ja-004", + "description": "Japanese mixed with Latin", + "text": "Pretext\u306FJavaScript\u30E9\u30A4\u30D6\u30E9\u30EA\u3067\u3059\u3002", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "ja", + "languageGroup": "cjk" + } +] diff --git a/test/fixtures/complex-script-samples.json b/test/fixtures/complex-script-samples.json new file mode 100644 index 00000000..2053f57e --- /dev/null +++ b/test/fixtures/complex-script-samples.json @@ -0,0 +1,72 @@ +[ + { + "id": "th-001", + "description": "Short Thai phrase", + "text": "\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e35\u0e0a\u0e32\u0e27\u0e42\u0e25\u0e01", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "th", + "languageGroup": "complex-script" + }, + { + "id": "th-002", + "description": "Thai sentence without spaces (cluster-based breaking)", + "text": "\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49\u0e0a\u0e48\u0e2d\u0e07\u0e27\u0e48\u0e32\u0e07\u0e43\u0e19\u0e01\u0e32\u0e23\u0e41\u0e1a\u0e48\u0e07\u0e04\u0e33", + "font": "16px Arial", + "maxWidth": 150, + "lineHeight": 20, + "language": "th", + "languageGroup": "complex-script" + }, + { + "id": "th-003", + "description": "Thai mixed with English", + "text": "Pretext \u0e23\u0e2d\u0e07\u0e23\u0e31\u0e1a\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22", + "font": "16px Arial", + "maxWidth": 250, + "lineHeight": 20, + "language": "th", + "languageGroup": "complex-script" + }, + { + "id": "my-001", + "description": "Short Myanmar phrase", + "text": "\u1019\u103c\u1014\u103a\u1019\u102c \u1001\u103b\u1031\u102c\u1000\u103a\u1019\u103a\u102e\u1038", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "my", + "languageGroup": "complex-script" + }, + { + "id": "my-002", + "description": "Myanmar sentence", + "text": "\u1019\u103c\u1014\u103a\u1019\u102c\u1005\u102c \u1005\u102c\u1015\u102d\u102f\u1038\u1021\u1004\u103a\u1038 \u1015\u102c\u1016\u102c\u101e\u102c \u1014\u100a\u103a\u102c\u101e\u1031\u102c\u101c\u103b\u103e\u1021\u1015\u103a\u101e\u100a\u103a\u104b", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "my", + "languageGroup": "complex-script" + }, + { + "id": "km-001", + "description": "Short Khmer phrase", + "text": "\u179f\u17bd\u1780\u179f\u17d0\u178f\u17b7\u1780\u179b\u17c4\u1780", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "km", + "languageGroup": "complex-script" + }, + { + "id": "km-002", + "description": "Khmer sentence", + "text": "\u17a2\u1780\u17d2\u179f\u179a\u1780\u1798\u17d2\u179b\u17b6\u1780\u17d2\u179a\u1795\u17d2\u179f\u17bd\u179b\u1790\u17b6\u1780 \u1780\u17b6\u179a\u179a\u1780\u17d2\u179f\u17b6\u1780\u179a\u1794\u179f\u17cb\u179a\u1794\u179f\u1780\u17d0\u179a\u1793\u17d0\u1780\u17d2\u179f\u179a \u1781\u17d2\u179c\u17c4\u1793\u200b\u1793\u1787\u17d0\u1799\u200b\u178a\u17c2\u1793\u1780\u17b6\u179a\u200b\u1780\u1784\u17d0\u179a\u200b\u178a\u17d2\u178a\u1781\u17d2\u179c\u17c2\u1793\u200b\u1798\u17d2\u178f\u17c2\u178f", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "km", + "languageGroup": "complex-script" + } +] diff --git a/test/fixtures/english-samples.json b/test/fixtures/english-samples.json new file mode 100644 index 00000000..eedb6510 --- /dev/null +++ b/test/fixtures/english-samples.json @@ -0,0 +1,163 @@ +[ + { + "id": "en-001", + "description": "Short English sentence", + "text": "Hello, world!", + "font": "16px Arial", + "maxWidth": 400, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-002", + "description": "English sentence that wraps at narrow width", + "text": "The quick brown fox jumps over the lazy dog.", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-003", + "description": "English sentence with punctuation", + "text": "Wait—don't go! Please, come back.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-004", + "description": "English with numbers", + "text": "There are 42 items in 3 categories.", + "font": "14px Georgia", + "maxWidth": 250, + "lineHeight": 18, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-005", + "description": "Single long word that must break", + "text": "pneumonoultramicroscopicsilicovolcanoconiosis", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "es-001", + "description": "Spanish sentence with accents", + "text": "El niño jugó en el jardín con alegría.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "es", + "languageGroup": "ltr-simple" + }, + { + "id": "fr-001", + "description": "French sentence with accents and ligatures", + "text": "L'été est beau à Paris, n'est-ce pas?", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "fr", + "languageGroup": "ltr-simple" + }, + { + "id": "en-006", + "description": "English with URL-like text", + "text": "Visit https://example.com/path?q=hello for more info.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-007", + "description": "Long paragraph of English text", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "font": "16px Arial", + "maxWidth": 400, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-008", + "description": "English text at large font size", + "text": "Big headline text", + "font": "32px Arial", + "maxWidth": 300, + "lineHeight": 40, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-009", + "description": "English with soft hyphens", + "text": "Donaudampfschiff\u00ADfahrts\u00ADgesellschaft", + "font": "16px Arial", + "maxWidth": 150, + "lineHeight": 20, + "language": "de", + "languageGroup": "ltr-simple" + }, + { + "id": "en-010", + "description": "English with non-breaking space", + "text": "New\u00A0York is a city.", + "font": "16px Arial", + "maxWidth": 200, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-011", + "description": "English at very narrow width", + "text": "Word wrapping at very narrow container", + "font": "16px Arial", + "maxWidth": 80, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-012", + "description": "Empty string", + "text": "", + "font": "16px Arial", + "maxWidth": 400, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-013", + "description": "Single space", + "text": " ", + "font": "16px Arial", + "maxWidth": 400, + "lineHeight": 20, + "language": "en", + "languageGroup": "ltr-simple" + }, + { + "id": "en-014", + "description": "English with tabs in pre-wrap mode", + "text": "col1\tcol2\tcol3", + "font": "16px monospace", + "maxWidth": 400, + "lineHeight": 20, + "whiteSpace": "pre-wrap", + "language": "en", + "languageGroup": "ltr-simple" + } +] diff --git a/test/fixtures/mixed-bidi-samples.json b/test/fixtures/mixed-bidi-samples.json new file mode 100644 index 00000000..2853d0a0 --- /dev/null +++ b/test/fixtures/mixed-bidi-samples.json @@ -0,0 +1,72 @@ +[ + { + "id": "mb-001", + "description": "English with Arabic phrase", + "text": "Hello \u0645\u0631\u062D\u0628\u0627 world", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "en", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-002", + "description": "Arabic with English brand name", + "text": "\u0634\u0631\u0643\u0629 Google \u0644\u0644\u062A\u0643\u0646\u0648\u0644\u0648\u062C\u064A\u0627", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ar", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-003", + "description": "English sentence ending with Arabic", + "text": "The Arabic word for hello is \u0645\u0631\u062D\u0628\u0627.", + "font": "16px Arial", + "maxWidth": 350, + "lineHeight": 20, + "language": "en", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-004", + "description": "Hebrew mixed with English numbers", + "text": "\u05D4\u05DE\u05D7\u05D9\u05E8 \u05D4\u05D5\u05D0 100 \u05E9\u05E7\u05DC.", + "font": "16px Arial", + "maxWidth": 250, + "lineHeight": 20, + "language": "he", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-005", + "description": "Mixed bidi paragraph", + "text": "This is English. \u0647\u0630\u0627 \u0647\u0648 \u0639\u0631\u0628\u064A. And back to English again.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "en", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-006", + "description": "Arabic URL in English text", + "text": "Visit \u0645\u0648\u0642\u0639\u0646\u0627.com for info.", + "font": "16px Arial", + "maxWidth": 250, + "lineHeight": 20, + "language": "en", + "languageGroup": "mixed-bidi" + }, + { + "id": "mb-007", + "description": "Mixed bidi at narrow width forcing wrap", + "text": "English \u0639\u0631\u0628\u064A English \u0639\u0631\u0628\u064A English \u0639\u0631\u0628\u064A", + "font": "16px Arial", + "maxWidth": 120, + "lineHeight": 20, + "language": "en", + "languageGroup": "mixed-bidi" + } +] diff --git a/test/fixtures/rtl-samples.json b/test/fixtures/rtl-samples.json new file mode 100644 index 00000000..57e41563 --- /dev/null +++ b/test/fixtures/rtl-samples.json @@ -0,0 +1,82 @@ +[ + { + "id": "ar-001", + "description": "Short Arabic phrase", + "text": "\u0645\u0631\u062D\u0628\u0627\u064B \u0628\u0627\u0644\u0639\u0627\u0644\u0645", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ar", + "languageGroup": "rtl" + }, + { + "id": "ar-002", + "description": "Arabic sentence with punctuation", + "text": "\u0627\u0644\u0642\u0637 \u0641\u064A \u0627\u0644\u0645\u0646\u0632\u0644\u060C \u0648\u0627\u0644\u0643\u0644\u0628 \u0641\u064A \u0627\u0644\u062D\u062F\u064A\u0642\u0629.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ar", + "languageGroup": "rtl" + }, + { + "id": "ar-003", + "description": "Arabic paragraph at narrow width", + "text": "\u0647\u0630\u0627 \u0646\u0635 \u0637\u0648\u064A\u0644 \u0628\u0627\u0644\u0644\u063A\u0629 \u0627\u0644\u0639\u0631\u0628\u064A\u0629 \u0644\u0627\u062E\u062A\u0628\u0627\u0631 \u0643\u0633\u0631 \u0627\u0644\u0633\u0637\u0631 \u0641\u064A \u0648\u0639\u0627\u0621 \u0636\u064A\u0642.", + "font": "16px Arial", + "maxWidth": 150, + "lineHeight": 20, + "language": "ar", + "languageGroup": "rtl" + }, + { + "id": "he-001", + "description": "Short Hebrew phrase", + "text": "\u05E9\u05DC\u05D5\u05DD \u05E2\u05D5\u05DC\u05DD", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "he", + "languageGroup": "rtl" + }, + { + "id": "he-002", + "description": "Hebrew sentence", + "text": "\u05D4\u05D9\u05D5\u05DD \u05D4\u05D5\u05D0 \u05D9\u05D5\u05DD \u05D9\u05E4\u05D4 \u05DC\u05DC\u05DE\u05D9\u05D3\u05D4.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "he", + "languageGroup": "rtl" + }, + { + "id": "ur-001", + "description": "Short Urdu phrase", + "text": "\u06AF\u0641\u062A\u06AF\u0648 \u06A9\u0627 \u0633\u0644\u0633\u0644\u06C1", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ur", + "languageGroup": "rtl" + }, + { + "id": "ar-004", + "description": "Arabic numerals in Arabic text", + "text": "\u0647\u0646\u0627\u0643 \u0661\u0662 \u0639\u0646\u0635\u0631\u0627\u064B \u0641\u064A \u0627\u0644\u0642\u0627\u0626\u0645\u0629.", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ar", + "languageGroup": "rtl" + }, + { + "id": "ar-005", + "description": "Arabic text with diacritics", + "text": "\u0628\u0650\u0633\u0652\u0645\u0650 \u0627\u0644\u0644\u0651\u064E\u0647\u0650 \u0627\u0644\u0631\u0651\u064E\u062D\u0652\u0645\u064E\u0646\u0650 \u0627\u0644\u0631\u0651\u064E\u062D\u0650\u064A\u0645\u0650", + "font": "16px Arial", + "maxWidth": 300, + "lineHeight": 20, + "language": "ar", + "languageGroup": "rtl" + } +] diff --git a/test/measurement-validator.test.ts b/test/measurement-validator.test.ts new file mode 100644 index 00000000..8d155de6 --- /dev/null +++ b/test/measurement-validator.test.ts @@ -0,0 +1,182 @@ +// Unit and integration tests for the measurement validator (Phase 1 + Phase 2). +// +// These tests exercise the comparator and report-generator modules using a +// fake DOM adapter (no real browser required), keeping them fast and +// deterministic. For browser-specific accuracy, use the browser checker pages. + +import { describe, expect, test } from 'bun:test' +import { compareMeasurements } from '../src/measurement-validator/comparator.js' +import { + formatDivergenceConsole, + formatResultConsole, + formatResultJSON, + buildGroupStats, +} from '../src/measurement-validator/report-generator.js' +import { + DEFAULT_TOLERANCE, + type MeasurementSample, +} from '../src/measurement-validator/types.js' +import type { DOMLineMetrics } from '../src/measurement-validator/dom-adapter.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fakeSample(overrides: Partial = {}): MeasurementSample { + return { + text: 'Hello, world!', + font: '16px Arial', + maxWidth: 400, + lineHeight: 20, + ...overrides, + } +} + +function fakeDOMLines(lines: Array<{ text: string; width: number }>): DOMLineMetrics[] { + return lines +} + +// --------------------------------------------------------------------------- +// compareMeasurements +// --------------------------------------------------------------------------- + +describe('compareMeasurements', () => { + test('pass when Pretext and DOM agree within tolerance', () => { + const sample = fakeSample({ text: 'hello world' }) + // Inject fake DOM lines that match what Pretext would produce for simple text. + // We choose widths close to zero to stay within the default passDelta threshold + // even though the fake DOM widths may not exactly match canvas output. + const domLines = fakeDOMLines([{ text: 'hello world', width: 0 }]) + const result = compareMeasurements(sample, domLines) + + expect(result.sample).toBe(sample) + expect(result.pretextLineCount).toBeGreaterThan(0) + expect(result.lines.length).toBeGreaterThan(0) + expect(result.durationMs).toBeGreaterThanOrEqual(0) + }) + + test('detects line count mismatch', () => { + const sample = fakeSample({ text: 'hello world', maxWidth: 400 }) + // Provide 2 DOM lines when Pretext likely produces 1 + const domLines = fakeDOMLines([ + { text: 'hello', width: 30 }, + { text: 'world', width: 30 }, + ]) + const result = compareMeasurements(sample, domLines) + + // At minimum, lineCountMatch should reflect the mismatch correctly + expect(typeof result.lineCountMatch).toBe('boolean') + expect(typeof result.overallSeverity).toBe('string') + }) + + test('severity escalates with large delta', () => { + const sample = fakeSample() + // Provide DOM lines with zero width so delta = pretextWidth, which is large + const domLines = fakeDOMLines([{ text: 'Hello, world!', width: 0 }]) + const result = compareMeasurements(sample, domLines) + + expect(['pass', 'minor', 'major', 'critical']).toContain(result.overallSeverity) + expect(result.passRate).toBeGreaterThanOrEqual(0) + expect(result.passRate).toBeLessThanOrEqual(1) + expect(result.maxDelta).toBeGreaterThanOrEqual(0) + }) + + test('handles empty text without throwing', () => { + const sample = fakeSample({ text: '' }) + const domLines: DOMLineMetrics[] = [] + const result = compareMeasurements(sample, domLines) + expect(result).toBeDefined() + expect(result.overallSeverity).toBe('pass') + }) + + test('custom tolerance is respected', () => { + const sample = fakeSample() + const domLines = fakeDOMLines([{ text: 'Hello, world!', width: 0 }]) + // Very tight tolerance — almost any delta triggers critical + const result = compareMeasurements(sample, domLines, { + tolerance: { passDelta: 0, minorDelta: 0.1, majorDelta: 0.5 }, + }) + expect(['minor', 'major', 'critical']).toContain(result.overallSeverity) + }) + + test('uses DEFAULT_TOLERANCE when no options provided', () => { + expect(DEFAULT_TOLERANCE.passDelta).toBe(0.5) + expect(DEFAULT_TOLERANCE.minorDelta).toBe(1.0) + expect(DEFAULT_TOLERANCE.majorDelta).toBe(2.0) + }) +}) + +// --------------------------------------------------------------------------- +// Report generator +// --------------------------------------------------------------------------- + +describe('formatResultJSON', () => { + test('returns valid JSON', () => { + const sample = fakeSample() + const domLines = fakeDOMLines([{ text: 'Hello, world!', width: 80 }]) + const result = compareMeasurements(sample, domLines) + const json = formatResultJSON(result) + expect(() => JSON.parse(json)).not.toThrow() + const parsed = JSON.parse(json) as typeof result + expect(parsed.sample.text).toBe('Hello, world!') + }) +}) + +describe('formatResultConsole', () => { + test('returns non-empty string', () => { + const sample = fakeSample() + const domLines = fakeDOMLines([{ text: 'Hello, world!', width: 80 }]) + const result = compareMeasurements(sample, domLines) + const output = formatResultConsole(result) + expect(typeof output).toBe('string') + expect(output.length).toBeGreaterThan(0) + }) +}) + +describe('formatDivergenceConsole', () => { + test('no divergence message', () => { + const output = formatDivergenceConsole({ + detected: false, + severity: 'minor', + confidence: 1, + recommendation: 'No divergence detected.', + details: {}, + }) + expect(output).toContain('No divergence') + }) + + test('divergence with rootCause', () => { + const output = formatDivergenceConsole({ + detected: true, + severity: 'critical', + rootCause: 'font_fallback', + confidence: 0.95, + recommendation: 'Font not loaded.', + details: {}, + }) + expect(output).toContain('font_fallback') + }) +}) + +describe('buildGroupStats', () => { + test('computes correct stats for empty results', () => { + const stats = buildGroupStats('ltr-simple', []) + expect(stats.total).toBe(0) + expect(stats.passed).toBe(0) + expect(stats.passRate).toBe(1) + expect(stats.maxDelta).toBe(0) + expect(stats.averageDelta).toBe(0) + }) + + test('computes correct stats for passing results', () => { + const sample = fakeSample() + const domLines = fakeDOMLines([{ text: 'Hello, world!', width: 0 }]) + const result = compareMeasurements(sample, domLines) + + const stats = buildGroupStats('ltr-simple', [result]) + expect(stats.total).toBe(1) + expect(stats.group).toBe('ltr-simple') + expect(stats.passRate).toBeGreaterThanOrEqual(0) + expect(stats.passRate).toBeLessThanOrEqual(1) + }) +})