diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..8a61604 --- /dev/null +++ b/src/session.js @@ -0,0 +1,104 @@ +// Reines Modul: kein window, kein document, kein localStorage. + +import { specsCompatible } from './spec.js'; + +const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +/** @returns {{stacks: Array, entries: Array, nextEntryId: number}} */ +export function createSession() { + return { stacks: [], entries: [], nextEntryId: 1 }; +} + +function nextStackId(session) { + const used = new Set(session.stacks.map((stack) => stack.id)); + for (const letter of STACK_LETTERS) { + if (!used.has(letter)) return letter; + } + // Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht. + return `A${session.stacks.length}`; +} + +/** + * Schlaegt vor, wohin ein Modul gehoert. + * Genau ein vertraeglicher Stapel -> Zuweisung. + * Keiner -> neuer Stapel. + * Mehrere -> mehrdeutig, der Nutzer entscheidet. + */ +export function proposeAssignment(session, spec) { + const candidates = session.stacks + .filter((stack) => specsCompatible(stack.spec, spec)) + .map((stack) => stack.id); + + if (candidates.length === 1) { + return { kind: 'match', stackId: candidates[0], candidates }; + } + if (candidates.length === 0) { + return { kind: 'new', stackId: nextStackId(session), candidates: [] }; + } + 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) { + let stack = session.stacks.find((candidate) => candidate.id === stackId); + if (!stack) { + stack = { id: stackId, spec, count: 0 }; + session.stacks.push(stack); + session.stacks.sort((a, b) => a.id.localeCompare(b.id)); + } else { + // 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) { + stack.spec[field] = spec[field]; + } + } + } + + stack.count += 1; + const entry = { entryId: session.nextEntryId++, spec, stackId, source }; + session.entries.push(entry); + return entry; +} + +function dropEmptyStacks(session) { + session.stacks = session.stacks.filter((stack) => stack.count > 0); +} + +/** Nimmt den zuletzt erfassten Eintrag zurueck. */ +export function undoLast(session) { + const entry = session.entries.pop(); + if (!entry) return null; + const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); + if (stack) stack.count -= 1; + dropEmptyStacks(session); + return entry; +} + +/** Ordnet einen bereits erfassten Eintrag einem anderen Stapel zu. */ +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); + if (from) from.count -= 1; + + let to = session.stacks.find((candidate) => candidate.id === stackId); + if (!to) { + to = { id: stackId, spec: entry.spec, count: 0 }; + session.stacks.push(to); + session.stacks.sort((a, b) => a.id.localeCompare(b.id)); + } + to.count += 1; + entry.stackId = stackId; + dropEmptyStacks(session); +} + +/** Entfernt einen einzelnen Eintrag aus der Sitzung. */ +export function removeEntry(session, entryId) { + const index = session.entries.findIndex((candidate) => candidate.entryId === entryId); + if (index === -1) return; + const [entry] = session.entries.splice(index, 1); + const stack = session.stacks.find((candidate) => candidate.id === entry.stackId); + if (stack) stack.count -= 1; + dropEmptyStacks(session); +} diff --git a/test/session.test.js b/test/session.test.js new file mode 100644 index 0000000..35e2b82 --- /dev/null +++ b/test/session.test.js @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { emptySpec } from '../src/spec.js'; +import { + createSession, proposeAssignment, commitAssignment, + undoLast, moveEntry, removeEntry, +} from '../src/session.js'; + +const s64 = () => ({ ...emptySpec(), capacityGb: 64, formFactor: 'LRDIMM', rank: '4DRx4', speed: 'PC4-2400', partNumber: 'M386A8K40BM1-CRC4Y' }); +const s32 = () => ({ ...emptySpec(), capacityGb: 32, formFactor: 'RDIMM', rank: '2Rx4', speed: 'PC4-2666', partNumber: 'M393A4K40BB1-CTD' }); + +function scan(session, spec, source = 'barcode') { + const plan = proposeAssignment(session, spec); + return commitAssignment(session, spec, source, plan.stackId); +} + +test('erster Scan legt Stapel A an', () => { + const session = createSession(); + const plan = proposeAssignment(session, s64()); + assert.equal(plan.kind, 'new'); + assert.equal(plan.stackId, 'A'); +}); + +test('gleiches Modul landet auf demselben Stapel', () => { + const session = createSession(); + scan(session, s64()); + const plan = proposeAssignment(session, s64()); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('anderes Modul legt Stapel B an', () => { + const session = createSession(); + scan(session, s64()); + const plan = proposeAssignment(session, s32()); + assert.equal(plan.kind, 'new'); + assert.equal(plan.stackId, 'B'); +}); + +test('eine Verwechslung erzeugt keinen Fast-Duplikat-Stapel', () => { + const session = createSession(); + scan(session, s64()); + const verrauscht = { ...s64(), partNumber: 'M386A8K4OBM1-CRC4Y' }; + const plan = proposeAssignment(session, verrauscht); + assert.equal(plan.kind, 'match'); + assert.equal(plan.stackId, 'A'); +}); + +test('Scan ohne Teilenummer bei zwei passenden Stapeln ist mehrdeutig', () => { + const session = createSession(); + scan(session, { ...s64(), partNumber: 'M386A8K40BM1-CRC4Y' }); + scan(session, { ...s64(), partNumber: 'HMAA8GL7AMR4N-UH' }); + const ohnePn = { ...s64(), partNumber: null }; + const plan = proposeAssignment(session, ohnePn); + assert.equal(plan.kind, 'ambiguous'); + assert.deepEqual(plan.candidates, ['A', 'B']); +}); + +test('commitAssignment zaehlt den Stapel hoch und fuehrt die Liste', () => { + const session = createSession(); + scan(session, s64()); + scan(session, s64()); + assert.equal(session.stacks.length, 1); + assert.equal(session.stacks[0].count, 2); + assert.equal(session.entries.length, 2); +}); + +test('undoLast nimmt den letzten Eintrag zurueck', () => { + const session = createSession(); + scan(session, s64()); + scan(session, s64()); + const entfernt = undoLast(session); + assert.equal(entfernt.stackId, 'A'); + assert.equal(session.stacks[0].count, 1); + assert.equal(session.entries.length, 1); +}); + +test('undoLast entfernt einen leer gewordenen Stapel', () => { + const session = createSession(); + scan(session, s64()); + undoLast(session); + assert.equal(session.stacks.length, 0); + assert.equal(undoLast(session), null); +}); + +test('moveEntry sortiert einen Eintrag um', () => { + const session = createSession(); + const a = scan(session, s64()); + scan(session, s32()); + moveEntry(session, a.entryId, 'B'); + const stapelA = session.stacks.find((s) => s.id === 'A'); + const stapelB = session.stacks.find((s) => s.id === 'B'); + assert.equal(stapelA, undefined, 'leerer Stapel wird entfernt'); + assert.equal(stapelB.count, 2); +}); + +test('removeEntry entfernt einen einzelnen Eintrag', () => { + const session = createSession(); + const a = scan(session, s64()); + scan(session, s64()); + removeEntry(session, a.entryId); + assert.equal(session.entries.length, 1); + assert.equal(session.stacks[0].count, 1); +});