Files
ocr_scanner/docs/superpowers/plans/2026-07-28-ram-sortierhilfe.md

75 KiB
Raw Permalink Blame History

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

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:

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:

<!doctype html>
<html lang="de">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <base href="./" />
    <title>RAM-Sortierhilfe</title>
    <link rel="stylesheet" href="src/styles.css" />
  </head>
  <body>
    <main id="app"></main>
    <script type="module" src="src/main.js"></script>
  </body>
</html>

src/styles.css (Platzhalter, wird in Task 11 gefüllt):

:root { color-scheme: dark; }
body { margin: 0; font-family: system-ui, sans-serif; }

src/main.js (Platzhalter, wird in Task 13 gefüllt):

document.querySelector('#app').textContent = 'RAM-Sortierhilfe';
  • Step 3: Fehlschlagenden Test schreiben

test/spec.test.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('  pc42400 '), '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
// 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
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:

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:

/**
 * 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
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<string,string>, density: Record<string,{capacityGb:number, rank:string}>, speed: Record<string,string> }
    • decodePartNumber(pn: string) -> Spec (immer ein Spec-Objekt; alle nicht ableitbaren Felder null)
  • Step 1: Fehlschlagenden Test schreiben

test/pn-decoder.test.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
// 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<bauform><generation><dichte><rev>-<speed><rest>
    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
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
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:

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
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
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:

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
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
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<Recognition>
    • deps = { decodeBarcodes: (frame) => Promise<string[]>, runOcr: (frame) => Promise<string> }
    • Recognition = { spec: Spec, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red' }
  • Step 1: Fehlschlagenden Test schreiben

test/pipeline.test.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
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
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:

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
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
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<ImageData>
  • Step 1: src/camera.js implementieren

/**
 * Startet den Kamerastrom in einem <video>-Element.
 * Bevorzugt die Ruecckamera und eine hohe Aufloesung, damit die
 * kleine Etikettenschrift lesbar bleibt.
 */
export async function startCamera(videoElement) {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      facingMode: { ideal: 'environment' },
      width: { ideal: 1920 },
      height: { ideal: 1080 },
    },
    audio: false,
  });

  videoElement.srcObject = stream;
  videoElement.setAttribute('playsinline', '');
  await videoElement.play();

  return {
    stop() {
      for (const track of stream.getTracks()) track.stop();
      videoElement.srcObject = null;
    },
  };
}

function drawScaled(source, sourceWidth, sourceHeight, maxEdge) {
  const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
  const width = Math.round(sourceWidth * scale);
  const height = Math.round(sourceHeight * scale);

  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext('2d', { willReadFrequently: true });
  context.drawImage(source, 0, 0, width, height);
  return context.getImageData(0, 0, width, height);
}

/** Einzelbild aus dem laufenden Kamerastrom. */
export function grabFrame(videoElement, maxEdge = 1280) {
  return drawScaled(
    videoElement,
    videoElement.videoWidth,
    videoElement.videoHeight,
    maxEdge,
  );
}

/** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */
export async function imageDataFromFile(file, maxEdge = 1280) {
  const bitmap = await createImageBitmap(file);
  const data = drawScaled(bitmap, bitmap.width, bitmap.height, maxEdge);
  bitmap.close();
  return data;
}
  • Step 2: Vorübergehende Sichtprüfung in src/main.js
import { startCamera, grabFrame } from './camera.js';

const app = document.querySelector('#app');
app.innerHTML = `
  <video id="v" style="width:100%"></video>
  <button id="b" style="min-height:56px;width:100%">Bild aufnehmen</button>
  <pre id="out"></pre>
`;

const video = document.querySelector('#v');
const out = document.querySelector('#out');

startCamera(video)
  .then(() => { out.textContent = 'Kamera laeuft'; })
  .catch((error) => { out.textContent = `Kamera nicht verfuegbar: ${error.message}`; });

document.querySelector('#b').addEventListener('click', () => {
  const frame = grabFrame(video);
  out.textContent = `Bild: ${frame.width}x${frame.height}`;
});
  • Step 3: Im Browser prüfen

Run: npm run dev Dann die von VCH gemeldete Preview-URL auf dem Handy öffnen. Expected: Kamerabild erscheint, Knopf meldet Bild: 1280x720 (oder ähnlich). Erscheint stattdessen „Kamera nicht verfuegbar", prüfen, dass die URL mit https:// beginnt.

  • Step 4: Committen
git add src/camera.js src/main.js
git commit -m "feat: Kamera-Adapter mit Datei-Ersatzweg"

Task 9: Barcode-Adapter

Files:

  • Create: src/barcode.js
  • Modify: src/main.js (Sichtprüfung erweitern)

