From a7adfeb9da31b457e11a135a21e3045846675c77 Mon Sep 17 00:00:00 2001 From: vchuser Date: Tue, 28 Jul 2026 14:16:31 +0200 Subject: [PATCH] feat: toleranter Spec-Vergleich gegen OCR-Verwechslungen --- src/spec.js | 59 +++++++++++++++++++++++++++++++++++++++++ test/spec-match.test.js | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/spec-match.test.js diff --git a/src/spec.js b/src/spec.js index c4b32b3..6853260 100644 --- a/src/spec.js +++ b/src/spec.js @@ -53,3 +53,62 @@ export function fingerprint(spec) { export function isUsable(spec) { return typeof spec.capacityGb === 'number' && Number.isFinite(spec.capacityGb); } + +/** + * Zeichen, die OCR auf glaenzenden Etiketten regelmaessig verwechselt, + * werden auf einen gemeinsamen Vertreter abgebildet. D bleibt bewusst + * unangetastet, sonst kollidiert 4DRx4 mit 40Rx4. + */ +const CONFUSIONS = { + O: '0', Q: '0', + I: '1', L: '1', + B: '8', + S: '5', + Z: '2', + G: '6', +}; + +/** Vergleichsform eines Tokens: normalisiert, ohne Trennzeichen, verwechslungsfrei. */ +export function canonical(raw) { + const normalized = normalizeToken(raw).replace(/[^A-Z0-9]/g, ''); + let out = ''; + for (const char of normalized) { + out += CONFUSIONS[char] ?? char; + } + return out; +} + +/** + * Gleicht einen gelesenen Wert gegen eine Liste bekannter Werte ab. + * Erst exakt, dann ueber die Vergleichsform. Mehrdeutigkeit gilt als Treffer-los. + */ +export function matchKnown(raw, list) { + const normalized = normalizeToken(raw); + if (normalized === '') return null; + + for (const candidate of list) { + if (normalizeToken(String(candidate)) === normalized) return candidate; + } + + const target = canonical(normalized); + const hits = list.filter((candidate) => canonical(String(candidate)) === target); + return hits.length === 1 ? hits[0] : null; +} + +/** + * Zwei Specs sind vertraeglich, wenn alle beidseitig gesetzten Felder + * in ihrer Vergleichsform uebereinstimmen. Felder, die auf einer Seite + * fehlen, verhindern die Vertraeglichkeit nicht - die Entscheidung + * darueber faellt in session.js. + */ +export function specsCompatible(a, b) { + const fields = ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']; + for (const field of fields) { + const left = a[field]; + const right = b[field]; + if (left === null || left === undefined) continue; + if (right === null || right === undefined) continue; + if (canonical(String(left)) !== canonical(String(right))) return false; + } + return true; +} diff --git a/test/spec-match.test.js b/test/spec-match.test.js new file mode 100644 index 0000000..60094c9 --- /dev/null +++ b/test/spec-match.test.js @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { KNOWN, emptySpec, canonical, matchKnown, specsCompatible } from '../src/spec.js'; + +test('canonical bildet verwechselbare Zeichen auf einen Vertreter ab', () => { + assert.equal(canonical('PC4-24O0'), canonical('PC4-2400')); + assert.equal(canonical('1RX8'), canonical('IRX8')); + assert.equal(canonical('BGB'), canonical('868')); + assert.equal(canonical('LRDIMM'), canonical('1RD1MM')); +}); + +test('canonical laesst D unangetastet, damit 4DRx4 erhalten bleibt', () => { + assert.notEqual(canonical('4DRX4'), canonical('40RX4')); +}); + +test('matchKnown findet den exakten Wert', () => { + assert.equal(matchKnown('PC4-2400', KNOWN.speed), 'PC4-2400'); + assert.equal(matchKnown('lrdimm', KNOWN.formFactor), 'LRDIMM'); +}); + +test('matchKnown repariert eine Verwechslung', () => { + assert.equal(matchKnown('PC4-24O0', KNOWN.speed), 'PC4-2400'); + assert.equal(matchKnown('4DRX4', KNOWN.rank), '4DRx4'); +}); + +test('matchKnown liefert null bei unbekanntem Wert', () => { + assert.equal(matchKnown('PC4-9999', KNOWN.speed), null); + assert.equal(matchKnown('', KNOWN.speed), null); +}); + +test('matchKnown funktioniert auch mit Zahlenlisten', () => { + assert.equal(matchKnown('64', KNOWN.capacityGb), 64); + assert.equal(matchKnown('6A', KNOWN.capacityGb), null); +}); + +test('specsCompatible vergleicht nur beidseitig gesetzte Felder', () => { + const voll = { capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y', dateCode: '1908' }; + const ohnePn = { ...voll, partNumber: null, dateCode: null }; + assert.equal(specsCompatible(voll, ohnePn), true); +}); + +test('specsCompatible erkennt einen echten Unterschied', () => { + const a = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400' }; + const b = { ...emptySpec(), capacityGb: 32, speed: 'PC4-2400' }; + assert.equal(specsCompatible(a, b), false); +}); + +test('specsCompatible trennt gleiche Spec mit unterschiedlicher Teilenummer', () => { + const samsung = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y' }; + const hynix = { ...emptySpec(), capacityGb: 64, speed: 'PC4-2400', partNumber: 'HMAA8GL7AMR4N-UH' }; + assert.equal(specsCompatible(samsung, hynix), false); +}); + +test('specsCompatible toleriert eine Verwechslung in der Teilenummer', () => { + const a = { ...emptySpec(), capacityGb: 64, partNumber: 'M386A8K40BM1-CRC4Y' }; + const b = { ...emptySpec(), capacityGb: 64, partNumber: 'M386A8K4OBM1-CRC4Y' }; + assert.equal(specsCompatible(a, b), true); +});