Compare commits

..
10 Commits
Author SHA1 Message Date
vchuser b679df6fd3 Doku: Codeabgleich nur innerhalb vertraeglicher Angaben, Grenze ergaenzt
README beschreibt jetzt, dass ein Codetreffer einen Widerspruch der
technischen Angaben nicht mehr ueberstimmt (Fehler 1) und dass die
Codemenge eines Stapels ein abgeleiteter statt angesammelter Wert ist
(Fehler 2). Neue Grenze dokumentiert: ein gelesener Barcode-Inhalt ist exakt
gelesen, aber nicht garantiert eine Teilenummer - eine geteilte Los- oder
Wiederverkaeufer-Kennung kann zwei verschiedene Module verbinden, wogegen
die neue Widerspruchspruefung schuetzt. Testanzahl auf 124 aktualisiert.
2026-07-29 09:22:11 +02:00
vchuser 266babc8bf Codemenge eines Stapels beim Laden aus den Eintraegen ableiten statt uebernehmen
Ergaenzt src/session.js aus dem vorigen Commit: Da stack.codes jetzt aus
entry.codes abgeleitet wird statt angesammelt zu werden, muss loadSession
denselben Ansatz verfolgen (wie bereits bei stack.count) - sonst wuerde ein
gesicherter, zwischenzeitlich per Umsortieren/Entfernen bereinigter Stand beim
Wiederherstellen einen laengst nicht mehr zutreffenden Code zurueckbringen.
entry.codes ist wie stack.codes optional, ein Stand ohne dieses Feld gilt
weiterhin nicht als unstimmig (Abwaertsvertraeglichkeit bleibt erhalten).
2026-07-29 09:22:04 +02:00
vchuser f948620555 Codetreffer ueberstimmt keinen Widerspruch der technischen Angaben mehr
Ein gelesener Rohcode ist nicht garantiert eine Teilenummer: teilen sich
zwei technisch verschiedene Module z.B. denselben Los-Tag, hat der bisherige
Codeabgleich sie ohne Rueckfrage auf denselben Stapel gezogen. proposeAssignment
verlangt fuer einen Codekandidaten jetzt zusaetzlich specsCompatible; widerspricht
der einzige Treffer, faellt die Zuordnung auf den Vergleich der technischen
Angaben zurueck und im Zweifel auf einen eigenen Stapel.

TDD: Test zuerst rot gesehen (Los-Tag-Fall aus der Beanstandung), dann die
Filterung ergaenzt.
2026-07-29 09:21:42 +02:00
vchuser 7b4a3532bb Aktualisiere README fuer Barcode-Codeabgleich und gelockerten Gruen-Massstab
Beschreibt die neue Vorrangregel (bekannter Barcode-Inhalt entscheidet vor
dem Vergleich der technischen Angaben), dass eine exakt gelesene Teilenummer
allein fuer Gruen genuegt, und den OCR-Kurzschluss bei bereits bekanntem
Code. Ergaenzt entsprechende Pruefpunkte im Abschnitt "Am Gerät noch zu
pruefen" und aktualisiert die Testanzahl auf 121.
2026-07-29 09:05:35 +02:00
vchuser c9084830b8 Verdrahte Barcode-Codeabgleich und OCR-Kurzschluss in main.js
processCapture() reicht der Pipeline jetzt eine isKnownCode-Abhaengigkeit
herein, die gegen die Codes der Stapel der aktuellen Sitzung prueft, und
gibt die von recognize() gelieferten Rohcodes an proposeAssignment und
commitAssignment weiter, damit ein bereits bekannter Barcode-Inhalt die
Stapelzuordnung entscheidet.
2026-07-29 09:05:29 +02:00
vchuser 61dde3eb08 Stapel merken sich gelesene Barcode-Inhalte, entscheiden die Zuordnung
Jeder Stapel fuehrt jetzt die Menge der Barcode-Zeichenketten, die bei
seinen Modulen gelesen wurden (stack.codes). Traegt ein gescanntes Modul
einen Code, den ein Stapel bereits kennt, hat das Vorrang vor dem Vergleich
der technischen Angaben - ein Barcode ist exakt gelesen, abgeleitete Angaben
koennen fehlerbehaftet sein. Genau ein Stapel ueber einen bekannten Code
-> Zuweisung, auch bei unvollstaendigen Angaben; mehrere -> weiterhin
mehrdeutig, der Nutzer entscheidet; kein passender Code -> unveraendert die
bisherige Regel. Eine Seriennummer, die bei jedem Modul anders ist, laeuft
dabei einfach ins Leere, ohne die Zuordnung ueber die Teilenummer zu
verhindern.

proposeAssignment und commitAssignment bekommen dafuer einen neuen, optional
en codes-Parameter (Vorgabe []); bestehende Aufrufe ohne diesen Parameter
verhalten sich unveraendert.

storage.js sichert und prueft stack.codes jetzt mit: fehlt das Feld (Stand
aus einer aelteren Fassung), gilt der Stapel als ohne bekannte Codes statt
den ganzen Stand zu verwerfen; ist es vorhanden, muss es eine Liste von
Zeichenketten sein wie jedes andere Feld auch.
2026-07-29 09:05:23 +02:00
vchuser 661bd07a26 Barcode-Teilenummer allein genuegt fuer Gruen, bekannter Code ueberspringt OCR
Der Teilenummer-Decoder kennt bislang nur das Samsung-Schema; ein Hynix-Modul
mit einwandfrei gelesenem, aber unbekanntem Barcode-Schema endete deshalb in
Rot. Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
Teilenummer genuegt, um ein Modul wiederzuerkennen. recognize() greift jetzt
erst dann auf Rot zurueck, wenn auch die Texterkennung keine Kapazitaet und
keine eindeutige Teilenummer liefert; die bestehende Vorsicht bei
widerspruechlichen unverwertbaren Barcodes bleibt dabei unangetastet.

Zusaetzlich kann recognize() jetzt ueber deps.isKnownCode (optional) erfahren,
dass ein gelesener Code bereits einem Stapel bekannt ist, und die bis zu 20
Sekunden dauernde Texterkennung dann ueberspringen. Ohne diese Abhaengigkeit
verhaelt sich die Pipeline unveraendert. recognize() liefert zusaetzlich die
rohen gelesenen Barcode-Inhalte (codes), damit session.js sie einem Stapel
zuordnen kann.
2026-07-29 09:05:05 +02:00
vchuserandClaude Opus 5 70f6a4fbce Benenne Konstante REFERENZ zu REFERENCE um und entferne Vorlagenfelder aus package.json
- Benenne REFERENZ in test/ocr-extract.test.js zu REFERENCE um
  (Bezeichner auf Englisch, Kommentare/Texte auf Deutsch)