Interfaces:

  • Consumes: zxing-wasm

  • Produces: decodeBarcodes(imageData) -> Promise<string[]> — dekodierte Texte, leeres Array wenn nichts gefunden

  • Step 1: src/barcode.js implementieren

import { readBarcodes, prepareZXingModule } from 'zxing-wasm/reader';

let vorbereitet = false;

async function ensureReady() {
  if (vorbereitet) return;
  await prepareZXingModule({ fireImmediately: true });
  vorbereitet = true;
}

/**
 * Dekodiert Code-128 und DataMatrix aus einem Einzelbild.
 * Barcode-Dekodierung ist exakt - deshalb ist sie die bevorzugte Quelle.
 * @returns {Promise<string[]>}
 */
export async function decodeBarcodes(imageData) {
  await ensureReady();
  const results = await readBarcodes(imageData, {
    formats: ['Code128', 'DataMatrix'],
    tryHarder: true,
    maxNumberOfSymbols: 4,
  });
  return results.map((result) => result.text).filter((text) => text.length > 0);
}

Sollte der Import-Pfad zxing-wasm/reader in der installierten Fassung nicht existieren, mit node -e "console.log(Object.keys(require('zxing-wasm')))" bzw. einem Blick in node_modules/zxing-wasm/package.json (exports) den korrekten Einstiegspunkt ermitteln und hier eintragen. Die Funktionsnamen readBarcodes und prepareZXingModule sind stabil.

  • Step 2: Sichtprüfung in src/main.js erweitern

Den Klick-Handler ersetzen durch:

import { decodeBarcodes } from './barcode.js';

document.querySelector('#b').addEventListener('click', async () => {
  const frame = grabFrame(video);
  const codes = await decodeBarcodes(frame);
  out.textContent = codes.length ? codes.join('\n') : 'kein Barcode gefunden';
});
  • Step 3: Mit dem Referenzmodul prüfen

Run: npm run dev, Preview-URL auf dem Handy öffnen, das Samsung-Etikett ins Bild halten, Knopf drücken. Expected: Der Code-128 wird gelesen, angezeigt wird unter anderem M386A8K40BM1-CRC4Y (aus dem Code-128) sowie der DataMatrix-Inhalt.

  • Step 4: Committen
git add src/barcode.js src/main.js
git commit -m "feat: Barcode-Adapter fuer Code-128 und DataMatrix"

Task 10: OCR-Adapter mit Bildaufbereitung

Files:

  • Create: src/ocr.js
  • Test: test/ocr-preprocess.test.js

Interfaces:

  • Consumes: tesseract.js

  • Produces:

    • preprocess(imageData) -> ImageData — Graustufen, Kontrastspreizung, Schwellwert (rein rechnend, ohne DOM)
    • runOcr(imageData) -> Promise<string>
    • isOcrAvailable() -> boolean
  • Step 1: Fehlschlagenden Test für die Bildaufbereitung schreiben

test/ocr-preprocess.test.js:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { preprocess } from '../src/ocr.js';

/** Minimaler ImageData-Ersatz, damit der Test ohne Browser laeuft. */
function bild(pixel) {
  const data = new Uint8ClampedArray(pixel.length * 4);
  pixel.forEach((wert, i) => {
    data[i * 4] = wert;
    data[i * 4 + 1] = wert;
    data[i * 4 + 2] = wert;
    data[i * 4 + 3] = 255;
  });
  return { width: pixel.length, height: 1, data };
}

test('preprocess erzeugt ein reines Schwarz-Weiss-Bild', () => {
  const ergebnis = preprocess(bild([10, 40, 200, 250]));
  for (let i = 0; i < ergebnis.data.length; i += 4) {
    const wert = ergebnis.data[i];
    assert.ok(wert === 0 || wert === 255, `Pixel ${i / 4} ist ${wert}`);
    assert.equal(ergebnis.data[i + 1], wert);
    assert.equal(ergebnis.data[i + 2], wert);
    assert.equal(ergebnis.data[i + 3], 255);
  }
});

test('preprocess trennt dunkle von hellen Pixeln', () => {
  const ergebnis = preprocess(bild([10, 40, 200, 250]));
  assert.equal(ergebnis.data[0], 0, 'dunkelstes Pixel wird schwarz');
  assert.equal(ergebnis.data[12], 255, 'hellstes Pixel wird weiss');
});

test('preprocess laesst Breite und Hoehe unveraendert', () => {
  const ergebnis = preprocess(bild([0, 128, 255]));
  assert.equal(ergebnis.width, 3);
  assert.equal(ergebnis.height, 1);
});
  • Step 2: Test laufen lassen, Fehlschlag prüfen

