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); }); }); describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => { // gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp // (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp. const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct']; const makeLedger = () => { const ledger = new WagonStockLedger( new Map([ ['nw5', 3], ['pw2', 2], ]), stops.length - 1, new Map([ ['gmp', new Map([['nw5', 2], ['pw2', 2]])], ['mojo', new Map([['nw5', 1]])], ]), stops, ); ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]); return ledger; }; const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to }); it('a leg past the cut sees only the wagons that reach it', () => { const ledger = makeLedger(); // gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through. expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1); // gmp -> lebu: both gmp NW5 serve the short leg. expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2); // PW2 uncut — both ride anywhere from gmp. expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2); // mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut. expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1); }); it('cut debit and booking consumption stack', () => { const ledger = makeLedger(); expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1); expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0); // Short leg still has the cut wagon (1 = 2 total − 1 consumed through-rider). expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1); }); it('ignores a cut yard that is not on the stops', () => { const ledger = makeLedger(); ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]); expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2); }); });