Add tareWeightTons property to CreateWagonTypeDto and update related components

This commit is contained in:
Marshal
2026-07-09 06:05:34 +00:00
parent bedcdca78b
commit 1b2b3f68f8
19 changed files with 979 additions and 263 deletions

View File

@@ -1,64 +1,183 @@
import {
bookingGrossWeightTons,
bookingTrainLengthMeters,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
const nw5 = { lengthMeters: 14, capacityTons: 70 };
// 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 };
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
[nw5],
);
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
expect(shortLoco.maxWagonSlots).not.toBe(53);
const heavyLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
[nw5],
);
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
const caps = (over = {}) => ({
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonSlots: 54,
...over,
});
it('uses shortest wagon type when mixed types are present', () => {
const longBulk = { lengthMeters: 18, capacityTons: 80 };
const mixed = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5, longBulk],
);
expect(mixed.maxWagonSlots).toBe(
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
);
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('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => {
const pw2 = { lengthMeters: 17.066, capacityTons: 70 };
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
[pw2],
);
expect(derived.maxWeightTons).toBe(3590);
});
it('ignores overage tolerance when unset (strict cap, no behavior change)', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[{ lengthMeters: 14, capacityTons: 70 }],
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
it('takes the weakest locomotive tolerance across a multi-locomotive set', () => {
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 },