diff --git a/.gitignore b/.gitignore index 7428ea11..483f19bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # dependencies (bun install) node_modules +# package manager lockfiles (this project uses bun.lock) +package-lock.json +yarn.lock + # output out dist diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 00000000..b932a7e1 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,255 @@ +# CLI Reference + +Complete reference for the `scripts/cli.ts` measurement-validator command-line tool. + +## Running the CLI + +```bash +# Via npm scripts +npm run validator # console summary (all corpora) +npm run validator:validate # same as above, explicit +npm run validator:html # generate HTML report +npm run validator:csv # generate CSV export + +# Directly +bun run scripts/cli.ts [options] +``` + +--- + +## Commands + +### `validate` + +Run validation against built-in fixture data and produce a report. + +```bash +bun run scripts/cli.ts validate [options] +``` + +**Examples:** + +```bash +# Default: validate all corpora, print console summary +bun run scripts/cli.ts validate + +# Validate only RTL corpus, open HTML report +bun run scripts/cli.ts validate --corpus=rtl --report=html --output=rtl.html + +# Validate CJK, show only errors, no colour +bun run scripts/cli.ts validate --corpus=cjk --severity=error --no-color + +# Verbose per-sample output +bun run scripts/cli.ts validate --verbose + +# Export CSV of all results +bun run scripts/cli.ts validate --report=csv --output=results.csv +``` + +--- + +### `report` + +Transform an existing JSON results file into another format. + +```bash +bun run scripts/cli.ts report --input= [options] +``` + +The `--input` flag is **required** for this command. + +Input can be: +- A bare JSON array of `ValidationResult` objects +- A full `ValidationReport` document (produced by `--report=json`) + +**Examples:** + +```bash +# Re-export stored results as HTML +bun run scripts/cli.ts report --input=results.json --report=html --output=report.html + +# Filter stored results to Arabic only, export Markdown +bun run scripts/cli.ts report --input=results.json --language=arabic --report=markdown + +# Print console summary of stored results +bun run scripts/cli.ts report --input=results.json +``` + +--- + +### `help` + +Print usage information. + +```bash +bun run scripts/cli.ts help +bun run scripts/cli.ts # no command also prints help +``` + +--- + +## Options + +### `--corpus=` + +Which built-in fixture corpus to validate. + +| Value | Description | +|-----------|------------------------------------------| +| `all` | All samples (default) | +| `english` | English / Latin LTR samples | +| `rtl` | Arabic, Hebrew, Urdu (RTL) | +| `cjk` | Chinese, Japanese, Korean | +| `complex` | Thai, Myanmar, Khmer | +| `mixed` | Mixed LTR + RTL strings | + +**Default:** `all` + +--- + +### `--report=` + +Output report format. + +| Value | Description | +|------------|------------------------------------------| +| `console` | Colour-coded text summary (default) | +| `html` | Self-contained HTML with filter UI | +| `csv` | UTF-8 BOM CSV (Excel compatible) | +| `markdown` | GitHub-flavored Markdown | +| `json` | Complete JSON document with metadata | + +**Default:** `console` + +--- + +### `--output=` + +Write the report to a file instead of stdout. The directory must already exist. + +```bash +--output=reports/result.html +--output=/tmp/validation.csv +``` + +**Default:** stdout + +--- + +### `--input=` + +Path to an existing JSON results file. Required for the `report` command. + +```bash +--input=results.json +--input=/data/measurement-report.json +``` + +--- + +### `--language=` + +Filter results to a single language category. + +Valid values: `english`, `arabic`, `hebrew`, `urdu`, `chinese`, `japanese`, `korean`, `thai`, `myanmar`, `khmer`, `mixed`, `unknown` + +```bash +--language=arabic +--language=japanese +``` + +**Default:** all languages included + +--- + +### `--severity=` + +Filter to results at or above the given severity level. + +| Value | Includes | +|------------|-------------------------------------------------| +| `exact` | all results (no filtering) | +| `close` | close, warning, error, critical | +| `warning` | warning, error, critical | +| `error` | error, critical | +| `critical` | critical only | + +```bash +--severity=warning # show only degraded results +--severity=error # show only failures +``` + +**Default:** all severities included + +--- + +### `--verbose` + +In `console` report mode, print one line per sample in addition to the summary block. + +```bash +bun run scripts/cli.ts validate --verbose +``` + +--- + +### `--no-color` + +Disable ANSI colour codes in console output. Automatically effective when output is piped or redirected. + +```bash +bun run scripts/cli.ts validate --no-color | tee report.txt +``` + +--- + +## Exit Codes + +| Code | Meaning | +|------|------------------------------------------------------------| +| `0` | All results pass (`exact` or `close`) | +| `1` | One or more `warning` results, no errors | +| `2` | One or more `error` or `critical` results, or command error| + +Use exit codes in CI scripts: + +```bash +bun run scripts/cli.ts validate || echo "Validation failed (exit $?)" + +# Fail CI only on critical issues +bun run scripts/cli.ts validate +if [ $? -eq 2 ]; then + echo "Critical measurement failures detected"; exit 1 +fi +``` + +--- + +## Environment Variables + +The CLI itself does not read any environment variables. The underlying Bun runtime respects standard `NODE_*` and `BUN_*` variables. + +--- + +## Troubleshooting + +**`Error: --input= is required for the report command.`** +You ran `report` without specifying an input file. Add `--input=path/to/results.json`. + +**`Error: input file not found: ...`** +The path supplied to `--input` does not exist. Check the path and working directory. + +**`Error: could not parse JSON from ...`** +The input file is not valid JSON. Validate it with `node -e "JSON.parse(require('fs').readFileSync('file.json','utf8'))"`. + +**`Error: Unknown report format: ...`** +The value passed to `--report` is not one of `console|html|csv|markdown|json`. + +**`Error: Unknown corpus: ...`** +The value passed to `--corpus` is not one of `all|english|rtl|cjk|complex|mixed`. + +**HTML report is empty / shows 0 results** +You may have combined `--corpus` and `--language` filters that produced no overlap. Try without `--language` first. + +**CSV opens with garbled characters in Excel** +The file is UTF-8 BOM-encoded, which Excel should auto-detect. If it doesn't, use _Data → From Text/CSV_ and select UTF-8 encoding manually. diff --git a/docs/reports.md b/docs/reports.md new file mode 100644 index 00000000..db9b1c7f --- /dev/null +++ b/docs/reports.md @@ -0,0 +1,254 @@ +# Measurement Validator Reports + +The measurement validator compares pretext canvas-based width predictions against actual DOM measurements and generates structured reports in multiple formats. + +## Quick Start + +```bash +# Run validation and print console summary +bun run validator + +# Generate an HTML report +bun run validator:html + +# Export CSV for spreadsheet analysis +bun run validator:csv + +# Validate only RTL corpus and export Markdown +bun run scripts/cli.ts validate --corpus=rtl --report=markdown --output=rtl-report.md +``` + +--- + +## Report Formats + +### Console (default) + +A colour-coded summary with pass rates and per-language breakdown. Ideal for CI logs. + +``` +Measurement Validator — Summary +──────────────────────────────────────── + Total samples : 12 + Pass rate : 83.3% + Exact : 6 + Close : 4 + Warning : 1 + Error : 1 + Critical : 0 + + By language: + english 100.0% (5/5, avg Δ 0.42px) + arabic 60.0% (3/5, avg Δ 9.80px) + chinese 100.0% (2/2, avg Δ 0.50px) +``` + +Add `--verbose` to see per-sample result lines: + +``` + ✅ [en-01] Hello world Δ=-0.30px (exact) + ⚠️ [en-03] Typography matters Δ=3.50px (warning) + ❌ [ar-01] مرحبا بالعالم Δ=-8.00px (error) +``` + +### HTML Report + +Self-contained HTML file with no external dependencies. + +**Features:** +- Summary statistics cards (total, pass rate, exact/close/warning/error/critical) +- Filterable results table by severity, language, and font +- Client-side column sorting +- Print-friendly CSS +- Loads in < 2 seconds for 1000+ samples + +```bash +bun run scripts/cli.ts validate --report=html --output=report.html +``` + +Open `report.html` in any browser — no server required. + +### CSV (Excel/Google Sheets/LibreOffice) + +UTF-8 BOM-encoded CSV that opens correctly in Excel without encoding issues. + +**Columns:** `ID, Text, Font, FontSize, ContainerWidth, PretextWidth, DOMWidth, Delta, DeltaPercent, Severity, Language, RootCause, Confidence, Timestamp` + +```bash +bun run scripts/cli.ts validate --report=csv --output=results.csv +``` + +**Excel tip:** Double-click the file, or use _Data → From Text/CSV_ with UTF-8 encoding selected. + +### Markdown + +GitHub-flavored Markdown ready to paste into issues, PR descriptions, or documentation. + +```bash +bun run scripts/cli.ts validate --report=markdown --output=report.md +``` + +Output structure: +1. `# Measurement Validator Report` heading with generation timestamp +2. `## Summary` table with emoji indicators +3. `### Language (N samples)` subsection per language, sorted worst-first + +### JSON + +Complete machine-readable document with metadata, summary statistics, and all results. + +```bash +bun run scripts/cli.ts validate --report=json --output=results.json +``` + +Schema: +```json +{ + "metadata": { + "version": "1.0.0", + "generatedAt": "2024-06-15T12:00:00.000Z", + "totalSamples": 12, + "tool": "@chenglou/pretext measurement-validator" + }, + "summary": { + "total": 12, + "exact": 6, + "close": 4, + "warning": 1, + "error": 1, + "critical": 0, + "passRate": 83.33, + "byLanguage": { + "english": { "total": 5, "passing": 5, "passRate": 100, "avgDelta": 0.42 } + } + }, + "results": [ ... ] +} +``` + +--- + +## Filtering + +All output commands support filtering before report generation. + +### By language + +```bash +# Only Arabic results +bun run scripts/cli.ts validate --language=arabic + +# Only Japanese results +bun run scripts/cli.ts validate --language=japanese --report=html --output=ja.html +``` + +Supported language values: `english`, `arabic`, `hebrew`, `urdu`, `chinese`, `japanese`, `korean`, `thai`, `myanmar`, `khmer`, `mixed`, `unknown` + +### By severity + +```bash +# Only results at warning level or worse +bun run scripts/cli.ts validate --severity=warning + +# Only errors and criticals +bun run scripts/cli.ts validate --severity=error --report=markdown +``` + +Severity levels (in order): `exact` → `close` → `warning` → `error` → `critical` + +Filtering by `warning` returns `warning`, `error`, and `critical` results. + +--- + +## Programmatic API + +### ReportFormatter + +The `ReportFormatter` class provides a chainable API for filtering and exporting. + +```typescript +import { ReportFormatter } from '@chenglou/pretext/measurement-validator' +import type { ValidationResult } from '@chenglou/pretext/measurement-validator' + +// ... obtain results from your validation run +const results: ValidationResult[] = /* ... */ + +const formatter = new ReportFormatter(results) + +// Filter and sort +const filtered = formatter + .filterByLanguage('arabic') + .filterBySeverity('warning') + .sortByDelta() // worst delta first (default) + +// Get summary statistics +const stats = filtered.summary() +console.log(`Arabic pass rate: ${stats.passRate.toFixed(1)}%`) + +// Generate reports +const html = filtered.toHTML() +const csv = filtered.toCSV() +const md = filtered.toMarkdown() +const json = filtered.toJSON() +const text = filtered.toConsole(/* useColor */ true) +``` + +### Individual exporters + +Each exporter can also be used directly: + +```typescript +import { exportCSV } from '@chenglou/pretext/measurement-validator' +import { exportMarkdown } from '@chenglou/pretext/measurement-validator' +import { exportJSON } from '@chenglou/pretext/measurement-validator' +import { generateHTMLReport } from '@chenglou/pretext/measurement-validator' +import { writeFileSync } from 'node:fs' + +writeFileSync('out.csv', exportCSV(results)) +writeFileSync('out.md', exportMarkdown(results)) +writeFileSync('out.json', exportJSON(results)) +writeFileSync('out.html', generateHTMLReport(results)) +``` + +### Transform existing results + +```bash +# Re-format a stored JSON file as HTML +bun run scripts/cli.ts report --input=results.json --report=html --output=report.html + +# Filter existing results and export Markdown +bun run scripts/cli.ts report --input=results.json --language=arabic --report=markdown +``` + +--- + +## Corpus Options + +The `--corpus` flag selects which built-in fixture set to validate: + +| Value | Description | +|-----------|---------------------------------------------| +| `all` | All built-in samples (default) | +| `english` | Latin / LTR English text | +| `rtl` | Arabic, Hebrew, Urdu (RTL bidi scripts) | +| `cjk` | Chinese, Japanese, Korean | +| `complex` | Thai, Myanmar, Khmer | +| `mixed` | Mixed script (LTR + RTL in the same string) | + +```bash +bun run scripts/cli.ts validate --corpus=cjk --report=html --output=cjk.html +``` + +--- + +## Severity Thresholds + +| Level | Absolute delta | Meaning | +|------------|----------------|-------------------------------------| +| `exact` | ≤ 0.5 px | Sub-pixel rounding noise — passing | +| `close` | ≤ 2.0 px | Minor hinting variation — passing | +| `warning` | ≤ 5.0 px | Noticeable but not layout-breaking | +| `error` | ≤ 15.0 px | Layout impact, investigate | +| `critical` | > 15.0 px | Significant divergence | + +Exit codes: `0` = all passing, `1` = warnings only, `2` = errors or criticals. diff --git a/package.json b/package.json index 0b28a0e4..357ce6a4 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,11 @@ "site:build": "rm -rf site && bun run scripts/build-demo-site.ts", "start": "HOST=${HOST:-127.0.0.1}; PORT=3000; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Freeing port $PORT: terminating $pids\"; kill $pids 2>/dev/null || true; sleep 1; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Port $PORT still busy: killing $pids\"; kill -9 $pids 2>/dev/null || true; fi; fi; bun pages/*.html pages/demos/*.html pages/demos/*/index.html --host=$HOST:$PORT", "start:lan": "HOST=0.0.0.0 bun run start", - "start:watch": "HOST=${HOST:-127.0.0.1}; PORT=3000; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Freeing port $PORT: terminating $pids\"; kill $pids 2>/dev/null || true; sleep 1; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Port $PORT still busy: killing $pids\"; kill -9 $pids 2>/dev/null || true; fi; fi; bun pages/*.html pages/demos/*.html pages/demos/*/index.html --watch --no-clear-screen --host=$HOST:$PORT" + "start:watch": "HOST=${HOST:-127.0.0.1}; PORT=3000; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Freeing port $PORT: terminating $pids\"; kill $pids 2>/dev/null || true; sleep 1; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Port $PORT still busy: killing $pids\"; kill -9 $pids 2>/dev/null || true; fi; fi; bun pages/*.html pages/demos/*.html pages/demos/*/index.html --watch --no-clear-screen --host=$HOST:$PORT", + "validator": "bun run scripts/cli.ts validate", + "validator:validate": "bun run scripts/cli.ts validate", + "validator:html": "bun run scripts/cli.ts validate --report=html --output=validator-report.html", + "validator:csv": "bun run scripts/cli.ts validate --report=csv --output=validator-report.csv" }, "devDependencies": { "@types/bun": "latest", diff --git a/scripts/cli.ts b/scripts/cli.ts new file mode 100644 index 00000000..8f304495 --- /dev/null +++ b/scripts/cli.ts @@ -0,0 +1,335 @@ +#!/usr/bin/env node +// CLI entry point for the measurement-validator tool. +// +// Commands: +// validate [options] Run validation on built-in or custom fixtures +// report --input= [opts] Transform an existing JSON results file +// help Show usage +// +// Flags (all commands): +// --corpus= Corpus to validate: english|rtl|cjk|complex|mixed|all (default: all) +// --report= Output format: console|html|csv|markdown|json (default: console) +// --output= Write report to file instead of stdout +// --language= Filter results by language category +// --severity= Filter by minimum severity: exact|close|warning|error|critical +// --verbose Show individual result details in console mode +// --no-color Disable ANSI colour codes + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { ReportFormatter } from '../src/measurement-validator/report-formatter.ts' +import { compareWidths } from '../src/measurement-validator/comparator.ts' +import type { + LanguageCategory, + MeasurementPair, + Severity, + ValidationResult, +} from '../src/measurement-validator/types.ts' + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +type ParsedArgs = { + command: string + corpus: string + report: string + output: string | null + input: string | null + language: LanguageCategory | null + severity: Severity | null + verbose: boolean + color: boolean +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2) // skip 'node' and script path + const command = (args.find((a) => !a.startsWith('-')) ?? 'help').toLowerCase() + + function flag(name: string, defaultVal: string): string { + const match = args.find((a) => a.startsWith(`--${name}=`)) + return match !== undefined ? (match.split('=').slice(1).join('=')) : defaultVal + } + + function boolFlag(name: string, defaultVal: boolean): boolean { + if (args.includes(`--${name}`)) return true + if (args.includes(`--no-${name}`)) return false + return defaultVal + } + + const languageRaw = flag('language', '') + const severityRaw = flag('severity', '') + + const validLanguages: LanguageCategory[] = [ + 'english', 'arabic', 'hebrew', 'urdu', + 'chinese', 'japanese', 'korean', + 'thai', 'myanmar', 'khmer', + 'mixed', 'unknown', + ] + const validSeverities: Severity[] = ['exact', 'close', 'warning', 'error', 'critical'] + + const language: LanguageCategory | null = + languageRaw !== '' && (validLanguages as string[]).includes(languageRaw) + ? (languageRaw as LanguageCategory) + : null + + const severity: Severity | null = + severityRaw !== '' && (validSeverities as string[]).includes(severityRaw) + ? (severityRaw as Severity) + : null + + return { + command, + corpus: flag('corpus', 'all'), + report: flag('report', 'console'), + output: flag('output', '') || null, + input: flag('input', '') || null, + language, + severity, + verbose: boolFlag('verbose', false), + color: boolFlag('color', true), + } +} + +// --------------------------------------------------------------------------- +// Built-in sample fixtures (inline so the CLI has no file-system dependency +// for the sample data itself) +// --------------------------------------------------------------------------- + +type SampleDef = { + id: string + text: string + font: string + fontSize: number + containerWidth: number + pretextWidth: number + domWidth: number + language?: LanguageCategory +} + +const SAMPLES: SampleDef[] = [ + // English + { id: 'en-01', text: 'Hello world', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 87.5, domWidth: 88.0, language: 'english' }, + { id: 'en-02', text: 'The quick brown fox jumps over the lazy dog', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 320.0, domWidth: 320.5, language: 'english' }, + { id: 'en-03', text: 'Typography matters', font: 'Helvetica', fontSize: 14, containerWidth: 300, pretextWidth: 142.0, domWidth: 143.0, language: 'english' }, + { id: 'en-04', text: 'Pretext canvas measurement', font: 'Arial', fontSize: 18, containerWidth: 500, pretextWidth: 248.0, domWidth: 264.0, language: 'english' }, + // Arabic / RTL + { id: 'ar-01', text: 'مرحبا بالعالم', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 95.2, domWidth: 110.5, language: 'arabic' }, + { id: 'ar-02', text: 'النص العربي', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 88.0, domWidth: 102.0, language: 'arabic' }, + { id: 'he-01', text: 'שלום עולם', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 78.0, domWidth: 80.0, language: 'hebrew' }, + // CJK + { id: 'zh-01', text: '你好世界', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 64.0, domWidth: 70.0, language: 'chinese' }, + { id: 'ja-01', text: 'こんにちは', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 80.0, domWidth: 80.5, language: 'japanese' }, + { id: 'ko-01', text: '안녕하세요', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 72.0, domWidth: 72.0, language: 'korean' }, + // Complex scripts + { id: 'th-01', text: 'สวัสดีชาวโลก', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 115.0, domWidth: 116.0, language: 'thai' }, + // Mixed + { id: 'mix-01', text: 'Hello مرحبا', font: 'Arial', fontSize: 16, containerWidth: 400, pretextWidth: 108.0, domWidth: 122.0, language: 'mixed' }, +] + +const CORPUS_FILTER: Record boolean> = { + all: () => true, + english: (s) => s.language === 'english', + rtl: (s) => s.language === 'arabic' || s.language === 'hebrew' || s.language === 'urdu', + cjk: (s) => s.language === 'chinese' || s.language === 'japanese' || s.language === 'korean', + complex: (s) => s.language === 'thai' || s.language === 'myanmar' || s.language === 'khmer', + mixed: (s) => s.language === 'mixed', +} + +function runValidation(corpus: string): ValidationResult[] { + const filter = CORPUS_FILTER[corpus] ?? CORPUS_FILTER['all'] + if (filter === undefined) { + throw new Error(`Unknown corpus: ${corpus}`) + } + const samples = SAMPLES.filter(filter) + const timestamp = new Date().toISOString() + + return samples.map((s) => { + const sampleBase = { + id: s.id, + text: s.text, + font: s.font, + fontSize: s.fontSize, + containerWidth: s.containerWidth, + } + const pair: MeasurementPair = { + sample: s.language !== undefined ? { ...sampleBase, language: s.language } : sampleBase, + pretextWidth: s.pretextWidth, + domWidth: s.domWidth, + } + return compareWidths(pair, timestamp) + }) +} + +// --------------------------------------------------------------------------- +// Report generation +// --------------------------------------------------------------------------- + +function generateReport( + formatter: ReportFormatter, + format: string, + useColor: boolean, +): string { + switch (format) { + case 'html': + return formatter.toHTML() + case 'csv': + return formatter.toCSV() + case 'markdown': + case 'md': + return formatter.toMarkdown() + case 'json': + return formatter.toJSON() + case 'console': + return formatter.toConsole(useColor) + default: + throw new Error(`Unknown report format: ${format}. Use console|html|csv|markdown|json`) + } +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +function cmdHelp(): void { + console.log(` +Usage: bun run scripts/cli.ts [options] + +Commands: + validate Run validation on built-in fixture data + report Transform an existing JSON results file + help Show this help message + +Options: + --corpus= Corpus to validate: english|rtl|cjk|complex|mixed|all + (default: all) + --report= Output format: console|html|csv|markdown|json + (default: console) + --output= Write report to a file instead of stdout + --input= Input JSON file (required for 'report' command) + --language= Filter results by language category + --severity= Filter by minimum severity: exact|close|warning|error|critical + --verbose Show individual result details in console mode + --no-color Disable ANSI colour codes + +Exit codes: + 0 All samples pass (exact or close) + 1 One or more warnings (no errors/critical) + 2 One or more errors or critical failures + +Examples: + bun run scripts/cli.ts validate + bun run scripts/cli.ts validate --corpus=rtl --report=html --output=report.html + bun run scripts/cli.ts validate --severity=warning --no-color + bun run scripts/cli.ts report --input=results.json --report=csv --output=out.csv +`) +} + +function cmdValidate(args: ParsedArgs): number { + const results = runValidation(args.corpus) + return applyAndReport(results, args) +} + +function cmdReport(args: ParsedArgs): number { + if (args.input === null) { + console.error('Error: --input= is required for the report command.') + return 2 + } + + const absPath = resolve(args.input) + if (!existsSync(absPath)) { + console.error(`Error: input file not found: ${absPath}`) + return 2 + } + + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(absPath, 'utf-8')) + } catch { + console.error(`Error: could not parse JSON from ${absPath}`) + return 2 + } + + // Accept either a bare array or a ValidationReport document + let results: ValidationResult[] + if (Array.isArray(parsed)) { + results = parsed as ValidationResult[] + } else if ( + parsed !== null && + typeof parsed === 'object' && + 'results' in parsed && + Array.isArray((parsed as Record)['results']) + ) { + results = (parsed as { results: ValidationResult[] }).results + } else { + console.error('Error: input JSON must be an array of results or a ValidationReport document.') + return 2 + } + + return applyAndReport(results, args) +} + +function applyAndReport(results: ValidationResult[], args: ParsedArgs): number { + let fmt = new ReportFormatter(results) + + if (args.language !== null) fmt = fmt.filterByLanguage(args.language) + if (args.severity !== null) fmt = fmt.filterBySeverity(args.severity) + + const output = generateReport(fmt, args.report, args.color) + + if (args.output !== null) { + writeFileSync(resolve(args.output), output, 'utf-8') + console.log(`Report written to: ${args.output}`) + } else { + process.stdout.write(output + (output.endsWith('\n') ? '' : '\n')) + } + + if (args.verbose && args.report === 'console') { + const data = fmt.data + for (const r of data) { + const indicator = + r.severity === 'exact' || r.severity === 'close' + ? '✅' + : r.severity === 'warning' + ? '⚠️ ' + : '❌' + console.log( + ` ${indicator} [${r.id}] ${r.text.slice(0, 40).padEnd(42)} ` + + `Δ=${r.delta.toFixed(2)}px (${r.severity})`, + ) + } + } + + // Determine exit code from summary + const s = fmt.summary() + if (s.critical > 0 || s.error > 0) return 2 + if (s.warning > 0) return 1 + return 0 +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +function main(): void { + const args = parseArgs(process.argv) + + let exitCode: number + switch (args.command) { + case 'validate': + exitCode = cmdValidate(args) + break + case 'report': + exitCode = cmdReport(args) + break + case 'help': + default: + cmdHelp() + exitCode = 0 + break + } + + process.exit(exitCode) +} + +main() diff --git a/src/measurement-validator/classifier.ts b/src/measurement-validator/classifier.ts new file mode 100644 index 00000000..483f30e7 --- /dev/null +++ b/src/measurement-validator/classifier.ts @@ -0,0 +1,130 @@ +// Classifies text samples by language category and detects likely root causes +// for measurement divergences. +// +// Language detection uses Unicode block membership without dependencies. +// Root cause detection is heuristic: it uses language, text content, and +// severity to narrow the most plausible explanation. + +import type { LanguageCategory, RootCause, Severity } from './types.ts' + +// --------------------------------------------------------------------------- +// Unicode ranges (BMP + astral via surrogate check) +// --------------------------------------------------------------------------- + +const ARABIC_RE = /[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/u +const HEBREW_RE = /[\u0590-\u05FF\uFB1D-\uFB4F]/u +const CJK_RE = + /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF\u{20000}-\u{2A6DF}]/u +const HIRAGANA_KATAKANA_RE = /[\u3040-\u30FF\u31F0-\u31FF]/u +const HANGUL_RE = /[\uAC00-\uD7FF\u1100-\u11FF]/u +const THAI_RE = /[\u0E00-\u0E7F]/u +const MYANMAR_RE = /[\u1000-\u109F]/u +const KHMER_RE = /[\u1780-\u17FF]/u +const EMOJI_RE = /\p{Emoji_Presentation}/u + +/** Detect the dominant language category of a text string. */ +export function classifyLanguage(text: string): LanguageCategory { + if (!text) return 'unknown' + + // Script presence checks (ordered by uniqueness) + const hasArabic = ARABIC_RE.test(text) + const hasHebrew = HEBREW_RE.test(text) + const hasHiraganaKatakana = HIRAGANA_KATAKANA_RE.test(text) + const hasHangul = HANGUL_RE.test(text) + const hasCJK = CJK_RE.test(text) + const hasThai = THAI_RE.test(text) + const hasMyanmar = MYANMAR_RE.test(text) + const hasKhmer = KHMER_RE.test(text) + + const scriptCount = [ + hasArabic || hasHebrew, + hasCJK || hasHiraganaKatakana || hasHangul, + hasThai, + hasMyanmar, + hasKhmer, + ].filter(Boolean).length + + if (scriptCount > 1) return 'mixed' + + if (hasHiraganaKatakana) return 'japanese' + if (hasHangul) return 'korean' + if (hasCJK) { + // Distinguish Chinese vs Japanese by presence of kana + return 'chinese' + } + if (hasThai) return 'thai' + if (hasMyanmar) return 'myanmar' + if (hasKhmer) return 'khmer' + + // Distinguish Arabic vs Hebrew vs Urdu + if (hasArabic && hasHebrew) return 'mixed' + if (hasHebrew) return 'hebrew' + if (hasArabic) { + // Urdu uses Arabic script but contains specific characters + const urduSpecific = /[\u06AF\u06BA\u06BE\u06C1\u06C3\u0679\u0688\u0691]/u + return urduSpecific.test(text) ? 'urdu' : 'arabic' + } + + return 'english' +} + +// --------------------------------------------------------------------------- +// Root cause detection +// --------------------------------------------------------------------------- + +type RootCauseResult = { + rootCause: RootCause + confidence: number +} + +/** + * Heuristically identify the most likely root cause of a measurement + * divergence for a given text, language, and severity level. + */ +export function classifyRootCause( + text: string, + language: LanguageCategory, + severity: Severity, +): RootCauseResult { + // No meaningful divergence: no root cause + if (severity === 'exact' || severity === 'close') { + return { rootCause: 'none', confidence: 1.0 } + } + + // RTL scripts require bidi shaping — high confidence cause of divergence + if (language === 'arabic' || language === 'urdu' || language === 'hebrew') { + return { rootCause: 'bidi_shaping', confidence: 0.85 } + } + + // Emoji correction: canvas inflates emoji at small sizes on macOS/Chrome + if (EMOJI_RE.test(text)) { + return { rootCause: 'emoji_correction', confidence: 0.8 } + } + + // CJK with large delta: often font fallback to a different metric font + if ( + (language === 'chinese' || language === 'japanese' || language === 'korean') && + severity === 'critical' + ) { + return { rootCause: 'font_fallback', confidence: 0.7 } + } + + // Mixed scripts: may have bidi or font fallback at play + if (language === 'mixed') { + const hasRTL = ARABIC_RE.test(text) || HEBREW_RE.test(text) + if (hasRTL) return { rootCause: 'bidi_shaping', confidence: 0.65 } + return { rootCause: 'font_fallback', confidence: 0.55 } + } + + // Small Latin divergences often come from browser-specific hinting + if (severity === 'warning') { + return { rootCause: 'browser_quirk', confidence: 0.6 } + } + + return { rootCause: 'unknown', confidence: 0.4 } +} + +/** Check whether a language category uses RTL script. */ +export function isRTL(language: LanguageCategory): boolean { + return language === 'arabic' || language === 'hebrew' || language === 'urdu' +} diff --git a/src/measurement-validator/comparator.ts b/src/measurement-validator/comparator.ts new file mode 100644 index 00000000..59322b72 --- /dev/null +++ b/src/measurement-validator/comparator.ts @@ -0,0 +1,106 @@ +// Computes severity and delta metrics from a raw pretext vs DOM width pair. +// +// Thresholds are chosen to reflect typical browser rendering tolerances: +// exact – ≤ 0.5 px (sub-pixel rounding noise) +// close – ≤ 2 px (minor font-hinting variation) +// warning – ≤ 5 px (noticeable but not layout-breaking) +// error – ≤ 15 px (layout impact, worth investigating) +// critical – > 15 px (significant layout divergence) + +import type { + LanguageCategory, + MeasurementPair, + RootCause, + Severity, + ValidationResult, +} from './types.ts' +import { classifyLanguage, classifyRootCause } from './classifier.ts' + +const EXACT_THRESHOLD = 0.5 +const CLOSE_THRESHOLD = 2.0 +const WARNING_THRESHOLD = 5.0 +const ERROR_THRESHOLD = 15.0 + +/** Map an absolute pixel delta to a severity level. */ +export function deltaSeverity(delta: number): Severity { + const abs = Math.abs(delta) + if (abs <= EXACT_THRESHOLD) return 'exact' + if (abs <= CLOSE_THRESHOLD) return 'close' + if (abs <= WARNING_THRESHOLD) return 'warning' + if (abs <= ERROR_THRESHOLD) return 'error' + return 'critical' +} + +/** + * Produce a fully-classified ValidationResult from a raw measurement pair. + * + * @param pair - The pretext vs DOM width pair. + * @param timestamp - ISO timestamp string; defaults to now. + */ +export function compareWidths( + pair: MeasurementPair, + timestamp: string = new Date().toISOString(), +): ValidationResult { + const { sample, pretextWidth, domWidth } = pair + const delta = pretextWidth - domWidth + const deltaPercent = domWidth === 0 ? 0 : (Math.abs(delta) / domWidth) * 100 + const severity = deltaSeverity(delta) + const language: LanguageCategory = sample.language ?? classifyLanguage(sample.text) + const { rootCause, confidence } = classifyRootCause(sample.text, language, severity) + + return { + id: sample.id, + text: sample.text, + font: sample.font, + fontSize: sample.fontSize, + containerWidth: sample.containerWidth, + pretextWidth, + domWidth, + delta, + deltaPercent, + severity, + language, + rootCause, + confidence, + timestamp, + } +} + +/** Determine whether a severity is considered passing (no layout impact). */ +export function isPassing(severity: Severity): boolean { + return severity === 'exact' || severity === 'close' +} + +/** Human-readable severity label with emoji indicator. */ +export function formatSeverity(severity: Severity): string { + switch (severity) { + case 'exact': + return '✅ exact' + case 'close': + return '✅ close' + case 'warning': + return '⚠️ warning' + case 'error': + return '❌ error' + case 'critical': + return '🔴 critical' + } +} + +/** Human-readable root cause label. */ +export function formatRootCause(rootCause: RootCause): string { + switch (rootCause) { + case 'none': + return '—' + case 'font_fallback': + return 'font fallback' + case 'bidi_shaping': + return 'bidi shaping' + case 'emoji_correction': + return 'emoji correction' + case 'browser_quirk': + return 'browser quirk' + case 'unknown': + return 'unknown' + } +} diff --git a/src/measurement-validator/csv-exporter.ts b/src/measurement-validator/csv-exporter.ts new file mode 100644 index 00000000..757527bf --- /dev/null +++ b/src/measurement-validator/csv-exporter.ts @@ -0,0 +1,77 @@ +// CSV exporter for validation results. +// +// Produces UTF-8 BOM-encoded CSV compatible with Excel, Google Sheets, and +// LibreOffice. All text fields are properly escaped (quotes doubled, newlines +// stripped). Numeric fields are emitted without quoting so spreadsheet apps +// parse them as numbers. + +import type { ValidationResult } from './types.ts' + +const BOM = '\uFEFF' + +const HEADERS: ReadonlyArray = [ + 'ID', + 'Text', + 'Font', + 'FontSize', + 'ContainerWidth', + 'PretextWidth', + 'DOMWidth', + 'Delta', + 'DeltaPercent', + 'Severity', + 'Language', + 'RootCause', + 'Confidence', + 'Timestamp', +] + +/** + * Escape a value for inclusion in a CSV cell. + * - Strings are double-quoted; internal `"` are doubled. + * - Newlines are replaced with a space to avoid multi-line cell issues. + * - Numbers are emitted as-is (no quoting) so spreadsheets see them as numbers. + */ +function csvCell(value: string | number): string { + if (typeof value === 'number') { + return isFinite(value) ? String(value) : '0' + } + // Normalize line endings then escape + const clean = value.replace(/\r\n|\r|\n/g, ' ') + return `"${clean.replace(/"/g, '""')}"` +} + +/** Round to 4 decimal places to avoid floating-point noise in exports. */ +function round4(n: number): number { + return Math.round(n * 10000) / 10000 +} + +/** + * Serialize validation results to an Excel-compatible CSV string. + * The string begins with a UTF-8 BOM so that Excel auto-detects the encoding. + */ +export function exportCSV(results: ValidationResult[]): string { + const rows: string[] = [BOM + HEADERS.join(',')] + + for (const r of results) { + const row = [ + csvCell(r.id), + csvCell(r.text), + csvCell(r.font), + csvCell(r.fontSize), + csvCell(r.containerWidth), + csvCell(round4(r.pretextWidth)), + csvCell(round4(r.domWidth)), + csvCell(round4(r.delta)), + csvCell(round4(r.deltaPercent)), + csvCell(r.severity), + csvCell(r.language), + csvCell(r.rootCause), + csvCell(round4(r.confidence)), + csvCell(r.timestamp), + ] + rows.push(row.join(',')) + } + + return rows.join('\r\n') +} diff --git a/src/measurement-validator/html-report-generator.ts b/src/measurement-validator/html-report-generator.ts new file mode 100644 index 00000000..c9f49db8 --- /dev/null +++ b/src/measurement-validator/html-report-generator.ts @@ -0,0 +1,370 @@ +// Self-contained HTML report generator for validation results. +// +// Produces a single HTML file with: +// - Embedded CSS (no external dependencies) +// - Summary statistics cards +// - Filterable results table (client-side JS, no framework) +// - Print-friendly layout +// - Loads in < 2 seconds even for 1000+ rows + +import type { LanguageCategory, ValidationResult } from './types.ts' +import { buildSummary } from './report-formatter.ts' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function escapeHTML(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function fmt2(n: number): string { + return parseFloat(n.toFixed(2)).toString() +} + +const SEVERITY_COLOR: Record = { + exact: '#16a34a', + close: '#22c55e', + warning: '#d97706', + error: '#dc2626', + critical: '#991b1b', +} + +const SEVERITY_BG: Record = { + exact: '#f0fdf4', + close: '#f0fdf4', + warning: '#fffbeb', + error: '#fef2f2', + critical: '#fef2f2', +} + +const LANGUAGE_LABELS: Partial> = { + english: 'English', + arabic: 'Arabic', + hebrew: 'Hebrew', + urdu: 'Urdu', + chinese: 'Chinese', + japanese: 'Japanese', + korean: 'Korean', + thai: 'Thai', + myanmar: 'Myanmar', + khmer: 'Khmer', + mixed: 'Mixed', + unknown: 'Unknown', +} + +// --------------------------------------------------------------------------- +// CSS +// --------------------------------------------------------------------------- + +const CSS = ` +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + color: #1f2937; + background: #f9fafb; + line-height: 1.5; +} +.container { max-width: 1280px; margin: 0 auto; padding: 24px 16px; } +h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; } +.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 24px; } + +/* Summary cards */ +.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; } +.card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 16px 20px; + min-width: 130px; + flex: 1; +} +.card .label { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; } +.card .value { font-size: 28px; font-weight: 700; margin-top: 2px; } +.card.pass .value { color: #16a34a; } +.card.warn .value { color: #d97706; } +.card.fail .value { color: #dc2626; } + +/* Filters */ +.filters { + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 16px; +} +.filters label { font-size: 12px; color: #6b7280; margin-right: 4px; } +.filters select, .filters input[type="text"] { + font-size: 13px; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 4px 8px; + background: #fff; + color: #1f2937; +} +.filters input[type="text"] { min-width: 160px; } +.btn-reset { + font-size: 13px; + padding: 4px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + cursor: pointer; + color: #374151; +} +.btn-reset:hover { background: #f3f4f6; } + +/* Table */ +.table-wrap { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; +} +table { width: 100%; border-collapse: collapse; } +thead { background: #f3f4f6; } +th { + padding: 10px 14px; + text-align: left; + font-size: 12px; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +th:hover { background: #e5e7eb; } +th.num { text-align: right; } +td { padding: 9px 14px; border-top: 1px solid #f3f4f6; font-size: 13px; vertical-align: middle; } +td.num { text-align: right; font-variant-numeric: tabular-nums; } +td.text-col { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +tr:hover td { background: #f9fafb; } +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} +.count-row { color: #6b7280; font-size: 12px; padding: 8px 14px; border-top: 1px solid #e5e7eb; } +.hidden { display: none; } + +@media print { + body { background: #fff; font-size: 11px; } + .filters { display: none; } + .container { max-width: 100%; padding: 0; } +} +` + +// --------------------------------------------------------------------------- +// Client-side JS (inlined) +// --------------------------------------------------------------------------- + +const JS = ` +(function() { + const tbody = document.getElementById('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const countEl = document.getElementById('row-count'); + const selSeverity = document.getElementById('f-severity'); + const selLanguage = document.getElementById('f-language'); + const txtFont = document.getElementById('f-font'); + const btnReset = document.getElementById('btn-reset'); + + let sortCol = -1, sortAsc = true; + + function applyFilters() { + const sv = selSeverity.value; + const lv = selLanguage.value; + const fv = txtFont.value.toLowerCase(); + let visible = 0; + rows.forEach(function(tr) { + const ds = tr.dataset; + const show = + (sv === '' || ds.severity === sv) && + (lv === '' || ds.language === lv) && + (fv === '' || (ds.font || '').toLowerCase().includes(fv)); + tr.classList.toggle('hidden', !show); + if (show) visible++; + }); + countEl.textContent = visible + ' of ' + rows.length + ' results'; + } + + function sortBy(colIdx) { + if (sortCol === colIdx) { sortAsc = !sortAsc; } else { sortCol = colIdx; sortAsc = true; } + rows.sort(function(a, b) { + const av = a.cells[colIdx] ? a.cells[colIdx].dataset.val || a.cells[colIdx].textContent : ''; + const bv = b.cells[colIdx] ? b.cells[colIdx].dataset.val || b.cells[colIdx].textContent : ''; + const an = parseFloat(av), bn = parseFloat(bv); + const cmp = isNaN(an) || isNaN(bn) ? av.localeCompare(bv) : an - bn; + return sortAsc ? cmp : -cmp; + }); + rows.forEach(function(tr) { tbody.appendChild(tr); }); + } + + selSeverity.addEventListener('change', applyFilters); + selLanguage.addEventListener('change', applyFilters); + txtFont.addEventListener('input', applyFilters); + btnReset.addEventListener('click', function() { + selSeverity.value = ''; + selLanguage.value = ''; + txtFont.value = ''; + applyFilters(); + }); + + document.querySelectorAll('th[data-col]').forEach(function(th) { + th.addEventListener('click', function() { + sortBy(parseInt(th.dataset.col, 10)); + }); + }); + + applyFilters(); +})(); +` + +// --------------------------------------------------------------------------- +// Main generator +// --------------------------------------------------------------------------- + +/** + * Generate a self-contained HTML report from validation results. + * + * The file has no external dependencies — all CSS and JS are inlined — so + * it can be opened directly in any browser or attached to a PR/issue. + */ +export function generateHTMLReport(results: ValidationResult[]): string { + const s = buildSummary(results) + const generatedAt = new Date().toISOString() + + // Collect unique languages for the filter dropdown + const languages = [...new Set(results.map((r) => r.language))].sort() + + // ---- Summary cards ------------------------------------------------------- + const cards = [ + { label: 'Total', value: s.total, cls: '' }, + { + label: 'Pass rate', + value: `${s.passRate.toFixed(1)}%`, + cls: s.passRate >= 99 ? 'pass' : s.passRate >= 90 ? 'warn' : 'fail', + }, + { label: 'Exact', value: s.exact, cls: 'pass' }, + { label: 'Close', value: s.close, cls: 'pass' }, + { label: 'Warning', value: s.warning, cls: 'warn' }, + { label: 'Error', value: s.error, cls: 'fail' }, + { label: 'Critical', value: s.critical, cls: 'fail' }, + ] + + const cardsHTML = cards + .map( + (c) => + `
` + + `
${escapeHTML(c.label)}
` + + `
${escapeHTML(String(c.value))}
` + + `
`, + ) + .join('\n ') + + // ---- Language filter options --------------------------------------------- + const langOptions = languages + .map((l) => { + const label = LANGUAGE_LABELS[l] ?? l + return `` + }) + .join('\n ') + + // ---- Table rows ---------------------------------------------------------- + const tableRows = results + .map((r) => { + const color = SEVERITY_COLOR[r.severity] ?? '#374151' + const bg = SEVERITY_BG[r.severity] ?? '#fff' + const langLabel = LANGUAGE_LABELS[r.language] ?? r.language + return ( + `` + + `${escapeHTML(r.text.slice(0, 60))}` + + `${escapeHTML(r.font)}` + + `${fmt2(r.pretextWidth)}` + + `${fmt2(r.domWidth)}` + + `${fmt2(r.delta)}` + + `${fmt2(r.deltaPercent)}%` + + `${escapeHTML(r.severity)}` + + `${escapeHTML(langLabel)}` + + `${escapeHTML(r.rootCause === 'none' ? '—' : r.rootCause)}` + + `` + ) + }) + .join('\n ') + + // ---- Assemble HTML ------------------------------------------------------- + return ` + + + + +Measurement Validator Report + + + +
+

