Compare commits

...
4 Commits
Author SHA1 Message Date
TanerUslu 87ba439287 Doku: voller OCR-Rohtext in "Text erkennen" (Rahmen, Whitelist, Anzeige)
README auf den aktuellen Stand gebracht: Zielrahmen ist jetzt auch in "Text
erkennen" wirksam (grabFrameRegion statt grabFrame), breitere Zeichen-
Whitelist samt Abwägung, additiver OCR-Rohtext (rawText) in Pipeline,
Speicherung und Anzeige, wartende statt automatisch ausblendende
Rückmeldung in "Text erkennen". Datei-Tabelle, Abschnitt
"Sitzungssicherung im Detail", "Grenzen" und die manuelle Geräte-Prüfliste
entsprechend ergänzt; Testanzahl 144 → 154.
2026-07-29 15:19:34 +02:00
TanerUslu b903b1c7aa OCR-Rohtext additiv zurückgeben, je Eintrag speichern und anzeigen
recognize() (pipeline.js) liefert zusätzlich den ungefilterten OCR-Rohtext
(rawText) zurück - leer, wenn keine Texterkennung lief. Rein additiv: die
Stapelzuordnung (session.js/spec.js, unangetastet) stützt sich weiterhin
ausschließlich auf die daraus abgeleiteten, verstandenen Spec-Felder.

main.js hängt den Rohtext nach dem Buchen als zusätzliche Eigenschaft an den
Eintrag (analog zu den bereits vorhandenen Barcode-Inhalten je Eintrag).
storage.js sichert/prüft/stellt entry.rawText nach demselben optionalen
Muster wie entry.codes wieder her - ein gesicherter Stand ohne dieses Feld
wird nicht verworfen.

In Funktion "Text erkennen" bleibt die Treffer-Rückmeldung jetzt stehen, bis
der Nutzer sie wegtippt, und zeigt den vollen erkannten Text (lesbar über
dasselbe Scroll-Muster wie die übrigen Vollbild-Overlays); in den beiden
Barcode-Funktionen bleibt es beim automatischen Ausblenden. Das
zurückgegebene Versprechen von showResult löst in jedem Fall - Zeitgeber,
Wegtippen, verdrängende neue Rückmeldung - genau einmal ein, damit die
Bedienung nie dauerhaft gesperrt bleibt. Die Sitzungsliste zeigt den Rohtext
zusätzlich je Eintrag zum Nachlesen.

