feat: toleranter Spec-Vergleich gegen OCR-Verwechslungen

This commit is contained in:
vchuser
2026-07-28 14:16:31 +02:00
parent 3aa54d6c78
commit a7adfeb9da
2 changed files with 117 additions and 0 deletions
+59
View File
@@ -53,3 +53,62 @@ export function fingerprint(spec) {
export function isUsable(spec) { export function isUsable(spec) {
return typeof spec.capacityGb === 'number' && Number.isFinite(spec.capacityGb); 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;
}
+58
View File
@@ -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);
});