import { WagonStockLedger } from './wagon-stock-ledger.util'; const WHOLE = { fromEdge: 0, toEdge: 1 }; describe('WagonStockLedger', () => { it('reports the wagons of a booking\'s OWN types, not the train total', () => { // The reported case: 20 free wagons on the train, but only 16 of them NW5. const ledger = new WagonStockLedger( new Map([ ['nw5', 16], ['pw2', 4], ]), 1, ); expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16); expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4); // A cargo type mapped to both may ride either, so they add up. expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20); // Duplicates must not double-count. expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16); // An unconfigured type has no stock. expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0); }); it('consumes what it can and reports the shortfall', () => { const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1); // A 20-wagon booking can only take 16 — the caller splits on that number. expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16); expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0); }); it('drains the deepest stock first across candidate types', () => { const ledger = new WagonStockLedger( new Map([ ['nw5', 10], ['nw7', 3], ]), 1, ); expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12); // 10 from NW5 then 2 from NW7 — one NW7 left. expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1); expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); }); it('frees stock past an alight yard — disjoint legs never compete', () => { // Three stops (A→B→C) = two edges. An intercity booking riding A→B must // not consume the wagon on B→C. const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2); const firstLeg = { fromEdge: 0, toEdge: 1 }; const secondLeg = { fromEdge: 1, toEdge: 2 }; ledger.consume(['nw5'], 5, firstLeg); expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0); expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5); // A whole-route booking sees the busiest edge it crosses, so it is blocked. expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0); }); it('counts the busiest edge within a leg, not the sum of edges', () => { const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3); ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 }); ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 }); // Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once. expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4); expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); }); }); describe('WagonStockLedger — multi-yard consist', () => { // The reported case: a built train of 53 wagons, 20 standing in Dire and 33 // in Mojo. Each yard may only sell the wagons physically standing there. const DIRE = 'yard-dire'; const MOJO = 'yard-mojo'; const ADDIS = 'yard-addis'; const STOPS = [DIRE, MOJO, ADDIS]; const EDGES = STOPS.length - 1; const splitStock = () => new Map([ [DIRE, new Map([['nw5', 20]])], [MOJO, new Map([['nw5', 33]])], ]); // Legs along Dire → Mojo → Addis. const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 }; const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 }; const splitLedger = () => new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS); it('offers each yard only the wagons standing there', () => { const ledger = splitLedger(); expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20); expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); }); it('keeps the yards independent — Dire bookings never eat Mojo stock', () => { const ledger = splitLedger(); // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge… expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20); expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0); // …but those are Dire's steel, so Mojo still has its own 33 to sell. expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33); expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0); }); it('never lends a free Dire wagon to a Mojo customer', () => { const ledger = splitLedger(); // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty. expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5); // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not // offered here, exactly as the operator requires. expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33); }); it('offers nothing at the destination — there is nothing to pick up there', () => { const ledger = splitLedger(); // A leg boarding at the last stop has no pool of its own. expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0); }); it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => { const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [ 'yard-add', 'yard-dire', 'yard-indode', 'yard-mojo', 'yard-djibouti', ]; const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI]; const ledger = new WagonStockLedger( new Map([['nw5', 53]]), stops.length - 1, new Map([ [DIRE_2, new Map([['nw5', 20]])], [MOJO_2, new Map([['nw5', 33]])], ]), stops, ); const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 }); // Addis: the train starts empty — nothing to sell. expect(ledger.availableFor(['nw5'], to(0))).toBe(0); // Dire: the 20 wagons waiting there. expect(ledger.availableFor(['nw5'], to(1))).toBe(20); // Indode: the same 20 wagons, which have moved with the train. expect(ledger.availableFor(['nw5'], to(2))).toBe(0); // Mojo: its own 33 only. expect(ledger.availableFor(['nw5'], to(3))).toBe(33); }); it('single-yard consist keeps the original whole-train behaviour', () => { // No byYardId (the train is not split) — every leg sees the whole train, // exactly as before this feature. const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES); expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53); expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); }); });