Tests (pipeline.test.js, storage.test.js): rawText nur bei tatsächlich
gelaufener Texterkennung, unterschiedlicher Rohtext beeinflusst die
Stapelzuordnung nachweislich nicht, Rohtext übersteht Sichern/Laden inkl.
Abwärtskompatibilität zu einem Stand ohne dieses Feld.
2026-07-29 15:19:13 +02:00
TanerUslu 1c10c68053 Zeichen-Whitelist der Texterkennung um Kleinbuchstaben erweitert
tessedit_char_whitelist ließ bisher nur Großbuchstaben, Ziffern und wenige
Sonderzeichen zu. Der Nutzer will den vollständigen Etikettentext sehen, nicht
nur die von der App verstandenen Felder - die Whitelist deckt deshalb jetzt
Groß- und Kleinbuchstaben, Ziffern sowie die auf Etiketten üblichen Satz- und
Sonderzeichen ab. Das kann die Genauigkeit bei den Codeteilen (Kapazität,
Rank, Geschwindigkeit, Teilenummer) etwas verringern - eine bewusst in Kauf
genommene Abwägung, siehe Kommentar an Ort und Stelle.
2026-07-29 15:18:56 +02:00
TanerUslu 54b1447f21 Text erkennen liest den Zielrahmen in voller Auflösung, nicht das Vollbild
grabFrameRegion() statt grabFrame(): "Modul scannen" bekam bisher das ganze
Kamerabild, auf 1280 Pixel herunterskaliert - der Zielrahmen war dabei rein
kosmetisch, obwohl er dem Nutzer einen wirksamen Ausschnitt suggeriert. Jetzt
liest die Texterkennung genau den Rahmenausschnitt, in nativer Auflösung -
denselben, den auch die laufende Barcode-Suche längst benutzt. Die
Dateiauswahl (onPickFile) bleibt unverändert beim Vollbild, da sie keinen
Zielrahmen kennt.
2026-07-29 15:18:48 +02:00
10 changed files with 401 additions and 57 deletions
+100 -29
View File
@@ -80,9 +80,14 @@ keine Daten verlassen das Gerät.
„QR-Code" sucht die App ohnehin laufend, ein Knopf ohne Texterkennung hätte
dort nichts zu tun. Der Zielrahmen hebt sich in „Strichcode" und „QR-Code"
hervor, sobald ein passender Code im Bild ist; in „Text erkennen" bleibt er
schlicht, weil dort nichts laufend erkannt wird. Ein Wechsel der Funktion
während des Sortierens (z. B. bei gemischter Ware) gilt sofort: laufende
Suche startet oder stoppt, der Knopf erscheint oder verschwindet.
schlicht, weil dort nichts laufend erkannt wird. Trotzdem ist der Rahmen
auch dort wirksam: „Modul scannen" liest genau den Bildausschnitt innerhalb
des Rahmens, in voller Kameraauflösung, nicht mehr das gesamte,
heruntergerechnete Kamerabild (`grabFrameRegion` in `src/camera.js`
derselbe Ausschnitt, den auch die laufende Barcode-Suche benutzt). Ein
Wechsel der Funktion während des Sortierens (z. B. bei gemischter Ware)
gilt sofort: laufende Suche startet oder stoppt, der Knopf erscheint oder
verschwindet.
Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage
nach dem Fortsetzen (siehe Punkt 5 unten) und erst danach die Funktionswahl
@@ -123,20 +128,41 @@ keine Daten verlassen das Gerät.
3. **Texterkennung** (ausschließlich in „Text erkennen", ausgelöst durch
„Modul scannen"). Anders als früher ist das kein automatischer Rückfall
vom Barcode-Weg mehr, sondern die einzige Quelle dieser Funktion — sie
liest ohnehin nie einen Barcode (siehe Punkt 0). Das Kamerabild wird dafür
liest ohnehin nie einen Barcode (siehe Punkt 0). Gelesen wird der
Bildausschnitt innerhalb des Zielrahmens, in voller Kameraauflösung
(`grabFrameRegion`, siehe Punkt 0) — nicht mehr das ganze, auf 1280 Pixel
heruntergerechnete Kamerabild. Das Bild 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. Da in dieser Funktion nie eine
per Barcode gelesene Teilenummer vorliegt, entscheiden hier ausschließlich
die aus dem erkannten Text abgeleiteten technischen Angaben (Kapazität,
Bauform, Rank, Geschwindigkeit) über die Stapelzuordnung
(`specsCompatible` in `src/spec.js`) — anders als in „Strichcode"/
„QR-Code", wo meist die exakte Teilenummer trägt.
Min/Max-Spreizung leicht kippen würden. Die Zeichen-Whitelist von
Tesseract (`tessedit_char_whitelist` in `src/ocr.js`) lässt Groß- **und**
Kleinbuchstaben, Ziffern sowie die auf Etiketten üblichen Satz- und
Sonderzeichen zu — nicht mehr nur Großbuchstaben. Das OCR-Ergebnis wird
anschließend gegen die bekannten Spec-Werte abgeglichen, was die
typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst; das kann durch die
breitere Whitelist bei den Codeteilen (Kapazität, Rank, Geschwindigkeit,
Teilenummer) etwas an Genauigkeit kosten, weil Tesseract jetzt z. B.
zwischen „O“ und „o“ unterscheiden muss — eine bewusst in Kauf genommene
Abwägung, damit der vollständige Etikettentext lesbar wird. Da in dieser
Funktion nie eine per Barcode gelesene Teilenummer vorliegt, entscheiden
hier ausschließlich die aus dem erkannten Text abgeleiteten technischen
Angaben (Kapazität, Bauform, Rank, Geschwindigkeit) über die
Stapelzuordnung (`specsCompatible` in `src/spec.js`) — anders als in
„Strichcode"/„QR-Code", wo meist die exakte Teilenummer trägt.
Der vollständige, ungefilterte Erkennungstext (nicht nur die daraus
verstandenen Felder) wird zusätzlich mitgeführt (`recognize` in
`src/pipeline.js` liefert ihn als `rawText` zurück) und je Eintrag
gespeichert — rein additiv, ohne jeden Einfluss auf die Stapelzuordnung
(siehe „Grenzen" unten). In Funktion „Text erkennen" bleibt die
Treffer-Rückmeldung deshalb stehen, bis der Nutzer sie wegtippt, und zeigt
den vollen erkannten Text an, statt sich nach gut einer Sekunde von selbst
auszublenden wie in den beiden Barcode-Funktionen (`showResult` in
`src/ui/result-overlay.js`). Der Rohtext jedes Eintrags lässt sich später
auch in der Sitzungsliste nachlesen.
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
@@ -187,12 +213,15 @@ korrekt funktioniert.
npm test
```
144 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
154 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
und Mehrdeutigkeitsbehandlung, die Sitzungssicherung samt Prüfung eines
wiederhergestellten Zustands, die drei Scan-Funktionen (`scan-modes.js`
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen,
Mehrdeutigkeitsbehandlung und dem additiven OCR-Rohtext (`rawText` wird nur
zurückgegeben, wenn die Texterkennung tatsächlich lief, und beeinflusst
nachweislich nie die Stapelzuordnung), die Sitzungssicherung samt Prüfung
eines wiederhergestellten Zustands (einschließlich `entry.rawText`), die drei
Scan-Funktionen (`scan-modes.js`
Codearten je Funktion, DataMatrix/Aztec/PDF417 in „QR-Code") sowie die je Funktion
tatsächlich aufgerufenen Adapter (`scan-recognition.js` — Texterkennung läuft
nachweislich nie in „Strichcode"/„QR-Code", Barcode-Dekodierung nachweislich
@@ -263,19 +292,19 @@ worauf du bei der Veröffentlichung achten solltest.
| `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 — 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`) — unverändert; kennt keine Scan-Funktionen |
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) — kennt keine Scan-Funktionen; liefert zusätzlich (additiv) den ungefilterten OCR-Rohtext (`rawText`), leer, wenn keine Texterkennung lief |
| `src/scan-modes.js` | Die drei Scan-Funktionen (Strichcode, QR-Code, Text erkennen): Kennung, deutsche Beschriftung, gelesene Codearten, laufende Suche ja/nein, Texterkennung ja/nein — einzige Quelle, sowohl für die Auswahl-Oberfläche als auch für `main.js` |
| `src/scan-recognition.js` | Baut die an `recognize()` übergebenen Adapter anhand der gewählten Funktion: in „Strichcode"/„QR-Code" liefert die Texterkennung sofort leeren Text ohne Tesseract anzustoßen, in „Text erkennen" liefert die Barcode-Dekodierung sofort eine leere Liste ohne `zxing-wasm` anzustoßen |
| `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/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten); `entry.rawText` ist wie `entry.codes` optional und wird bei Fehlen als leerer Rohtext wiederhergestellt |
| `src/camera.js` | Kamerastart, Einzelbildaufnahme (herunterskaliert, für die Dateiauswahl), Rahmenausschnitt in voller Auflösung (`grabFrameRegion`, für die laufende Barcode-Suche **und** für „Modul scannen" in „Text erkennen"), Bilddatei-Ersatzweg |
| `src/barcode.js` | Adapter zu `zxing-wasm`; `decodeBarcodes(imageData, formats)` bekommt die Codearten übergeben, ohne Angabe wie bisher (Code128 + DataMatrix) |
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` |
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js`; Zeichen-Whitelist deckt Groß- und Kleinbuchstaben, Ziffern sowie übliche Etiketten-Sonderzeichen ab |
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, antippbare Anzeige der gewählten Funktion (Wechsel), Scan-Knopf (nur in „Text erkennen" sichtbar), Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
| `src/ui/mode-dialog.js` | Vollbild-Auswahl der Scan-Funktion vor dem Start und beim Wechsel: drei große Flächen, eine je Funktion aus `src/scan-modes.js` |
| `src/ui/result-overlay.js` | Kurze Treffer-Rückmeldung (grün/gelb), blendet sich nach kurzer Zeit selbst wieder aus |
| `src/ui/result-overlay.js` | Treffer-Rückmeldung (grün/gelb): blendet sich in „Strichcode"/„QR-Code" nach kurzer Zeit selbst aus; in „Text erkennen" bleibt sie stehen, zeigt den vollen OCR-Rohtext und wartet auf eine Eingabe, bevor sie schließt |
| `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 — läuft vor der Funktionswahl |
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden |
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden, Anzeige des vollständigen OCR-Rohtexts je Eintrag (sofern vorhanden) |
| `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 |
@@ -309,6 +338,14 @@ 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.
Nach demselben Muster ist `entry.rawText` (der volle OCR-Rohtext, siehe
„Funktionsweise" oben) optional: Ist er vorhanden, muss er eine Zeichenkette
sein; ein gesicherter Stand aus einer Fassung vor dieser Erweiterung ohne
dieses Feld gilt nicht als unstimmig und wird beim Laden als Eintrag ohne
Rohtext (leere Zeichenkette) wiederhergestellt. Anders als `stack.codes` wird
`entry.rawText` beim Laden nicht neu abgeleitet — er hängt an genau diesem
Eintrag und fließt in keine Stapel-Berechnung ein.
## Grenzen
- Keine Bestandsführung über Sitzungen hinweg, kein Export. Die Sicherung in
@@ -346,6 +383,13 @@ als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt.
Validierung braucht deshalb echte Module, keine Code-Prüfung.
- Farb- und Schriftgestaltung ist bewusst schlicht gehalten und kann nach dem
ersten Einsatz am Tisch nachgezogen werden.
- Der volle OCR-Rohtext (`rawText`) dient ausschließlich der Anzeige für den
Nutzer (Treffer-Rückmeldung, Sitzungsliste). Die Stapelzuordnung stützt
sich bewusst weiterhin ausschließlich auf die daraus abgeleiteten,
verstandenen Spec-Felder — roher Erkennungstext schwankt zwischen
Aufnahmen (Zeilenumbrüche, zusätzlich erkannte Wörter, Groß-/
Kleinschreibung, …) und würde als Gruppierungsmerkmal für praktisch jedes
Modul einen eigenen Stapel erzeugen.
## Herstellertabellen erweitern
@@ -404,7 +448,11 @@ werden:
halten: ebenfalls Treffer.
- „Text erkennen" wählen: kein automatisches Hervorheben des Rahmens (er
bleibt durchgehend schlicht), „Modul scannen"-Knopf sichtbar und löst die
Texterkennung aus.
Texterkennung aus. Der Rahmen ist dort trotzdem wirksam: nur der
Bildausschnitt innerhalb des Rahmens wird gelesen (nicht das ganze
Kamerabild) — mit Text außerhalb des Rahmens auf demselben Etikett prüfen,
dass er **nicht** erkannt wird, während Text innerhalb des Rahmens
vollständig erscheint.
- Anzeige der gewählten Funktion in der Scan-Ansicht antippen: Auswahl
erscheint erneut, nach der Wahl gilt der Wechsel sofort — laufende Suche
startet/stoppt, Knopf erscheint/verschwindet, **ohne** die Seite neu zu
@@ -418,7 +466,21 @@ werden:
- In Funktion „Text erkennen" ein Etikett fotografieren und „Modul scannen"
antippen: die Otsu-Aufbereitung liefert auf einem echten, glänzenden
Etikettenfoto tatsächlich brauchbaren Text (bisher nur an synthetischen
Testbildern geprüft, nie an einem echten Foto).
Testbildern geprüft, nie an einem echten Foto). Prüfen, dass die
Rückmeldung dabei den **vollständigen** erkannten Text zeigt (Groß- und
Kleinbuchstaben, nicht nur die von der App verstandenen Felder wie
`32GB 2Rx4 PC4-2666`), stehen bleibt, bis sie weggetippt wird, und bei
langem Text lesbar bleibt (per Scrollen erreichbar, wächst nicht aus dem
Bildschirm heraus). Danach in der Sitzungsliste nachsehen, dass derselbe
Rohtext beim Eintrag zum Nachlesen erscheint.
- Da der Bildausschnitt für die Texterkennung jetzt deutlich größer ist als
das bisher übergebene, auf 1280 Pixel herunterskalierte Vollbild
(`grabFrameRegion` statt `grabFrame`, siehe „Funktionsweise"), dauert ein
einzelner Erkennungsversuch länger als vorher — beobachten, wie lange ein
normaler Scan auf einem durchschnittlichen Handy tatsächlich braucht, und
ob er dabei spürbar unter der 20-Sekunden-Zeitgrenze der Pipeline bleibt
(siehe dazu auch den folgenden Punkt zur Ladezeit des Tesseract-Arbeiters,
die zusätzlich in dieselbe Zeitgrenze fällt).
- Ladezeit des WASM-Barcode-Moduls (`zxing-wasm`) beim allerersten Scan einer
Sitzung in Funktion „Strichcode" oder „QR-Code" beobachten — bleibt sie
deutlich unter der 10-Sekunden-Zeitgrenze der Pipeline, und braucht ein
@@ -440,8 +502,11 @@ werden:
Ladevorgang dauerhaft "verfügbar", springt nach einem erzwungenen
Fehlschlag (z. B. Flugmodus beim ersten Laden) beim nächsten Erfolg wieder
darauf zurück?
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten
Etiketten keine tatsächlich benötigten Zeichen ausschließt.
- Stichprobe, ob die Zeichen-Whitelist der Texterkennung (Groß-/
Kleinbuchstaben, Ziffern, übliche Satz-/Sonderzeichen) auf echten Etiketten
keine tatsächlich benötigten Zeichen ausschließt, und ob die Erkennung der
Codeteile (Kapazität, Rank, Geschwindigkeit, Teilenummer) durch die jetzt
mögliche Groß-/Kleinschreibung noch verlässlich genug bleibt.
- Funktion „Strichcode"/„QR-Code": kein passender Code im Bild führt zu
Rot-Dialog (statt Fehler oder Absturz) — hier ohne jeden OCR-Versuch.
Funktion „Text erkennen": kein verwertbarer OCR-Text führt ebenfalls zu
@@ -461,9 +526,15 @@ werden:
es passend.
- Stapel-Leiste (`A: 12`, `B: 4`) aus rund einem Meter Entfernung mit einer
Hand lesbar.
- Grüne/gelbe Rückmeldung erscheint nach dem Scan und verschwindet nach
rund 1200 ms von selbst; bei zwei Scans deutlich unter 1200 ms Abstand
bleibt jeweils nur eine Rückmeldung sichtbar und die App bleibt bedienbar.
- In „Strichcode"/„QR-Code": Grüne/gelbe Rückmeldung erscheint nach dem Scan
und verschwindet nach rund 1200 ms von selbst; bei zwei Scans deutlich
unter 1200 ms Abstand bleibt jeweils nur eine Rückmeldung sichtbar und die
App bleibt bedienbar.
- In „Text erkennen": Rückmeldung bleibt stehen, bis sie über die
„weiter"-Schaltfläche (mindestens 56px hoch) weggetippt wird — kein
automatisches Ausblenden. Ein weiterer Scan, bevor die vorherige
Rückmeldung weggetippt wurde, ersetzt sie sofort durch die neue, ohne die
App dauerhaft zu sperren.
- Alle Bedienflächen (Scan-, Rückgängig-, Stapel-Knöpfe) mit dem Daumen
erreichbar, während die andere Hand das Modul hält.
- Rückgängig-Knopf: Screenreader liest "Letzten Scan zurücknehmen" vor.
+22 -4
View File
@@ -1,6 +1,6 @@
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js';
import { startCamera, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js';
@@ -50,7 +50,13 @@ let cameraUnavailableMessage = null;
let currentModeId = null;
const view = renderScanView(app, {
onCapture: () => processCapture(() => grabFrame(view.video)),
// "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe
// applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort
// einen Ausschnitt, den grabFrame() (Vollbild, herunterskaliert) bisher gar
// nicht einloeste. grabFrameRegion() liest genau den Rahmenausschnitt, in
// voller Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche
// benutzt (siehe runSearchAttempt() weiter unten).
onCapture: () => processCapture(() => grabFrameRegion(view.video)),
onUndo: () => {
// Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen
// werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen,
@@ -150,7 +156,7 @@ async function processCapture(getFrame) {
// selbst (pipeline.js) bleibt unveraendert und kennt keine Funktionen.
const { decodeBarcodes: decode, runOcr: recognizeText } =
buildRecognitionAdapters(currentModeId, { decodeBarcodes, runOcr });
const { spec, source, confidence, codes } = await recognize(
const { spec, source, confidence, codes, rawText } = await recognize(
frame,
{ decodeBarcodes: decode, runOcr: recognizeText, isKnownCode },
);
@@ -188,13 +194,25 @@ async function processCapture(getFrame) {
if (targetSession !== session) return;
const entry = commitAssignment(targetSession, spec, source, stackId, codes);
// entry.rawText ist rein additiv (siehe recognize() in pipeline.js) -
// session.js kennt das Feld nicht und bleibt deshalb unangetastet; es wird
// hier von aussen an den bereits gebuchten Eintrag angehaengt, genau wie
// die Stapelzuordnung selbst nie vom Rohtext abhaengt.
entry.rawText = rawText;
view.setLast(describeEntry(spec, entry.stackId));
syncView();
// Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr
// rueckzumelden - der Dialog selbst war die Rueckmeldung.
if (confidence !== 'red') {
await showResult(app, { stackId: entry.stackId, spec, confidence });
// Nur in Funktion 3 ("Text erkennen") ist der erkannte Text lang genug,
// dass er gelesen werden will - dort bleibt die Rueckmeldung deshalb
// stehen, bis der Nutzer sie wegtippt, statt sich nach kurzer Zeit von
// selbst auszublenden (siehe showResult()). In den beiden Barcode-
// Funktionen zaehlt Tempo, dort bleibt es beim automatischen Ausblenden.
await showResult(app, {
stackId: entry.stackId, spec, confidence, rawText, autoHide: currentModeId !== 'ocr',
});
}
} catch (error) {
failed = true;
+13 -2
View File
@@ -117,8 +117,19 @@ function getWorker() {
workerPromise = createWorker('eng')
.then(async (worker) => {
await worker.setParameters({
// Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen.
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. x',
// Der Nutzer will den vollstaendigen Etikettentext sehen, nicht nur
// die von der App verstandenen Felder (die ohnehin nur Grossbuch-
// staben, Ziffern und wenige Zeichen brauchen) - deshalb Gross- UND
// Kleinbuchstaben, Ziffern sowie die auf Etiketten ueblichen Satz-
// und Sonderzeichen. Das kann die Genauigkeit bei den Codeteilen
// (Kapazitaet, Rank, Geschwindigkeit, Teilenummer) etwas verringern,
// weil Tesseract nun z.B. zwischen "O" und "o" unterscheiden muss -
// hingenommen, weil extractFields() ohnehin verwechslungstolerant
// gegen die bekannten Werte prueft (siehe spec.js/CONFUSIONS) und
// der Nutzer diesen Tausch ausdruecklich will.
tessedit_char_whitelist:
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ ' -/.,:;()+*_#%&',
});
return worker;
})
+14 -8
View File
@@ -60,10 +60,16 @@ function allCompatible(specs) {
* 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[]}>}
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[], rawText: 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.
* rawText ist der ungefilterte Texterkennungs-Rohtext, rein additiv: er
* fliesst nirgends in die Stapelzuordnung ein (die stuetzt sich weiterhin
* ausschliesslich auf die aus ihm abgeleiteten, verstandenen Spec-Felder,
* siehe extractFields/ocr-extract.js) und dient allein der Anzeige fuer den
* Nutzer. Leer, wenn die Texterkennung in diesem Durchlauf nicht lief (z.B.
* weil ein gruener Barcode-Treffer sie ueberfluessig gemacht hat).
*/
export async function recognize(frame, deps) {
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
@@ -99,7 +105,7 @@ export async function recognize(frame, deps) {
}
if (usable.length === 1) {
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
}
if (usable.length > 1) {
if (allCompatible(usable)) {
@@ -109,10 +115,10 @@ 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', codes };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
}
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
return { spec: emptySpec(), source: 'none', confidence: 'red', codes };
return { spec: emptySpec(), source: 'none', confidence: 'red', codes, rawText: '' };
}
// Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die
@@ -121,7 +127,7 @@ export async function recognize(frame, deps) {
// 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 };
return { spec: best, source: 'barcode', confidence: 'green', codes, rawText: '' };
}
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
@@ -136,7 +142,7 @@ export async function recognize(frame, deps) {
}
if (isUsable(merged)) {
return { spec: merged, source: 'ocr', confidence: 'yellow', codes };
return { spec: merged, source: 'ocr', confidence: 'yellow', codes, rawText: text };
}
// Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
// Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis
@@ -144,7 +150,7 @@ export async function recognize(frame, deps) {
// 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: 'barcode', confidence: 'green', codes, rawText: text };
}
return { spec: merged, source: 'none', confidence: 'red', codes };
return { spec: merged, source: 'none', confidence: 'red', codes, rawText: text };
}
+12
View File
@@ -52,6 +52,9 @@ function isUsableStack(stack) {
// dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als
// unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von
// Zeichenketten sein.
// entry.rawText (der OCR-Rohtext, siehe pipeline.js/recognize) ist nach
// demselben Muster optional: ein Stand aus einer Fassung ohne dieses Feld ist
// nicht unstimmig; ist es vorhanden, muss es eine Zeichenkette sein.
function isUsableEntry(entry) {
return (
isPlainObject(entry)
@@ -59,6 +62,7 @@ function isUsableEntry(entry) {
&& typeof entry.stackId === 'string'
&& isPlainObject(entry.spec)
&& (entry.codes === undefined || isStringArray(entry.codes))
&& (entry.rawText === undefined || typeof entry.rawText === 'string')
);
}
@@ -107,6 +111,14 @@ export function loadSession(store) {
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
// rawText fehlt bei einem Eintrag aus einer Fassung vor dieser Erweiterung
// (siehe isUsableEntry oben) - beim Wiederherstellen wird das einheitlich
// als leerer Rohtext behandelt, damit main.js/session-list.js sich nicht
// um "undefined vs. leer" kuemmern muessen.
for (const entry of parsed.entries) {
if (entry.rawText === undefined) entry.rawText = '';
}
for (const stack of parsed.stacks) {
const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id);
stack.count = stackEntries.length;
+15
View File
@@ -125,6 +125,21 @@ body {
.overlay .detail { font-size: 20px; line-height: 1.5; }
.overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; }
/* Voller OCR-Rohtext in der wartenden Rueckmeldung (Funktion "Text
erkennen", siehe result-overlay.js) - linksbuendig und mit Zeilenumbruch,
damit ein langer Etikettentext lesbar bleibt statt einer einzelnen,
abgeschnittenen Zeile. Waechst nicht aus dem Bildschirm heraus, weil
`.overlay` selbst scrollbar ist (overflow-y: auto, siehe oben). */
.overlay .raw-text {
font-size: 16px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
overflow-wrap: break-word;
max-width: 420px;
width: 100%;
}
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; }
+82 -14
View File
@@ -1,38 +1,106 @@
import { describeSpec } from './describe-spec.js';
/** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Zeitgeber fest. */
/** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Abschlussfunktion fest. */
const currentFeedback = new WeakMap();
/**
* Blendet die Treffer-Rueckmeldung ein und nach kurzer Zeit wieder aus.
* Gruen bei Barcode, gelb bei OCR. Keine Eingabe noetig.
* Blendet die Treffer-Rueckmeldung ein. Gruen bei Barcode, gelb bei OCR.
*
* In den Barcode-Funktionen (autoHide: true, Voreinstellung) blendet sie sich
* nach kurzer Zeit von selbst wieder aus - dort zaehlt Tempo, keine Eingabe
* noetig. In Funktion 3 ("Text erkennen", autoHide: false) ist der erkannte
* Text potenziell ein ganzer Etikettentext und damit zu lang fuer eine knappe
* automatische Anzeige: die Rueckmeldung bleibt dort stehen, bis der Nutzer
* sie ueber die Schaltflaeche wegtippt, und zeigt zusaetzlich den vollen
* Rohtext (rawText). Lang werdender Text bleibt lesbar ueber dasselbe Muster,
* mit dem auch die anderen Vollbild-Overlays lange Inhalte handhaben: die
* `.overlay`-Klasse ist selbst scrollbar (overflow-y: auto), statt aus dem
* Bildschirm herauszuwachsen (siehe styles.css).
*
* Das zurueckgegebene Versprechen wird in jedem Fall genau einmal eingeloest -
* beim automatischen Ausblenden, beim Wegtippen und auch dann, wenn eine neue
* Rueckmeldung (z.B. durch einen weiteren Scan) diese hier verdraengt, bevor
* sie selbst eingeloest wurde. Die App sperrt waehrend der laufenden
* Erkennung die Bedienung (siehe processCapture() in main.js) - ein nie
* eingeloestes Versprechen wuerde sie dauerhaft lahmlegen.
*
* @param {HTMLElement} root
* @param {{stackId: string, spec: object, confidence: string, rawText?: string, autoHide?: boolean}} args
* @param {number} durationMs Anzeigedauer bei autoHide: true.
* @returns {Promise<void>}
*/
export function showResult(root, { stackId, spec, confidence }, durationMs = 1200) {
export function showResult(
root,
{ stackId, spec, confidence, rawText = '', autoHide = true },
durationMs = 1200,
) {
// Eine noch offene vorherige Rueckmeldung wird sofort abgeschlossen (nicht
// nur entfernt) - ihr Versprechen darf nicht offen bleiben, nur weil eine
// neue Rueckmeldung sie verdraengt.
const previous = currentFeedback.get(root);
if (previous) {
clearTimeout(previous.timer);
previous.overlay.remove();
previous.resolve();
currentFeedback.delete(root);
}
if (previous) previous.finish();
const overlay = document.createElement('div');
overlay.className = `overlay ${confidence}`;
if (!autoHide) {
// Nur die wartende Variante ist eine echte Eingabeaufforderung - siehe
// ambiguous-dialog.js/mode-dialog.js fuer dasselbe Muster (role="dialog",
// aria-modal, Ueberschrift referenziert per aria-labelledby).
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'result-overlay-heading');
}
overlay.innerHTML = `
<div class="stack-id">STAPEL ${stackId}</div>
<div class="stack-id" id="result-overlay-heading">STAPEL ${stackId}</div>
<div class="detail"></div>
<div class="pn"></div>
<div class="raw-text" id="raw-text" hidden></div>
`;
overlay.querySelector('.detail').textContent = describeSpec(spec);
overlay.querySelector('.pn').textContent = spec.partNumber ?? '';
if (!autoHide) {
// Der vollstaendige erkannte Text - Fremdinhalt vom Etikett, deshalb als
// Text gesetzt, nicht als Auszeichnung (siehe ambiguous-dialog.js/#read).
const rawTextEl = overlay.querySelector('#raw-text');
rawTextEl.hidden = false;
rawTextEl.textContent = rawText;
}
root.appendChild(overlay);
return new Promise((resolve) => {
const timer = setTimeout(() => {
let settled = false;
let timer = null;
// Schuetzt vor Mehrfacheinloesung: gleichzeitig ablaufender Zeitgeber und
// Tippen auf "weiter", oder ein Abschluss durch eine verdraengende neue
// Rueckmeldung, nachdem der Zeitgeber schon gelaufen ist.
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
overlay.remove();
currentFeedback.delete(root);
resolve();
}, durationMs);
currentFeedback.set(root, { overlay, timer, resolve });
};
if (autoHide) {
timer = setTimeout(finish, durationMs);
} else {
// Bedienflaeche mindestens 56px hoch (siehe Vorgaben) - der einzige Weg,
// diese Rueckmeldung zu schliessen, solange sie nicht autoHide ist.
const dismissButton = document.createElement('button');
dismissButton.className = 'action';
dismissButton.style.minHeight = '56px';
dismissButton.textContent = 'weiter';
dismissButton.addEventListener('click', finish);
overlay.appendChild(dismissButton);
dismissButton.focus();
}
currentFeedback.set(root, { finish });
});
}
+16
View File
@@ -90,6 +90,22 @@ export function renderSessionList(root, session, { onMove, onRemove, onEndSessio
row.appendChild(removeButton);
body.appendChild(row);
// entry.rawText ist additiv (siehe pipeline.js/recognize) und nur bei
// per Texterkennung erfassten Eintraegen nicht leer - zum Nachlesen des
// vollstaendigen Etikettentexts, unabhaengig von den daraus verstandenen
// Feldern oben in derselben Zeile.
if (entry.rawText) {
const rawTextRow = document.createElement('div');
rawTextRow.style.fontSize = '12px';
rawTextRow.style.opacity = '0.7';
rawTextRow.style.textAlign = 'left';
rawTextRow.style.whiteSpace = 'pre-wrap';
rawTextRow.style.overflowWrap = 'break-word';
rawTextRow.style.marginBottom = '6px';
rawTextRow.textContent = `Erkannter Text: ${entry.rawText}`;
body.appendChild(rawTextRow);
}
}
}
+91
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { recognize } from '../src/pipeline.js';
import { emptySpec } from '../src/spec.js';
import { createSession, proposeAssignment, commitAssignment } from '../src/session.js';
const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
@@ -281,3 +282,93 @@ test('ohne isKnownCode-Abhaengigkeit bleibt die Pipeline ohne sie lauffaehig (Te
assert.equal(ocrCalled, true);
assert.equal(result.confidence, 'green');
});
// --- Erweiterung: Rohtext als additives Feld (Funktion "Text erkennen") ---
//
// Der Rohtext ist reine Anzeige-Information fuer den Nutzer - er verlaesst
// die Erkennung, wird aber nirgends zur Stapelzuordnung herangezogen (siehe
// den letzten Test unten, der recognize() mit session.js zusammenspielen
// laesst).
test('recognize liefert den Rohtext, wenn die Texterkennung gelaufen ist (gelbes Ergebnis)', async () => {
const result = await recognize({}, deps({ codes: [], text: OCR_TEXT }));
assert.equal(result.source, 'ocr');
assert.equal(result.rawText, OCR_TEXT);
});
test('recognize liefert den Rohtext auch, wenn die Texterkennung lief, aber nichts Verwertbares fand (rot)', async () => {
const result = await recognize({}, deps({ codes: [], text: 'Made in Philippines' }));
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert den Rohtext, wenn OCR keine Kapazitaet findet, aber eine unverwertbare Barcode-Teilenummer bestaetigt (gruen)', async () => {
// '7325773' hat kein bekanntes Nummernschema (decodePartNumber liefert
// keine Kapazitaet) - die Texterkennung laeuft trotzdem und liefert Text
// ohne jede Kapazitaetsangabe. Die Teilenummer aus dem Barcode traegt
// (siehe recognize()), das Ergebnis bleibt gruen - der gelaufene Rohtext
// wird trotzdem zurueckgegeben.
const result = await recognize({}, {
decodeBarcodes: async () => ['7325773'],
runOcr: async () => 'Made in Philippines',
});
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
assert.equal(result.spec.partNumber, '7325773');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn die Texterkennung nicht lief (gruener Barcode-Treffer)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.source, 'barcode');
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn ein bekannter Code die Texterkennung ueberfluessig macht', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
});
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext bei mehrdeutigen verwertbaren Barcodes (rot, ohne OCR)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y', 'M386A8K40BM1-CWE4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, '');
});
test('der Rohtext beeinflusst die Stapelzuordnung nicht: zwei Aufnahmen mit unterschiedlichem Rohtext, aber gleichen verstandenen Feldern, landen auf demselben Stapel', async () => {
const firstText = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
// Anderer Rohtext (andere Reihenfolge, zusaetzliches "Made in Korea",
// andere Gross-/Kleinschreibung) - dieselben verstandenen Felder.
const secondText = 'made in korea m386a8k40bm1-crc4y 64gb 4drx4 PC4-2400 1908';
const first = await recognize({}, deps({ codes: [], text: firstText }));
const second = await recognize({}, deps({ codes: [], text: secondText }));
assert.notEqual(first.rawText, second.rawText, 'Vorbedingung: die Rohtexte unterscheiden sich tatsaechlich');
assert.deepEqual(first.spec, second.spec, 'Vorbedingung: dieselben verstandenen Felder');
const session = createSession();
const plan1 = proposeAssignment(session, first.spec, first.codes);
const entry1 = commitAssignment(session, first.spec, first.source, plan1.stackId, first.codes);
// Der Rohtext haengt (wie main.js es tut) rein additiv am Eintrag, ohne
// dass session.js davon weiss - siehe main.js/processCapture().
entry1.rawText = first.rawText;
const plan2 = proposeAssignment(session, second.spec, second.codes);
assert.equal(plan2.kind, 'match', 'das zweite Modul muss denselben Stapel treffen');
const entry2 = commitAssignment(session, second.spec, second.source, plan2.stackId, second.codes);
entry2.rawText = second.rawText;
assert.equal(entry1.stackId, entry2.stackId, 'unterschiedlicher Rohtext darf keinen eigenen Stapel erzeugen');
assert.notEqual(entry1.rawText, entry2.rawText, 'die Eintraege fuehren trotzdem je ihren eigenen Rohtext');
});
+36
View File
@@ -308,3 +308,39 @@ test('Eintrag mit gebrochener entryId liefert null', () => {
}));
assert.equal(loadSession(store), null);
});
// --- Erweiterung: der OCR-Rohtext eines Eintrags (entry.rawText) ---
test('Rohtext eines Eintrags ueberlebt Sichern und Laden', () => {
const store = fakeStore();
const session = createSession();
const entry = commitAssignment(session, { ...emptySpec(), capacityGb: 32 }, 'ocr', 'A');
entry.rawText = '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea';
saveSession(session, store);
const reloaded = loadSession(store);
assert.notEqual(reloaded, null);
assert.equal(reloaded.entries[0].rawText, '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea');
});
test('ein Stand ohne rawText-Feld (aeltere Fassung) wird nicht verworfen, der Eintrag gilt als ohne Rohtext', () => {
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 rawText-Feld darf nicht verworfen werden');
assert.equal(reloaded.entries[0].rawText, '');
});
test('Eintrag mit rawText als Nicht-Zeichenkette liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: emptySpec() }],
entries: [{ entryId: 1, stackId: 'A', spec: emptySpec(), source: 'barcode', rawText: 123 }],
nextEntryId: 2,
}));
assert.equal(loadSession(store), null);
});