Run: npm test Expected: FAIL — Cannot find module '.../src/ocr.js'

  • Step 3: src/ocr.js implementieren
import { createWorker } from 'tesseract.js';

let workerPromise = null;
let verfuegbar = true;

/**
 * Graustufen, Kontrastspreizung, globaler Schwellwert.
 * Glaenzende Metalletiketten liefern flaue Bilder; ohne diese
 * Aufbereitung liest Tesseract dort kaum etwas Brauchbares.
 * Rein rechnend - erzeugt kein Canvas und ist damit testbar.
 */
export function preprocess(imageData) {
  const { width, height, data } = imageData;
  const grau = new Uint8ClampedArray(width * height);

  let min = 255;
  let max = 0;
  for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
    const wert = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
    grau[p] = wert;
    if (wert < min) min = wert;
    if (wert > max) max = wert;
  }

  const spanne = Math.max(1, max - min);
  const schwelle = 128;
  const out = new Uint8ClampedArray(data.length);
  for (let p = 0; p < grau.length; p += 1) {
    const gespreizt = ((grau[p] - min) * 255) / spanne;
    const wert = gespreizt >= schwelle ? 255 : 0;
    const i = p * 4;
    out[i] = wert;
    out[i + 1] = wert;
    out[i + 2] = wert;
    out[i + 3] = 255;
  }

  return { width, height, data: out };
}

function getWorker() {
  if (!workerPromise) {
    workerPromise = createWorker('eng').then(async (worker) => {
      await worker.setParameters({
        // Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen.
        tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. ',
      });
      return worker;
    });
  }
  return workerPromise;
}

/** True, solange Tesseract nicht endgueltig als nicht ladbar erkannt wurde. */
export function isOcrAvailable() {
  return verfuegbar;
}

/**
 * Liest den Klartext eines Etiketts.
 * Scheitert das Laden von Tesseract, bleibt die App barcode-only nutzbar.
 * @returns {Promise<string>}
 */
export async function runOcr(imageData) {
  const vorbereitet = preprocess(imageData);
  const canvas = document.createElement('canvas');
  canvas.width = vorbereitet.width;
  canvas.height = vorbereitet.height;
  canvas
    .getContext('2d')
    .putImageData(new ImageData(vorbereitet.data, vorbereitet.width, vorbereitet.height), 0, 0);

  try {
    const worker = await getWorker();
    const { data } = await worker.recognize(canvas);
    return data.text ?? '';
  } catch (error) {
    verfuegbar = false;
    workerPromise = null;
    throw error;
  }
}
  • Step 4: Test laufen lassen, Erfolg prüfen

Run: npm test Expected: PASS — 3 neue Tests grün

  • Step 5: Committen
git add src/ocr.js test/ocr-preprocess.test.js
git commit -m "feat: OCR-Adapter mit Bildaufbereitung"

Task 11: Scan-Ansicht und Treffer-Rückmeldung

Files:

  • Create: src/ui/scan-view.js, src/ui/result-overlay.js
  • Modify: src/styles.css

Interfaces:

  • Consumes: nichts (reine Darstellung, Daten kommen als Parameter)

  • Produces:

    • renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) -> { video, setStacks, setLast, setStatus, openFilePicker }
    • showResult(root, { stackId, spec, confidence }) -> Promise<void> — blendet ein, wartet 1200 ms, blendet aus
  • Step 1: src/styles.css schreiben

:root {
  color-scheme: dark;
  --bg: #14161a;
  --fg: #f2f4f7;
  --muted: #9aa3af;
  --green: #1f8a4c;
  --yellow: #b8860b;
  --red: #a02c2c;
  --line: #2a2e36;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: system-ui, -apple-system, sans-serif;
  overscroll-behavior: none;
}

#app { display: flex; flex-direction: column; height: 100dvh; }

.camera { position: relative; flex: 1; overflow: hidden; background: #000; }
.camera video { width: 100%; height: 100%; object-fit: cover; }

.frame {
  position: absolute;
  inset: 20% 8%;
  border: 3px solid rgba(255, 255, 255, 0.7);
  border-radius: 8px;
  pointer-events: none;
}

.stacks {
  display: flex;
  gap: 8px;
  padding: 10px 12px;
  overflow-x: auto;
  border-top: 1px solid var(--line);
  min-height: 56px;
  align-items: center;
}

.stacks button {
  flex: none;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #1c2027;
  color: var(--fg);
  font-size: 17px;
  font-weight: 600;
}

.last {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border-top: 1px solid var(--line);
  min-height: 56px;
  font-size: 15px;
  color: var(--muted);
}

.last span { flex: 1; }

.action {
  min-height: 56px;
  border: none;
  border-radius: 10px;
  background: #2b6cb0;
  color: #fff;
  font-size: 18px;
  font-weight: 600;
  padding: 0 18px;
}

.action.secondary { background: #2a2e36; }

.capture { margin: 12px; }

.overlay {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 24px;
  text-align: center;
  z-index: 10;
}

.overlay.green { background: var(--green); }
.overlay.yellow { background: var(--yellow); }
.overlay.red { background: var(--red); }

.overlay .stack-id { font-size: 68px; font-weight: 800; letter-spacing: 2px; }
.overlay .detail { font-size: 20px; line-height: 1.5; }
.overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; }

.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; }