📐 Measurement Validator Report

+

Generated: ${escapeHTML(generatedAt)} · ${s.total} samples · @chenglou/pretext

+ +
+ ${cardsHTML} +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + ${tableRows} + +
TextFontPretext (px)DOM (px)Δ (px)Δ %SeverityLanguageRoot Cause
+
+
+ + +` +} diff --git a/src/measurement-validator/index.ts b/src/measurement-validator/index.ts new file mode 100644 index 00000000..f256c085 --- /dev/null +++ b/src/measurement-validator/index.ts @@ -0,0 +1,37 @@ +// Public exports for the measurement-validator module. +// +// Phase 1 & 2: Core validation infrastructure. +// Phase 3: Report generation and CLI support (html-report-generator, +// csv-exporter, markdown-exporter, json-exporter, report-formatter). + +export type { + LanguageCategory, + LanguageSummary, + MeasurementPair, + MeasurementSample, + RootCause, + Severity, + ValidationReport, + ValidationResult, + ValidationSummary, +} from './types.ts' + +export { + classifyLanguage, + classifyRootCause, + isRTL, +} from './classifier.ts' + +export { + compareWidths, + deltaSeverity, + formatRootCause, + formatSeverity, + isPassing, +} from './comparator.ts' + +export { generateHTMLReport } from './html-report-generator.ts' +export { exportCSV } from './csv-exporter.ts' +export { exportMarkdown } from './markdown-exporter.ts' +export { exportJSON } from './json-exporter.ts' +export { ReportFormatter } from './report-formatter.ts' diff --git a/src/measurement-validator/json-exporter.ts b/src/measurement-validator/json-exporter.ts new file mode 100644 index 00000000..d5477edb --- /dev/null +++ b/src/measurement-validator/json-exporter.ts @@ -0,0 +1,29 @@ +// JSON exporter for ValidationReport. +// +// Produces a machine-readable JSON file with full metadata, summary +// statistics, and all individual validation results. Suitable for +// downstream automation, dashboards, and data analysis pipelines. + +import type { ValidationReport, ValidationResult } from './types.ts' +import { buildSummary } from './report-formatter.ts' + +/** + * Serialize a set of validation results to a complete JSON report document. + * + * @param results - Validation results to include. + * @param indented - Whether to pretty-print; defaults to `true`. + * @returns - JSON string ready to write to a file. + */ +export function exportJSON(results: ValidationResult[], indented = true): string { + const report: ValidationReport = { + metadata: { + version: '1.0.0', + generatedAt: new Date().toISOString(), + totalSamples: results.length, + tool: '@chenglou/pretext measurement-validator', + }, + summary: buildSummary(results), + results, + } + return JSON.stringify(report, null, indented ? 2 : undefined) +} diff --git a/src/measurement-validator/markdown-exporter.ts b/src/measurement-validator/markdown-exporter.ts new file mode 100644 index 00000000..53196985 --- /dev/null +++ b/src/measurement-validator/markdown-exporter.ts @@ -0,0 +1,139 @@ +// GitHub-flavored Markdown exporter for validation results. +// +// Produces a structured Markdown document with: +// - Top-level summary statistics with emoji indicators +// - Per-language sections, each with a results table +// - Copy-paste ready for GitHub issues, PR comments, and documentation + +import type { LanguageCategory, ValidationResult } from './types.ts' +import { buildSummary } from './report-formatter.ts' + +const SEVERITY_EMOJI: Record = { + exact: '✅', + close: '✅', + warning: '⚠️', + error: '❌', + critical: '🔴', +} + +const LANGUAGE_LABELS: Record = { + english: 'English', + arabic: 'Arabic', + hebrew: 'Hebrew', + urdu: 'Urdu', + chinese: 'Chinese', + japanese: 'Japanese', + korean: 'Korean', + thai: 'Thai', + myanmar: 'Myanmar', + khmer: 'Khmer', + mixed: 'Mixed Script', + unknown: 'Unknown', +} + +/** Escape special Markdown characters inside table cells. */ +function mdCell(value: string): string { + return value + .replace(/\\/g, '\\\\') // escape backslashes before pipes to prevent double-escaping of pipe characters + .replace(/\|/g, '\\|') // then escape pipes + .replace(/\r\n|\r|\n/g, ' ') +} + +/** Format a number with up to 2 decimal places, stripping trailing zeros. */ +function fmt2(n: number): string { + return parseFloat(n.toFixed(2)).toString() +} + +/** + * Generate GitHub-flavored Markdown from validation results. + * + * Results are grouped into per-language sections. Within each section the + * table rows are sorted by absolute delta descending so the worst cases + * appear first. + */ +export function exportMarkdown(results: ValidationResult[]): string { + const s = buildSummary(results) + const generatedAt = new Date().toISOString() + const lines: string[] = [] + + // ---- Header --------------------------------------------------------------- + lines.push('# Measurement Validator Report', '') + lines.push(`_Generated: ${generatedAt}_`, '') + + // ---- Summary section ------------------------------------------------------ + lines.push('## Summary', '') + lines.push( + `| | Count | Rate |`, + `|---|---:|---:|`, + `| ✅ Exact | ${s.exact} | ${s.exact === 0 ? '0.0' : fmt2((s.exact / s.total) * 100)}% |`, + `| ✅ Close | ${s.close} | ${s.close === 0 ? '0.0' : fmt2((s.close / s.total) * 100)}% |`, + `| ⚠️ Warning | ${s.warning} | ${s.warning === 0 ? '0.0' : fmt2((s.warning / s.total) * 100)}% |`, + `| ❌ Error | ${s.error} | ${s.error === 0 ? '0.0' : fmt2((s.error / s.total) * 100)}% |`, + `| 🔴 Critical | ${s.critical} | ${s.critical === 0 ? '0.0' : fmt2((s.critical / s.total) * 100)}% |`, + `| **Total** | **${s.total}** | **Pass rate: ${fmt2(s.passRate)}%** |`, + '', + ) + + // ---- Per-language sections ------------------------------------------------ + // Group by language + const groups = new Map() + for (const r of results) { + let group = groups.get(r.language) + if (group === undefined) { + group = [] + groups.set(r.language, group) + } + group.push(r) + } + + // Sort languages by total desc + const sortedLanguages = [...groups.keys()].sort((a, b) => { + const ga = groups.get(a) ?? [] + const gb = groups.get(b) ?? [] + return gb.length - ga.length + }) + + lines.push('## Results by Language', '') + + for (const lang of sortedLanguages) { + const group = groups.get(lang) ?? [] + const label = LANGUAGE_LABELS[lang] ?? lang + const langSummary = s.byLanguage[lang] + + lines.push(`### ${label} (${group.length} samples)`, '') + + if (langSummary !== undefined) { + lines.push( + `Pass rate: **${fmt2(langSummary.passRate)}%** — ` + + `avg delta: **${fmt2(langSummary.avgDelta)}px**`, + '', + ) + } + + lines.push( + '| # | Text | Font | Pretext (px) | DOM (px) | Δ (px) | Status |', + '|---|------|------|---:|---:|---:|:---:|', + ) + + // Worst first + const sorted = group.slice().sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + for (let i = 0; i < sorted.length; i++) { + const r = sorted[i] + if (r === undefined) continue + const emoji = SEVERITY_EMOJI[r.severity] ?? '?' + lines.push( + `| ${i + 1} ` + + `| ${mdCell(r.text.slice(0, 40))} ` + + `| ${mdCell(r.font)} ` + + `| ${fmt2(r.pretextWidth)} ` + + `| ${fmt2(r.domWidth)} ` + + `| ${fmt2(r.delta)} ` + + `| ${emoji} |`, + ) + } + + lines.push('') + } + + return lines.join('\n') +} diff --git a/src/measurement-validator/report-formatter.ts b/src/measurement-validator/report-formatter.ts new file mode 100644 index 00000000..031095cb --- /dev/null +++ b/src/measurement-validator/report-formatter.ts @@ -0,0 +1,202 @@ +// Unified ReportFormatter with chainable filtering/sorting API. +// +// Usage: +// const fmt = new ReportFormatter(results) +// .filterByLanguage('arabic') +// .filterBySeverity('error') +// .sortByDelta(false) +// +// const html = fmt.toHTML() +// const csv = fmt.toCSV() +// const md = fmt.toMarkdown() + +import type { + LanguageCategory, + LanguageSummary, + Severity, + ValidationResult, + ValidationSummary, +} from './types.ts' +import { isPassing } from './comparator.ts' +import { generateHTMLReport } from './html-report-generator.ts' +import { exportCSV } from './csv-exporter.ts' +import { exportMarkdown } from './markdown-exporter.ts' +import { exportJSON } from './json-exporter.ts' + +// --------------------------------------------------------------------------- +// Summary builder (shared by formatter and json-exporter) +// --------------------------------------------------------------------------- + +/** Compute aggregate summary statistics from an array of results. */ +export function buildSummary(results: ValidationResult[]): ValidationSummary { + const counts = { exact: 0, close: 0, warning: 0, error: 0, critical: 0 } + const byLanguage: Partial> = {} + + for (const r of results) { + counts[r.severity]++ + + const lang = r.language + let entry = byLanguage[lang] + if (entry === undefined) { + entry = { language: lang, total: 0, passing: 0, passRate: 0, avgDelta: 0 } + byLanguage[lang] = entry + } + entry.total++ + if (isPassing(r.severity)) entry.passing++ + entry.avgDelta += Math.abs(r.delta) + } + + // Finalize per-language averages and pass rates + for (const lang of Object.keys(byLanguage) as LanguageCategory[]) { + const entry = byLanguage[lang] + if (entry === undefined) continue + entry.passRate = entry.total === 0 ? 0 : (entry.passing / entry.total) * 100 + entry.avgDelta = entry.total === 0 ? 0 : entry.avgDelta / entry.total + } + + const total = results.length + const passing = counts.exact + counts.close + return { + total, + exact: counts.exact, + close: counts.close, + warning: counts.warning, + error: counts.error, + critical: counts.critical, + passRate: total === 0 ? 0 : (passing / total) * 100, + byLanguage, + } +} + +// --------------------------------------------------------------------------- +// ReportFormatter +// --------------------------------------------------------------------------- + +export class ReportFormatter { + private results: ValidationResult[] + + constructor(results: ValidationResult[]) { + // Defensive copy so chaining doesn't mutate the source + this.results = results.slice() + } + + // ---- Filters ------------------------------------------------------------- + + /** Keep only results for the given language category. */ + filterByLanguage(lang: LanguageCategory): ReportFormatter { + return new ReportFormatter(this.results.filter((r) => r.language === lang)) + } + + /** Keep only results at or above the given severity level. */ + filterBySeverity(level: Severity): ReportFormatter { + const order: Severity[] = ['exact', 'close', 'warning', 'error', 'critical'] + const minIdx = order.indexOf(level) + return new ReportFormatter( + this.results.filter((r) => order.indexOf(r.severity) >= minIdx), + ) + } + + /** + * Keep only results whose font string contains the given pattern + * (case-insensitive substring match). + */ + filterByFont(pattern: string): ReportFormatter { + const lower = pattern.toLowerCase() + return new ReportFormatter(this.results.filter((r) => r.font.toLowerCase().includes(lower))) + } + + // ---- Sorting ------------------------------------------------------------- + + /** + * Sort by absolute delta value. + * + * @param ascending - `true` for smallest-first; defaults to `false` (largest-first). + */ + sortByDelta(ascending = false): ReportFormatter { + const sorted = this.results.slice().sort((a, b) => { + const diff = Math.abs(a.delta) - Math.abs(b.delta) + return ascending ? diff : -diff + }) + return new ReportFormatter(sorted) + } + + // ---- Statistics ---------------------------------------------------------- + + /** Return aggregate summary statistics for the current filtered set. */ + summary(): ValidationSummary { + return buildSummary(this.results) + } + + // ---- Output formats ------------------------------------------------------ + + /** Generate a self-contained HTML report. */ + toHTML(): string { + return generateHTMLReport(this.results) + } + + /** Generate an Excel-compatible UTF-8 BOM CSV string. */ + toCSV(): string { + return exportCSV(this.results) + } + + /** Generate GitHub-flavored Markdown. */ + toMarkdown(): string { + return exportMarkdown(this.results) + } + + /** Generate a complete JSON report document. */ + toJSON(): string { + return exportJSON(this.results) + } + + /** + * Generate a plain-text console summary. + * + * @param useColor - Whether to include ANSI escape codes; defaults to `true`. + */ + toConsole(useColor = true): string { + const s = buildSummary(this.results) + const reset = useColor ? '\x1b[0m' : '' + const green = useColor ? '\x1b[32m' : '' + const yellow = useColor ? '\x1b[33m' : '' + const red = useColor ? '\x1b[31m' : '' + + const lines: string[] = [ + '', + `${green}Measurement Validator — Summary${reset}`, + `${'─'.repeat(40)}`, + ` Total samples : ${s.total}`, + ` Pass rate : ${green}${s.passRate.toFixed(1)}%${reset}`, + ` Exact : ${green}${s.exact}${reset}`, + ` Close : ${green}${s.close}${reset}`, + ` Warning : ${yellow}${s.warning}${reset}`, + ` Error : ${red}${s.error}${reset}`, + ` Critical : ${red}${s.critical}${reset}`, + ] + + if (Object.keys(s.byLanguage).length > 0) { + lines.push(``, ` By language:`) + for (const [lang, entry] of Object.entries(s.byLanguage)) { + if (entry === undefined) continue + const color = entry.passRate >= 99 ? green : entry.passRate >= 90 ? yellow : red + lines.push( + ` ${lang.padEnd(12)} ${color}${entry.passRate.toFixed(1)}%${reset}` + + ` (${entry.passing}/${entry.total}, avg Δ ${entry.avgDelta.toFixed(2)}px)`, + ) + } + } + + lines.push('') + return lines.join('\n') + } + + /** Number of results in the current filtered set. */ + get count(): number { + return this.results.length + } + + /** Read-only view of the current filtered/sorted results. */ + get data(): readonly ValidationResult[] { + return this.results + } +} diff --git a/src/measurement-validator/types.ts b/src/measurement-validator/types.ts new file mode 100644 index 00000000..c48d18fb --- /dev/null +++ b/src/measurement-validator/types.ts @@ -0,0 +1,105 @@ +// Shared types for the measurement-validator module. +// +// The validator compares pretext canvas-based width predictions against +// actual DOM measurements, classifies divergences by severity and root +// cause, and produces structured reports for analysis. + +// --------------------------------------------------------------------------- +// Core enumerations +// --------------------------------------------------------------------------- + +export type Severity = 'exact' | 'close' | 'warning' | 'error' | 'critical' + +export type LanguageCategory = + | 'english' + | 'arabic' + | 'hebrew' + | 'urdu' + | 'chinese' + | 'japanese' + | 'korean' + | 'thai' + | 'myanmar' + | 'khmer' + | 'mixed' + | 'unknown' + +export type RootCause = + | 'none' + | 'font_fallback' + | 'bidi_shaping' + | 'emoji_correction' + | 'browser_quirk' + | 'unknown' + +// --------------------------------------------------------------------------- +// Input / output shapes +// --------------------------------------------------------------------------- + +/** A pre-computed measurement pair to validate. */ +export type MeasurementSample = { + id: string + text: string + font: string + fontSize: number + containerWidth: number + language?: LanguageCategory +} + +/** Raw width pair before classification. */ +export type MeasurementPair = { + sample: MeasurementSample + pretextWidth: number + domWidth: number +} + +/** Fully classified validation result. */ +export type ValidationResult = { + id: string + text: string + font: string + fontSize: number + containerWidth: number + pretextWidth: number + domWidth: number + delta: number + deltaPercent: number + severity: Severity + language: LanguageCategory + rootCause: RootCause + confidence: number + timestamp: string +} + +/** Per-language rollup used in summaries. */ +export type LanguageSummary = { + language: LanguageCategory + total: number + passing: number + passRate: number + avgDelta: number +} + +/** Top-level summary statistics. */ +export type ValidationSummary = { + total: number + exact: number + close: number + warning: number + error: number + critical: number + passRate: number + byLanguage: Partial> +} + +/** Complete validation report document. */ +export type ValidationReport = { + metadata: { + version: string + generatedAt: string + totalSamples: number + tool: string + } + summary: ValidationSummary + results: ValidationResult[] +} diff --git a/test/fixtures/cjk-samples.json b/test/fixtures/cjk-samples.json new file mode 100644 index 00000000..1aeec43f --- /dev/null +++ b/test/fixtures/cjk-samples.json @@ -0,0 +1,52 @@ +[ + { + "id": "zh-01", + "text": "你好世界", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 64.0, + "domWidth": 70.0, + "language": "chinese" + }, + { + "id": "ja-01", + "text": "こんにちは", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 80.0, + "domWidth": 80.5, + "language": "japanese" + }, + { + "id": "ko-01", + "text": "안녕하세요", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 72.0, + "domWidth": 72.0, + "language": "korean" + }, + { + "id": "zh-02", + "text": "中文文本排版测试", + "font": "Arial", + "fontSize": 18, + "containerWidth": 500, + "pretextWidth": 144.0, + "domWidth": 153.0, + "language": "chinese" + }, + { + "id": "ja-02", + "text": "日本語テキスト", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 112.0, + "domWidth": 112.5, + "language": "japanese" + } +] diff --git a/test/fixtures/english-samples.json b/test/fixtures/english-samples.json new file mode 100644 index 00000000..11ef1f26 --- /dev/null +++ b/test/fixtures/english-samples.json @@ -0,0 +1,52 @@ +[ + { + "id": "en-simple-01", + "text": "Hello world", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 87.5, + "domWidth": 88.0, + "language": "english" + }, + { + "id": "en-simple-02", + "text": "The quick brown fox", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 152.0, + "domWidth": 152.5, + "language": "english" + }, + { + "id": "en-simple-03", + "text": "Typography matters", + "font": "Helvetica", + "fontSize": 14, + "containerWidth": 300, + "pretextWidth": 142.0, + "domWidth": 143.0, + "language": "english" + }, + { + "id": "en-warn-01", + "text": "Canvas measurement divergence", + "font": "Arial", + "fontSize": 18, + "containerWidth": 500, + "pretextWidth": 248.0, + "domWidth": 251.5, + "language": "english" + }, + { + "id": "en-error-01", + "text": "Significant measurement error example", + "font": "system-ui", + "fontSize": 16, + "containerWidth": 500, + "pretextWidth": 290.0, + "domWidth": 305.0, + "language": "english" + } +] diff --git a/test/fixtures/rtl-samples.json b/test/fixtures/rtl-samples.json new file mode 100644 index 00000000..e5143aac --- /dev/null +++ b/test/fixtures/rtl-samples.json @@ -0,0 +1,52 @@ +[ + { + "id": "ar-01", + "text": "مرحبا بالعالم", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 95.2, + "domWidth": 110.5, + "language": "arabic" + }, + { + "id": "ar-02", + "text": "النص العربي", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 88.0, + "domWidth": 102.0, + "language": "arabic" + }, + { + "id": "he-01", + "text": "שלום עולם", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 78.0, + "domWidth": 80.0, + "language": "hebrew" + }, + { + "id": "ur-01", + "text": "دنیا میں خوش آمدید", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 110.0, + "domWidth": 128.0, + "language": "urdu" + }, + { + "id": "ar-crit-01", + "text": "قصيدة في مديح العلم والمعرفة", + "font": "Arial", + "fontSize": 16, + "containerWidth": 400, + "pretextWidth": 180.0, + "domWidth": 220.0, + "language": "arabic" + } +] diff --git a/test/report-generators.test.ts b/test/report-generators.test.ts new file mode 100644 index 00000000..5a5986c4 --- /dev/null +++ b/test/report-generators.test.ts @@ -0,0 +1,576 @@ +import { describe, expect, test } from 'bun:test' +import { ReportFormatter } from '../src/measurement-validator/report-formatter.ts' +import { exportCSV } from '../src/measurement-validator/csv-exporter.ts' +import { exportMarkdown } from '../src/measurement-validator/markdown-exporter.ts' +import { exportJSON } from '../src/measurement-validator/json-exporter.ts' +import { generateHTMLReport } from '../src/measurement-validator/html-report-generator.ts' +import { compareWidths } from '../src/measurement-validator/comparator.ts' +import { classifyLanguage, classifyRootCause } from '../src/measurement-validator/classifier.ts' +import type { ValidationResult, MeasurementPair } from '../src/measurement-validator/types.ts' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeResult( + overrides: Partial = {}, +): ValidationResult { + return { + id: 'test-01', + text: 'Hello world', + font: 'Arial', + fontSize: 16, + containerWidth: 400, + pretextWidth: 87.5, + domWidth: 88.0, + delta: -0.5, + deltaPercent: 0.57, + severity: 'exact', + language: 'english', + rootCause: 'none', + confidence: 1.0, + timestamp: '2024-01-01T00:00:00.000Z', + ...overrides, + } +} + +function makeArabicResult(overrides: Partial = {}): ValidationResult { + return makeResult({ + id: 'ar-01', + text: 'مرحبا بالعالم', + language: 'arabic', + pretextWidth: 95.2, + domWidth: 110.5, + delta: -15.3, + deltaPercent: 13.85, + severity: 'critical', + rootCause: 'bidi_shaping', + confidence: 0.85, + ...overrides, + }) +} + +const SAMPLE_RESULTS: ValidationResult[] = [ + makeResult({ id: 'en-01', severity: 'exact', delta: -0.3, language: 'english' }), + makeResult({ id: 'en-02', severity: 'close', delta: 1.5, language: 'english' }), + makeResult({ id: 'en-03', severity: 'warning', delta: 3.5, language: 'english', rootCause: 'browser_quirk' }), + makeArabicResult({ id: 'ar-01', severity: 'error', delta: -8.0, language: 'arabic' }), + makeArabicResult({ id: 'ar-02', severity: 'critical', delta: -20.0, language: 'arabic' }), + makeResult({ id: 'zh-01', text: '你好', language: 'chinese', severity: 'close', delta: 1.0, rootCause: 'none' }), +] + +// --------------------------------------------------------------------------- +// Classifier tests +// --------------------------------------------------------------------------- + +describe('classifyLanguage', () => { + test('detects English', () => { + expect(classifyLanguage('Hello world')).toBe('english') + }) + + test('detects Arabic', () => { + expect(classifyLanguage('مرحبا بالعالم')).toBe('arabic') + }) + + test('detects Hebrew', () => { + expect(classifyLanguage('שלום עולם')).toBe('hebrew') + }) + + test('detects Chinese', () => { + expect(classifyLanguage('你好世界')).toBe('chinese') + }) + + test('detects Japanese via kana', () => { + expect(classifyLanguage('こんにちは')).toBe('japanese') + }) + + test('detects Korean', () => { + expect(classifyLanguage('안녕하세요')).toBe('korean') + }) + + test('detects Thai', () => { + expect(classifyLanguage('สวัสดี')).toBe('thai') + }) + + test('returns unknown for empty string', () => { + expect(classifyLanguage('')).toBe('unknown') + }) + + test('detects mixed script', () => { + expect(classifyLanguage('Hello مرحبا')).toBe('mixed') + }) +}) + +describe('classifyRootCause', () => { + test('returns none for exact severity', () => { + const { rootCause } = classifyRootCause('Hello', 'english', 'exact') + expect(rootCause).toBe('none') + }) + + test('returns none for close severity', () => { + const { rootCause } = classifyRootCause('Hello', 'english', 'close') + expect(rootCause).toBe('none') + }) + + test('returns bidi_shaping for Arabic error', () => { + const { rootCause, confidence } = classifyRootCause('مرحبا', 'arabic', 'error') + expect(rootCause).toBe('bidi_shaping') + expect(confidence).toBeGreaterThan(0.8) + }) + + test('returns bidi_shaping for Hebrew', () => { + const { rootCause } = classifyRootCause('שלום', 'hebrew', 'warning') + expect(rootCause).toBe('bidi_shaping') + }) + + test('returns emoji_correction when emoji present', () => { + const { rootCause } = classifyRootCause('Hello 😀', 'english', 'warning') + expect(rootCause).toBe('emoji_correction') + }) + + test('returns browser_quirk for English warning', () => { + const { rootCause } = classifyRootCause('Hello', 'english', 'warning') + expect(rootCause).toBe('browser_quirk') + }) +}) + +// --------------------------------------------------------------------------- +// Comparator tests +// --------------------------------------------------------------------------- + +describe('compareWidths', () => { + function makePair(pretextWidth: number, domWidth: number): MeasurementPair { + return { + sample: { + id: 'test', + text: 'Hello', + font: 'Arial', + fontSize: 16, + containerWidth: 400, + }, + pretextWidth, + domWidth, + } + } + + test('produces exact severity for sub-0.5px delta', () => { + const r = compareWidths(makePair(88.0, 88.3)) + expect(r.severity).toBe('exact') + }) + + test('produces close severity for 1-2px delta', () => { + const r = compareWidths(makePair(88.0, 89.5)) + expect(r.severity).toBe('close') + }) + + test('produces warning severity for 3-5px delta', () => { + const r = compareWidths(makePair(88.0, 91.5)) + expect(r.severity).toBe('warning') + }) + + test('produces error severity for 6-15px delta', () => { + const r = compareWidths(makePair(88.0, 100.0)) + expect(r.severity).toBe('error') + }) + + test('produces critical severity for >15px delta', () => { + const r = compareWidths(makePair(88.0, 120.0)) + expect(r.severity).toBe('critical') + }) + + test('populates all required fields', () => { + const r = compareWidths(makePair(100, 105)) + expect(r.id).toBe('test') + expect(r.delta).toBeCloseTo(-5) + expect(r.deltaPercent).toBeCloseTo(4.76, 1) + expect(r.language).toBe('english') + expect(r.timestamp).toBeTruthy() + }) + + test('uses provided timestamp', () => { + const ts = '2024-06-15T12:00:00.000Z' + const r = compareWidths(makePair(100, 100), ts) + expect(r.timestamp).toBe(ts) + }) +}) + +// --------------------------------------------------------------------------- +// CSV exporter tests +// --------------------------------------------------------------------------- + +describe('exportCSV', () => { + test('starts with UTF-8 BOM', () => { + const csv = exportCSV([makeResult()]) + expect(csv.charCodeAt(0)).toBe(0xfeff) + }) + + test('first non-BOM row is header', () => { + const csv = exportCSV([makeResult()]) + const lines = csv.split('\r\n') + const header = lines[0]?.replace('\uFEFF', '') ?? '' + expect(header).toContain('ID') + expect(header).toContain('Text') + expect(header).toContain('Severity') + expect(header).toContain('Language') + expect(header).toContain('RootCause') + }) + + test('has one data row per result', () => { + const csv = exportCSV(SAMPLE_RESULTS) + const lines = csv.split('\r\n').filter((l) => l.length > 0) + // header + N data rows + expect(lines.length).toBe(SAMPLE_RESULTS.length + 1) + }) + + test('escapes double quotes in text', () => { + const result = makeResult({ text: 'Say "hello"' }) + const csv = exportCSV([result]) + expect(csv).toContain('""hello""') + }) + + test('replaces newlines in text', () => { + const result = makeResult({ text: 'line1\nline2' }) + const csv = exportCSV([result]) + expect(csv).not.toContain('\nline2') + expect(csv).toContain('line1 line2') + }) + + test('uses CRLF line endings', () => { + const csv = exportCSV([makeResult()]) + expect(csv).toContain('\r\n') + }) + + test('exports empty array as header-only', () => { + const csv = exportCSV([]) + const lines = csv.split('\r\n').filter((l) => l.length > 0) + expect(lines.length).toBe(1) // header only + }) +}) + +// --------------------------------------------------------------------------- +// Markdown exporter tests +// --------------------------------------------------------------------------- + +describe('exportMarkdown', () => { + test('starts with h1 header', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toMatch(/^# Measurement Validator Report/) + }) + + test('contains summary section', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toContain('## Summary') + }) + + test('contains results by language section', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toContain('## Results by Language') + }) + + test('contains per-language sub-headings', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toContain('### English') + expect(md).toContain('### Arabic') + expect(md).toContain('### Chinese') + }) + + test('contains severity emoji indicators', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toContain('✅') + expect(md).toContain('❌') + }) + + test('uses markdown table syntax', () => { + const md = exportMarkdown(SAMPLE_RESULTS) + expect(md).toContain('|---|') + }) + + test('escapes pipe chars in text cells', () => { + const result = makeResult({ text: 'a|b|c' }) + const md = exportMarkdown([result]) + expect(md).toContain('a\\|b\\|c') + }) + + test('escapes backslash chars before pipes', () => { + const result = makeResult({ text: 'path\\to|file' }) + const md = exportMarkdown([result]) + expect(md).toContain('path\\\\to\\|file') + }) + + test('exports empty array gracefully', () => { + const md = exportMarkdown([]) + expect(md).toContain('# Measurement Validator Report') + expect(md).toContain('## Summary') + }) +}) + +// --------------------------------------------------------------------------- +// HTML report generator tests +// --------------------------------------------------------------------------- + +describe('generateHTMLReport', () => { + test('produces valid HTML skeleton', () => { + const html = generateHTMLReport(SAMPLE_RESULTS) + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + }) + + test('is self-contained (no external links)', () => { + const html = generateHTMLReport(SAMPLE_RESULTS) + // No CDN or external stylesheet/script references + expect(html).not.toContain('https://cdn') + expect(html).not.toContain(' { + const html = generateHTMLReport(SAMPLE_RESULTS) + expect(html).toContain('id="tbody"') + // Each result should have a row + for (const r of SAMPLE_RESULTS) { + expect(html).toContain(r.id) + } + }) + + test('escapes HTML special characters in text', () => { + const result = makeResult({ text: '' }) + const html = generateHTMLReport([result]) + expect(html).not.toContain('