# 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