Compare commits
10
Commits
e50e8f4cd2
...
b679df6fd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b679df6fd3 | ||
|
|
266babc8bf | ||
|
|
f948620555 | ||
|
|
7b4a3532bb | ||
|
|
c9084830b8 | ||
|
|
61dde3eb08 | ||
|
|
661bd07a26 | ||
|
|
70f6a4fbce | ||
|
|
5413be105d | ||
|
|
1acfee76d8 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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');
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user