- Entferne »main«: "index.js" (Datei existiert nicht, nicht relevant für Anwendung)
- Entferne »directories«: { "doc": "docs" } (nicht relevant)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:33:13 +02:00
vchuserandClaude Opus 5 5413be105d Exportiere nextStackId aus session.js (ergaenzt vorigen Commit)
Wurde versehentlich nicht mit "Konsolidiere Spec-Kurzbeschreibung..."
committet, obwohl main.js dort bereits auf den Export umgestellt wurde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:25:28 +02:00
vchuserandClaude Opus 5 1acfee76d8 Korrigiere README: erfundene Meldungen, veraltete und fehlende Abschnitte
Entfernt zwei nie existierende Statusmeldungen ("Kamera laeuft", "kein
Barcode gefunden"). Ergaenzt die seit final-fix-1 geltende, bisher
unerwaehnte Kandidaten-Regel fuer Stapel (sharesSetField), sowie zwei seit
final-fix-1 vorhandene, aber nirgends dokumentierte Funktionen:
Sitzungsfortsetzung beim Start und Ersatzweg ohne Kamera. Korrigiert einen
seit final-fix-1 falschen Pruefpunkt (automatische statt tatsaechlicher,
per Dialog bestaetigter Sitzungswiederherstellung nach Neuladen). Ergaenzt
die Dateistruktur-Tabelle um die bisher fehlenden src/ui/*.js-Dateien
(resume-dialog.js, describe-spec.js) und aktualisiert die veraltete
Testanzahl auf den aktuellen Stand.

Fuegt den Abschlussbericht dieser Aufraeumrunde hinzu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:25:15 +02:00
10 changed files with 614 additions and 69 deletions
+135 -38
View File
@@ -2,18 +2,49 @@
Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
App nennt den Stapel. Verglichen wird über alle Merkmale, die bei Modul und
Stapel bekannt sind — Kapazität, Bauform, Rank, Geschwindigkeit und, sofern
gelesen, Hersteller-Teilenummer (`specsCompatible` in `src/spec.js`); ein auf
einer Seite fehlendes Merkmal wird dabei übersprungen, nicht als Widerspruch
gewertet. Passt danach genau ein Stapel, wird das Modul zugewiesen; passen
mehrere, fragt die App nach (`proposeAssignment` in `src/session.js`). Das hat
eine bewusst in Kauf genommene Folge: Konnte die Teilenummer nicht gelesen
werden — der häufige Fall bei überklebten oder beschädigten Etiketten — und
passt anhand der übrigen Merkmale nur ein Stapel, landet das Modul dort, auch
wenn seine tatsächliche Teilenummer eine andere wäre. Diese Abwägung ist
Absicht: Ohne sie müsste die App bei jedem zweiten Modul nachfragen, sobald
die Texterkennung keine Teilenummer liefert.
App nennt den Stapel.
Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt
tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab.
Jeder Stapel führt dafür die Menge der Barcode-Zeichenketten, die bei seinen
*aktuell* ihm zugeordneten Modulen gelesen wurden (`stack.codes`); diese Menge
wird nicht angesammelt, sondern aus den Codes der zugeordneten Einträge
(`entry.codes`) abgeleitet — genau wie der Stapelzähler (siehe „Sitzungssicherung
im Detail" unten). Sortiert der Nutzer ein Modul um oder entfernt es, verliert
der alte Stapel dessen Codes deshalb sofort wieder; ein bereinigter Fehler
zieht kein weiteres Modul mehr fälschlich an. Trägt ein neu gescanntes Modul
einen Code, den ein Stapel bereits kennt, ist dieser Stapel Kandidat — aber
nur, wenn die technischen Angaben beider Seiten sich dabei nicht widersprechen
(`specsCompatible` in `src/spec.js`, siehe unten); ein Codetreffer allein
genügt nicht, sonst könnte ein Code, der gar keine Teilenummer ist, zwei
technisch verschiedene Module zusammenführen (siehe „Grenzen"). Genau ein
solcher Kandidat gehört dorthin, auch wenn die technischen Angaben
unvollständig sind (`proposeAssignment` in `src/session.js`). Auf Etiketten
stehen oft zwei Codes, von denen einer die Seriennummer ist, die bei jedem
Modul anders ist: Sie läuft beim Codeabgleich einfach ins Leere, während die
danebenstehende Teilenummer trägt — kein Sonderfall, nur eine Konsequenz des
Mengenvergleichs.
Kennt kein Stapel einen der gelesenen Codes ohne Widerspruch (typischerweise
beim ersten Modul eines neuen Stapels, aber auch dann, wenn ein Codetreffer an
der Widerspruchsprüfung scheitert), vergleicht die App stattdessen über alle
Merkmale, die
bei Modul und Stapel bekannt sind — Kapazität, Bauform, Rank, Geschwindigkeit
und, sofern gelesen, Hersteller-Teilenummer (`specsCompatible` in
`src/spec.js`); ein auf einer Seite fehlendes Merkmal wird dabei übersprungen,
nicht als Widerspruch gewertet. Ein Stapel gilt dabei nur als Kandidat, wenn
er mit dem Modul mindestens ein tatsächlich erkanntes Merkmal teilt
(`sharesSetField` in `src/session.js`) — ein Stapel ganz ohne erkannte Angaben
(etwa aus einem manuell entschiedenen Rot-Fall) ist deshalb nie automatisch
treffend, sonst würde er lautlos jedes folgende Modul an sich ziehen. Passt
danach genau ein Stapel, wird das Modul zugewiesen; passen mehrere oder mehrere
über einen bekannten Code, fragt die App nach. Das hat eine bewusst in Kauf
genommene Folge: Konnte die Teilenummer nicht gelesen werden — der häufige
Fall bei überklebten oder beschädigten Etiketten — und passt anhand der
übrigen Merkmale nur ein Stapel, landet das Modul dort, auch wenn seine
tatsächliche Teilenummer eine andere wäre. Diese Abwägung ist Absicht: Ohne
sie müsste die App bei jedem zweiten Modul nachfragen, sobald die
Texterkennung keine Teilenummer liefert.
Die Erkennung läuft vollständig lokal im Browser — kein Server, keine Cloud,
keine Daten verlassen das Gerät.
@@ -26,25 +57,50 @@ keine Daten verlassen das Gerät.
dieselbe Teilenummer), gilt das als mehrdeutig — die App rät nicht,
sondern fragt nach. Das gilt nur für Barcodes mit einem bekannten
Nummernschema; widersprechen sich mehrere Barcodes mit unbekanntem
Nummernschema, wird keine ihrer Teilenummern übernommen und das Ergebnis
stützt sich allein auf die Texterkennung, ohne dass die App nachfragt.
Nummernschema, wird keine ihrer Teilenummern übernommen.
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y`
werden Kapazität, Bauform und Geschwindigkeit tabellengesteuert
abgeleitet. Gelingt das, entfällt OCR vollständig.
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war (z. B.
überklebtes oder beschädigtes Etikett). Das Kamerabild wird dafür in
Graustufen gewandelt und mit einem **Otsu-Schwellwert** in Schwarz/Weiß
aufbereitet: Der Schwellwert wird aus der Helligkeitsverteilung des
gesamten Bildes bestimmt, nicht aus einer festen Kontrastspreizung
zwischen hellstem und dunkelstem Pixel. Das macht die Aufbereitung robust
gegen einzelne Lichtreflexe auf glänzenden Metalletiketten, die eine reine
Min/Max-Spreizung leicht kippen würden. Das OCR-Ergebnis wird
anschließend gegen die bekannten Spec-Werte abgeglichen, was die
typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst.
abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün.
Kennt der Decoder das Nummernschema nicht (siehe „Grenzen“ unten), ist das
allein noch kein Grund für Rot: Zum Sortieren muss keine Kapazität bekannt
sein, eine exakt gelesene Teilenummer genügt, um ein Modul wiederzuerkennen
(`recognize` in `src/pipeline.js`). Findet die anschließende Texterkennung
dazu auch keine Kapazität, ist das Ergebnis trotzdem grün, nur eben ohne
abgeleitete Angaben in der Rückmeldung. Ist einer der gelesenen Codes
bereits einem Stapel dieser Sitzung bekannt, steht die Zuordnung ohnehin
fest — dann entfällt auch die Texterkennung, weil sie nichts mehr beitragen
könnte, das über die Zuordnung entscheidet. Bei einem tatsächlich neuen
Modul läuft sie dagegen wie gehabt, denn dort liefert sie die lesbare
Beschriftung des neuen Stapels.
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war oder
sein Nummernschema unbekannt ist (z. B. überklebtes/beschädigtes Etikett
oder ein noch nicht in `src/pn-tables.js` hinterlegter Hersteller). Das
Kamerabild wird dafür in Graustufen gewandelt und mit einem
**Otsu-Schwellwert** in Schwarz/Weiß aufbereitet: Der Schwellwert wird aus
der Helligkeitsverteilung des gesamten Bildes bestimmt, nicht aus einer
festen Kontrastspreizung zwischen hellstem und dunkelstem Pixel. Das macht
die Aufbereitung robust gegen einzelne Lichtreflexe auf glänzenden
Metalletiketten, die eine reine Min/Max-Spreizung leicht kippen würden.
Das OCR-Ergebnis wird anschließend 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 roter Konfidenz (nichts Eindeutiges erkannt oder
widersprüchliche Barcodes) oder mehrdeutiger Stapelzuordnung fragt die
App nach.
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
oder mehrdeutiger Stapelzuordnung fragt die App nach.
5. **Sitzungsfortsetzung beim Start.** Findet die App beim Laden eine in
`localStorage` gesicherte, nicht leere Sitzung vor, wird sie nicht
stillschweigend übernommen, sondern zur Fortsetzung oder zum Verwerfen
angeboten (`askResumeSession` in `src/ui/resume-dialog.js`, verdrahtet über
`initResumeOffer()` in `src/main.js`). Erst „fortsetzen“ stellt Stapel,
Zähler und die „Zuletzt“-Zeile wieder her; „verwerfen“ löscht den
gesicherten Stand und die App startet leer.
6. **Ersatzweg ohne Kamera.** Lässt sich die Kamera nicht starten (z. B.
verweigerte Berechtigung), bleibt die App bedienbar: eine dauerhaft
sichtbare Schaltfläche „Bild auswählen“ öffnet stattdessen die native
Dateiauswahl (`setFilePickerVisible` in `src/ui/scan-view.js`). Die App
versucht danach nicht automatisch erneut, die Kamera zu starten - der
Kamera-Hinweistext bleibt für den Rest der Sitzung stehen und wird nicht
von der OCR-Verfügbarkeitsanzeige überschrieben.
Barcode- und OCR-Adapter sind gegen hängende Aufrufe abgesichert: Die
Erkennungs-Pipeline (`src/pipeline.js`) bricht einen Barcode-Versuch nach 10
@@ -77,7 +133,7 @@ korrekt funktioniert.
npm test
```
97 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
124 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen
@@ -112,13 +168,18 @@ nicht gesetzt ist).
| `src/pn-tables.js` | Herstellertabellen für den Teilenummer-Decoder |
| `src/pn-decoder.js` | Teilenummer → Spec-Felder |
| `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder |
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen |
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen und Mehrdeutigkeitsbehandlung |
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen — inklusive der aus den Einträgen abgeleiteten, je Stapel bekannten Barcode-Inhalte (`stack.codes`), die vor dem Vergleich der technischen Angaben über die Zuordnung entscheiden, sofern diese Angaben nicht widersprechen |
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) |
| `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten) |
| `src/camera.js` | Kamerastart, Einzelbildaufnahme, Bilddatei-Ersatzweg |
| `src/barcode.js` | Adapter zu `zxing-wasm` |
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` |
| `src/ui/` | Ansichten: Scan-Ansicht, Ergebnis-Einblendung, Rot-Dialog bei Mehrdeutigkeit, Sitzungsliste |
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, Scan-Knopf, Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
| `src/ui/result-overlay.js` | Kurze Treffer-Rückmeldung (grün/gelb), blendet sich nach kurzer Zeit selbst wieder aus |
| `src/ui/ambiguous-dialog.js` | Rot-Dialog bei roter Konfidenz oder mehrdeutiger Stapelzuordnung |
| `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen |
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden |
| `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt |
| `src/main.js` | Verdrahtung aller Module zur lauffähigen App |
| `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste |
@@ -139,7 +200,15 @@ auch nur eine dieser Bedingungen nicht zu, wird die gesamte gespeicherte
Sitzung verworfen (die App startet leer), statt mit einem halb kaputten
Zustand lautlos weiterzuarbeiten. Der gespeicherte Stapelzähler selbst wird
nie übernommen, sondern beim Laden immer neu aus der tatsächlichen Zahl der
zugeordneten Einträge berechnet.
zugeordneten Einträge berechnet — genauso die Codemenge eines Stapels
(`stack.codes`): Sie wird nie aus dem gesicherten Wert übernommen, sondern
stets aus den Codes der ihm tatsächlich zugeordneten Einträge (`entry.codes`)
neu zusammengesetzt, dieselbe Ableitung, die `session.js` nach jedem
Umsortieren oder Entfernen eines Eintrags vornimmt. Sind `stack.codes` bzw.
`entry.codes` vorhanden, werden sie streng geprüft (Liste von
Zeichenketten); ein gesicherter Stand aus einer Fassung vor dieser Erweiterung
ohne diese Felder gilt dagegen nicht als unstimmig, sondern wird beim Laden
als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt.
## Grenzen
@@ -148,8 +217,22 @@ zugeordneten Einträge berechnet.
Sitzung, keine dauerhafte Datenhaltung.
- Das Laden der Seite braucht eine Verbindung; das Sortieren selbst nicht
(nach dem Laden läuft alles lokal im Browser).
- Der Codeabgleich (siehe oben) weiß nicht, ob ein gelesener Barcode-Inhalt
tatsächlich eine Teilenummer ist. Trägt ein Etikett daneben noch eine
Los- oder Wiederverkäufer-Kennung und teilen zwei technisch verschiedene
Module dieselbe, würde ein reiner Codetreffer sie fälschlich zusammenführen.
Dagegen schützt, dass ein Codetreffer allein nie genügt: Ein Stapel ist über
einen Code nur dann Kandidat, wenn die technischen Angaben ihm zusätzlich
nicht widersprechen (`specsCompatible`, siehe `proposeAssignment` in
`src/session.js`) — widerspricht der Stapel trotz passendem Code, fällt das
Modul auf einen eigenen Stapel zurück, statt lautlos falsch einsortiert zu
werden.
- Der Teilenummer-Decoder kennt bisher nur das Samsung-DDR4-Schema. Unbekannte
Schemata sind kein Fehler — sie führen automatisch zum OCR-Weg.
Schemata sind kein Fehler — sie führen automatisch zum OCR-Weg, und selbst
wenn dieser keine Kapazität findet, genügt die exakt gelesene Teilenummer
allein für Grün (siehe „Funktionsweise“ oben). Nur Kapazität, Bauform,
Rank und Geschwindigkeit hängen am Tabelleneintrag — die Wiedererkennung
eines bereits erfassten Moduls über seinen Barcode-Inhalt nicht.
- **Der Teilenummer-Decoder ist nur teilweise gegen reale Module geprüft.**
Belegt ist ausschließlich der Samsung-Eintrag `A8K40` (64 GB, 4DRx4) mit
dem Geschwindigkeitscode `CRC`, anhand des Referenzmoduls
@@ -198,7 +281,8 @@ werden:
**Kamera und Aufnahme**
- Preview-URL auf dem Handy öffnen (muss `https://` sein), Rückkamera-Bild
erscheint im Vorschaubereich, "Kamera läuft" wird angezeigt.
erscheint im Vorschaubereich; die Statuszeile bleibt leer, solange Kamera
und Texterkennung verfügbar sind.
- Verweigerte Kamera-Berechtigung: verständliche Fehlermeldung erscheint
*und* die Aufnahme-LED des Geräts erlischt tatsächlich wieder.
- Aufnahme-Knopf antippen, bevor das Kamerabild bereit ist: verständliche
@@ -232,7 +316,18 @@ werden:
darauf zurück?
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten
Etiketten keine tatsächlich benötigten Zeichen ausschließt.
- Bild ohne lesbaren Code: "kein Barcode gefunden" statt Fehler oder Absturz.
- Bild ohne lesbaren Barcode und ohne verwertbaren OCR-Text: Rot-Dialog
erscheint statt Fehler oder Absturz.
- Modul mit unbekanntem Nummernschema (z. B. ein Hynix-Modul) scannen: grüne
Rückmeldung statt Rot, auch wenn keine Kapazität abgeleitet werden kann.
Dasselbe Modul ein zweites Mal scannen: landet auf demselben Stapel — und
die Rückmeldung erscheint spürbar schneller als beim ersten Scan, weil die
Texterkennung diesmal übersprungen wird (nur am Gerät beobachtbar, da
`node --test` keine echte Ladezeit misst).
- Etikett mit zwei Barcodes (Teile- *und* Seriennummer) mehrfach scannen:
beide Scans landen auf demselben Stapel, obwohl die Seriennummer bei realen
Modulen nie identisch ist — das belegt, dass die Teilenummer und nicht die
Seriennummer die Zuordnung trägt.
**Bedienung und Anzeige**
- Zielrahmen liegt sinnvoll über dem Kamerabild, `object-fit: cover` füllt
@@ -254,8 +349,10 @@ werden:
- Ein Modul zweimal scannen: beide Male derselbe Stapel, Zähler steigt.
- Rücknahme-Fläche nimmt genau den zuletzt erfassten Eintrag zurück, Zähler
sinkt entsprechend.
- Seite neu laden während einer laufenden Sitzung: Stapel und Zähler bleiben
erhalten.
- Seite neu laden während einer laufenden Sitzung: Dialog „Gesicherte
Sitzung gefunden“ erscheint; erst nach „fortsetzen“ bleiben Stapel und
Zähler erhalten und die „Zuletzt“-Zeile zeigt wieder den tatsächlich
letzten Eintrag; „verwerfen“ führt zu einem leeren Start.
- Sitzungsliste öffnen, Eintrag umsortieren, Eintrag entfernen: Ansicht baut
sich korrekt neu auf.
- "Sitzung beenden": Ansicht wird leer, und ein anschließendes Neuladen
-4
View File
@@ -2,10 +2,6 @@
"name": "ram-sortierhilfe",
"version": "1.0.0",
"description": "Erkennt gebrauchte Server-RAM-Module per Handykamera und leitet beim Sortieren nach Stapeln an.",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "node --test test/",
"dev": "vite --host 0.0.0.0",
+16 -3
View File
@@ -4,6 +4,7 @@ import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js';
import { normalizeToken } from './spec.js';
import {
createSession, proposeAssignment, commitAssignment,
undoLast, moveEntry, removeEntry, nextStackId,
@@ -88,9 +89,21 @@ async function processCapture(getFrame) {
try {
const frame = await getFrame();
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
// Ist einer der gleich gelesenen Codes bereits einem Stapel dieser
// Sitzung bekannt, kann die Pipeline die bis zu 20 Sekunden dauernde
// Texterkennung ueberspringen (siehe recognize() in pipeline.js).
const isKnownCode = (code) => {
const normalized = normalizeToken(code);
return targetSession.stacks.some(
(stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized),
);
};
const { spec, source, confidence, codes } = await recognize(
frame,
{ decodeBarcodes, runOcr, isKnownCode },
);
const plan = proposeAssignment(targetSession, spec);
const plan = proposeAssignment(targetSession, spec, codes);
let stackId = plan.stackId;
if (confidence === 'red' || plan.kind === 'ambiguous') {
@@ -118,7 +131,7 @@ async function processCapture(getFrame) {
// Sitzung bucht das Ergebnis nicht mehr nach.
if (targetSession !== session) return;
const entry = commitAssignment(targetSession, spec, source, stackId);
const entry = commitAssignment(targetSession, spec, source, stackId, codes);
view.setLast(describeEntry(spec, entry.stackId));
syncView();
+31 -6
View File
@@ -55,7 +55,15 @@ function allCompatible(specs) {
* deps.runOcr sind erforderlich. deps.barcodeTimeoutMs und deps.ocrTimeoutMs
* sind optional und ueberschreiben die eingebauten Obergrenzen - fuer Tests,
* die einen haengenden Adapter simulieren, ohne tatsaechlich zu warten.
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red'}>}
* deps.isKnownCode ist optional: eine Funktion (code: string) => boolean,
* mit der der Aufrufer prueft, ob ein roher Barcode-Inhalt bereits einem
* Stapel bekannt ist. Ist einer der gelesenen Codes bekannt, steht die
* Zuordnung fest und die Texterkennung wird uebersprungen. Fehlt die
* Funktion, verhaelt sich die Pipeline wie ohne diese Optimierung.
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[]}>}
* codes sind die im Bild gelesenen rohen Barcode-Zeichenketten (koennen
* neben der Teilenummer z.B. auch eine Seriennummer enthalten) - zur
* Weitergabe an proposeAssignment/commitAssignment in session.js.
*/
export async function recognize(frame, deps) {
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
@@ -91,7 +99,7 @@ export async function recognize(frame, deps) {
}
if (usable.length === 1) {
return { spec: usable[0], source: 'barcode', confidence: 'green' };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
}
if (usable.length > 1) {
if (allCompatible(usable)) {
@@ -101,10 +109,19 @@ export async function recognize(frame, deps) {
// da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden,
// sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen,
// der erste Treffer genuegt.
return { spec: usable[0], source: 'barcode', confidence: 'green' };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
}
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
return { spec: emptySpec(), source: 'none', confidence: 'red' };
return { spec: emptySpec(), source: 'none', confidence: 'red', codes };
}
// Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die
// Zuordnung fest (siehe proposeAssignment in session.js) - die bis zu
// zwanzig Sekunden dauernde Texterkennung waere reine Zeitverschwendung.
// Bei einem tatsaechlich neuen Modul (kein Code bekannt) laeuft sie wie
// bisher, denn dort liefert sie die lesbare Beschriftung des neuen Stapels.
if (typeof deps.isKnownCode === 'function' && codes.some((code) => deps.isKnownCode(code))) {
return { spec: best, source: 'barcode', confidence: 'green', codes };
}
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
@@ -119,7 +136,15 @@ export async function recognize(frame, deps) {
}
if (isUsable(merged)) {
return { spec: merged, source: 'ocr', confidence: 'yellow' };
return { spec: merged, source: 'ocr', confidence: 'yellow', codes };
}
return { spec: merged, source: 'none', confidence: 'red' };
// Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
// Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis
// hierher nur, wenn genau ein unverwertbarer Code sie lieferte oder
// mehrere sich einig waren (siehe 'best' oben) - bei widersprechenden
// Codes bleibt merged.partNumber null und die Vorsicht damit erhalten.
if (merged.partNumber !== null) {
return { spec: merged, source: 'barcode', confidence: 'green', codes };
}
return { spec: merged, source: 'none', confidence: 'red', codes };
}
+114 -13
View File
@@ -1,6 +1,6 @@
// Reines Modul: kein window, kein document, kein localStorage.
import { specsCompatible } from './spec.js';
import { specsCompatible, normalizeToken } from './spec.js';
const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -9,7 +9,13 @@ export function createSession() {
return { stacks: [], entries: [], nextEntryId: 1 };
}
function nextStackId(session) {
/**
* Naechster noch nicht vergebener Stapel-Buchstabe. Exportiert, weil auch
* main.js einen freien Buchstaben braucht, wenn der Nutzer im Rot-Dialog
* ausdruecklich einen neuen Stapel waehlt - dieselbe Regel gilt fuer beide
* Faelle, deshalb eine gemeinsame Funktion statt zweier Kopien.
*/
export 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;
@@ -35,12 +41,78 @@ function sharesSetField(a, b) {
}
/**
* Schlaegt vor, wohin ein Modul gehoert.
* Genau ein vertraeglicher Stapel mit gemeinsamer Grundlage -> Zuweisung.
* Keiner -> neuer Stapel.
* Mehrere -> mehrdeutig, der Nutzer entscheidet.
* Fasst eine Liste roher Barcode-Zeichenketten zu einer Menge normalisierter
* Werte zusammen. Ein Barcode ist exakt gelesen - deshalb genuegt hier die
* blosse Gross-/Leerzeichen-Normalisierung (normalizeToken), nicht der
* verwechslungstolerante Vergleich (canonical), der fuer die fehleranfaellige
* Texterkennung gedacht ist.
*/
export function proposeAssignment(session, spec) {
function normalizedCodeSet(codes) {
const set = new Set();
for (const code of codes) {
const normalized = normalizeToken(code);
if (normalized) set.add(normalized);
}
return set;
}
/**
* Die Codemenge eines Stapels ist kein angesammelter, sondern ein
* abgeleiteter Wert: sie ergibt sich aus den Codes der ihm *aktuell*
* zugeordneten Eintraege - genau wie stack.count (siehe loadSession in
* src/storage.js, das denselben Ansatz beim Laden verfolgt). Ein Code
* verschwindet damit automatisch aus einem Stapel, sobald kein Eintrag ihn
* mehr mitbringt (nach moveEntry, removeEntry oder undoLast) - anstatt sich
* dauerhaft am Stapel festzusetzen.
*/
function recomputeStackCodes(session, stackId) {
const stack = session.stacks.find((candidate) => candidate.id === stackId);
if (!stack) return;
const set = new Set();
for (const entry of session.entries) {
if (entry.stackId !== stackId) continue;
for (const code of entry.codes ?? []) set.add(code);
}
stack.codes = [...set];
}
/**
* Schlaegt vor, wohin ein Modul gehoert.
*
* Ein bekannter Barcode-Inhalt hat Vorrang vor dem Vergleich der technischen
* Angaben, denn ein Barcode ist exakt gelesen, waehrend abgeleitete Angaben
* fehlerbehaftet sein koennen (siehe README, Abschnitt Barcode-Erkennung).
* Ein Codetreffer allein genuegt aber nicht: nicht jeder gelesene Rohcode ist
* eine Teilenummer - steht neben ihr noch eine Los- oder Wiederverkaeufer-
* Kennung auf dem Etikett, kann die zwei technisch verschiedene Module
* verbinden. Ein Stapel ist deshalb ueber einen Code nur dann Kandidat, wenn
* die technischen Angaben zusaetzlich nicht widersprechen (specsCompatible);
* der Code entscheidet dann innerhalb dessen, was ohnehin vertraeglich ist,
* er darf einen Widerspruch nicht ueberstimmen. Genau ein solcher Kandidat
* -> Zuweisung, auch bei unvollstaendigen technischen Angaben. Mehrere ->
* mehrdeutig, der Nutzer entscheidet. Kennt kein Stapel einen der Codes ohne
* Widerspruch, gilt unveraendert die bisherige Regel: genau ein vertraeglicher
* Stapel mit gemeinsamer Grundlage -> Zuweisung, keiner -> neuer Stapel,
* mehrere -> mehrdeutig.
*/
export function proposeAssignment(session, spec, codes = []) {
const scannedCodes = normalizedCodeSet(codes);
if (scannedCodes.size > 0) {
const codeCandidates = session.stacks
.filter((stack) => Array.isArray(stack.codes) && stack.codes.some((code) => scannedCodes.has(code)))
.filter((stack) => specsCompatible(stack.spec, spec))
.map((stack) => stack.id);
if (codeCandidates.length === 1) {
return { kind: 'match', stackId: codeCandidates[0], candidates: codeCandidates };
}
if (codeCandidates.length > 1) {
return { kind: 'ambiguous', stackId: null, candidates: codeCandidates };
}
// Kein Stapel kennt einen der Codes (haeufig: eine bei jedem Modul
// wechselnde Seriennummer) - weiter mit dem bisherigen Vergleich.
}
const candidates = session.stacks
.filter((stack) => specsCompatible(stack.spec, spec) && sharesSetField(stack.spec, spec))
.map((stack) => stack.id);
@@ -54,11 +126,18 @@ export function proposeAssignment(session, spec) {
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) {
/**
* Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an.
* `codes` sind die bei diesem Modul gelesenen rohen Barcode-Zeichenketten
* (z.B. Teile- und Seriennummer); sie werden am Eintrag gespeichert, aus dem
* sich die Code-Menge des Stapels ableitet (siehe recomputeStackCodes), damit
* ein spaeteres Modul mit demselben Inhalt darueber zugeordnet werden kann
* (siehe proposeAssignment).
*/
export function commitAssignment(session, spec, source, stackId, codes = []) {
let stack = session.stacks.find((candidate) => candidate.id === stackId);
if (!stack) {
stack = { id: stackId, spec: { ...spec }, count: 0 };
stack = { id: stackId, spec: { ...spec }, count: 0, codes: [] };
session.stacks.push(stack);
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
} else {
@@ -73,8 +152,17 @@ export function commitAssignment(session, spec, source, stackId) {
stack.count += 1;
// entry.spec ist das Protokoll dessen, was tatsaechlich gescannt wurde, und muss
// unabhaengig vom Stapel-Spec bleiben - eigene Kopie, kein geteiltes Objekt.
const entry = { entryId: session.nextEntryId++, spec: { ...spec }, stackId, source };
// entry.codes haelt fest, welche Rohcodes bei diesem Eintrag gelesen wurden -
// die Grundlage, aus der recomputeStackCodes die Codemenge des Stapels ableitet.
const entry = {
entryId: session.nextEntryId++,
spec: { ...spec },
stackId,
source,
codes: [...normalizedCodeSet(codes)],
};
session.entries.push(entry);
recomputeStackCodes(session, stackId);
return entry;
}
@@ -94,6 +182,9 @@ export function undoLast(session) {
if (!entry) return null;
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
if (stack) stack.count -= 1;
// Der zurueckgenommene Eintrag zaehlt nicht mehr zu diesem Stapel - seine
// Codes duerfen deshalb auch keinen kuenftigen Treffer mehr begruenden.
recomputeStackCodes(session, entry.stackId);
dropEmptyStacks(session);
return entry;
}
@@ -103,17 +194,24 @@ 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);
const fromId = entry.stackId;
const from = session.stacks.find((candidate) => candidate.id === fromId);
if (from) from.count -= 1;
let to = session.stacks.find((candidate) => candidate.id === stackId);
if (!to) {
to = { id: stackId, spec: { ...entry.spec }, count: 0 };
to = { id: stackId, spec: { ...entry.spec }, count: 0, codes: [] };
session.stacks.push(to);
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
}
to.count += 1;
entry.stackId = stackId;
// Die Codemenge beider betroffenen Stapel ist abgeleitet (siehe
// recomputeStackCodes) - nach dem Umhaengen des Eintrags muss sie fuer den
// alten wie den neuen Stapel neu bestimmt werden, sonst bliebe der Code des
// umsortierten Eintrags faelschlich am alten Stapel haengen.
recomputeStackCodes(session, fromId);
recomputeStackCodes(session, stackId);
dropEmptyStacks(session);
}
@@ -124,5 +222,8 @@ export function removeEntry(session, entryId) {
const [entry] = session.entries.splice(index, 1);
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
if (stack) stack.count -= 1;
// Siehe moveEntry: der entfernte Eintrag darf seinen Code nicht laenger am
// Stapel hinterlassen.
recomputeStackCodes(session, entry.stackId);
dropEmptyStacks(session);
}
+34 -2
View File
@@ -28,21 +28,37 @@ function isPositiveInteger(value) {
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
}
function isStringArray(value) {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
// stack.codes ist optional: ein Stand aus einer aelteren Fassung ohne dieses
// Feld ist kein unstimmiger Zustand und wird nicht verworfen (siehe
// loadSession), sondern beim Wiederherstellen als leere Codeliste behandelt.
// Ist das Feld dagegen vorhanden, muss es zur restlichen strengen Pruefung
// passen: eine Liste von Zeichenketten, sonst ist der Stand in sich
// unstimmig.
function isUsableStack(stack) {
return (
isPlainObject(stack)
&& typeof stack.id === 'string'
&& isFiniteNumber(stack.count)
&& isPlainObject(stack.spec)
&& (stack.codes === undefined || isStringArray(stack.codes))
);
}
// entry.codes ist wie stack.codes optional: ein Stand aus einer Fassung vor
// dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als
// unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von
// Zeichenketten sein.
function isUsableEntry(entry) {
return (
isPlainObject(entry)
&& isPositiveInteger(entry.entryId)
&& typeof entry.stackId === 'string'
&& isPlainObject(entry.spec)
&& (entry.codes === undefined || isStringArray(entry.codes))
);
}
@@ -57,7 +73,14 @@ function isUsableEntry(entry) {
* Der gespeicherte Stapelzaehler (stack.count) wird nie uebernommen, sondern
* beim Laden stets aus der Zahl der ihm tatsaechlich zugeordneten Eintraege
* neu berechnet - er dient in isUsableStack nur noch dazu, grob kaputten
* Inhalt schon an der Typprueung scheitern zu lassen. Zeigt ein Eintrag auf
* Inhalt schon an der Typprueung scheitern zu lassen. Genauso wird die
* gespeicherte Codemenge eines Stapels (stack.codes) nie uebernommen, sondern
* aus den Codes seiner tatsaechlich zugeordneten Eintraege (entry.codes) neu
* zusammengesetzt - dieselbe Ableitung, die session.js nach jedem Umsortieren
* oder Entfernen eines Eintrags vornimmt (siehe recomputeStackCodes dort);
* ein zwischenzeitlich per Hand korrigierter, aber vor dieser Erweiterung
* gesicherter Stand wuerde sonst einen laengst nicht mehr zutreffenden Code
* am falschen Stapel wiederherstellen. Zeigt ein Eintrag auf
* eine Stapelkennung, die es nicht gibt, kommt eine Eintragsnummer oder eine
* Stapelkennung mehrfach vor, oder ist nextEntryId nicht groesser als jede
* vorhandene Eintragsnummer, ist der Inhalt in sich unstimmig und wird
@@ -85,7 +108,16 @@ export function loadSession(store) {
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
for (const stack of parsed.stacks) {
stack.count = parsed.entries.filter((entry) => entry.stackId === stack.id).length;
const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id);
stack.count = stackEntries.length;
// Codemenge aus den Eintraegen ableiten statt dem gesicherten Wert zu
// vertrauen (siehe Kommentar oben). Ein Eintrag aus einer Fassung ohne
// entry.codes traegt keine Codes bei, gilt aber nicht als unstimmig.
const codes = new Set();
for (const entry of stackEntries) {
for (const code of entry.codes ?? []) codes.add(code);
}
stack.codes = [...codes];
}
return parsed;
+2 -2
View File
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractFields } from '../src/ocr-extract.js';
const REFERENZ = `SAMSUNG
const REFERENCE = `SAMSUNG
Made in Philippines
K136000908252DF287
64GB 4DRx4 PC4-2400T-LD1-11-MC0
@@ -10,7 +10,7 @@ M386A8K40BM1-CRC4Y S
1908`;
test('liest alle Felder aus dem Referenz-Etikett', () => {
const spec = extractFields(REFERENZ);
const spec = extractFields(REFERENCE);
assert.equal(spec.capacityGb, 64);
assert.equal(spec.rank, '4DRx4');
assert.equal(spec.speed, 'PC4-2400');
+90
View File
@@ -191,3 +191,93 @@ test('zwei unverwertbare Barcodes, die sich nur in einer Verwechslung unterschei
assert.equal(result.confidence, 'yellow');
assert.equal(result.spec.partNumber, '732O773');
});
// --- Erweiterung: unbekanntes Nummernschema mit gelesener Teilenummer genuegt fuer Gruen ---
test('Barcode mit unbekanntem Nummernschema und gelesener Teilenummer ergibt gruen, auch ohne Kapazitaet', async () => {
const result = await recognize({}, {
// Hynix-Teilenummer: Schema unbekannt (nur Samsung ist hinterlegt), aber
// exakt gelesen - und OCR findet auf dem Etikett ebenfalls nichts
// Verwertbares. Trotzdem genuegt die gelesene Teilenummer fuer Gruen.
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => '',
});
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
assert.equal(result.spec.partNumber, 'HMA84GL7AFR4N-UH');
assert.equal(result.spec.capacityGb, null);
});
test('findet OCR trotz unbekanntem Nummernschema eine Kapazitaet, wird sie uebernommen (weiterhin gelb, nicht gruen)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => OCR_TEXT,
});
assert.equal(result.source, 'ocr');
assert.equal(result.confidence, 'yellow');
assert.equal(result.spec.capacityGb, 64);
// Die Teilenummer aus dem Barcode ist exakter und bleibt erhalten.
assert.equal(result.spec.partNumber, 'HMA84GL7AFR4N-UH');
});
test('widersprechende unverwertbare Barcodes ohne jede OCR-Kapazitaet: die Vorsicht bleibt erhalten, kein Gruen', () => recognize({}, {
decodeBarcodes: async () => ['7325773', '9999999'],
runOcr: async () => '',
}).then((result) => {
assert.equal(result.source, 'none');
assert.equal(result.confidence, 'red');
}));
test('recognize liefert die gelesenen Rohcodes zur Weitergabe an die Stapelzuordnung', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH', 'SN-000123'],
runOcr: async () => '',
});
assert.deepEqual([...result.codes].sort(), ['HMA84GL7AFR4N-UH', 'SN-000123'].sort());
});
// --- Erweiterung: ein bekannter Code ueberspringt die Texterkennung ---
test('bekannter Code ueberspringt die Texterkennung vollstaendig', async () => {
let ocrCalled = false;
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { ocrCalled = true; return ''; },
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
});
assert.equal(ocrCalled, false, 'ein bekannter Code macht die Texterkennung ueberfluessig');
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
});
test('bekannter Code unter mehreren Codes ueberspringt die Texterkennung trotzdem (Seriennummer laeuft ins Leere)', async () => {
let ocrCalled = false;
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH', 'SN-000123'],
runOcr: async () => { ocrCalled = true; return ''; },
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
});
assert.equal(ocrCalled, false);
assert.equal(result.confidence, 'green');
});
test('unbekannter Code (isKnownCode liefert stets false) ueberspringt die Texterkennung nicht', async () => {
let ocrCalled = false;
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { ocrCalled = true; return ''; },
isKnownCode: () => false,
});
assert.equal(ocrCalled, true);
assert.equal(result.confidence, 'green');
});
test('ohne isKnownCode-Abhaengigkeit bleibt die Pipeline ohne sie lauffaehig (Texterkennung laeuft wie bisher)', async () => {
let ocrCalled = false;
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { ocrCalled = true; return ''; },
});
assert.equal(ocrCalled, true);
assert.equal(result.confidence, 'green');
});
+137 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { emptySpec } from '../src/spec.js';
import { emptySpec, specsCompatible } from '../src/spec.js';
import {
createSession, proposeAssignment, commitAssignment,
undoLast, moveEntry, removeEntry,
@@ -158,6 +158,142 @@ test('ein voellig unerkanntes Modul erzeugt einen eigenen Stapel und zieht keine
assert.equal(plan.kind, 'new', 'der leere Stapel darf ein erkanntes Modul nicht an sich ziehen');
});
// --- Erweiterung: bekannte Barcode-Inhalte entscheiden die Zuordnung ---
test('bekannter Barcode-Inhalt entscheidet die Zuordnung, auch bei unvollstaendigen Angaben', () => {
const session = createSession();
const unknownScheme = { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' };
commitAssignment(session, unknownScheme, 'barcode', 'A', ['HMA84GL7AFR4N-UH']);
// Angaben diesmal komplett leer - ohne Codeabgleich waere das mangels
// gemeinsamen Merkmals (sharesSetField) niemals ein Treffer.
const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH']);
assert.equal(plan.kind, 'match');
assert.equal(plan.stackId, 'A');
});
test('anderer Barcode-Inhalt erzeugt einen eigenen Stapel', () => {
const session = createSession();
commitAssignment(session, { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }, 'barcode', 'A', ['HMA84GL7AFR4N-UH']);
const plan = proposeAssignment(session, { ...emptySpec(), partNumber: 'ANDERE-PN' }, ['ANDERE-PN']);
assert.equal(plan.kind, 'new');
assert.notEqual(plan.stackId, 'A');
});
test('eine bei jedem Modul wechselnde Seriennummer verhindert die Zuordnung ueber die stabile Teilenummer nicht', () => {
const session = createSession();
commitAssignment(
session,
{ ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' },
'barcode',
'A',
['HMA84GL7AFR4N-UH', 'SN-AAA111'],
);
// Gleiche Teilenummer, aber eine andere Seriennummer als beim ersten Mal -
// die Seriennummer laeuft ins Leere, die Teilenummer traegt trotzdem.
const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH', 'SN-BBB222']);
assert.equal(plan.kind, 'match');
assert.equal(plan.stackId, 'A');
});
test('Codeabgleich hat Vorrang vor dem Vergleich der technischen Angaben', () => {
const session = createSession();
// B ist technisch identisch zu A (waere ohne Codeabgleich ebenfalls
// Kandidat und wuerde damit sogar Mehrdeutigkeit ausloesen), kennt den
// gescannten Code aber nicht.
commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']);
commitAssignment(session, s64(), 'barcode', 'B');
const plan = proposeAssignment(session, s64(), ['CODE-A']);
assert.equal(plan.kind, 'match');
assert.equal(plan.stackId, 'A');
});
test('mehrere Stapel mit passendem Code sind mehrdeutig, der Nutzer entscheidet', () => {
const session = createSession();
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
commitAssignment(session, emptySpec(), 'barcode', 'B', ['CODE-X']);
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
assert.equal(plan.kind, 'ambiguous');
assert.deepEqual(plan.candidates.sort(), ['A', 'B']);
});
test('passt kein Code, bleibt die bisherige Regel (Vergleich der Angaben) unveraendert', () => {
const session = createSession();
commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']);
const plan = proposeAssignment(session, s64(), ['VOELLIG-ANDERER-CODE']);
assert.equal(plan.kind, 'match');
assert.equal(plan.stackId, 'A');
});
test('proposeAssignment und commitAssignment funktionieren ohne codes-Parameter weiterhin (Abwaertskompatibilitaet)', () => {
const session = createSession();
const plan = proposeAssignment(session, s64());
const entry = commitAssignment(session, s64(), 'barcode', plan.stackId);
assert.equal(entry.stackId, 'A');
assert.deepEqual(session.stacks[0].codes, []);
});
test('ein Stapel sammelt unterschiedliche Codes ueber mehrere Scans hinweg', () => {
const session = createSession();
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1']);
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1', 'CODE-2']);
const stackA = session.stacks.find((s) => s.id === 'A');
assert.deepEqual([...stackA.codes].sort(), ['CODE-1', 'CODE-2']);
});
// --- Fehler 1: ein Codetreffer darf einen Widerspruch der Angaben nicht ueberstimmen ---
test('Codetreffer mit widersprechenden technischen Angaben ist kein Kandidat (geteilter Los-Tag statt Teilenummer)', () => {
const session = createSession();
// Modul 1: Samsung 64GB LRDIMM, Etikett traegt zusaetzlich einen Los-Tag.
const modul1 = { ...emptySpec(), capacityGb: 64, formFactor: 'LRDIMM' };
commitAssignment(session, modul1, 'barcode', 'A', ['LOS-2024-07']);
// Modul 2: Samsung 64GB RDIMM (andere Bauform!), aber derselbe Los-Tag,
// weil beide Etiketten aus derselben Fertigungscharge stammen.
const modul2 = { ...emptySpec(), capacityGb: 64, formFactor: 'RDIMM' };
assert.equal(specsCompatible(modul1, modul2), false, 'Vorbedingung: die Bauformen widersprechen sich');
const plan = proposeAssignment(session, modul2, ['LOS-2024-07']);
assert.notEqual(plan.stackId, 'A', 'ein RDIMM darf trotz geteiltem Los-Tag nicht auf den LRDIMM-Stapel A');
assert.equal(plan.kind, 'new', 'ohne vertraeglichen Kandidaten faellt das Modul auf einen eigenen Stapel zurueck');
});
// --- Fehler 2: eine Korrektur (Umsortieren/Entfernen) muss den Code am alten Stapel entfernen ---
test('nach moveEntry zieht der Code des umsortierten Eintrags den alten Stapel nicht mehr an', () => {
const session = createSession();
const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
// Ein zweiter Eintrag mit anderem Code haelt Stapel A am Leben, damit der
// Test tatsaechlich prueft, dass CODE-X verschwindet - nicht nur, dass ein
// leerer Stapel entfernt wurde.
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']);
moveEntry(session, a.entryId, 'B');
const stackA = session.stacks.find((s) => s.id === 'A');
assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)');
assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Umsortieren nicht mehr zu Stapel A gehoeren');
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen');
});
test('nach removeEntry zieht der Code des entfernten Eintrags den alten Stapel nicht mehr an', () => {
const session = createSession();
const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']);
removeEntry(session, a.entryId);
const stackA = session.stacks.find((s) => s.id === 'A');
assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)');
assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Entfernen nicht mehr zu Stapel A gehoeren');
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen');
});
test('undoLast-Vertrag: nach einem Umsortieren wird weiterhin der zuletzt erfasste Eintrag zurueckgenommen', () => {
const session = createSession();
const a = scan(session, s64());
+55
View File
@@ -244,6 +244,61 @@ test('nextEntryId als Null liefert null', () => {
assert.equal(loadSession(store), null);
});
// --- Erweiterung: Barcode-Inhalte je Stapel ueberleben Sichern und Laden ---
test('Codes eines Stapels ueberleben Sichern und Laden', () => {
const store = fakeStore();
const session = createSession();
commitAssignment(
session,
{ ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' },
'barcode',
'A',
['HMA84GL7AFR4N-UH', 'SN-000'],
);
saveSession(session, store);
const reloaded = loadSession(store);
assert.notEqual(reloaded, null);
assert.deepEqual([...reloaded.stacks[0].codes].sort(), ['HMA84GL7AFR4N-UH', 'SN-000'].sort());
const plan = proposeAssignment(reloaded, emptySpec(), ['HMA84GL7AFR4N-UH']);
assert.equal(plan.kind, 'match');
assert.equal(plan.stackId, 'A');
});
test('ein Stand ohne codes-Feld (aeltere Fassung) wird nicht verworfen, der Stapel gilt als ohne bekannte Codes', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: { ...emptySpec(), capacityGb: 64 } }],
entries: [{ entryId: 1, stackId: 'A', spec: { ...emptySpec(), capacityGb: 64 }, source: 'barcode' }],
nextEntryId: 2,
}));
const reloaded = loadSession(store);
assert.notEqual(reloaded, null, 'ein Stand ohne codes-Feld darf nicht verworfen werden');
assert.deepEqual(reloaded.stacks[0].codes, []);
});
test('Stapel mit codes als Nicht-Array liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: 'nicht-array' }],
entries: [],
nextEntryId: 1,
}));
assert.equal(loadSession(store), null);
});
test('Stapel mit codes-Array, das Nicht-Zeichenketten enthaelt, liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: [123] }],
entries: [],
nextEntryId: 1,
}));
assert.equal(loadSession(store), null);
});
test('Eintrag mit gebrochener entryId liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({