diff --git a/src/session.js b/src/session.js index 73826e0..fa3691e 100644 --- a/src/session.js +++ b/src/session.js @@ -1,6 +1,6 @@ // Reines Modul: kein window, kein document, kein localStorage. -import { specsCompatible } from './spec.js'; +import { specsCompatible, normalizeToken } from './spec.js'; const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -41,12 +41,60 @@ function sharesSetField(a, b) { } /** - * Schlaegt vor, wohin ein Modul gehoert. - * Genau ein vertraeglicher Stapel mit gemeinsamer Grundlage -> Zuweisung. - * Keiner -> neuer Stapel. - * Mehrere -> mehrdeutig, der Nutzer entscheidet. + * Fasst eine Liste roher Barcode-Zeichenketten zu einer Menge normalisierter + * Werte zusammen. Ein Barcode ist exakt gelesen - deshalb genuegt hier die + * blosse Gross-/Leerzeichen-Normalisierung (normalizeToken), nicht der + * verwechslungstolerante Vergleich (canonical), der fuer die fehleranfaellige + * Texterkennung gedacht ist. */ -export function proposeAssignment(session, spec) { +function normalizedCodeSet(codes) { + const set = new Set(); + for (const code of codes) { + const normalized = normalizeToken(code); + if (normalized) set.add(normalized); + } + return set; +} + +/** + * Ergaenzt die Menge der bei einem Stapel bekannten Barcode-Inhalte um die + * neu gelesenen Codes (z.B. Teile- und Seriennummer desselben Etiketts). + */ +function addCodes(stack, codes) { + const set = new Set(stack.codes); + for (const code of normalizedCodeSet(codes)) set.add(code); + stack.codes = [...set]; +} + +/** + * Schlaegt vor, wohin ein Modul gehoert. + * + * Ein bekannter Barcode-Inhalt hat Vorrang vor dem Vergleich der technischen + * Angaben, denn ein Barcode ist exakt gelesen, waehrend abgeleitete Angaben + * fehlerbehaftet sein koennen (siehe README, Abschnitt Barcode-Erkennung). + * Genau ein Stapel mit einem der gescannten Codes -> Zuweisung, auch bei + * unvollstaendigen technischen Angaben. Mehrere -> mehrdeutig, der Nutzer + * entscheidet. Kennt kein Stapel einen der Codes, gilt unveraendert die + * bisherige Regel: genau ein vertraeglicher Stapel mit gemeinsamer + * Grundlage -> Zuweisung, keiner -> neuer Stapel, mehrere -> mehrdeutig. + */ +export function proposeAssignment(session, spec, codes = []) { + const scannedCodes = normalizedCodeSet(codes); + if (scannedCodes.size > 0) { + const codeCandidates = session.stacks + .filter((stack) => Array.isArray(stack.codes) && stack.codes.some((code) => scannedCodes.has(code))) + .map((stack) => stack.id); + + if (codeCandidates.length === 1) { + return { kind: 'match', stackId: codeCandidates[0], candidates: codeCandidates }; + } + if (codeCandidates.length > 1) { + return { kind: 'ambiguous', stackId: null, candidates: codeCandidates }; + } + // Kein Stapel kennt einen der Codes (haeufig: eine bei jedem Modul + // wechselnde Seriennummer) - weiter mit dem bisherigen Vergleich. + } + const candidates = session.stacks .filter((stack) => specsCompatible(stack.spec, spec) && sharesSetField(stack.spec, spec)) .map((stack) => stack.id); @@ -60,14 +108,21 @@ export function proposeAssignment(session, spec) { return { kind: 'ambiguous', stackId: null, candidates }; } -/** Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an. */ -export function commitAssignment(session, spec, source, stackId) { +/** + * Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an. + * `codes` sind die bei diesem Modul gelesenen rohen Barcode-Zeichenketten + * (z.B. Teile- und Seriennummer); sie werden der Code-Menge des Stapels + * hinzugefuegt, damit ein spaeteres Modul mit demselben Inhalt darueber + * zugeordnet werden kann (siehe proposeAssignment). + */ +export function commitAssignment(session, spec, source, stackId, codes = []) { let stack = session.stacks.find((candidate) => candidate.id === stackId); if (!stack) { - stack = { id: stackId, spec: { ...spec }, count: 0 }; + stack = { id: stackId, spec: { ...spec }, count: 0, codes: [] }; session.stacks.push(stack); session.stacks.sort((a, b) => a.id.localeCompare(b.id)); } else { + if (!Array.isArray(stack.codes)) stack.codes = []; // Ein spaeterer, vollstaendigerer Scan ergaenzt fehlende Felder des Stapels. for (const field of ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']) { if (stack.spec[field] === null && spec[field] !== null) { @@ -76,6 +131,8 @@ export function commitAssignment(session, spec, source, stackId) { } } + addCodes(stack, codes); + stack.count += 1; // entry.spec ist das Protokoll dessen, was tatsaechlich gescannt wurde, und muss // unabhaengig vom Stapel-Spec bleiben - eigene Kopie, kein geteiltes Objekt. @@ -114,7 +171,9 @@ export function moveEntry(session, entryId, stackId) { let to = session.stacks.find((candidate) => candidate.id === stackId); if (!to) { - to = { id: stackId, spec: { ...entry.spec }, count: 0 }; + // entry selbst fuehrt keine Codes (nur Stapel tun das) - ein durch + // Umsortieren neu entstandener Stapel startet deshalb ohne bekannte Codes. + to = { id: stackId, spec: { ...entry.spec }, count: 0, codes: [] }; session.stacks.push(to); session.stacks.sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/storage.js b/src/storage.js index 733ebcb..bc5578e 100644 --- a/src/storage.js +++ b/src/storage.js @@ -28,12 +28,23 @@ function isPositiveInteger(value) { return typeof value === 'number' && Number.isInteger(value) && value >= 1; } +function isStringArray(value) { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +// stack.codes ist optional: ein Stand aus einer aelteren Fassung ohne dieses +// Feld ist kein unstimmiger Zustand und wird nicht verworfen (siehe +// loadSession), sondern beim Wiederherstellen als leere Codeliste behandelt. +// Ist das Feld dagegen vorhanden, muss es zur restlichen strengen Pruefung +// passen: eine Liste von Zeichenketten, sonst ist der Stand in sich +// unstimmig. function isUsableStack(stack) { return ( isPlainObject(stack) && typeof stack.id === 'string' && isFiniteNumber(stack.count) && isPlainObject(stack.spec) + && (stack.codes === undefined || isStringArray(stack.codes)) ); } @@ -86,6 +97,9 @@ export function loadSession(store) { for (const stack of parsed.stacks) { stack.count = parsed.entries.filter((entry) => entry.stackId === stack.id).length; + // Ein Stand aus einer aelteren Fassung ohne stack.codes gilt als + // Stapel ohne bekannte Codes, nicht als unstimmiger Zustand. + if (!Array.isArray(stack.codes)) stack.codes = []; } return parsed; diff --git a/test/session.test.js b/test/session.test.js index 093ec30..7a079b3 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -158,6 +158,89 @@ test('ein voellig unerkanntes Modul erzeugt einen eigenen Stapel und zieht keine assert.equal(plan.kind, 'new', 'der leere Stapel darf ein erkanntes Modul nicht an sich ziehen'); }); +// --- Erweiterung: bekannte Barcode-Inhalte entscheiden die Zuordnung --- + +test('bekannter Barcode-Inhalt entscheidet die Zuordnung, auch bei unvollstaendigen Angaben', () => { + const session = createSession(); + const unknownScheme = { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }; + commitAssignment(session, unknownScheme, 'barcode', 'A', ['HMA84GL7AFR4N-UH']); + + // Angaben diesmal komplett leer - ohne Codeabgleich waere das mangels + // gemeinsamen Merkmals (sharesSetField) niemals ein Treffer. + const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH']); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('anderer Barcode-Inhalt erzeugt einen eigenen Stapel', () => { + const session = createSession(); + commitAssignment(session, { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }, 'barcode', 'A', ['HMA84GL7AFR4N-UH']); + const plan = proposeAssignment(session, { ...emptySpec(), partNumber: 'ANDERE-PN' }, ['ANDERE-PN']); + assert.equal(plan.kind, 'new'); + assert.notEqual(plan.stackId, 'A'); +}); + +test('eine bei jedem Modul wechselnde Seriennummer verhindert die Zuordnung ueber die stabile Teilenummer nicht', () => { + const session = createSession(); + commitAssignment( + session, + { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }, + 'barcode', + 'A', + ['HMA84GL7AFR4N-UH', 'SN-AAA111'], + ); + // Gleiche Teilenummer, aber eine andere Seriennummer als beim ersten Mal - + // die Seriennummer laeuft ins Leere, die Teilenummer traegt trotzdem. + const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH', 'SN-BBB222']); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('Codeabgleich hat Vorrang vor dem Vergleich der technischen Angaben', () => { + const session = createSession(); + // B ist technisch identisch zu A (waere ohne Codeabgleich ebenfalls + // Kandidat und wuerde damit sogar Mehrdeutigkeit ausloesen), kennt den + // gescannten Code aber nicht. + commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']); + commitAssignment(session, s64(), 'barcode', 'B'); + const plan = proposeAssignment(session, s64(), ['CODE-A']); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('mehrere Stapel mit passendem Code sind mehrdeutig, der Nutzer entscheidet', () => { + const session = createSession(); + commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']); + commitAssignment(session, emptySpec(), 'barcode', 'B', ['CODE-X']); + const plan = proposeAssignment(session, emptySpec(), ['CODE-X']); + assert.equal(plan.kind, 'ambiguous'); + assert.deepEqual(plan.candidates.sort(), ['A', 'B']); +}); + +test('passt kein Code, bleibt die bisherige Regel (Vergleich der Angaben) unveraendert', () => { + const session = createSession(); + commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']); + const plan = proposeAssignment(session, s64(), ['VOELLIG-ANDERER-CODE']); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('proposeAssignment und commitAssignment funktionieren ohne codes-Parameter weiterhin (Abwaertskompatibilitaet)', () => { + const session = createSession(); + const plan = proposeAssignment(session, s64()); + const entry = commitAssignment(session, s64(), 'barcode', plan.stackId); + assert.equal(entry.stackId, 'A'); + assert.deepEqual(session.stacks[0].codes, []); +}); + +test('ein Stapel sammelt unterschiedliche Codes ueber mehrere Scans hinweg', () => { + const session = createSession(); + commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1']); + commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1', 'CODE-2']); + const stackA = session.stacks.find((s) => s.id === 'A'); + assert.deepEqual([...stackA.codes].sort(), ['CODE-1', 'CODE-2']); +}); + test('undoLast-Vertrag: nach einem Umsortieren wird weiterhin der zuletzt erfasste Eintrag zurueckgenommen', () => { const session = createSession(); const a = scan(session, s64()); diff --git a/test/storage.test.js b/test/storage.test.js index 8ab355e..3ecff65 100644 --- a/test/storage.test.js +++ b/test/storage.test.js @@ -244,6 +244,61 @@ test('nextEntryId als Null liefert null', () => { assert.equal(loadSession(store), null); }); +// --- Erweiterung: Barcode-Inhalte je Stapel ueberleben Sichern und Laden --- + +test('Codes eines Stapels ueberleben Sichern und Laden', () => { + const store = fakeStore(); + const session = createSession(); + commitAssignment( + session, + { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }, + 'barcode', + 'A', + ['HMA84GL7AFR4N-UH', 'SN-000'], + ); + saveSession(session, store); + + const reloaded = loadSession(store); + assert.notEqual(reloaded, null); + assert.deepEqual([...reloaded.stacks[0].codes].sort(), ['HMA84GL7AFR4N-UH', 'SN-000'].sort()); + + const plan = proposeAssignment(reloaded, emptySpec(), ['HMA84GL7AFR4N-UH']); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('ein Stand ohne codes-Feld (aeltere Fassung) wird nicht verworfen, der Stapel gilt als ohne bekannte Codes', () => { + 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 codes-Feld darf nicht verworfen werden'); + assert.deepEqual(reloaded.stacks[0].codes, []); +}); + +test('Stapel mit codes als Nicht-Array liefert null', () => { + const store = fakeStore(); + store.setItem('ram-sortierhilfe:session', JSON.stringify({ + stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: 'nicht-array' }], + entries: [], + nextEntryId: 1, + })); + assert.equal(loadSession(store), null); +}); + +test('Stapel mit codes-Array, das Nicht-Zeichenketten enthaelt, liefert null', () => { + const store = fakeStore(); + store.setItem('ram-sortierhilfe:session', JSON.stringify({ + stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: [123] }], + entries: [], + nextEntryId: 1, + })); + assert.equal(loadSession(store), null); +}); + test('Eintrag mit gebrochener entryId liefert null', () => { const store = fakeStore(); store.setItem('ram-sortierhilfe:session', JSON.stringify({