diff --git a/src/session.js b/src/session.js index fa3691e..cf491de 100644 --- a/src/session.js +++ b/src/session.js @@ -57,12 +57,22 @@ function normalizedCodeSet(codes) { } /** - * Ergaenzt die Menge der bei einem Stapel bekannten Barcode-Inhalte um die - * neu gelesenen Codes (z.B. Teile- und Seriennummer desselben Etiketts). + * Die Codemenge eines Stapels ist kein angesammelter, sondern ein + * abgeleiteter Wert: sie ergibt sich aus den Codes der ihm *aktuell* + * zugeordneten Eintraege - genau wie stack.count (siehe loadSession in + * src/storage.js, das denselben Ansatz beim Laden verfolgt). Ein Code + * verschwindet damit automatisch aus einem Stapel, sobald kein Eintrag ihn + * mehr mitbringt (nach moveEntry, removeEntry oder undoLast) - anstatt sich + * dauerhaft am Stapel festzusetzen. */ -function addCodes(stack, codes) { - const set = new Set(stack.codes); - for (const code of normalizedCodeSet(codes)) set.add(code); +function recomputeStackCodes(session, stackId) { + const stack = session.stacks.find((candidate) => candidate.id === stackId); + if (!stack) return; + const set = new Set(); + for (const entry of session.entries) { + if (entry.stackId !== stackId) continue; + for (const code of entry.codes ?? []) set.add(code); + } stack.codes = [...set]; } @@ -72,17 +82,25 @@ function addCodes(stack, codes) { * 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. + * Ein Codetreffer allein genuegt aber nicht: nicht jeder gelesene Rohcode ist + * eine Teilenummer - steht neben ihr noch eine Los- oder Wiederverkaeufer- + * Kennung auf dem Etikett, kann die zwei technisch verschiedene Module + * verbinden. Ein Stapel ist deshalb ueber einen Code nur dann Kandidat, wenn + * die technischen Angaben zusaetzlich nicht widersprechen (specsCompatible); + * der Code entscheidet dann innerhalb dessen, was ohnehin vertraeglich ist, + * er darf einen Widerspruch nicht ueberstimmen. Genau ein solcher Kandidat + * -> Zuweisung, auch bei unvollstaendigen technischen Angaben. Mehrere -> + * mehrdeutig, der Nutzer entscheidet. Kennt kein Stapel einen der Codes ohne + * Widerspruch, 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))) + .filter((stack) => specsCompatible(stack.spec, spec)) .map((stack) => stack.id); if (codeCandidates.length === 1) { @@ -111,9 +129,10 @@ export function proposeAssignment(session, spec, codes = []) { /** * 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). + * (z.B. Teile- und Seriennummer); sie werden am Eintrag gespeichert, aus dem + * sich die Code-Menge des Stapels ableitet (siehe recomputeStackCodes), 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); @@ -122,7 +141,6 @@ export function commitAssignment(session, spec, source, stackId, 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) { @@ -131,13 +149,20 @@ export function commitAssignment(session, spec, source, stackId, codes = []) { } } - 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. - const entry = { entryId: session.nextEntryId++, spec: { ...spec }, stackId, source }; + // entry.codes haelt fest, welche Rohcodes bei diesem Eintrag gelesen wurden - + // die Grundlage, aus der recomputeStackCodes die Codemenge des Stapels ableitet. + const entry = { + entryId: session.nextEntryId++, + spec: { ...spec }, + stackId, + source, + codes: [...normalizedCodeSet(codes)], + }; session.entries.push(entry); + recomputeStackCodes(session, stackId); return entry; } @@ -157,6 +182,9 @@ export function undoLast(session) { if (!entry) return null; const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); if (stack) stack.count -= 1; + // Der zurueckgenommene Eintrag zaehlt nicht mehr zu diesem Stapel - seine + // Codes duerfen deshalb auch keinen kuenftigen Treffer mehr begruenden. + recomputeStackCodes(session, entry.stackId); dropEmptyStacks(session); return entry; } @@ -166,19 +194,24 @@ export function moveEntry(session, entryId, stackId) { const entry = session.entries.find((candidate) => candidate.entryId === entryId); if (!entry || entry.stackId === stackId) return; - const from = session.stacks.find((candidate) => candidate.id === entry.stackId); + const fromId = entry.stackId; + const from = session.stacks.find((candidate) => candidate.id === fromId); if (from) from.count -= 1; let to = session.stacks.find((candidate) => candidate.id === stackId); if (!to) { - // 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)); } to.count += 1; entry.stackId = stackId; + // Die Codemenge beider betroffenen Stapel ist abgeleitet (siehe + // recomputeStackCodes) - nach dem Umhaengen des Eintrags muss sie fuer den + // alten wie den neuen Stapel neu bestimmt werden, sonst bliebe der Code des + // umsortierten Eintrags faelschlich am alten Stapel haengen. + recomputeStackCodes(session, fromId); + recomputeStackCodes(session, stackId); dropEmptyStacks(session); } @@ -189,5 +222,8 @@ export function removeEntry(session, entryId) { const [entry] = session.entries.splice(index, 1); const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); if (stack) stack.count -= 1; + // Siehe moveEntry: der entfernte Eintrag darf seinen Code nicht laenger am + // Stapel hinterlassen. + recomputeStackCodes(session, entry.stackId); dropEmptyStacks(session); } diff --git a/test/session.test.js b/test/session.test.js index 7a079b3..2ef25cb 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { emptySpec } from '../src/spec.js'; +import { emptySpec, specsCompatible } from '../src/spec.js'; import { createSession, proposeAssignment, commitAssignment, undoLast, moveEntry, removeEntry, @@ -241,6 +241,59 @@ test('ein Stapel sammelt unterschiedliche Codes ueber mehrere Scans hinweg', () assert.deepEqual([...stackA.codes].sort(), ['CODE-1', 'CODE-2']); }); +// --- Fehler 1: ein Codetreffer darf einen Widerspruch der Angaben nicht ueberstimmen --- + +test('Codetreffer mit widersprechenden technischen Angaben ist kein Kandidat (geteilter Los-Tag statt Teilenummer)', () => { + const session = createSession(); + // Modul 1: Samsung 64GB LRDIMM, Etikett traegt zusaetzlich einen Los-Tag. + const modul1 = { ...emptySpec(), capacityGb: 64, formFactor: 'LRDIMM' }; + commitAssignment(session, modul1, 'barcode', 'A', ['LOS-2024-07']); + + // Modul 2: Samsung 64GB RDIMM (andere Bauform!), aber derselbe Los-Tag, + // weil beide Etiketten aus derselben Fertigungscharge stammen. + const modul2 = { ...emptySpec(), capacityGb: 64, formFactor: 'RDIMM' }; + assert.equal(specsCompatible(modul1, modul2), false, 'Vorbedingung: die Bauformen widersprechen sich'); + + const plan = proposeAssignment(session, modul2, ['LOS-2024-07']); + assert.notEqual(plan.stackId, 'A', 'ein RDIMM darf trotz geteiltem Los-Tag nicht auf den LRDIMM-Stapel A'); + assert.equal(plan.kind, 'new', 'ohne vertraeglichen Kandidaten faellt das Modul auf einen eigenen Stapel zurueck'); +}); + +// --- Fehler 2: eine Korrektur (Umsortieren/Entfernen) muss den Code am alten Stapel entfernen --- + +test('nach moveEntry zieht der Code des umsortierten Eintrags den alten Stapel nicht mehr an', () => { + const session = createSession(); + const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']); + // Ein zweiter Eintrag mit anderem Code haelt Stapel A am Leben, damit der + // Test tatsaechlich prueft, dass CODE-X verschwindet - nicht nur, dass ein + // leerer Stapel entfernt wurde. + commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']); + + moveEntry(session, a.entryId, 'B'); + + const stackA = session.stacks.find((s) => s.id === 'A'); + assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)'); + assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Umsortieren nicht mehr zu Stapel A gehoeren'); + + const plan = proposeAssignment(session, emptySpec(), ['CODE-X']); + assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen'); +}); + +test('nach removeEntry zieht der Code des entfernten Eintrags den alten Stapel nicht mehr an', () => { + const session = createSession(); + const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']); + commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']); + + removeEntry(session, a.entryId); + + const stackA = session.stacks.find((s) => s.id === 'A'); + assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)'); + assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Entfernen nicht mehr zu Stapel A gehoeren'); + + const plan = proposeAssignment(session, emptySpec(), ['CODE-X']); + assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen'); +}); + test('undoLast-Vertrag: nach einem Umsortieren wird weiterhin der zuletzt erfasste Eintrag zurueckgenommen', () => { const session = createSession(); const a = scan(session, s64());