Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts
Marshal 2429f6b629 implement contract cancellation feature and update contract statuses
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
2026-07-28 05:02:58 +00:00

365 lines
14 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 {
bookingGrossWeightTons,
bookingTrainLengthMeters,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} 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('reports the base caps and tolerance separately so filling can budget on base', () => {
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: 90,
overageToleranceMeters: 20,
},
[pw2],
);
expect(derived.baseWeightTons).toBe(3500);
expect(derived.baseLengthMeters).toBe(760);
expect(derived.toleranceTons).toBe(90);
expect(derived.toleranceMeters).toBe(20);
expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons);
expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters);
});
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('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});
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();
});
describe('fullWagonsOnly (bulk)', () => {
it('offers only whole full wagons — each costs capacity + tare of gross room', () => {
// 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross,
// so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly
// 7 × 70 = 490T — the last wagon is never part-loaded into the leftover.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 704, lengthMeters: 500 },
9,
pw2,
{ fullWagonsOnly: true },
);
expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
});
it('never squeezes a part-loaded wagon into leftover weight room', () => {
// Same 744T room as the part-load scenario above: the scan would pick
// 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing
// stops at 7 fully loaded wagons.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 744, lengthMeters: 500 },
15,
pw2,
{ fullWagonsOnly: true },
);
expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
});
it('returns null when the room cannot take even one FULL wagon', () => {
// 67.6T left (3590 cap 3522.4 boarded): a part-loaded wagon would fit
// (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the
// booking must be skipped entirely, not trimmed onto the train.
expect(
sizePartialOfferWagons(
{ wagons: 40, weightTons: 67.6, lengthMeters: 500 },
3,
pw2,
{ fullWagonsOnly: true },
),
).toBeNull();
});
});
});
});