mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
543 lines
22 KiB
TypeScript
543 lines
22 KiB
TypeScript
import {
|
||
bookingCargoTons,
|
||
bookingGrossWeightTons,
|
||
bookingTrainLengthMeters,
|
||
bulkItemWagonsForAllowedTypes,
|
||
bulkItemWagonsRequired,
|
||
bulkTonsPerWagon,
|
||
bulkTonWagonsForAllowedTypes,
|
||
bulkTonWagonsRequired,
|
||
bulkWagonsForAllowedTypes,
|
||
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('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => {
|
||
// cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real
|
||
// tonnage rides in bulkTotalWeightTons.
|
||
const breakBulk = (quantity: number, weightTons: number) => ({
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: quantity,
|
||
bulkTotalWeightTons: weightTons,
|
||
});
|
||
|
||
it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => {
|
||
// 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12.
|
||
expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12);
|
||
});
|
||
|
||
it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => {
|
||
// 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE
|
||
// whole 40T item fits a wagon → 3 wagons.
|
||
expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3);
|
||
});
|
||
|
||
it('charges one wagon per item when a single item outweighs a wagon', () => {
|
||
expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2);
|
||
});
|
||
|
||
it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => {
|
||
expect(
|
||
bulkItemWagonsRequired(
|
||
{ freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null },
|
||
69,
|
||
),
|
||
).toBe(0);
|
||
expect(
|
||
bulkItemWagonsRequired(
|
||
{ freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 },
|
||
69,
|
||
),
|
||
).toBe(0);
|
||
});
|
||
|
||
it('returns 0 on zero/invalid capacity or amounts', () => {
|
||
expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0);
|
||
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
|
||
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
|
||
});
|
||
|
||
describe('configured items-fit (floor space vs tonnage)', () => {
|
||
it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
|
||
// floor(70/20) = 3 by tonnage < 4 by floor space.
|
||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
|
||
});
|
||
|
||
it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
|
||
// floor(70/10) = 7 by tonnage, but only 4 fit physically.
|
||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
|
||
});
|
||
|
||
it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
|
||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
|
||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
|
||
// floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
|
||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bulkItemWagonsForAllowedTypes', () => {
|
||
const breakBulk = (quantity: number, weightTons: number) => ({
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: quantity,
|
||
bulkTotalWeightTons: weightTons,
|
||
});
|
||
|
||
it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
|
||
const cargoType = {
|
||
wagonTypes: [
|
||
{ id: 'nw5', capacityTons: 70 },
|
||
{ id: 'nw7', capacityTons: 80 },
|
||
],
|
||
itemsPerWagonMap: { nw5: 4, nw7: 6 },
|
||
};
|
||
// 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
|
||
// NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
|
||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
|
||
});
|
||
|
||
it('equals the old max-capacity estimate when no fits are configured', () => {
|
||
const cargoType = {
|
||
wagonTypes: [
|
||
{ id: 'a', capacityTons: 50 },
|
||
{ id: 'b', capacityTons: 70 },
|
||
],
|
||
};
|
||
// Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
|
||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
|
||
});
|
||
|
||
it('falls back to the given capacity when the relation is missing', () => {
|
||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
|
||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
|
||
});
|
||
});
|
||
|
||
describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => {
|
||
// Sugar is loaded 50T per wagon even on a 70T wagon.
|
||
const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } };
|
||
const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons });
|
||
|
||
it('uses the configured cap instead of the rated capacity', () => {
|
||
expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50);
|
||
});
|
||
|
||
it('falls back to rated capacity when the cargo type caps nothing', () => {
|
||
expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70);
|
||
expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70);
|
||
expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70);
|
||
});
|
||
|
||
it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => {
|
||
// Saved when NW5 was rated 70T; the type was later re-rated to 45T.
|
||
expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45);
|
||
});
|
||
|
||
it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => {
|
||
expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4);
|
||
// Same booking, no cap → the old 3-wagon answer.
|
||
expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3);
|
||
});
|
||
|
||
it('picks the fewest-wagon allowed type, each on its own cap', () => {
|
||
const cargoType = {
|
||
wagonTypes: [
|
||
{ id: 'nw5', capacityTons: 70 },
|
||
{ id: 'nw7', capacityTons: 80 },
|
||
],
|
||
tonsPerWagonMap: { nw5: 50 },
|
||
};
|
||
// NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3.
|
||
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
|
||
});
|
||
|
||
it('routes PER_ITEM and PER_TON through one call', () => {
|
||
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
|
||
// PER_ITEM still wins where an item count is present.
|
||
const cars = {
|
||
wagonTypes: [{ id: 'nw5', capacityTons: 70 }],
|
||
itemsPerWagonMap: { nw5: 4 },
|
||
};
|
||
expect(
|
||
bulkWagonsForAllowedTypes(
|
||
{ freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 },
|
||
cars,
|
||
70,
|
||
),
|
||
).toBe(17);
|
||
});
|
||
});
|
||
|
||
describe('bookingCargoTons (break-bulk weight preference)', () => {
|
||
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
|
||
expect(
|
||
bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }),
|
||
).toBe(800);
|
||
});
|
||
|
||
it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => {
|
||
expect(
|
||
bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }),
|
||
).toBe(500);
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
});
|
||
});
|