.status { padding: 8px 12px; font-size: 14px; color: var(--muted); }
.status.warn { color: #f0b429; }
  • Step 2: src/ui/scan-view.js implementieren
/**
 * Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
 * Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
 */
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) {
  root.innerHTML = `
    <div class="camera">
      <video id="preview" playsinline muted></video>
      <div class="frame"></div>
    </div>
    <div class="status" id="status"></div>
    <button class="action capture" id="capture">Modul scannen</button>
    <div class="stacks" id="stacks"></div>
    <div class="last">
      <span id="last">Noch nichts erfasst</span>
      <button class="action secondary" id="undo" style="min-width:64px">&#8630;</button>
    </div>
    <input type="file" id="file" accept="image/*" hidden />
  `;

  const stacksEl = root.querySelector('#stacks');
  const lastEl = root.querySelector('#last');
  const statusEl = root.querySelector('#status');
  const fileEl = root.querySelector('#file');

  root.querySelector('#capture').addEventListener('click', onCapture);
  root.querySelector('#undo').addEventListener('click', onUndo);
  fileEl.addEventListener('change', () => {
    if (fileEl.files[0]) onPickFile(fileEl.files[0]);
    fileEl.value = '';
  });

  return {
    video: root.querySelector('#preview'),

    /** @param {{id: string, count: number}[]} stacks */
    setStacks(stacks) {
      stacksEl.innerHTML = '';
      if (stacks.length === 0) {
        stacksEl.textContent = 'Noch keine Stapel';
        return;
      }
      for (const stack of stacks) {
        const button = document.createElement('button');
        button.textContent = `${stack.id}: ${stack.count}`;
        button.addEventListener('click', onOpenList);
        stacksEl.appendChild(button);
      }
    },

    setLast(text) {
      lastEl.textContent = text;
    },

    /** @param {string} text @param {boolean} warn */
    setStatus(text, warn = false) {
      statusEl.textContent = text;
      statusEl.classList.toggle('warn', warn);
    },

    openFilePicker() {
      fileEl.hidden = false;
      fileEl.click();
    },
  };
}
  • Step 3: src/ui/result-overlay.js implementieren
function beschreibung(spec) {
  const teile = [
    spec.capacityGb ? `${spec.capacityGb}GB` : null,
    spec.rank,
    spec.speed,
    spec.formFactor,
  ].filter(Boolean);
  return teile.join(' ') || 'ohne Angaben';
}

/**
 * Blendet die Treffer-Rueckmeldung ein und nach kurzer Zeit wieder aus.
 * Gruen bei Barcode, gelb bei OCR. Keine Eingabe noetig.
 */
export function showResult(root, { stackId, spec, confidence }, dauerMs = 1200) {
  const overlay = document.createElement('div');
  overlay.className = `overlay ${confidence}`;
  overlay.innerHTML = `
    <div class="stack-id">STAPEL ${stackId}</div>
    <div class="detail"></div>
    <div class="pn"></div>
  `;
  overlay.querySelector('.detail').textContent = beschreibung(spec);
  overlay.querySelector('.pn').textContent = spec.partNumber ?? '';
  root.appendChild(overlay);

  return new Promise((resolve) => {
    setTimeout(() => {
      overlay.remove();
      resolve();
    }, dauerMs);
  });
}
  • Step 4: Im Browser prüfen

src/main.js vorübergehend so ergänzen, dass nach dem Aufnehmen showResult mit erfundenen Werten aufgerufen wird:

import { renderScanView } from './ui/scan-view.js';
import { showResult } from './ui/result-overlay.js';
import { startCamera } from './camera.js';

const app = document.querySelector('#app');
const view = renderScanView(app, {
  onCapture: () =>
    showResult(app, {
      stackId: 'B',
      spec: { capacityGb: 64, rank: '4DRx4', speed: 'PC4-2400', formFactor: 'LRDIMM', partNumber: 'M386A8K40BM1-CRC4Y' },
      confidence: 'green',
    }),
  onUndo: () => view.setLast('zurueckgenommen'),
  onOpenList: () => {},
  onPickFile: () => {},
});
view.setStacks([{ id: 'A', count: 12 }, { id: 'B', count: 4 }]);
startCamera(view.video).catch(() => view.setStatus('Kamera nicht verfuegbar', true));

