← back to Dw Photo Capture
test: analyzeOcr/vendor unit tests (overnight cycle 6)
bf7279e9ac5666c47ce57f6bbd2137d742d1bdfe · 2026-06-26 00:24:01 -0700 · Steve Abrams
21 assertions over the pure OCR/vendor functions. The harness slices the REAL
function source out of server.js (parseOcrRows, analyzeOcr, fuzzyVendor,
fingerprintVendor + the VENDOR_LEX/STRONG_PFX/CODE_TOKEN consts) and builds a module
via new Function(...) that returns the names — so it tests the shipping code, not copies.
Coverage: height parsing + barcode tagging; brand-on-swatch vendor detection; barcode
ground-truth ordering + forced topStrong; GRS- prefix strength; weak-read non-strength;
empty input; fuzzy misspelling resolution; fingerprint resolve + null. Wired into
'npm test' and the selfcheck unit-test layer. Found + fixed 2 harness bugs (eval-const
scope leak, comment-line over-grab) before landing. $0 (local).
Files touched
M OVERNIGHT_LEDGER.mdM package.jsonM selfcheck.shA test-analyze-ocr.js
Diff
commit bf7279e9ac5666c47ce57f6bbd2137d742d1bdfe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jun 26 00:24:01 2026 -0700
test: analyzeOcr/vendor unit tests (overnight cycle 6)
21 assertions over the pure OCR/vendor functions. The harness slices the REAL
function source out of server.js (parseOcrRows, analyzeOcr, fuzzyVendor,
fingerprintVendor + the VENDOR_LEX/STRONG_PFX/CODE_TOKEN consts) and builds a module
via new Function(...) that returns the names — so it tests the shipping code, not copies.
Coverage: height parsing + barcode tagging; brand-on-swatch vendor detection; barcode
ground-truth ordering + forced topStrong; GRS- prefix strength; weak-read non-strength;
empty input; fuzzy misspelling resolution; fingerprint resolve + null. Wired into
'npm test' and the selfcheck unit-test layer. Found + fixed 2 harness bugs (eval-const
scope leak, comment-line over-grab) before landing. $0 (local).
---
OVERNIGHT_LEDGER.md | 3 +-
package.json | 5 ++-
selfcheck.sh | 3 ++
test-analyze-ocr.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 131 insertions(+), 3 deletions(-)
diff --git a/OVERNIGHT_LEDGER.md b/OVERNIGHT_LEDGER.md
index 8c21a39..3267951 100644
--- a/OVERNIGHT_LEDGER.md
+++ b/OVERNIGHT_LEDGER.md
@@ -15,7 +15,7 @@ the single highest-value safe one, the officer executes it, and the loop resched
## Backlog (safe / reversible unless flagged GATED)
- [x] A-verdict: tighten logo matcher + validate fingerprints + re-harvest (DTD 3/3)
- [x] Wire logo fingerprints into /api/identify as a TIEBREAKER (DTD-A, name-match primary)
-- [ ] analyzeOcr unit tests (project values pure/testable functions)
+- [x] analyzeOcr unit tests — 21 assertions, npm test + selfcheck gate
- [x] Barcode UX: distinct "▍▍ BARCODE" lock label on a barcode read
- [x] /learn: shows logo fingerprint + flags rejected logos with their read-as
- [ ] Faster VLM path: moondream fast-mode toggle for /api/identify (env already supports model)
@@ -32,3 +32,4 @@ the single highest-value safe one, the officer executes it, and the loop resched
| 3 | — (clear win) | barcode lock shows distinct "▍▍ BARCODE" label (exact read vs OCR guess) | served HTTP 200, inline JS valid | $0 |
| 4 | — (clear win) | /learn surfaces logo fingerprint (reads-as + typeface) and flags the 6 rejected logos with what they actually read as | HTTP 200, 6 rejects visible (Versace→Savannah etc.) | $0 |
| 5 | **DTD 3/3 → A** | wire logo fingerprints into /api/identify as a TIEBREAKER (only when name-match is inconclusive) | clean name still via=name (no regression); fingerprint resolves Mind the Gap/Ralph Lauren in unit test | $0.003 (DTD) + $0 |
+| 6 | — (clear win) | analyzeOcr/vendor unit tests — harness slices the REAL fn source from server.js + eval-tests vs crafted inputs & real profile data; wired into npm test + selfcheck | 21/21 pass; lint clean; covers parseOcrRows/analyzeOcr(brand,barcode,prefix,weak,empty)/fuzzyVendor/fingerprintVendor | $0 |
diff --git a/package.json b/package.json
index 9f601e7..f7b3024 100644
--- a/package.json
+++ b/package.json
@@ -2,10 +2,11 @@
"name": "dw-photo-capture",
"version": "1.0.0",
"private": true,
- "description": "Mobile DW SKU photo-capture tool. Zero RUNTIME deps — node server.js requires nothing. The deps below are DEV-ONLY (linting) and live only in gitignored node_modules.",
+ "description": "Mobile DW SKU photo-capture tool. Zero RUNTIME deps \u2014 node server.js requires nothing. The deps below are DEV-ONLY (linting) and live only in gitignored node_modules.",
"scripts": {
"lint": "eslint server.js public/index.html *.cjs test-*.js",
- "lint:fix": "eslint --fix server.js public/index.html *.cjs test-*.js"
+ "lint:fix": "eslint --fix server.js public/index.html *.cjs test-*.js",
+ "test": "node test-analyze-ocr.js"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
diff --git a/selfcheck.sh b/selfcheck.sh
index 8b9c18a..a67bf97 100755
--- a/selfcheck.sh
+++ b/selfcheck.sh
@@ -11,6 +11,9 @@ else
echo "lint: skipped (run 'npm install' once to enable)"
fi
+echo "\n--- unit tests (pure OCR/vendor fns) ---"
+node "$(dirname "$0")/test-analyze-ocr.js" || echo "unit tests: FAILED above ^"
+
echo "\n--- /selfcheck (server-side) ---"
curl -s -u admin:DW2024! http://127.0.0.1:9890/selfcheck | python3 -m json.tool 2>/dev/null
# Deep: headless click-through for dead links (needs global playwright)
diff --git a/test-analyze-ocr.js b/test-analyze-ocr.js
new file mode 100644
index 0000000..800537b
--- /dev/null
+++ b/test-analyze-ocr.js
@@ -0,0 +1,123 @@
+#!/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'),
+ 'const LEXICON = VENDOR_LEX;',
+ sliceFn('parseOcrRows'),
+ sliceFn('analyzeOcr'),
+ sliceFn('_lev'),
+ sliceFn('fuzzyVendor'),
+ sliceFn('fingerprintVendor'),
+ 'return { parseOcrRows, analyzeOcr, fuzzyVendor, fingerprintVendor, VENDOR_LEX };',
+].join('\n\n');
+const { parseOcrRows, analyzeOcr, fuzzyVendor, fingerprintVendor } =
+ 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');
+}
+
+// ── 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: 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');
+}
+
+// ── 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');
+}
+
+console.log(`\nanalyzeOcr/vendor tests: ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
← 6264e28 Recognize Pattern refactor: dedupe SQL term-match, single-tr
·
back to Dw Photo Capture
·
auto-save: 2026-06-26T00:38:23 (2 files) — data/build.json d 934a758 →