# RAM-Sortierhilfe Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Eine Browser-App, die per Handykamera RAM-Module erkennt und dem Nutzer sofort sagt, auf welchen Stapel das Modul gehört. **Architecture:** Reine Client-Anwendung ohne Server. Ein Kamerabild durchläuft eine Pipeline: erst Barcode-Dekodierung (exakt), daraus per Teilenummer-Decoder die Specs; nur wenn das scheitert, greift OCR als Rückfallebene. Das Ergebnis wird auf einen Fingerabdruck reduziert und gegen die offenen Stapel der laufenden Sitzung abgeglichen. Der fachliche Kern (`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`) besteht aus reinen Funktionen ohne Kamera- und DOM-Zugriff und ist vollständig mit dem Node-Test-Runner testbar; Kamera, Barcode-Bibliothek und Tesseract sind dünne Adapter an den Rändern. **Tech Stack:** Vanilla JavaScript (ES Modules), Vite, `zxing-wasm` (Code-128 + DataMatrix), `tesseract.js` (OCR), `node:test` (Tests). Node 20.20.2, npm 10.8.2. ## Global Constraints - **Keine weiteren Abhängigkeiten** als `vite` (dev), `zxing-wasm`, `tesseract.js`. Kein Framework, keine Test-Bibliothek, keine Utility-Bibliothek. - **`type: "module"`** in `package.json`. Alle Dateien sind ES Modules. - **Relative Pfade überall.** `base: './'` in `vite.config.js`; in HTML/JS niemals mit `/` beginnende Asset- oder Fetch-Pfade (VCH-Reverse-Proxy). - **Dev-Server an alle Interfaces binden** (`--host 0.0.0.0`), sonst erkennt VCH den Port nicht. - **Produktionsserver hört auf `process.env.PORT`.** - **Reine Module** (`src/spec.js`, `src/pn-decoder.js`, `src/pn-tables.js`, `src/ocr-extract.js`, `src/session.js`, `src/pipeline.js`) dürfen **kein** `window`, `document`, `navigator` oder `localStorage` benutzen. Sie müssen unter blankem Node importierbar sein. - **Sprache:** Oberflächentexte und Kommentare auf Deutsch. Bezeichner im Code auf Englisch. - **Mobile first.** Bedienflächen mindestens 56 px hoch. - **Tests laufen mit** `npm test` (= `node --test test/`). --- ## Datei-Struktur | Datei | Verantwortung | |---|---| | `package.json`, `vite.config.js`, `.gitignore` | Projektgerüst | | `index.html` | Einstiegspunkt, Grundgerüst der Ansichten | | `src/styles.css` | Gestaltung | | `src/spec.js` | Spec-Objekt, bekannte Werte, Normalisierung, Fingerabdruck, toleranter Vergleich | | `src/pn-tables.js` | Herstellertabellen für den Teilenummer-Decoder | | `src/pn-decoder.js` | Teilenummer → Spec-Felder | | `src/ocr-extract.js` | Rohtext → Spec-Felder (rein, ohne Tesseract) | | `src/session.js` | Stapel halten, zuweisen, rückgängig machen | | `src/pipeline.js` | Orchestrierung Barcode → PN → OCR → Bewertung | | `src/storage.js` | Sitzung lokal sichern und wiederherstellen | | `src/camera.js` | Kamerastrom, Einzelbilder, Datei-Ersatzweg | | `src/barcode.js` | Adapter auf `zxing-wasm` | | `src/ocr.js` | Bildaufbereitung + Adapter auf `tesseract.js` | | `src/ui/scan-view.js` | Scan-Ansicht, Stapel-Leiste, Zuletzt-Zeile | | `src/ui/result-overlay.js` | Treffer-Rückmeldung (grün/gelb) | | `src/ui/ambiguous-dialog.js` | Rot-Dialog | | `src/ui/session-list.js` | Sitzungsliste, Umsortieren, Entfernen | | `src/main.js` | Verdrahtung aller Module | | `test/*.test.js` | Tests der reinen Module | --- ## Task 1: Projektgerüst und Spec-Grundlagen **Files:** - Create: `package.json`, `vite.config.js`, `.gitignore`, `index.html` - Create: `src/spec.js` - Test: `test/spec.test.js` **Interfaces:** - Consumes: nichts - Produces: - `KNOWN` — `{ capacityGb: number[], speed: string[], formFactor: string[], rank: string[] }` - `emptySpec() -> Spec` - `Spec` = `{ capacityGb: number|null, formFactor: string|null, rank: string|null, speed: string|null, partNumber: string|null, dateCode: string|null }` - `normalizeToken(raw: string) -> string` - `fingerprint(spec: Spec) -> string` - `isUsable(spec: Spec) -> boolean` - [ ] **Step 1: Projekt anlegen und Abhängigkeiten installieren** ```bash cd /home/vchuser/projects/ocr_scanner git init npm init -y npm pkg set type=module npm pkg set name=ram-sortierhilfe npm pkg set private=true npm pkg set scripts.dev="vite --host 0.0.0.0" npm pkg set scripts.build="vite build" npm pkg set scripts.preview="vite preview --host 0.0.0.0 --port ${PORT:-4173}" npm pkg set scripts.test="node --test test/" npm pkg delete scripts.lint 2>/dev/null || true npm install --save-dev vite npm install zxing-wasm tesseract.js ``` Erwartete Ausgabe: `npm install` endet ohne Fehler, `node_modules/` existiert. - [ ] **Step 2: Gerüstdateien anlegen** `.gitignore`: ``` node_modules/ dist/ .DS_Store ``` `vite.config.js`: ```js import { defineConfig } from 'vite'; export default defineConfig({ // Relative Basis, damit die App auch hinter einem Pfad-Proxy laeuft. base: './', server: { host: '0.0.0.0' }, }); ``` `index.html`: ```html RAM-Sortierhilfe
``` `src/styles.css` (Platzhalter, wird in Task 11 gefüllt): ```css :root { color-scheme: dark; } body { margin: 0; font-family: system-ui, sans-serif; } ``` `src/main.js` (Platzhalter, wird in Task 13 gefüllt): ```js document.querySelector('#app').textContent = 'RAM-Sortierhilfe'; ``` - [ ] **Step 3: Fehlschlagenden Test schreiben** `test/spec.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { KNOWN, emptySpec, normalizeToken, fingerprint, isUsable } from '../src/spec.js'; test('KNOWN enthaelt die erwarteten Wertelisten', () => { assert.ok(KNOWN.capacityGb.includes(64)); assert.ok(KNOWN.speed.includes('PC4-2400')); assert.ok(KNOWN.formFactor.includes('LRDIMM')); assert.ok(KNOWN.rank.includes('4DRx4')); }); test('emptySpec liefert alle Felder als null', () => { assert.deepEqual(emptySpec(), { capacityGb: null, formFactor: null, rank: null, speed: null, partNumber: null, dateCode: null, }); }); test('normalizeToken macht gross, trimmt und vereinheitlicht Bindestriche', () => { assert.equal(normalizeToken(' pc4‑2400 '), 'PC4-2400'); assert.equal(normalizeToken('4drx4'), '4DRX4'); assert.equal(normalizeToken(''), ''); assert.equal(normalizeToken(null), ''); }); test('fingerprint setzt die Felder in fester Reihenfolge zusammen', () => { const spec = { capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y', dateCode: '1908', }; assert.equal(fingerprint(spec), 'LRDIMM|64|4DRX4|PC4-2400|M386A8K40BM1-CRC4Y'); }); test('fingerprint laesst den Datumscode aussen vor', () => { const a = { capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'X', dateCode: '1908' }; const b = { ...a, dateCode: '2013' }; assert.equal(fingerprint(a), fingerprint(b)); }); test('fingerprint markiert fehlende Felder mit einem Fragezeichen', () => { const spec = { ...emptySpec(), capacityGb: 32 }; assert.equal(fingerprint(spec), '?|32|?|?|?'); }); test('isUsable verlangt eine Kapazitaet', () => { assert.equal(isUsable({ ...emptySpec(), capacityGb: 64 }), true); assert.equal(isUsable(emptySpec()), false); }); ``` - [ ] **Step 4: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/spec.js'` - [ ] **Step 5: `src/spec.js` implementieren** ```js // Reines Modul: kein window, kein document, kein localStorage. /** Bekannte Werte. Der Erwartungsraum ist klein - das repariert OCR-Lesefehler. */ export const KNOWN = { capacityGb: [4, 8, 16, 32, 64, 128, 256], speed: [ 'PC4-1600', 'PC4-1866', 'PC4-2133', 'PC4-2400', 'PC4-2666', 'PC4-2933', 'PC4-3200', ], formFactor: ['UDIMM', 'SODIMM', 'RDIMM', 'LRDIMM'], rank: ['1Rx8', '1Rx4', '2Rx8', '2Rx4', '4Rx4', '8Rx4', '2DRx4', '4DRx4', '2DRx8'], }; /** @returns {{capacityGb: null, formFactor: null, rank: null, speed: null, partNumber: null, dateCode: null}} */ export function emptySpec() { return { capacityGb: null, formFactor: null, rank: null, speed: null, partNumber: null, dateCode: null, }; } /** Grossbuchstaben, ohne Rand-Leerzeichen, mit vereinheitlichten Bindestrichen. */ export function normalizeToken(raw) { if (typeof raw !== 'string') return ''; return raw .replace(/[‐-―−]/g, '-') .trim() .toUpperCase(); } /** * Merkmalskombination, die ueber die Stapelzugehoerigkeit entscheidet. * Der Datumscode gehoert bewusst nicht dazu. */ export function fingerprint(spec) { const parts = [ spec.formFactor, spec.capacityGb, spec.rank, spec.speed, spec.partNumber, ]; return parts .map((value) => (value === null || value === undefined ? '?' : normalizeToken(String(value)))) .join('|'); } /** Ohne Kapazitaet ist keine sinnvolle Zuordnung moeglich. */ export function isUsable(spec) { return typeof spec.capacityGb === 'number' && Number.isFinite(spec.capacityGb); } ``` - [ ] **Step 6: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 7 Tests grün - [ ] **Step 7: Committen** ```bash git add .gitignore package.json package-lock.json vite.config.js index.html src/ test/ git commit -m "feat: Projektgeruest und Spec-Grundlagen" ``` --- ## Task 2: Toleranter Vergleich Fängt die typischen OCR-Verwechslungen ab, indem beide Seiten des Vergleichs auf eine gemeinsame Form gebracht werden. **Files:** - Modify: `src/spec.js` (anfügen) - Test: `test/spec-match.test.js` **Interfaces:** - Consumes: `normalizeToken`, `KNOWN` aus Task 1 - Produces: - `canonical(raw: string) -> string` - `matchKnown(raw: string, list: (string|number)[]) -> string|number|null` - `specsCompatible(a: Spec, b: Spec) -> boolean` - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/spec-match.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { KNOWN, emptySpec, canonical, matchKnown, specsCompatible } from '../src/spec.js'; test('canonical bildet verwechselbare Zeichen auf einen Vertreter ab', () => { assert.equal(canonical('PC4-24O0'), canonical('PC4-2400')); assert.equal(canonical('1RX8'), canonical('IRX8')); assert.equal(canonical('BGB'), canonical('868')); assert.equal(canonical('LRDIMM'), canonical('1RD1MM')); }); test('canonical laesst D unangetastet, damit 4DRx4 erhalten bleibt', () => { assert.notEqual(canonical('4DRX4'), canonical('40RX4')); }); test('matchKnown findet den exakten Wert', () => { assert.equal(matchKnown('PC4-2400', KNOWN.speed), 'PC4-2400'); assert.equal(matchKnown('lrdimm', KNOWN.formFactor), 'LRDIMM'); }); test('matchKnown repariert eine Verwechslung', () => { assert.equal(matchKnown('PC4-24O0', KNOWN.speed), 'PC4-2400'); assert.equal(matchKnown('4DRX4', KNOWN.rank), '4DRx4'); }); test('matchKnown liefert null bei unbekanntem Wert', () => { assert.equal(matchKnown('PC4-9999', KNOWN.speed), null); assert.equal(matchKnown('', KNOWN.speed), null); }); test('matchKnown funktioniert auch mit Zahlenlisten', () => { assert.equal(matchKnown('64', KNOWN.capacityGb), 64); assert.equal(matchKnown('6A', KNOWN.capacityGb), null); }); test('specsCompatible vergleicht nur beidseitig gesetzte Felder', () => { const voll = { capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y', dateCode: '1908' }; const ohnePn = { ...voll, partNumber: null, dateCode: null }; assert.equal(specsCompatible(voll, ohnePn), true); }); test('specsCompatible erkennt einen echten Unterschied', () => { const a = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400' }; const b = { ...emptySpec(), capacityGb: 32, speed: 'PC4-2400' }; assert.equal(specsCompatible(a, b), false); }); test('specsCompatible trennt gleiche Spec mit unterschiedlicher Teilenummer', () => { const samsung = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y' }; const hynix = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400', partNumber: 'HMAA8GL7AMR4N-UH' }; assert.equal(specsCompatible(samsung, hynix), false); }); test('specsCompatible toleriert eine Verwechslung in der Teilenummer', () => { const a = { ...emptySpec(), capacityGb: 64, partNumber: 'M386A8K40BM1-CRC4Y' }; const b = { ...emptySpec(), capacityGb: 64, partNumber: 'M386A8K4OBM1-CRC4Y' }; assert.equal(specsCompatible(a, b), true); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `canonical is not a function` - [ ] **Step 3: `src/spec.js` erweitern** An das Ende von `src/spec.js` anfügen: ```js /** * Zeichen, die OCR auf glaenzenden Etiketten regelmaessig verwechselt, * werden auf einen gemeinsamen Vertreter abgebildet. D bleibt bewusst * unangetastet, sonst kollidiert 4DRx4 mit 40Rx4. */ const CONFUSIONS = { O: '0', Q: '0', I: '1', L: '1', B: '8', S: '5', Z: '2', G: '6', }; /** Vergleichsform eines Tokens: normalisiert, ohne Trennzeichen, verwechslungsfrei. */ export function canonical(raw) { const normalized = normalizeToken(raw).replace(/[^A-Z0-9]/g, ''); let out = ''; for (const char of normalized) { out += CONFUSIONS[char] ?? char; } return out; } /** * Gleicht einen gelesenen Wert gegen eine Liste bekannter Werte ab. * Erst exakt, dann ueber die Vergleichsform. Mehrdeutigkeit gilt als Treffer-los. */ export function matchKnown(raw, list) { const normalized = normalizeToken(raw); if (normalized === '') return null; for (const candidate of list) { if (normalizeToken(String(candidate)) === normalized) return candidate; } const target = canonical(normalized); const hits = list.filter((candidate) => canonical(String(candidate)) === target); return hits.length === 1 ? hits[0] : null; } /** * Zwei Specs sind vertraeglich, wenn alle beidseitig gesetzten Felder * in ihrer Vergleichsform uebereinstimmen. Felder, die auf einer Seite * fehlen, verhindern die Vertraeglichkeit nicht - die Entscheidung * darueber faellt in session.js. */ export function specsCompatible(a, b) { const fields = ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']; for (const field of fields) { const left = a[field]; const right = b[field]; if (left === null || left === undefined) continue; if (right === null || right === undefined) continue; if (canonical(String(left)) !== canonical(String(right))) return false; } return true; } ``` - [ ] **Step 4: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — alle Tests aus Task 1 und Task 2 grün - [ ] **Step 5: Committen** ```bash git add src/spec.js test/spec-match.test.js git commit -m "feat: toleranter Spec-Vergleich gegen OCR-Verwechslungen" ``` --- ## Task 3: Teilenummer-Decoder Tabellengesteuert. Unbekannte Fragmente sind kein Fehler — das betroffene Feld bleibt offen und wird später durch OCR gefüllt. **Files:** - Create: `src/pn-tables.js`, `src/pn-decoder.js` - Test: `test/pn-decoder.test.js` **Interfaces:** - Consumes: `emptySpec`, `normalizeToken` aus `src/spec.js` - Produces: - `VENDOR_TABLES` — Array von `{ vendor, pattern: RegExp, formFactor: Record, density: Record, speed: Record }` - `decodePartNumber(pn: string) -> Spec` (immer ein Spec-Objekt; alle nicht ableitbaren Felder `null`) - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/pn-decoder.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { decodePartNumber } from '../src/pn-decoder.js'; test('dekodiert die Samsung-Teilenummer aus dem Referenzmodul', () => { const spec = decodePartNumber('M386A8K40BM1-CRC4Y'); assert.equal(spec.formFactor, 'LRDIMM'); assert.equal(spec.speed, 'PC4-2400'); assert.equal(spec.capacityGb, 64); assert.equal(spec.rank, '4DRx4'); assert.equal(spec.partNumber, 'M386A8K40BM1-CRC4Y'); }); test('erkennt die Bauform auch ohne bekannten Dichte-Code', () => { const spec = decodePartNumber('M393A2K43BB1-CTD'); assert.equal(spec.formFactor, 'RDIMM'); assert.equal(spec.speed, 'PC4-2666'); assert.equal(spec.capacityGb, null, 'unbekannter Dichte-Code laesst das Feld offen'); assert.equal(spec.rank, null); assert.equal(spec.partNumber, 'M393A2K43BB1-CTD'); }); test('normalisiert Kleinschreibung und Leerzeichen', () => { const spec = decodePartNumber(' m386a8k40bm1-crc4y '); assert.equal(spec.capacityGb, 64); assert.equal(spec.partNumber, 'M386A8K40BM1-CRC4Y'); }); test('unbekannter Hersteller liefert ein leeres Spec mit gesetzter Teilenummer', () => { const spec = decodePartNumber('7325773'); assert.equal(spec.formFactor, null); assert.equal(spec.speed, null); assert.equal(spec.capacityGb, null); assert.equal(spec.partNumber, '7325773'); }); test('leere Eingabe liefert ein vollstaendig leeres Spec', () => { const spec = decodePartNumber(''); assert.equal(spec.partNumber, null); assert.equal(spec.capacityGb, null); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/pn-decoder.js'` - [ ] **Step 3: `src/pn-tables.js` implementieren** ```js // Herstellertabellen fuer den Teilenummer-Decoder. // // ACHTUNG: Nur der Samsung-Eintrag 'A8K40' und der Geschwindigkeitscode 'CRC' // sind gegen ein reales Modul geprueft (64GB 4DRx4 PC4-2400T LRDIMM, // M386A8K40BM1-CRC4Y). Alle uebrigen Eintraege stammen aus der veroeffentlichten // Systematik und muessen vor produktivem Einsatz gegen reale Module bestaetigt // werden - siehe Task 14. Ein falscher Eintrag faellt beim Sortieren dadurch // auf, dass der OCR-Klartext dem dekodierten Wert widerspricht. export const VENDOR_TABLES = [ { vendor: 'Samsung', // M- pattern: /^M(\d{3})A([A-Z0-9]{4,6})[A-Z0-9]*-([A-Z]{3})/, formFactor: { 378: 'UDIMM', 391: 'UDIMM', 393: 'RDIMM', 386: 'LRDIMM', 471: 'SODIMM', 474: 'SODIMM', }, density: { // geprueft am Referenzmodul: A8K40: { capacityGb: 64, rank: '4DRx4' }, }, speed: { CPB: 'PC4-2133', CRC: 'PC4-2400', CTD: 'PC4-2666', CVF: 'PC4-2933', CWE: 'PC4-3200', }, }, ]; ``` - [ ] **Step 4: `src/pn-decoder.js` implementieren** ```js import { emptySpec, normalizeToken } from './spec.js'; import { VENDOR_TABLES } from './pn-tables.js'; /** * Leitet aus einer Hersteller-Teilenummer die Spec-Felder ab. * Nicht ableitbare Felder bleiben null - das ist kein Fehler, * sondern der regulaere Uebergang zum OCR-Weg. */ export function decodePartNumber(pn) { const spec = emptySpec(); const normalized = normalizeToken(pn); if (normalized === '') return spec; spec.partNumber = normalized; for (const table of VENDOR_TABLES) { const match = normalized.match(table.pattern); if (!match) continue; const [, formCode, densityCode, speedCode] = match; spec.formFactor = table.formFactor[formCode] ?? null; spec.speed = table.speed[speedCode] ?? null; const density = table.density[densityCode]; if (density) { spec.capacityGb = density.capacityGb; spec.rank = density.rank; } break; } return spec; } ``` - [ ] **Step 5: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 5 neue Tests grün - [ ] **Step 6: Committen** ```bash git add src/pn-tables.js src/pn-decoder.js test/pn-decoder.test.js git commit -m "feat: tabellengesteuerter Teilenummer-Decoder" ``` --- ## Task 4: OCR-Feldextraktion Reine Funktion: Rohtext hinein, Spec-Felder heraus. Kein Tesseract — dadurch mit den echten Problemfällen testbar. **Files:** - Create: `src/ocr-extract.js` - Test: `test/ocr-extract.test.js` **Interfaces:** - Consumes: `emptySpec`, `KNOWN`, `matchKnown`, `normalizeToken` aus `src/spec.js` - Produces: `extractFields(rawText: string) -> Spec` - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/ocr-extract.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { extractFields } from '../src/ocr-extract.js'; const REFERENZ = `SAMSUNG Made in Philippines K136000908252DF287 64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y S 1908`; test('liest alle Felder aus dem Referenz-Etikett', () => { const spec = extractFields(REFERENZ); assert.equal(spec.capacityGb, 64); assert.equal(spec.rank, '4DRx4'); assert.equal(spec.speed, 'PC4-2400'); assert.equal(spec.partNumber, 'M386A8K40BM1-CRC4Y'); assert.equal(spec.dateCode, '1908'); }); test('repariert eine Verwechslung in der Geschwindigkeit', () => { const spec = extractFields('64GB 4DRx4 PC4-24O0T-LD1-11-MC0'); assert.equal(spec.speed, 'PC4-2400'); }); test('repariert eine Verwechslung in der Kapazitaet', () => { const spec = extractFields('BGB 1Rx8 PC4-2400'); assert.equal(spec.capacityGb, 8, 'B wird als 8 gelesen'); assert.equal(spec.rank, '1Rx8'); }); test('erkennt die Bauform aus dem Klartext, wenn vorhanden', () => { const spec = extractFields('32GB 2Rx4 PC4-2666 RDIMM'); assert.equal(spec.formFactor, 'RDIMM'); }); test('laesst Felder offen, die nicht im Text stehen', () => { const spec = extractFields('Oracle PN: 7325773'); assert.equal(spec.capacityGb, null); assert.equal(spec.speed, null); assert.equal(spec.rank, null); }); test('unbekannte Geschwindigkeit bleibt null statt falsch geraten', () => { const spec = extractFields('64GB 4DRx4 PC4-9999'); assert.equal(spec.speed, null); assert.equal(spec.capacityGb, 64); }); test('leerer Text liefert ein leeres Spec', () => { const spec = extractFields(''); assert.equal(spec.capacityGb, null); assert.equal(spec.partNumber, null); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/ocr-extract.js'` - [ ] **Step 3: `src/ocr-extract.js` implementieren** ```js import { emptySpec, KNOWN, matchKnown, normalizeToken } from './spec.js'; // Die Muster sind bewusst grosszuegig: Was sie einsammeln, wird // anschliessend gegen die bekannten Werte abgeglichen. Ein Treffer, // der dort nicht besteht, wird verworfen statt geraten. const CAPACITY_PATTERN = /\b([0-9OQBSIL]{1,3})\s?GB\b/g; // Kein \b am Ende: auf Etiketten folgt der Geschwindigkeit oft direkt // ein Buchstabe (PC4-2400T), und dort gibt es keine Wortgrenze. const SPEED_PATTERN = /\bPC4[-\s]?([0-9OQ]{4})/g; const RANK_PATTERN = /\b([0-9OQ][DS]?R[Xx][0-9OQ])\b/g; const PART_NUMBER_PATTERN = /\b([A-Z]{1,3}[0-9]{2,4}[A-Z0-9]{4,}(?:-[A-Z0-9]{2,6})?)\b/g; const DATE_CODE_PATTERN = /(?:^|\s)([0-9]{4})(?=\s|$)/g; function firstMatch(text, pattern, transform) { pattern.lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { const value = transform(match[1]); if (value !== null) return value; } return null; } /** * Zerlegt den OCR-Rohtext eines Etiketts in Spec-Felder. * Jeder Kandidat wird gegen die bekannten Werte geprueft; besteht er * die Pruefung nicht, bleibt das Feld offen. */ export function extractFields(rawText) { const spec = emptySpec(); const text = normalizeToken(rawText).replace(/\s+/g, ' '); if (text === '') return spec; spec.capacityGb = firstMatch(text, CAPACITY_PATTERN, (raw) => matchKnown(raw, KNOWN.capacityGb), ); spec.speed = firstMatch(text, SPEED_PATTERN, (raw) => matchKnown(`PC4-${raw}`, KNOWN.speed), ); spec.rank = firstMatch(text, RANK_PATTERN, (raw) => matchKnown(raw, KNOWN.rank)); spec.formFactor = firstMatch(text, /\b(U?L?R?S?O?DIMM)\b/g, (raw) => matchKnown(raw, KNOWN.formFactor), ); // Teilenummer: der laengste Kandidat, der nicht der Seriennummer entspricht. PART_NUMBER_PATTERN.lastIndex = 0; const candidates = [...text.matchAll(PART_NUMBER_PATTERN)].map((m) => m[1]); const withDash = candidates.filter((c) => c.includes('-')); spec.partNumber = withDash[0] ?? null; // Datumscode: vierstellige Zahl, die fuer sich allein steht. DATE_CODE_PATTERN.lastIndex = 0; const dateMatch = [...text.matchAll(DATE_CODE_PATTERN)].map((m) => m[1]); spec.dateCode = dateMatch[0] ?? null; return spec; } ``` - [ ] **Step 4: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 7 neue Tests grün. Schlägt ein Test fehl, sind die regulären Ausdrücke anzupassen, **nicht** die Testerwartungen. - [ ] **Step 5: Committen** ```bash git add src/ocr-extract.js test/ocr-extract.test.js git commit -m "feat: OCR-Feldextraktion mit Abgleich gegen bekannte Werte" ``` --- ## Task 5: Sitzung und Stapel-Zuweisung **Files:** - Create: `src/session.js` - Test: `test/session.test.js` **Interfaces:** - Consumes: `specsCompatible`, `fingerprint` aus `src/spec.js` - Produces: - `createSession() -> Session` - `Session` = `{ stacks: Stack[], entries: Entry[], nextEntryId: number }` - `Stack` = `{ id: string, spec: Spec, count: number }` — `id` ist `'A'`, `'B'`, … - `Entry` = `{ entryId: number, spec: Spec, stackId: string, source: string }` - `proposeAssignment(session, spec) -> { kind: 'match'|'new'|'ambiguous', stackId: string|null, candidates: string[] }` - `commitAssignment(session, spec, source, stackId) -> Entry` - `undoLast(session) -> Entry|null` - `moveEntry(session, entryId, stackId) -> void` - `removeEntry(session, entryId) -> void` - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/session.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { emptySpec } from '../src/spec.js'; import { createSession, proposeAssignment, commitAssignment, undoLast, moveEntry, removeEntry, } from '../src/session.js'; const s64 = () => ({ ...emptySpec(), capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y' }); const s32 = () => ({ ...emptySpec(), capacityGb: 32, formFactor: 'RDIMM', rank: '2Rx4', speed: 'PC4-2666', partNumber: 'M393A4K40BB1-CTD' }); function scan(session, spec, source = 'barcode') { const plan = proposeAssignment(session, spec); return commitAssignment(session, spec, source, plan.stackId); } test('erster Scan legt Stapel A an', () => { const session = createSession(); const plan = proposeAssignment(session, s64()); assert.equal(plan.kind, 'new'); assert.equal(plan.stackId, 'A'); }); test('gleiches Modul landet auf demselben Stapel', () => { const session = createSession(); scan(session, s64()); const plan = proposeAssignment(session, s64()); assert.equal(plan.kind, 'match'); assert.equal(plan.stackId, 'A'); }); test('anderes Modul legt Stapel B an', () => { const session = createSession(); scan(session, s64()); const plan = proposeAssignment(session, s32()); assert.equal(plan.kind, 'new'); assert.equal(plan.stackId, 'B'); }); test('eine Verwechslung erzeugt keinen Fast-Duplikat-Stapel', () => { const session = createSession(); scan(session, s64()); const verrauscht = { ...s64(), partNumber: 'M386A8K4OBM1-CRC4Y' }; const plan = proposeAssignment(session, verrauscht); assert.equal(plan.kind, 'match'); assert.equal(plan.stackId, 'A'); }); test('Scan ohne Teilenummer bei zwei passenden Stapeln ist mehrdeutig', () => { const session = createSession(); scan(session, { ...s64(), partNumber: 'M386A8K40BM1-CRC4Y' }); scan(session, { ...s64(), partNumber: 'HMAA8GL7AMR4N-UH' }); const ohnePn = { ...s64(), partNumber: null }; const plan = proposeAssignment(session, ohnePn); assert.equal(plan.kind, 'ambiguous'); assert.deepEqual(plan.candidates, ['A', 'B']); }); test('commitAssignment zaehlt den Stapel hoch und fuehrt die Liste', () => { const session = createSession(); scan(session, s64()); scan(session, s64()); assert.equal(session.stacks.length, 1); assert.equal(session.stacks[0].count, 2); assert.equal(session.entries.length, 2); }); test('undoLast nimmt den letzten Eintrag zurueck', () => { const session = createSession(); scan(session, s64()); scan(session, s64()); const entfernt = undoLast(session); assert.equal(entfernt.stackId, 'A'); assert.equal(session.stacks[0].count, 1); assert.equal(session.entries.length, 1); }); test('undoLast entfernt einen leer gewordenen Stapel', () => { const session = createSession(); scan(session, s64()); undoLast(session); assert.equal(session.stacks.length, 0); assert.equal(undoLast(session), null); }); test('moveEntry sortiert einen Eintrag um', () => { const session = createSession(); const a = scan(session, s64()); scan(session, s32()); moveEntry(session, a.entryId, 'B'); const stapelA = session.stacks.find((s) => s.id === 'A'); const stapelB = session.stacks.find((s) => s.id === 'B'); assert.equal(stapelA, undefined, 'leerer Stapel wird entfernt'); assert.equal(stapelB.count, 2); }); test('removeEntry entfernt einen einzelnen Eintrag', () => { const session = createSession(); const a = scan(session, s64()); scan(session, s64()); removeEntry(session, a.entryId); assert.equal(session.entries.length, 1); assert.equal(session.stacks[0].count, 1); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/session.js'` - [ ] **Step 3: `src/session.js` implementieren** ```js import { specsCompatible } from './spec.js'; const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** @returns {{stacks: Array, entries: Array, nextEntryId: number}} */ export function createSession() { return { stacks: [], entries: [], nextEntryId: 1 }; } function nextStackId(session) { const used = new Set(session.stacks.map((stack) => stack.id)); for (const letter of STACK_LETTERS) { if (!used.has(letter)) return letter; } // Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht. return `A${session.stacks.length}`; } /** * Schlaegt vor, wohin ein Modul gehoert. * Genau ein vertraeglicher Stapel -> Zuweisung. * Keiner -> neuer Stapel. * Mehrere -> mehrdeutig, der Nutzer entscheidet. */ export function proposeAssignment(session, spec) { const candidates = session.stacks .filter((stack) => specsCompatible(stack.spec, spec)) .map((stack) => stack.id); if (candidates.length === 1) { return { kind: 'match', stackId: candidates[0], candidates }; } if (candidates.length === 0) { return { kind: 'new', stackId: nextStackId(session), candidates: [] }; } return { kind: 'ambiguous', stackId: null, candidates }; } /** Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an. */ export function commitAssignment(session, spec, source, stackId) { let stack = session.stacks.find((candidate) => candidate.id === stackId); if (!stack) { stack = { id: stackId, spec, count: 0 }; session.stacks.push(stack); session.stacks.sort((a, b) => a.id.localeCompare(b.id)); } else { // Ein spaeterer, vollstaendigerer Scan ergaenzt fehlende Felder des Stapels. for (const field of ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']) { if (stack.spec[field] === null && spec[field] !== null) { stack.spec[field] = spec[field]; } } } stack.count += 1; const entry = { entryId: session.nextEntryId++, spec, stackId, source }; session.entries.push(entry); return entry; } function dropEmptyStacks(session) { session.stacks = session.stacks.filter((stack) => stack.count > 0); } /** Nimmt den zuletzt erfassten Eintrag zurueck. */ export function undoLast(session) { const entry = session.entries.pop(); if (!entry) return null; const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); if (stack) stack.count -= 1; dropEmptyStacks(session); return entry; } /** Ordnet einen bereits erfassten Eintrag einem anderen Stapel zu. */ export function moveEntry(session, entryId, stackId) { const entry = session.entries.find((candidate) => candidate.entryId === entryId); if (!entry || entry.stackId === stackId) return; const from = session.stacks.find((candidate) => candidate.id === entry.stackId); if (from) from.count -= 1; let to = session.stacks.find((candidate) => candidate.id === stackId); if (!to) { to = { id: stackId, spec: entry.spec, count: 0 }; session.stacks.push(to); session.stacks.sort((a, b) => a.id.localeCompare(b.id)); } to.count += 1; entry.stackId = stackId; dropEmptyStacks(session); } /** Entfernt einen einzelnen Eintrag aus der Sitzung. */ export function removeEntry(session, entryId) { const index = session.entries.findIndex((candidate) => candidate.entryId === entryId); if (index === -1) return; const [entry] = session.entries.splice(index, 1); const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); if (stack) stack.count -= 1; dropEmptyStacks(session); } ``` - [ ] **Step 4: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 10 neue Tests grün - [ ] **Step 5: Committen** ```bash git add src/session.js test/session.test.js git commit -m "feat: Sitzungsverwaltung mit Stapel-Zuweisung und Ruecknahme" ``` --- ## Task 6: Erkennungs-Pipeline Orchestriert Barcode → Teilenummer → OCR und vergibt die Ampelfarbe. Die Adapter werden hineingereicht, damit die Pipeline ohne Browser testbar bleibt. **Files:** - Create: `src/pipeline.js` - Test: `test/pipeline.test.js` **Interfaces:** - Consumes: `emptySpec`, `isUsable` aus `src/spec.js`; `decodePartNumber`; `extractFields` - Produces: - `recognize(frame, deps) -> Promise` - `deps` = `{ decodeBarcodes: (frame) => Promise, runOcr: (frame) => Promise }` - `Recognition` = `{ spec: Spec, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red' }` - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/pipeline.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { recognize } from '../src/pipeline.js'; const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908'; const deps = ({ codes = [], text = '' }) => ({ decodeBarcodes: async () => codes, runOcr: async () => text, }); test('Barcode mit bekannter Teilenummer ergibt gruen und ueberspringt OCR', async () => { let ocrAufgerufen = false; const result = await recognize({}, { decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'], runOcr: async () => { ocrAufgerufen = true; return ''; }, }); assert.equal(result.source, 'barcode'); assert.equal(result.confidence, 'green'); assert.equal(result.spec.capacityGb, 64); assert.equal(ocrAufgerufen, false, 'OCR darf bei gruenem Barcode nicht laufen'); }); test('Barcode ohne bekannte Teilenummer faellt auf OCR zurueck', async () => { const result = await recognize({}, deps({ codes: ['7325773'], text: OCR_TEXT })); assert.equal(result.source, 'ocr'); assert.equal(result.confidence, 'yellow'); assert.equal(result.spec.capacityGb, 64); }); test('OCR-Ergebnis behaelt die Teilenummer aus dem Barcode bei', async () => { const result = await recognize({}, deps({ codes: ['7325773'], text: '64GB 4DRx4 PC4-2400' })); assert.equal(result.spec.partNumber, '7325773'); }); test('ohne Barcode und ohne brauchbaren Text ergibt rot', async () => { const result = await recognize({}, deps({ codes: [], text: 'Made in Philippines' })); assert.equal(result.source, 'none'); assert.equal(result.confidence, 'red'); }); test('OCR-Fehler fuehrt zu rot statt zu einem Absturz', async () => { const result = await recognize({}, { decodeBarcodes: async () => [], runOcr: async () => { throw new Error('tesseract nicht geladen'); }, }); assert.equal(result.confidence, 'red'); assert.equal(result.source, 'none'); }); test('Barcode-Fehler fuehrt nicht zum Abbruch, OCR uebernimmt', async () => { const result = await recognize({}, { decodeBarcodes: async () => { throw new Error('zxing nicht geladen'); }, runOcr: async () => OCR_TEXT, }); assert.equal(result.source, 'ocr'); assert.equal(result.spec.capacityGb, 64); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/pipeline.js'` - [ ] **Step 3: `src/pipeline.js` implementieren** ```js import { emptySpec, isUsable } from './spec.js'; import { decodePartNumber } from './pn-decoder.js'; import { extractFields } from './ocr-extract.js'; async function safely(fn, fallback) { try { return await fn(); } catch { return fallback; } } /** * Barcode zuerst, OCR nur als Rueckfallebene. * @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red'}>} */ export async function recognize(frame, deps) { const codes = await safely(() => deps.decodeBarcodes(frame), []); let best = emptySpec(); for (const code of codes) { const decoded = decodePartNumber(code); if (isUsable(decoded)) { return { spec: decoded, source: 'barcode', confidence: 'green' }; } // Teilenummer merken, auch wenn das Schema unbekannt ist. if (best.partNumber === null) best = decoded; } const text = await safely(() => deps.runOcr(frame), ''); const fromOcr = extractFields(text); // Der Barcode ist die exaktere Quelle: seine Teilenummer gewinnt. const merged = { ...fromOcr }; if (best.partNumber !== null) merged.partNumber = best.partNumber; for (const field of ['capacityGb', 'formFactor', 'rank', 'speed']) { if (merged[field] === null && best[field] !== null) merged[field] = best[field]; } if (isUsable(merged)) { return { spec: merged, source: 'ocr', confidence: 'yellow' }; } return { spec: merged, source: 'none', confidence: 'red' }; } ``` - [ ] **Step 4: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 6 neue Tests grün - [ ] **Step 5: Committen** ```bash git add src/pipeline.js test/pipeline.test.js git commit -m "feat: Erkennungs-Pipeline mit Barcode-Vorrang und OCR-Rueckfall" ``` --- ## Task 7: Absturzschutz für die Sitzung **Files:** - Create: `src/storage.js` - Test: `test/storage.test.js` **Interfaces:** - Consumes: nichts - Produces: - `saveSession(session, store) -> void` - `loadSession(store) -> Session|null` - `clearSession(store) -> void` - `store` ist ein Objekt mit `getItem`, `setItem`, `removeItem` (im Browser `window.localStorage`) - [ ] **Step 1: Fehlschlagenden Test schreiben** `test/storage.test.js`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { saveSession, loadSession, clearSession } from '../src/storage.js'; import { createSession, commitAssignment } from '../src/session.js'; import { emptySpec } from '../src/spec.js'; function fakeStore() { const data = new Map(); return { data, getItem: (key) => (data.has(key) ? data.get(key) : null), setItem: (key, value) => data.set(key, value), removeItem: (key) => data.delete(key), }; } test('leerer Speicher liefert null', () => { assert.equal(loadSession(fakeStore()), null); }); test('Sitzung ueberlebt Sichern und Laden', () => { const store = fakeStore(); const session = createSession(); commitAssignment(session, { ...emptySpec(), capacityGb: 64 }, 'barcode', 'A'); saveSession(session, store); const wieder = loadSession(store); assert.equal(wieder.stacks.length, 1); assert.equal(wieder.stacks[0].count, 1); assert.equal(wieder.entries[0].spec.capacityGb, 64); assert.equal(wieder.nextEntryId, 2); }); test('clearSession raeumt auf', () => { const store = fakeStore(); saveSession(createSession(), store); clearSession(store); assert.equal(loadSession(store), null); }); test('kaputter Inhalt liefert null statt einer Ausnahme', () => { const store = fakeStore(); store.setItem('ram-sortierhilfe:session', '{kein json'); assert.equal(loadSession(store), null); }); test('Sichern ohne funktionierenden Speicher wirft nicht', () => { const kaputt = { getItem: () => null, setItem: () => { throw new Error('quota exceeded'); }, removeItem: () => {}, }; assert.doesNotThrow(() => saveSession(createSession(), kaputt)); }); ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** Run: `npm test` Expected: FAIL — `Cannot find module '.../src/storage.js'` - [ ] **Step 3: `src/storage.js` implementieren** ```js const KEY = 'ram-sortierhilfe:session'; /** * Absturzschutz, keine Bestandsfuehrung: die laufende Sitzung wird * gesichert, damit ein versehentliches Neuladen sie nicht vernichtet. */ export function saveSession(session, store) { try { store.setItem(KEY, JSON.stringify(session)); } catch { // Voller oder gesperrter Speicher darf das Sortieren nicht unterbrechen. } } /** @returns {object|null} */ export function loadSession(store) { try { const raw = store.getItem(KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!Array.isArray(parsed.stacks) || !Array.isArray(parsed.entries)) return null; return parsed; } catch { return null; } } export function clearSession(store) { try { store.removeItem(KEY); } catch { // siehe oben } } ``` - [ ] **Step 4: Test laufen lassen, Erfolg prüfen** Run: `npm test` Expected: PASS — 5 neue Tests grün - [ ] **Step 5: Committen** ```bash git add src/storage.js test/storage.test.js git commit -m "feat: Absturzschutz fuer die laufende Sitzung" ``` --- ## Task 8: Kamera-Adapter **Files:** - Create: `src/camera.js` - Modify: `src/main.js` (vorübergehende Sichtprüfung) **Interfaces:** - Consumes: nichts - Produces: - `startCamera(videoElement) -> Promise<{ stop: () => void }>` — wirft bei Verweigerung - `grabFrame(videoElement, maxEdge = 1280) -> ImageData` - `imageDataFromFile(file, maxEdge = 1280) -> Promise` - [ ] **Step 1: `src/camera.js` implementieren** ```js /** * Startet den Kamerastrom in einem