← back to Dw Photo Capture
test-analyze-ocr.js
315 lines
#!/usr/bin/env node
// Unit tests for the PURE OCR/vendor functions in server.js. We slice the REAL function
// source out of server.js (not a copy) and eval it in scope, so these test the shipping
// code. Run: `node test-analyze-ocr.js`. $0 (local, no server, no network).
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, 'server.js'), 'utf8');
// --- slice helpers: pull a named const/function's exact source span out of server.js ---
// const span stops at the first ';' that ends the statement (optionally followed by a
// trailing // comment), so consts whose line has a comment aren't over-grabbed.
function sliceConst(name) {
const m = SRC.match(new RegExp(`\\nconst ${name} = [\\s\\S]*?;[ \\t]*(?://[^\\n]*)?\\n`));
if (!m) throw new Error('const not found: ' + name);
return m[0];
}
function sliceFn(name) {
const start = SRC.indexOf(`function ${name}(`);
if (start < 0) throw new Error('fn not found: ' + name);
let i = SRC.indexOf('{', start), depth = 0; // brace-match to the function's close
for (; i < SRC.length; i++) {
if (SRC[i] === '{') depth++;
else if (SRC[i] === '}') { depth--; if (depth === 0) { i++; break; } }
}
return SRC.slice(start, i);
}
// fingerprintVendor reads VENDOR_PROFILES — load the real profile data.
const VENDOR_PROFILES = (JSON.parse(fs.readFileSync(path.join(__dirname, 'data/vendor_profiles.json'), 'utf8')).profiles) || {};
// Build ONE module from the real source spans (deps first) and RETURN the names, so the
// const/let/function bindings survive (a bare `eval` of `const` would not leak out of scope).
// VENDOR_PROFILES is injected as a parameter; LEXICON = the curated seed for deterministic tests.
const moduleSrc = [
sliceConst('VENDOR_LEX'),
sliceConst('STRONG_PFX'),
sliceConst('CODE_TOKEN'),
sliceConst('COLOR_WORDS'),
sliceConst('COLOR_RE'),
sliceConst('COLOR_LABEL_RE'),
sliceConst('NAME_LABEL_RE'),
sliceConst('CODE_LABEL_RE'),
'const LEXICON = VENDOR_LEX;',
sliceFn('classifyFields'),
sliceFn('parseOcrRows'),
sliceFn('analyzeOcr'),
sliceFn('_lev'),
sliceFn('fuzzyVendor'),
sliceFn('fingerprintVendor'),
sliceConst('escapeRe'),
sliceFn('profileToLex'),
sliceConst('nmfr'),
sliceFn('brandRe'),
'return { parseOcrRows, analyzeOcr, fuzzyVendor, fingerprintVendor, profileToLex, nmfr, brandRe, VENDOR_LEX };',
].join('\n\n');
const { parseOcrRows, analyzeOcr, fuzzyVendor, fingerprintVendor, profileToLex, nmfr, brandRe } =
new Function('VENDOR_PROFILES', moduleSrc)(VENDOR_PROFILES);
// --- tiny assert harness ---
let pass = 0, fail = 0;
const eq = (got, want, msg) => {
const g = JSON.stringify(got), w = JSON.stringify(want);
if (g === w) { pass++; }
else { fail++; console.error(` ✗ ${msg}\n got ${g}\n want ${w}`); }
};
const ok = (cond, msg) => { if (cond) pass++; else { fail++; console.error(` ✗ ${msg}`); } };
// Build an OCR stdout the way bin/ocr emits it: "<height>\t<text>" lines, "BC\t<payload>\t<sym>" for barcodes.
const line = (h, t) => `${h}\t${t}`;
const bc = (payload, sym = 'Code128') => `BC\t${payload}\t${sym}`;
// ── parseOcrRows ──
{
const rows = parseOcrRows([line(120, 'WDW2310'), line(40, 'Mura 54in'), bc('GRS-26820')].join('\n'));
ok(rows.length === 3, 'parseOcrRows: 3 rows');
ok(rows.find(r => r.bc && r.t === 'GRS-26820'), 'parseOcrRows: barcode row tagged bc');
eq(rows[0], { h: 120, t: 'WDW2310' }, 'parseOcrRows: height parsed');
// barcode line WITH a symbology field captures sym; height is the sentinel 10000
eq(parseOcrRows('BC\tGRS-7777\tQR')[0], { h: 10000, t: 'GRS-7777', bc: true, sym: 'QR' }, 'parseOcrRows: barcode symbology captured');
// barcode line WITHOUT a symbology field omits sym
eq(parseOcrRows('BC\tGRS-7777')[0], { h: 10000, t: 'GRS-7777', bc: true }, 'parseOcrRows: barcode without symbology omits sym');
// a non-numeric height field falls back to 0
eq(parseOcrRows('abc\tHELLO')[0], { h: 0, t: 'HELLO' }, 'parseOcrRows: non-numeric height → 0');
}
// ── analyzeOcr: brand on swatch + big code → vendor + topStrong ──
{
const d = analyzeOcr(parseOcrRows([line(46, 'WINFIELD THYBONY'), line(150, 'WDW2310'), line(30, 'Collection Mura 54in')].join('\n')));
eq(d.vendor, 'Winfield Thybony', 'analyzeOcr: vendor detected from brand text');
eq(d.top, 'WDW2310', 'analyzeOcr: largest-font code is top');
ok(d.topStrong === true, 'analyzeOcr: topStrong true (brand + font-dominant)');
ok(!d.candidates.includes('WINFIELD'), 'analyzeOcr: prose words dropped');
}
// ── analyzeOcr: barcode is ground truth → outranks OCR codes, forces topStrong ──
{
const d = analyzeOcr(parseOcrRows([line(150, 'SOMETEXT'), line(80, 'AB1234'), bc('GRS-99999')].join('\n')));
eq(d.barcode, 'GRS-99999', 'analyzeOcr: barcode surfaced');
eq(d.top, 'GRS-99999', 'analyzeOcr: barcode is the top candidate');
ok(d.topStrong === true, 'analyzeOcr: barcode forces topStrong');
ok(d.candidates[0] === 'GRS-99999', 'analyzeOcr: barcode first in candidates');
}
// ── analyzeOcr: front (pattern face) vs back (printed label) side detection ──
{
const label = analyzeOcr(parseOcrRows([line(46, 'WINFIELD THYBONY'), line(150, 'WDW2310'), line(30, 'Collection Mura 54in')].join('\n')));
eq(label.side, 'back', 'side: label with vendor + code → back');
const barc = analyzeOcr(parseOcrRows(bc('GRS-99999')));
eq(barc.side, 'back', 'side: barcode → back');
ok(barc.side_confidence >= 0.95, 'side: barcode → high confidence');
const front = analyzeOcr(parseOcrRows(''));
eq(front.side, 'front', 'side: empty OCR (blank pattern face) → front');
const sparse = analyzeOcr(parseOcrRows(line(20, 'ab')));
eq(sparse.side, 'front', 'side: one tiny token (selvedge) → front');
}
// ── analyzeOcr.fields: classify sku# / model# / name / color from a label read ──
{
const d = analyzeOcr(parseOcrRows([line(46, 'WINFIELD THYBONY'), line(150, 'WDW2310'), line(30, 'Compass'), line(28, 'Color: Oatmeal')].join('\n')));
eq(d.fields.sku, 'WDW2310', 'fields: sku = top code');
eq(d.fields.color, 'Oatmeal', 'fields: color from "Color:" label');
eq(d.fields.name, 'Compass', 'fields: name = pattern prose (not vendor/color/code)');
// bare color word (no label) is still caught; barcode surfaces as a field
const d2 = analyzeOcr(parseOcrRows([line(120, 'GRS-26820'), line(30, 'Sage'), bc('012345678')].join('\n')));
eq(d2.fields.color, 'Sage', 'fields: color from a known color word');
eq(d2.fields.barcode, '012345678', 'fields: barcode surfaced as a field');
// no printed color → color null (no false positive)
const d3 = analyzeOcr(parseOcrRows(line(120, 'WDW9999')));
eq(d3.fields.color, null, 'fields: no color word → color null');
}
// ── analyzeOcr: no brand, bare GRS- code still strong via prefix ──
{
const d = analyzeOcr(parseOcrRows([line(120, 'GRS-26820')].join('\n')));
eq(d.vendor, null, 'analyzeOcr: no brand text → vendor null');
eq(d.top, 'GRS-26820', 'analyzeOcr: GRS- code is top');
ok(d.topStrong === true, 'analyzeOcr: GRS- prefix is self-evidently strong');
}
// ── analyzeOcr: weak read (no brand, no known prefix) → not strong ──
{
const d = analyzeOcr(parseOcrRows([line(60, 'XY7788')].join('\n')));
ok(d.topStrong === false, 'analyzeOcr: unknown code is NOT topStrong (needs a 2nd frame)');
}
// ── analyzeOcr: empty/garbage input ──
{
const d = analyzeOcr(parseOcrRows(''));
eq(d.top, null, 'analyzeOcr: empty → top null');
ok(d.topStrong === false, 'analyzeOcr: empty → topStrong false');
}
// ── fuzzyVendor: VLM misspelling resolves to a real seed vendor ──
{
eq(fuzzyVendor('BRUNSWIG & FILS'), 'Brunschwig & Fils', 'fuzzyVendor: misspelling → canonical');
eq(fuzzyVendor('zzz nonsense brand'), null, 'fuzzyVendor: nonsense → null');
// edge cases: empty / too-short / null inputs never throw and return null
eq(fuzzyVendor(''), null, 'fuzzyVendor: empty → null');
eq(fuzzyVendor('ab'), null, 'fuzzyVendor: <4-char → null');
eq(fuzzyVendor(null), null, 'fuzzyVendor: null → null (no throw)');
// a few real OCR/VLM misspellings within tolerance resolve to canonical seed vendors
eq(fuzzyVendor('SCHUMACKER'), 'Schumacher', 'fuzzyVendor: SCHUMACKER → Schumacher');
eq(fuzzyVendor('Phillip Jefries'), 'Phillip Jeffries', 'fuzzyVendor: Phillip Jefries → Phillip Jeffries');
// ambiguity guard: a partial read matching TWO different vendors → null, but an unambiguous
// exact read of either still resolves (guards against wrong-vendor binding via /api/learn).
// Only assert when the real profile data actually contains both Campbell vendors.
if (VENDOR_PROFILES['Alan Campbell'] && VENDOR_PROFILES['Nina Campbell']) {
eq(fuzzyVendor('CAMPBELL'), null, 'fuzzyVendor: ambiguous "CAMPBELL" (Alan vs Nina) → null');
eq(fuzzyVendor('NINA CAMPBELL'), 'Nina Campbell', 'fuzzyVendor: exact Nina Campbell still resolves');
} else { ok(true, 'fuzzyVendor: (Campbell collision pair not in data — ambiguity case skipped)'); }
}
// ── fingerprintVendor: resolves a validated fingerprint, null for unknown ──
{
const mtg = fingerprintVendor('MINDTHEGAP DESIGNSUPPLY', 'sans caps');
ok(mtg && mtg.vendor === 'Mind the Gap', 'fingerprintVendor: resolves Mind the Gap from its logo read');
eq(fingerprintVendor('totally unknown brand xyz', 'serif'), null, 'fingerprintVendor: unknown → null');
}
// ── adversarial / degraded OCR inputs (cycle 10 brainstorm: harden the ranking contract) ──
{
// a 3-char code is below the min length → not a candidate
eq(analyzeOcr(parseOcrRows(line(100, 'AB1'))).top, null, 'edge: 3-char code excluded');
// a token with no digit (marketing word) is never a candidate
eq(analyzeOcr(parseOcrRows(line(100, 'HELLO'))).top, null, 'edge: no-digit token excluded');
// a dashed alnum code is a valid candidate (but not strong without brand/known prefix)
const dash = analyzeOcr(parseOcrRows(line(100, '5255-16')));
eq(dash.top, '5255-16', 'edge: dashed code is a candidate');
ok(dash.topStrong === false, 'edge: dashed code with no brand/prefix is not strong');
// a barcode payload shorter than 4 chars is ignored (not ground truth)
const shortBc = analyzeOcr(parseOcrRows(bc('AB')));
eq(shortBc.barcode, null, 'edge: <4-char barcode ignored');
eq(shortBc.top, null, 'edge: <4-char barcode is not a candidate');
// two brand names on the swatch → the first in lexicon order wins (deterministic)
eq(analyzeOcr(parseOcrRows([line(40, 'THIBAUT'), line(40, 'SCHUMACHER'), line(120, '9010')].join('\n'))).vendor,
'Thibaut', 'edge: two brands → first lexicon match wins');
// brand text in lowercase still detected (matching is case-insensitive)
eq(analyzeOcr(parseOcrRows([line(40, 'thibaut'), line(120, '9010')].join('\n'))).vendor,
'Thibaut', 'edge: lowercase brand detected');
// the same code seen on two rows is de-duplicated to one candidate
eq(analyzeOcr(parseOcrRows([line(100, 'AB1234'), line(50, 'AB1234')].join('\n'))).candidates,
['AB1234'], 'edge: duplicate code de-duped');
// two barcodes → both are candidates, the first is top, and a barcode forces topStrong
const twoBc = analyzeOcr(parseOcrRows([bc('GRS-111'), bc('GRS-222')].join('\n')));
eq(twoBc.candidates, ['GRS-111', 'GRS-222'], 'edge: both barcodes are candidates');
ok(twoBc.topStrong === true, 'edge: a barcode forces topStrong even with two present');
}
// ── candidate ORDER contract that scanResolve depends on (cycle 17 audit) ──
// scanResolve loops candidates first-hit-wins, so the strongest must come first:
// barcode > brand-prefixed code > larger-font > generic. Lock that order.
{
// brand-prefixed code outranks a generic code at the SAME font height (vendor on swatch)
const bp = analyzeOcr(parseOcrRows([line(40, 'WINFIELD THYBONY'), line(100, 'WDW2310'), line(100, 'XQ5599')].join('\n')));
eq(bp.candidates[0], 'WDW2310', 'order: brand-prefixed code beats generic at equal font height');
// a barcode beats even a much larger-font OCR code
const bcWin = analyzeOcr(parseOcrRows([line(200, 'BIGCODE99'), bc('GRS-7777')].join('\n')));
eq(bcWin.candidates[0], 'GRS-7777', 'order: barcode beats a huge-font OCR code');
// with no barcode/brand, the larger-font generic code leads
const fontWin = analyzeOcr(parseOcrRows([line(60, 'AB1111'), line(140, 'CD2222')].join('\n')));
eq(fontWin.candidates[0], 'CD2222', 'order: larger font wins among generic codes');
}
// ── codes are preserved VERBATIM — no silent O↔0 / I↔1 normalization (cycle 20) ──
// The real catalog contains O/0- and I/1-distinct codes (e.g. RI5112 vs R15112 are DIFFERENT
// products — 14 such collisions found across 225k codes), so the scanner must search exactly
// what OCR read. An O/0 auto-normalization would resolve to the wrong product; this locks that
// analyzeOcr never transforms a code's letters/digits.
{
eq(analyzeOcr(parseOcrRows(line(100, 'RI5112'))).candidates, ['RI5112'], 'verbatim: letter-I code kept (not R15112)');
eq(analyzeOcr(parseOcrRows(line(100, 'R15112'))).candidates, ['R15112'], 'verbatim: digit-1 code kept (distinct from RI5112)');
eq(analyzeOcr(parseOcrRows(line(100, 'SNPBOBJML11I3'))).candidates, ['SNPBOBJML11I3'], 'verbatim: letter-O+I code unchanged');
}
// ── fingerprintVendor edge cases (cycle 12 brainstorm: harden the tiebreaker contract) ──
{
eq(fingerprintVendor('', 'serif'), null, 'fp edge: empty read → null');
eq(fingerprintVendor('ab', 'serif'), null, 'fp edge: <4-char read → null');
// punctuation/case is normalized away before matching
const k = fingerprintVendor('kravet!!!', 'sans caps');
ok(k && k.vendor === 'Kravet', 'fp edge: punctuation/case normalized → resolves');
// CRITICAL: a REJECTED logo (logo_valid=false) must never resolve, even though its
// stored logo_reads would match — proves the validation gate keeps mislabels out.
const rejected = Object.entries(VENDOR_PROFILES).find(([, p]) => p.logo_valid === false && p.logo_reads);
if (rejected) {
eq(fingerprintVendor(rejected[1].logo_reads, rejected[1].logo_typeface || ''), null,
`fp edge: rejected logo (${rejected[0]} reads '${rejected[1].logo_reads}') is never matched`);
} else { ok(true, 'fp edge: (no rejected logo in data to assert against)'); }
}
// ── profileToLex prefix support gate (cycle 14: one bad scan can't inject a prefix) ──
{
// a single-scan prefix (count 1) is gated out — no pfx, no alias → null profile
eq(profileToLex('TestCo', { prefixes: { XX: 1 }, alias_re: null, numeric_share: 0 }), null,
'profileToLex: a count-1 (unconfirmed) prefix is gated → null');
// a confirmed prefix (count ≥2) is included and matches its codes
const ok2 = profileToLex('TestCo', { prefixes: { XX: 2 }, alias_re: null, numeric_share: 0 });
ok(ok2 && ok2.pfx.test('XX1234'), 'profileToLex: count-2 prefix is included');
// mixed: the confirmed prefix matches, the unconfirmed one does NOT (one bad scan ignored)
const mix = profileToLex('TestCo', { prefixes: { YY: 5, XX: 1 }, alias_re: null, numeric_share: 0 });
ok(mix && mix.pfx.test('YY9'), 'profileToLex: confirmed YY matches');
ok(mix && !mix.pfx.test('XX9'), 'profileToLex: unconfirmed XX (count 1) does NOT match');
// a numeric-dominant vendor still matches bare numbers even with no confirmed prefix
const num = profileToLex('NumCo', { prefixes: {}, alias_re: null, numeric_share: 0.9 });
ok(num && num.pfx.test('5255'), 'profileToLex: numeric_share≥0.3 matches bare numbers');
}
// ── mfrToHouseSku: scanned vendor code → DW house SKU (cycle 21, was untested) ──
// Pure given MFR_INDEX — inject a controlled fixture (not the real 225k file) so the
// exact-match, normalization, length-guard, and bounded ±1-char fuzzy logic are deterministic.
{
const mfrMod = new Function('MFR_INDEX',
sliceConst('mfrNorm') + sliceFn('mfrToHouseSku') + 'return mfrToHouseSku;')({
'WDW2310': { sku: 'CHC-217000' },
'GRS26820': { sku: 'GRS-26820' },
});
eq(mfrMod('WDW2310'), 'CHC-217000', 'mfr: exact normalized match');
eq(mfrMod('wdw-2310'), 'CHC-217000', 'mfr: case + dash normalized to exact');
eq(mfrMod('GRS 26820'), 'GRS-26820', 'mfr: spaces stripped to exact');
eq(mfrMod('ABC'), null, 'mfr: <4 chars → null');
eq(mfrMod('ZZ9999'), null, 'mfr: no match anywhere → null');
// bounded fuzzy: DW sometimes drops/adds ONE trailing char — n starts with a known key
eq(mfrMod('WDW23107'), 'CHC-217000', 'mfr: +1 trailing char (n startsWith key) → fuzzy match');
// fuzzy is length-guarded: a 5-char code never fuzzy-matches (needs len ≥ 6)
eq(mfrMod('WDW23'), null, 'mfr: 5-char code does not fuzzy-match (length guard)');
}
// ── nmfr: mfr dedup normalizer (cycle 22, was untested) ──
// Used to dedup so a scanned WOS3467T doesn't create a duplicate of WOS3467 — a regression
// here would let duplicate products be created. Strips ONE trailing T, uppercases, trims.
{
eq(nmfr('WOS3467T'), 'WOS3467', 'nmfr: single trailing T stripped');
eq(nmfr('WOS3467'), 'WOS3467', 'nmfr: no trailing T → unchanged');
eq(nmfr(' wos3467t '), 'WOS3467', 'nmfr: trims + uppercases + strips T');
eq(nmfr('ABCTT'), 'ABCT', 'nmfr: only ONE trailing T removed');
eq(nmfr(null), '', 'nmfr: null → empty string (no throw)');
}
// ── brandRe: vendor name → OCR-detection regex source (cycle 22, was untested) ──
{
eq(brandRe('Winfield Thybony'), 'WINFIELD\\s*THYBONY', 'brandRe: multiword → \\s*-joined caps');
eq(brandRe('Romo'), 'ROMO', 'brandRe: single ≥4-char word kept');
eq(brandRe('Abc'), null, 'brandRe: single <4-char word → null (too noisy)');
eq(brandRe('AS Creation'), 'CREATION', 'brandRe: 2-char word dropped, ≥3-char kept');
eq(brandRe('123'), null, 'brandRe: no alpha words → null');
}
console.log(`\nanalyzeOcr/vendor tests: ${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);