75 KiB
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"inpackage.json. Alle Dateien sind ES Modules.- Relative Pfade überall.
base: './'invite.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 keinwindow,document,navigatoroderlocalStoragebenutzen. 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() -> SpecSpec={ capacityGb: number|null, formFactor: string|null, rank: string|null, speed: string|null, partNumber: string|null, dateCode: string|null }normalizeToken(raw: string) -> stringfingerprint(spec: Spec) -> stringisUsable(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(' 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.jsimplementieren
// 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,KNOWNaus Task 1 -
Produces:
canonical(raw: string) -> stringmatchKnown(raw: string, list: (string|number)[]) -> string|number|nullspecsCompatible(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.jserweitern
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,normalizeTokenaussrc/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 Feldernull)
-
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.jsimplementieren
// 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.jsimplementieren
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,normalizeTokenaussrc/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.jsimplementieren
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,fingerprintaussrc/spec.js -
Produces:
createSession() -> SessionSession={ stacks: Stack[], entries: Entry[], nextEntryId: number }Stack={ id: string, spec: Spec, count: number }—idist'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) -> EntryundoLast(session) -> Entry|nullmoveEntry(session, entryId, stackId) -> voidremoveEntry(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.jsimplementieren
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,isUsableaussrc/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.jsimplementieren
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) -> voidloadSession(store) -> Session|nullclearSession(store) -> voidstoreist ein Objekt mitgetItem,setItem,removeItem(im Browserwindow.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.jsimplementieren
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 VerweigerunggrabFrame(videoElement, maxEdge = 1280) -> ImageDataimageDataFromFile(file, maxEdge = 1280) -> Promise<ImageData>
-
Step 1:
src/camera.jsimplementieren
/**
* 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.jsimplementieren
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/readerin der installierten Fassung nicht existieren, mitnode -e "console.log(Object.keys(require('zxing-wasm')))"bzw. einem Blick innode_modules/zxing-wasm/package.json(exports) den korrekten Einstiegspunkt ermitteln und hier eintragen. Die FunktionsnamenreadBarcodesundprepareZXingModulesind stabil.
- Step 2: Sichtprüfung in
src/main.jserweitern
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.jsimplementieren
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.cssschreiben
: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.jsimplementieren
/**
* 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">↶</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.jsimplementieren
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.jsimplementieren
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.jsimplementieren
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.jsvollstä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:
- Modul mit dem Samsung-Etikett scannen → grüne Rückmeldung,
STAPEL A,64GB 4DRx4 PC4-2400 LRDIMM. - Dasselbe Modul erneut scannen → wieder
STAPEL A, Zähler steht auf 2. - Ein anderes Modul scannen →
STAPEL B. - Rückgängig drücken → Zähler von B fällt weg, Zuletzt-Zeile meldet die Rücknahme.
- Etikett abdecken und scannen → roter Dialog erscheint.
- Seite neu laden → die Stapel sind noch da.
- 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.mdschreiben
# 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
A8K40und der GeschwindigkeitscodeCRCsind 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.