diff --git a/src/pipeline.js b/src/pipeline.js index 0ed20dd..4e9f84c 100644 --- a/src/pipeline.js +++ b/src/pipeline.js @@ -1,4 +1,4 @@ -import { emptySpec, isUsable } from './spec.js'; +import { emptySpec, isUsable, specsCompatible } from './spec.js'; import { decodePartNumber } from './pn-decoder.js'; import { extractFields } from './ocr-extract.js'; @@ -10,24 +10,66 @@ async function safely(fn, fallback) { } } +/** + * Fuehrt mehrere untereinander vertraegliche Specs zusammen: ein spaeterer + * Treffer fuellt nur Felder, die noch offen sind, ueberschreibt aber nie + * ein bereits gesetztes Feld. + */ +function mergeCompatibleSpecs(specs) { + const merged = { ...specs[0] }; + for (let i = 1; i < specs.length; i++) { + for (const field of Object.keys(merged)) { + if (merged[field] === null && specs[i][field] !== null) { + merged[field] = specs[i][field]; + } + } + } + return merged; +} + +/** Prueft, ob alle Specs paarweise vertraeglich sind. */ +function allCompatible(specs) { + for (let i = 0; i < specs.length; i++) { + for (let j = i + 1; j < specs.length; j++) { + if (!specsCompatible(specs[i], specs[j])) return false; + } + } + return true; +} + /** * Barcode zuerst, OCR nur als Rueckfallebene. * @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red'}>} */ export async function recognize(frame, deps) { - const codes = await safely(() => deps.decodeBarcodes(frame), []); + const decodedCodes = await safely(() => deps.decodeBarcodes(frame), []); + const codes = Array.isArray(decodedCodes) ? decodedCodes : []; let best = emptySpec(); + const usable = []; for (const code of codes) { const decoded = decodePartNumber(code); if (isUsable(decoded)) { - return { spec: decoded, source: 'barcode', confidence: 'green' }; + usable.push(decoded); + continue; } // Teilenummer merken, auch wenn das Schema unbekannt ist. if (best.partNumber === null) best = decoded; } - const text = await safely(() => deps.runOcr(frame), ''); + if (usable.length === 1) { + return { spec: usable[0], source: 'barcode', confidence: 'green' }; + } + if (usable.length > 1) { + if (allCompatible(usable)) { + return { spec: mergeCompatibleSpecs(usable), source: 'barcode', confidence: 'green' }; + } + // Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet. + return { spec: emptySpec(), source: 'none', confidence: 'red' }; + } + + const ocrResult = await safely(() => deps.runOcr(frame), ''); + const text = typeof ocrResult === 'string' ? ocrResult : ''; const fromOcr = extractFields(text); // Der Barcode ist die exaktere Quelle: seine Teilenummer gewinnt. diff --git a/src/pn-tables.js b/src/pn-tables.js index 23c943d..acdb543 100644 --- a/src/pn-tables.js +++ b/src/pn-tables.js @@ -4,8 +4,10 @@ // sind gegen ein reales Modul geprueft (64GB 4DRx4 PC4-2400T LRDIMM, // M386A8K40BM1-CRC4Y). Alle uebrigen Eintraege stammen aus der veroeffentlichten // Systematik und muessen vor produktivem Einsatz gegen reale Module bestaetigt -// werden - siehe Task 14. Ein falscher Eintrag faellt beim Sortieren dadurch -// auf, dass der OCR-Klartext dem dekodierten Wert widerspricht. +// werden - siehe Task 14. Ein falscher Eintrag faellt auf dem Barcode-Weg +// nicht automatisch auf, weil dort keine Texterkennung laeuft, gegen die er +// widersprechen koennte - die Eintraege muessen deshalb gegen reale Module +// geprueft werden. export const VENDOR_TABLES = [ { diff --git a/test/pipeline.test.js b/test/pipeline.test.js index c6aa516..d05cb9d 100644 --- a/test/pipeline.test.js +++ b/test/pipeline.test.js @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { recognize } from '../src/pipeline.js'; +import { emptySpec } from '../src/spec.js'; const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908'; @@ -56,3 +57,78 @@ test('Barcode-Fehler fuehrt nicht zum Abbruch, OCR uebernimmt', async () => { assert.equal(result.source, 'ocr'); assert.equal(result.spec.capacityGb, 64); }); + +// --- Beanstandung 1: unerwartete Rueckgabeform statt eines Fehlers --- + +test('decodeBarcodes liefert null statt einer Liste, kein Absturz', async () => { + const result = await recognize({}, { + decodeBarcodes: async () => null, + runOcr: async () => OCR_TEXT, + }); + assert.equal(result.source, 'ocr'); + assert.equal(result.confidence, 'yellow'); + assert.equal(result.spec.capacityGb, 64); +}); + +test('decodeBarcodes liefert undefined statt einer Liste, kein Absturz', async () => { + const result = await recognize({}, { + decodeBarcodes: async () => undefined, + runOcr: async () => OCR_TEXT, + }); + assert.equal(result.source, 'ocr'); + assert.equal(result.confidence, 'yellow'); + assert.equal(result.spec.capacityGb, 64); +}); + +test('decodeBarcodes liefert ein Objekt statt einer Liste, kein Absturz', async () => { + const result = await recognize({}, { + decodeBarcodes: async () => ({ notAList: true }), + runOcr: async () => OCR_TEXT, + }); + assert.equal(result.source, 'ocr'); + assert.equal(result.confidence, 'yellow'); + assert.equal(result.spec.capacityGb, 64); +}); + +test('runOcr liefert keine Zeichenkette, wird wie leerer Text behandelt', async () => { + const result = await recognize({}, { + decodeBarcodes: async () => [], + runOcr: async () => ({ unerwartet: true }), + }); + assert.equal(result.source, 'none'); + assert.equal(result.confidence, 'red'); +}); + +// --- Beanstandung 2: mehrere verwertbare Barcodes im selben Bild --- + +test('mehrere unvertraegliche Barcodes ergeben rot statt eines ungeprueften ersten Treffers', async () => { + let ocrAufgerufen = false; + const result = await recognize({}, { + // Gleiche Bauform und Dichte, aber widersprechende Geschwindigkeit: + // zwei unterschiedliche Module im selben Bild. + decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y', 'M386A8K40BM1-CWE4Y'], + runOcr: async () => { ocrAufgerufen = true; return ''; }, + }); + assert.equal(result.source, 'none'); + assert.equal(result.confidence, 'red'); + assert.deepEqual(result.spec, emptySpec()); + assert.equal(ocrAufgerufen, false, 'bei mehrdeutigem Barcode-Ergebnis soll nicht geraten werden'); +}); + +test('mehrere untereinander vertraegliche Barcodes werden zusammengefuehrt und bleiben gruen', async () => { + let ocrAufgerufen = false; + const result = await recognize({}, { + // Zwei Lesungen desselben Etiketts (z.B. Mehrfachtreffer durch + // Spiegelung) - inhaltlich identisch, nur die Schreibweise weicht + // an einer verwechslungstoleranten Stelle ab (B/8). + decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y', 'M386A8K408M1-CRC4Y'], + runOcr: async () => { ocrAufgerufen = true; return ''; }, + }); + assert.equal(result.source, 'barcode'); + assert.equal(result.confidence, 'green'); + assert.equal(result.spec.capacityGb, 64); + assert.equal(result.spec.formFactor, 'LRDIMM'); + assert.equal(result.spec.rank, '4DRx4'); + assert.equal(result.spec.speed, 'PC4-2400'); + assert.equal(ocrAufgerufen, false, 'OCR darf bei gruenem Mehrfachtreffer nicht laufen'); +});