mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
- planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
211 lines
8.5 KiB
TypeScript
211 lines
8.5 KiB
TypeScript
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);
|
||
});
|
||
});
|