import { bookingGrossWeightTons, bookingTrainLengthMeters, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, grossWagonWeightTons, minLocomotiveLimits, sizePartialOfferWagons, } from './train-capacity.util'; describe('train-capacity.util', () => { // Real EDR wagon specs. const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 }; const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 }; const caps = (over = {}) => ({ maxWeightTons: 3500, maxLengthMeters: 760, maxWagonSlots: 54, ...over, }); const slots = (n: number, type: typeof nw5, cargoTons: number) => Array.from({ length: n }, () => ({ lengthMeters: type.lengthMeters, tareWeightTons: type.tareWeightTons, cargoTons, })); describe('deriveTrainCapacityFromLocomotive', () => { it('derives wagon slots from train length, not a fixed 53', () => { const shortLoco = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, [nw5], ); expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966) expect(shortLoco.maxWagonSlots).not.toBe(53); }); it('does not shrink slots by assuming every wagon rides at full payload', () => { // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a // LENGTH figure — the cargo that decides weight does not exist yet. const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, [nw5], ); expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30 expect(derived.maxWeightTons).toBe(2100); }); it('admits the railway 53-wagon NW5 marshalling figure', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, [nw5], ); expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53); }); it('uses the shortest wagon type when mixed types are present', () => { const mixed = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, [nw5, pw2, gw2], ); expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62 }); it('extends weight/length caps by the locomotive overage tolerance', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, [pw2], ); expect(derived.maxWeightTons).toBe(3590); }); it('ignores overage tolerance when unset (strict cap)', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, [nw5], ); expect(derived.maxWeightTons).toBe(3500); expect(derived.maxLengthMeters).toBe(760); }); it('floors the locomotive by the global rule caps', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 }, [nw5], { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 }, ); expect(derived.maxWeightTons).toBe(3500); expect(derived.maxLengthMeters).toBe(760); }); }); describe('gross weight', () => { it('counts the wagon as well as its cargo', () => { expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2); }); it('charges a booking one tare per wagon it occupies', () => { // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel. expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2); }); it('is cargo alone when the wagon type has no tare on record', () => { expect(bookingGrossWeightTons(100, 3, 0)).toBe(100); }); }); describe('consistUsage', () => { it('sums each wagon own length and tare rather than averaging a type', () => { const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)]; const usage = consistUsage(mixed, caps()); expect(usage.wagonCount).toBe(3); expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066 expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2 expect(usage.usedCargoWeightTons).toBe(40); expect(usage.usedGrossWeightTons).toBe(110); expect(usage.remainingGrossWeightTons).toBe(3390); expect(usage.remainingWagons).toBe(51); }); it('reports an empty consist as fully available', () => { const usage = consistUsage([], caps()); expect(usage.usedGrossWeightTons).toBe(0); expect(usage.remainingLengthMeters).toBe(760); expect(usage.remainingWagons).toBe(54); }); }); describe('consistViolations', () => { it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => { // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T. const consist = slots(37, pw2, 70); expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([ expect.stringContaining('3522.4T'), ]); expect( consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })), ).toEqual([]); }); it('blocks a train the old cargo-only math would have waved through', () => { // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T. const consist = slots(37, pw2, 70); const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0); expect(cargoOnly).toBeLessThan(3500); expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]); }); it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => { // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T. expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]); }); it('flags an over-length consist', () => { const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 })); expect(violations).toEqual([expect.stringContaining('exceeds max train length')]); }); it('flags an over-count consist', () => { const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 })); expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]); }); it('reports every broken axis at once', () => { expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3); }); }); it('computes booking length by freight type', () => { expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); it('takes the weakest locomotive across a multi-locomotive set', () => { const limits = minLocomotiveLimits([ { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, ]); expect(limits?.maxPullWeightTons).toBe(3500); expect(limits?.overageToleranceTons).toBe(20); }); describe('sizePartialOfferWagons', () => { it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => { // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2 // wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty // of slots/length. The boundary 1000T booking (15 wagons) must be offered // the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling // 542.4T of cargo — gross exactly 744. const offer = sizePartialOfferWagons( { wagons: 40, weightTons: 744, lengthMeters: 500 }, 15, pw2, ); expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 }); }); it('still sizes by wagon slots when they bind first (legacy behavior)', () => { const offer = sizePartialOfferWagons( { wagons: 3, weightTons: 100000, lengthMeters: 100000 }, 15, pw2, ); expect(offer?.wagons).toBe(3); }); it('sizes by the LENGTH axis when it binds first', () => { // 60m of train left → 3 PW2 (17.066m) fit, the 4th does not. const offer = sizePartialOfferWagons( { wagons: 40, weightTons: 100000, lengthMeters: 60 }, 15, pw2, ); expect(offer?.wagons).toBe(3); }); it('never offers all of the booking — a split is a strict subset', () => { const offer = sizePartialOfferWagons( { wagons: 40, weightTons: 100000, lengthMeters: 100000 }, 15, pw2, ); expect(offer?.wagons).toBe(14); }); it('returns null when not even one part-loaded wagon fits the weight room', () => { expect( sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2), ).toBeNull(); }); }); });