Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts
Marshal 8e6fc09aac feat(train-scheduling): mid-route consist changes, audit history, safer workspace
- 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>
2026-08-23 03:55:54 +00:00

211 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});