Run: npm run dev, Preview-URL auf dem Handy öffnen. Expected: Kamerabild mit Zielrahmen, Stapel-Leiste zeigt A: 12 und B: 4, Knopf blendet die grüne Rückmeldung ein, die nach gut einer Sekunde von selbst verschwindet. Alle Bedienflächen mit dem Daumen erreichbar.

  • Step 5: Committen
git add src/styles.css src/ui/scan-view.js src/ui/result-overlay.js src/main.js
git commit -m "feat: Scan-Ansicht und Treffer-Rueckmeldung"

Task 12: Rot-Dialog

Files:

  • Create: src/ui/ambiguous-dialog.js

Interfaces:

  • Consumes: nichts

  • Produces: askForStack(root, { spec, candidates }) -> Promise<{ action: 'stack', stackId: string } | { action: 'new' } | { action: 'retry' }>

  • Step 1: src/ui/ambiguous-dialog.js implementieren

function gelesen(spec) {
  const teile = [
    spec.capacityGb ? `${spec.capacityGb}GB` : null,
    spec.rank,
    spec.speed,
    spec.formFactor,
    spec.partNumber,
  ].filter(Boolean);
  return teile.length ? teile.join(' ') : 'nichts Verwertbares';
}

/**
 * Haelt den Ablauf an und laesst den Nutzer entscheiden.
 * Der einzige Punkt, an dem eine Eingabe verlangt wird.
 * @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>}
 */
export function askForStack(root, { spec, candidates }) {
  const overlay = document.createElement('div');
  overlay.className = 'overlay red';
  overlay.innerHTML = `
    <div class="stack-id" style="font-size:34px">Nicht eindeutig</div>
    <div class="detail">Gelesen: <span id="read"></span></div>
    <div class="choices" id="choices"></div>
  `;
  overlay.querySelector('#read').textContent = gelesen(spec);

  const choices = overlay.querySelector('#choices');
  root.appendChild(overlay);

  return new Promise((resolve) => {
    const finish = (result) => {
      overlay.remove();
      resolve(result);
    };

    for (const stackId of candidates) {
      const button = document.createElement('button');
      button.className = 'action';
      button.textContent = `Stapel ${stackId}`;
      button.addEventListener('click', () => finish({ action: 'stack', stackId }));
      choices.appendChild(button);
    }

    const neu = document.createElement('button');
    neu.className = 'action secondary';
    neu.textContent = 'neuer Stapel';
    neu.addEventListener('click', () => finish({ action: 'new' }));
    choices.appendChild(neu);

    const nochmal = document.createElement('button');
    nochmal.className = 'action secondary';
    nochmal.textContent = 'nochmal scannen';
    nochmal.addEventListener('click', () => finish({ action: 'retry' }));
    choices.appendChild(nochmal);
  });
}
  • Step 2: Im Browser prüfen

In src/main.js den onCapture-Rückruf vorübergehend ersetzen:

onCapture: async () => {
  const antwort = await askForStack(app, {
    spec: { capacityGb: 64, rank: null, speed: null, formFactor: null, partNumber: null },
    candidates: ['A', 'B'],
  });
  view.setLast(`Antwort: ${JSON.stringify(antwort)}`);
},

Dazu oben import { askForStack } from './ui/ambiguous-dialog.js'; ergänzen.

Run: npm run dev, Preview-URL öffnen, Knopf drücken. Expected: Roter Dialog mit vier großen Flächen (Stapel A, Stapel B, neuer Stapel, nochmal scannen). Jeder Tipp schließt den Dialog und schreibt die Antwort in die Zuletzt-Zeile.

  • Step 3: Committen
git add src/ui/ambiguous-dialog.js src/main.js
git commit -m "feat: Rot-Dialog fuer mehrdeutige Erkennung"

Task 13: Sitzungsliste und Verdrahtung

Hier entsteht die vollständige App.

Files:

  • Create: src/ui/session-list.js
  • Modify: src/main.js (vollständig ersetzen)

Interfaces:

  • Consumes: alle bisherigen Module

  • Produces: renderSessionList(root, session, { onMove, onRemove, onEndSession, onClose }) -> void

  • Step 1: src/ui/session-list.js implementieren

function beschreibung(spec) {
  const teile = [
    spec.capacityGb ? `${spec.capacityGb}GB` : '?',
    spec.rank ?? '?',
    spec.speed ?? '?',
    spec.formFactor ?? '?',
  ];
  return teile.join(' ');
}

