From 82898a1539da5a51c5ef06c312e8fb5f7eb22ca0 Mon Sep 17 00:00:00 2001 From: vchuser Date: Tue, 28 Jul 2026 14:07:51 +0200 Subject: [PATCH] chore: Spezifikation und Umsetzungsplan --- .gitignore | 4 + .vch-description | 1 + CLAUDE.md | 114 + README.md | 37 + .../plans/2026-07-28-ram-sortierhilfe.md | 2446 +++++++++++++++++ .../2026-07-28-ram-sortierhilfe-design.md | 253 ++ 6 files changed, 2855 insertions(+) create mode 100644 .gitignore create mode 100644 .vch-description create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/superpowers/plans/2026-07-28-ram-sortierhilfe.md create mode 100644 docs/superpowers/specs/2026-07-28-ram-sortierhilfe-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..357c204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +.superpowers/ diff --git a/.vch-description b/.vch-description new file mode 100644 index 0000000..e7c7d39 --- /dev/null +++ b/.vch-description @@ -0,0 +1 @@ +Describe your project here. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a82d3ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# Project + +## Environment + +You are working inside a VCH cloud development environment. + +- **OS:** Debian-based LXC container +- **Editor:** code-server (VS Code in browser) on port 8080 +- **CLI:** Claude Code is available as `claude` in terminal +- **Git:** Pre-configured. Gitea integration available through VCH. +- **Preview:** Start a dev server on any port — VCH detects listening ports automatically and provides preview URLs with SSL. +- **Publishing:** Assign a public subdomain to any port via the VCH Publish page (automatic SSL). +- **User:** `vchuser` with home at `/home/vchuser` + +## Preview Access (IMPORTANT) + +Apps are accessed through a **reverse proxy**, not directly. There are two modes: + +1. **Subdomain proxy** (preferred): `https://lxc{VMID}-{PORT}.dev.example.com/` — your app is at the domain root, everything works normally. +2. **Path-based proxy** (fallback): `https://host/proxy/{INSTANCE}/p/{PORT}/` — your app is behind a path prefix. + +### Rules for portable apps that work in both modes: + +- **ALWAYS use relative paths** for assets, API calls, and links: + - `css/style.css` ✅ — NOT `/css/style.css` ❌ + - `api/users` ✅ — NOT `/api/users` ❌ + - `fetch('api/data')` ✅ — NOT `fetch('/api/data')` ❌ +- **Static file serving:** Configure your server to serve from the request path, not the filesystem root. +- **`` tag:** If you must use absolute paths, add `` in ``. + +### Framework-specific configuration: + +- **Express:** `app.use(express.static('public'))` works — just ensure HTML references are relative. +- **Vite:** Set `base: './'` in `vite.config.ts`. +- **Next.js:** Set `basePath` in `next.config.js` if using path-based proxy, or leave default for subdomain proxy. +- **Create React App:** Set `"homepage": "."` in `package.json`. + +## Development Conventions + +- Write clear, descriptive commit messages (imperative mood: "Add feature", not "Added feature") +- Prefer small, focused commits over large ones +- Run linters and formatters before committing +- Write tests for critical business logic +- Keep dependencies minimal — use native browser/Node APIs where possible + +## README Requirement (MANDATORY) + +Every project MUST have a meaningful `README.md` in the project root. This is enforced by the VCH audit system — projects without a proper README cannot be published. + +Your README must include at minimum: +- **Project description** — what the project does (not just the template placeholder) +- **Installation instructions** — how to install dependencies +- **Development instructions** — how to start the dev server +- **Tech stack** — what technologies are used + +Update the README whenever you add features, change setup steps, or modify the tech stack. The audit will reject READMEs that are still just the default template. + +## Project Description (MANDATORY) + +Every project has a `.vch-description` file in the project root. Keep this file up to date with a clear, non-technical description of your project: +- What the project does +- What problem it solves or what it's used for +- Who it's for + +Do NOT include technical details (tech stack, dependencies, setup instructions) — those belong in the README. The `.vch-description` content is shown in the VCH Showcase and is synced automatically when an audit runs. + +Update `.vch-description` whenever the project's purpose or scope changes significantly. + +## Web Best Practices + +- Use semantic HTML elements (`nav`, `main`, `article`, `section`, etc.) +- Mobile-first responsive design +- Follow accessibility guidelines (ARIA labels, keyboard navigation, color contrast) +- Optimize images and assets for performance +- Use environment variables for configuration — never hardcode secrets + +## Commands + +Fill in project-specific commands below: + +- **Install dependencies:** `npm install` +- **Start dev server:** `npm run dev` +- **Run tests:** `npm test` +- **Build for production:** `npm run build` +- **Lint:** `npm run lint` + +## Deploy preparation + +If this project should be deployed and reads environment variables from `process.env` (or equivalent), create `.env.example` at the repo root. List every variable the app reads, one per line: `KEY=example-value`. Example values are hints only — they are **not** used in production. If the project needs no env vars, no file is required. + +Vibecoders' tip: when in doubt, run `grep -RhoE "process\.env\.[A-Z_]+" src/ | sort -u` and put every result into `.env.example`. + +**Listen on `PORT` (IMPORTANT).** VCH assigns every deployed app its own host port automatically — you never pick or coordinate ports, and two projects on the same machine never clash. Your app MUST bind the port from the `PORT` environment variable, not a hardcoded one: + +- Node/Express: `app.listen(process.env.PORT || 3000)` +- Next.js: `next start` honours `PORT` automatically +- Vite preview / other servers: pass `--port "$PORT"` (or read `process.env.PORT`) + +A hardcoded port works locally but fails the production health-check (VCH checks the assigned port, which usually isn't 3000). For Docker, set `port:` below to the port your app listens on *inside* the container — VCH maps the auto-assigned host port to it for you. + +**Containerized projects (Docker).** If your project has a `Dockerfile` (or a `compose.yaml`/`docker-compose.yml`), add a `.vch/deploy.yaml` at the repo root so VCH deploys it correctly: + +```yaml +runtime: docker +port: 3000 # the port your app LISTENS ON inside the container +health: / # a path that returns 2xx/3xx when the app is up +``` + +- VCH builds your image, runs the container with `--restart=always`, injects production env vars via `--env-file` at runtime, and health-checks `port`. +- **Build-time variables:** only variables prefixed `NEXT_PUBLIC_`, `VITE_`, or `PUBLIC_` are passed to the build as `--build-arg` (they are client-visible by convention). Secrets are runtime-only and are never baked into the image — declare matching `ARG` lines in your Dockerfile for the public ones. +- **Compose:** the app service MUST publish its port as `ports: ["${VCH_PORT}:"]`. VCH sets `VCH_PORT` so it can run an isolated candidate stack during audits without touching your live stack. Add `env_file: [.env]` to any service that needs production env vars — VCH writes your production variables to a `.env` file next to your compose file. +- **VCH runs exactly ONE compose file — no `-f` override chains.** VCH auto-discovers a single compose file (`compose.yaml`/`compose.yml`/`docker-compose.yaml`/`docker-compose.yml`) at the repo root **or in a subfolder** (e.g. `docker/`) and runs only that file. A separate override such as `docker-compose.ports.yml` or `*.override.yml` is **ignored** — put the `${VCH_PORT}` port mapping in the *main* compose file, not in an override. If several matching compose files exist, choose one explicitly with `compose: ` in `.vch/deploy.yaml`. +- **The whole compose starts.** VCH brings up the entire compose project, so DB/cache/auth services your compose defines itself (Postgres, Redis, etc.) start automatically — you do **not** also need a separate managed database for them. (Services behind a compose `profiles:` key stay off unless you activate the profile.) +- If you omit `.vch/deploy.yaml`, VCH falls back to the Dockerfile `EXPOSE` port (or `3000`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..85d3dfd --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# ocr_scanner + +> Short description of what this project does. + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- npm or pnpm + +### Installation + +```bash +npm install +``` + +### Development + +```bash +npm run dev +``` + +## Tech Stack + +- ... + +## Project Structure + +``` +src/ + ... +``` + +## License + +... diff --git a/docs/superpowers/plans/2026-07-28-ram-sortierhilfe.md b/docs/superpowers/plans/2026-07-28-ram-sortierhilfe.md new file mode 100644 index 0000000..556e46a --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ram-sortierhilfe.md @@ -0,0 +1,2446 @@ +# 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