diff --git a/src/pipeline.js b/src/pipeline.js index bb79f6a..4471d70 100644 --- a/src/pipeline.js +++ b/src/pipeline.js @@ -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 }; } diff --git a/src/storage.js b/src/storage.js index 288c270..c30359d 100644 --- a/src/storage.js +++ b/src/storage.js @@ -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; diff --git a/src/styles.css b/src/styles.css index 01f5f15..4612f92 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/result-overlay.js b/src/ui/result-overlay.js index ea5b635..ff78b82 100644 --- a/src/ui/result-overlay.js +++ b/src/ui/result-overlay.js @@ -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} */ -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 = ` -
STAPEL ${stackId}
+
STAPEL ${stackId}
+ `; 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 }); }); } diff --git a/src/ui/session-list.js b/src/ui/session-list.js index 83938f8..0468c78 100644 --- a/src/ui/session-list.js +++ b/src/ui/session-list.js @@ -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); + } } } diff --git a/test/pipeline.test.js b/test/pipeline.test.js index d72d4f2..ea789a4 100644 --- a/test/pipeline.test.js +++ b/test/pipeline.test.js @@ -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'); +}); diff --git a/test/storage.test.js b/test/storage.test.js index 3ecff65..bd465a3 100644 --- a/test/storage.test.js +++ b/test/storage.test.js @@ -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); +});