/** Vollbild-Liste aller Stapel der laufenden Sitzung. */
export function renderSessionList(root, session, { onMove, onRemove, onEndSession, onClose }) {
  const overlay = document.createElement('div');
  overlay.className = 'overlay';
  overlay.style.background = 'var(--bg)';
  overlay.style.justifyContent = 'flex-start';
  overlay.style.overflowY = 'auto';
  overlay.innerHTML = `
    <div class="stack-id" style="font-size:26px">Sitzung</div>
    <div class="choices" id="body" style="max-width:520px"></div>
    <div class="choices" style="max-width:520px">
      <button class="action" id="close">zurueck zum Scannen</button>
      <button class="action secondary" id="end">Sitzung beenden</button>
    </div>
  `;

  const body = overlay.querySelector('#body');

  for (const stack of session.stacks) {
    const kopf = document.createElement('div');
    kopf.className = 'detail';
    kopf.style.textAlign = 'left';
    kopf.style.marginTop = '12px';
    kopf.textContent = `Stapel ${stack.id}${stack.count}x ${beschreibung(stack.spec)}`;
    body.appendChild(kopf);

    for (const entry of session.entries.filter((e) => e.stackId === stack.id)) {
      const zeile = document.createElement('div');
      zeile.style.display = 'flex';
      zeile.style.gap = '8px';
      zeile.style.alignItems = 'center';

      const text = document.createElement('span');
      text.style.flex = '1';
      text.style.fontSize = '14px';
      text.style.textAlign = 'left';
      text.textContent = entry.spec.partNumber ?? beschreibung(entry.spec);
      zeile.appendChild(text);

      const wahl = document.createElement('select');
      wahl.style.minHeight = '44px';
      for (const ziel of session.stacks) {
        const option = document.createElement('option');
        option.value = ziel.id;
        option.textContent = ziel.id;
        option.selected = ziel.id === entry.stackId;
        wahl.appendChild(option);
      }
      wahl.addEventListener('change', () => onMove(entry.entryId, wahl.value));
      zeile.appendChild(wahl);

      const weg = document.createElement('button');
      weg.className = 'action secondary';
      weg.style.minWidth = '56px';
      weg.textContent = '✕';
      weg.addEventListener('click', () => onRemove(entry.entryId));
      zeile.appendChild(weg);

      body.appendChild(zeile);
    }
  }

  if (session.stacks.length === 0) {
    body.textContent = 'Noch nichts erfasst.';
  }

  overlay.querySelector('#close').addEventListener('click', () => {
    overlay.remove();
    onClose();
  });
  overlay.querySelector('#end').addEventListener('click', () => {
    overlay.remove();
    onEndSession();
  });

  root.appendChild(overlay);
}
  • Step 2: src/main.js vollständig ersetzen
import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js';
import {
  createSession, proposeAssignment, commitAssignment,
  undoLast, moveEntry, removeEntry,
} from './session.js';
import { saveSession, loadSession, clearSession } from './storage.js';
import { renderScanView } from './ui/scan-view.js';
import { showResult } from './ui/result-overlay.js';
import { askForStack } from './ui/ambiguous-dialog.js';
import { renderSessionList } from './ui/session-list.js';

const app = document.querySelector('#app');
const store = window.localStorage;

let session = loadSession(store) ?? createSession();
let beschaeftigt = false;

const view = renderScanView(app, {
  onCapture: () => verarbeite(() => grabFrame(view.video)),
  onUndo: () => {
    const entry = undoLast(session);
    view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen');
    aktualisiere();
  },
  onOpenList: oeffneListe,
  onPickFile: (file) => verarbeite(() => imageDataFromFile(file)),
});

function aktualisiere() {
  view.setStacks(session.stacks);
  saveSession(session, store);
}

function beschreibe(spec, stackId) {
  const teile = [
    spec.capacityGb ? `${spec.capacityGb}GB` : '?',
    spec.speed ?? '?',
    spec.formFactor ?? '',
  ].filter(Boolean);
  return `${teile.join(' ')} → Stapel ${stackId}`;
}

async function verarbeite(frameLiefern) {
  if (beschaeftigt) return;
  beschaeftigt = true;
  view.setStatus('erkenne …');

  try {
    const frame = await frameLiefern();
    const { spec, confidence } = await recognize(frame, {
      decodeBarcodes,
      runOcr,
    });

    const plan = proposeAssignment(session, spec);
    let stackId = plan.stackId;

    if (confidence === 'red' || plan.kind === 'ambiguous') {
      const antwort = await askForStack(app, {
        spec,
        candidates: plan.kind === 'ambiguous' ? plan.candidates : session.stacks.map((s) => s.id),
      });
      if (antwort.action === 'retry') return;
      stackId = antwort.action === 'stack' ? antwort.stackId : naechsterFreierStapel();
    }

    const entry = commitAssignment(session, spec, confidence, stackId);
    view.setLast(beschreibe(spec, entry.stackId));
    aktualisiere();

    if (confidence !== 'red') {
      await showResult(app, { stackId: entry.stackId, spec, confidence });
    }
  } catch (error) {
    view.setStatus(`Fehler: ${error.message}`, true);
  } finally {
    beschaeftigt = false;
    if (!isOcrAvailable()) {
      view.setStatus('Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', true);
    } else {
      view.setStatus('');
    }
  }
}

function naechsterFreierStapel() {
  const used = new Set(session.stacks.map((stack) => stack.id));
  for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
    if (!used.has(letter)) return letter;
  }
  return `A${session.stacks.length}`;
}

function oeffneListe() {
  renderSessionList(app, session, {
    onMove: (entryId, stackId) => {
      moveEntry(session, entryId, stackId);
      aktualisiere();
    },
    onRemove: (entryId) => {
      removeEntry(session, entryId);
      aktualisiere();
      document.querySelectorAll('.overlay').forEach((el) => el.remove());
      oeffneListe();
    },
    onEndSession: () => {
      session = createSession();
      clearSession(store);
      view.setLast('Sitzung beendet');
      aktualisiere();
    },
    onClose: () => {},
  });
}

startCamera(view.video).catch((error) => {
  view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true);
  view.openFilePicker();
});

aktualisiere();
  • Step 3: Tests laufen lassen

Run: npm test Expected: PASS — alle bisherigen Tests weiterhin grün (die Verdrahtung berührt die reinen Module nicht).

  • Step 4: Vollständigen Durchlauf im Browser prüfen

Run: npm run dev, Preview-URL auf dem Handy öffnen.

Prüfen mit dem Samsung-Referenzmodul:

  1. Modul mit dem Samsung-Etikett scannen → grüne Rückmeldung, STAPEL A, 64GB 4DRx4 PC4-2400 LRDIMM.
  2. Dasselbe Modul erneut scannen → wieder STAPEL A, Zähler steht auf 2.
  3. Ein anderes Modul scannen → STAPEL B.
  4. Rückgängig drücken → Zähler von B fällt weg, Zuletzt-Zeile meldet die Rücknahme.
  5. Etikett abdecken und scannen → roter Dialog erscheint.
  6. Seite neu laden → die Stapel sind noch da.
  7. Sitzungsliste öffnen, einen Eintrag umsortieren, Sitzung beenden → alles leer.
  • Step 5: Committen
git add src/ui/session-list.js src/main.js
git commit -m "feat: Sitzungsliste und Verdrahtung zur vollstaendigen App"

Task 14: Dokumentation, Tabellen-Validierung, Abnahme

Files:

  • Create: README.md, .vch-description (letzteres überschreiben)
  • Modify: src/pn-tables.js (nach Validierung)

Interfaces:

  • Consumes: nichts

  • Produces: nichts

  • Step 1: .vch-description überschreiben

Sortierhilfe fuer gebrauchte Server-RAM-Module. Man haelt ein Modul vor die
Handykamera, und die App sagt sofort, auf welchen Stapel es gehoert - Module
mit identischen technischen Daten und identischer Hersteller-Teilenummer
landen zusammen, damit daraus verkaufsfertige Kits entstehen.

Gedacht fuer den Wareneingang im Gebrauchthandel mit Serverkomponenten, wo
gemischte Chargen ankommen und von Hand sortiert werden muessen. Die App
ersetzt das Abtippen von Etiketten und das Vergleichen im Kopf.
  • Step 2: README.md schreiben
# RAM-Sortierhilfe

Browser-App, die per Handykamera RAM-Module erkennt und beim physischen
Sortieren am Tisch anleitet: Modul vor die Kamera halten, die App nennt den
Stapel. Module mit identischer Spec **und** identischer Hersteller-Teilenummer
bilden einen Stapel.

Die Erkennung läuft vollständig lokal im Browser — kein Server, keine Cloud,
keine Daten verlassen das Gerät.

## Funktionsweise

1. **Barcode zuerst.** Code-128 und DataMatrix werden aus dem Kamerabild
   dekodiert. Das ist exakt, im Gegensatz zu Texterkennung.
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y`
   werden Kapazität, Bauform und Geschwindigkeit abgeleitet. Gelingt das,
   entfällt OCR vollständig.
3. **OCR als Rückfallebene.** Nur bei überklebten oder beschädigten Etiketten.
   Das Ergebnis wird gegen die bekannten Spec-Werte abgeglichen, was die
   typischen Verwechslungen (0/O, 1/I, 8/B) auflöst.
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
   durch; nur bei Unsicherheit fragt die App nach.

## Installation

```bash
npm install
```

## Entwicklung

```bash
npm run dev
```

Der Server bindet an alle Interfaces; VCH stellt eine Preview-URL mit SSL
bereit. **HTTPS ist Pflicht** — ohne sichere Verbindung gibt der Browser die
Kamera nicht frei.

## Tests

```bash
npm test
```

Getestet werden die reinen Module: Teilenummer-Decoder, Spec-Normalisierung
und toleranter Vergleich, OCR-Feldextraktion, Stapel-Zuweisung, Pipeline und
Sitzungssicherung. Kamera und Oberfläche werden manuell geprüft.

## Produktion

```bash
npm run build
npm run preview
```

`preview` hört auf `PORT` aus der Umgebung.

## Technik

- Vanilla JavaScript (ES Modules), kein Framework
- [Vite](https://vite.dev/) als Entwicklungsserver und Bündler
- [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm) für Code-128 und DataMatrix
- [`tesseract.js`](https://tesseract.projectnaptha.com/) für Texterkennung
- `node:test` für Tests, ohne zusätzliche Test-Bibliothek

## Aufbau

| Datei | Verantwortung |
|---|---|
| `src/spec.js` | Spec-Objekt, bekannte Werte, Fingerabdruck, toleranter Vergleich |
| `src/pn-tables.js` | Herstellertabellen |
| `src/pn-decoder.js` | Teilenummer → Spec-Felder |
| `src/ocr-extract.js` | Rohtext → Spec-Felder |
| `src/session.js` | Stapel halten und zuweisen |
| `src/pipeline.js` | Barcode → PN → OCR → Ampelfarbe |
| `src/storage.js` | Absturzschutz der laufenden Sitzung |
| `src/camera.js`, `src/barcode.js`, `src/ocr.js` | Adapter an den Rändern |
| `src/ui/` | Ansichten |

`spec`, `pn-decoder`, `ocr-extract`, `session` und `pipeline` sind reine
Funktionen ohne Browser-Zugriff und deshalb vollständig testbar.

## Grenzen

- Keine Bestandsführung über Sitzungen hinweg, kein Export.
- Das Laden der Seite braucht eine Verbindung; das Sortieren selbst nicht.
- Der Teilenummer-Decoder kennt bisher nur das Samsung-DDR4-Schema. Unbekannte
  Schemata sind kein Fehler — sie führen zum OCR-Weg. Erweiterung siehe unten.

## Herstellertabellen erweitern

`src/pn-tables.js` ist tabellengesteuert. Ein neuer Hersteller braucht einen
Eintrag mit `pattern`, `formFactor`, `density` und `speed` — keine
Codeänderung. Jeder neue Eintrag gehört mit einem realen Modul in
`test/pn-decoder.test.js` abgesichert.
  • Step 3: Herstellertabellen gegen reale Module validieren

Diese Aufgabe braucht Eingaben, die im Plan nicht vorliegen. Vom Nutzer werden Fotos oder abgetippte Angaben realer Module benötigt: jeweils Hersteller-Teilenummer und die dazugehörige Klartextzeile mit Kapazität, Rank und Geschwindigkeit.

Solange sie fehlen, gilt:

  • Der Samsung-Eintrag A8K40 und der Geschwindigkeitscode CRC sind belegt.
  • Die übrigen Geschwindigkeitscodes (CPB, CTD, CVF, CWE) und die Bauform-Codes stammen aus der veröffentlichten Systematik und sind nicht gegen ein reales Modul geprüft.
  • Hynix und Micron fehlen vollständig; deren Module laufen über den OCR-Weg.

Für jedes gelieferte Modul: einen Testfall in test/pn-decoder.test.js ergänzen, dann den passenden Tabelleneintrag in src/pn-tables.js hinzufügen, bis der Test grün ist.

  • Step 4: Gesamtabnahme

Run: npm test Expected: PASS — alle Tests grün.

Run: npm run build Expected: dist/ wird erzeugt, keine Fehler.

Run: npm run preview, Preview-URL auf dem Handy öffnen. Expected: Der Durchlauf aus Task 13, Step 4 funktioniert auch im gebauten Stand.

  • Step 5: Committen
git add README.md .vch-description src/pn-tables.js test/pn-decoder.test.js
git commit -m "docs: README und Projektbeschreibung"

Offene Punkte

  • Herstellertabellen (Task 14, Step 3) brauchen reale Module vom Nutzer. Ohne sie funktioniert die App, nur läuft mehr Ware über den langsameren und ungenaueren OCR-Weg.
  • Farb- und Schriftgestaltung ist bewusst schlicht gehalten und kann nach dem ersten Einsatz am Tisch nachgezogen werden.