mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 12:00:59 +00:00
573 lines
23 KiB
TypeScript
573 lines
23 KiB
TypeScript
/**
|
||
* GROUP 2 · S9–S12 — when WEIGHT binds before slots.
|
||
*
|
||
* Group 1 kept every non-slot axis slack so wagon arithmetic was the only thing
|
||
* under test. This group inverts it: the locomotives pull 3 500 T base (two
|
||
* 1 750 T units — pull weight ADDS UP across a set) and the cargo is heavy
|
||
* enough that the pull limit runs out before the slots do.
|
||
*
|
||
* The arithmetic follows from `grossWagonWeightTons` = tare + cargo. The weight
|
||
* axis is GROSS: a locomotive hauls the wagon as well as what is in it. NW5
|
||
* tare 22.4 T, two 20ft per wagon:
|
||
*
|
||
* heavy (28 T VGM): 2 × 28 + 22.4 = 78.4 T per wagon
|
||
* light (12 T VGM): 2 × 12 + 22.4 = 46.4 T per wagon
|
||
*
|
||
* S9 35 wagons × 78.4 = 2 744 T fits; 10 more would be 3 528 T > 3 500 T,
|
||
* so the next booking is cut down on WEIGHT and the window closes FULL
|
||
* with 19 slots still empty — the verdict names pull, not slots.
|
||
* S10 that same 3 528 T is admitted WHOLE by the pair carrying a 90 T
|
||
* tolerance (cap 3 590 T). Tolerance buys a whole booking, nothing else.
|
||
* S11 with 756 T of base room left, the split offer is sized from BASE room
|
||
* only — it may never reach into the tolerance.
|
||
* S12 light cargo: every slot fills at ~72% of the pull limit. SLOTS bind.
|
||
*
|
||
* WHY LOCOMOTIVE PAIRS AND NOT THE BUILT TRAINS
|
||
*
|
||
* `remainingBudget` replaces the whole limit set with
|
||
* `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the
|
||
* moment a schedule has a built train — so on a built consist the batch is
|
||
* blind to pull weight and only slots bind. The locomotive-pair path keeps the
|
||
* real limits, which is where this group's axis actually lives. The last
|
||
* describe pins the built-train hole itself: the batch reserves a load the
|
||
* locomotives cannot pull, and only the allocator notices — after payment.
|
||
*
|
||
* Fixture: seed-g2-weight.sql (LOCO-G2-A/B 1 750 T + 0 tolerance,
|
||
* LOCO-G2-C/D 1 750 T + 45 T each, TRN-G2-BASE for the built-train case).
|
||
*/
|
||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import { closeDb, gateway, sleep } from "./client";
|
||
import {
|
||
allocatedGrossTons,
|
||
allocatedWagons,
|
||
bookContainersReady,
|
||
bookingRow,
|
||
closeBookingWindow,
|
||
completeDocReview,
|
||
createBuiltTrainSchedule,
|
||
createSchedule,
|
||
departureAt,
|
||
eatDayStr,
|
||
endPaymentPhase,
|
||
ensureCorridorRoute,
|
||
expectNoPartialOffer,
|
||
extendPayWindow,
|
||
forceWindowOpen,
|
||
payViaGateway,
|
||
pollAllocations,
|
||
pollBookingStatus,
|
||
pollCycleConcluded,
|
||
pollPartialOffer,
|
||
pollWindow,
|
||
releaseUnpaidHolds,
|
||
resetCorridorDay,
|
||
scheduleRow,
|
||
seedTenantContracts,
|
||
setPriority,
|
||
wagonAllocationCount,
|
||
} from "./flows";
|
||
|
||
const STAMP = String(Date.now());
|
||
|
||
/** NW5 tare and capacity — the fixture's whole premise. */
|
||
const NW5_TARE = 22.4;
|
||
const NW5_CAPACITY = 70;
|
||
const HEAVY_VGM = 28;
|
||
const LIGHT_VGM = 12;
|
||
/** Two 20ft ride one NW5. */
|
||
const grossPerWagon = (vgm: number) => 2 * vgm + NW5_TARE;
|
||
/** Base pull of the 1 750 T + 1 750 T pair. */
|
||
const BASE_TONS = 3500;
|
||
/** LOCO-G2-C/D: 45 T + 45 T. Weight tolerance adds up too. */
|
||
const TOLERANCE_TONS = 90;
|
||
const BASE_PAIR: [string, string] = ["LOCO-G2-A", "LOCO-G2-B"];
|
||
const TOL_PAIR: [string, string] = ["LOCO-G2-C", "LOCO-G2-D"];
|
||
/** floor(760 m / 13.966 m) — the slot count a 760 m pair derives. */
|
||
const SLOTS = 54;
|
||
|
||
/**
|
||
* How the engine sizes a partial when weight is the binding axis: it walks
|
||
* candidate wagon counts and keeps the one carrying the most cargo, measuring
|
||
* each wagon at its FULL capacity rather than at the booking's real density.
|
||
* With 756 T of room that peaks at 8 wagons (8 × 70 = 560 T of nominal cargo)
|
||
* rather than 9 (756 − 9 × 22.4 = 554.4 T), even though this cargo only weighs
|
||
* 56 T per wagon. Conservative, and never dependent on the tolerance.
|
||
*/
|
||
function offerWagonsFor(roomTons: number, bookingWagons: number, freeSlots: number): number {
|
||
let best = 0;
|
||
let bestCargo = 0;
|
||
for (let w = 1; w <= Math.min(freeSlots, bookingWagons - 1); w += 1) {
|
||
const cargo = Math.min(w * NW5_CAPACITY, roomTons - w * NW5_TARE);
|
||
if (cargo > bestCargo) {
|
||
bestCargo = cargo;
|
||
best = w;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S9 — weight binds before slots
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g2 s9: weight cuts a booking down while slots sit empty", () => {
|
||
const DEPARTURE = departureAt(58);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
WA: { twenty: 40, wagons: 20 },
|
||
WB: { twenty: 30, wagons: 15 },
|
||
WC: { twenty: 20, wagons: 10 },
|
||
} as const;
|
||
const ORDER = ["WA", "WB", "WC"] as const;
|
||
const BOARDED = SHAPES.WA.wagons + SHAPES.WB.wagons; // 35
|
||
const FREE_SLOTS = SLOTS - BOARDED; // 19
|
||
/** 3 500 − 2 744 = 756 T of pull left, against 19 free slots. */
|
||
const BASE_ROOM = BASE_TONS - BOARDED * grossPerWagon(HEAVY_VGM); // 756
|
||
const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.WC.wagons, FREE_SLOTS); // 8
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 30_000;
|
||
for (const [i, suffix] of ORDER.entries()) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
twenty: SHAPES[suffix].twenty,
|
||
scheduledDate: BOOKING_DAY,
|
||
vgmTons: HEAVY_VGM,
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].twenty;
|
||
await setPriority(booking.get(suffix)!, i + 1);
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("the heavy-wagon arithmetic is what the scenario assumes", async () => {
|
||
expect(grossPerWagon(HEAVY_VGM), "2 × 28 T + 22.4 T tare").toBe(78.4);
|
||
expect(BOARDED * grossPerWagon(HEAVY_VGM), "35 wagons fit the 3 500 T base").toBe(2744);
|
||
expect(
|
||
(BOARDED + SHAPES.WC.wagons) * grossPerWagon(HEAVY_VGM),
|
||
"WC's 10 more would breach the base",
|
||
).toBeGreaterThan(BASE_TONS);
|
||
expect(Number((await scheduleRow(scheduleId)).max_wagons), "54 slots").toBe(SLOTS);
|
||
expect(FREE_SLOTS, "19 slots would still be free").toBe(19);
|
||
});
|
||
|
||
it("WA and WB board whole; WC is cut down by WEIGHT, not by slots", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
|
||
for (const suffix of ["WA", "WB"] as const) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||
}
|
||
|
||
// 19 slots are free and WC needs 10 — on the slot axis it fits easily. What
|
||
// it gets instead is an offer sized by the 756 T of pull left.
|
||
const offered = Number((await pollPartialOffer(booking.get("WC")!)).offered_wagons);
|
||
expect(offered, "sized by remaining pull, not by slots").toBe(EXPECTED_OFFER);
|
||
expect(offered, "less than the 10 wagons WC asked for").toBeLessThan(SHAPES.WC.wagons);
|
||
expect(offered, "and far less than the free slots").toBeLessThan(FREE_SLOTS);
|
||
expect(
|
||
offered * grossPerWagon(HEAVY_VGM),
|
||
"the offered part fits the base room",
|
||
).toBeLessThanOrEqual(BASE_ROOM);
|
||
});
|
||
|
||
it("the verdict names WEIGHT: 35 of 54 slots with the pull limit spent", async () => {
|
||
await extendPayWindow(scheduleId, [booking.get("WA")!, booking.get("WB")!]);
|
||
for (const suffix of ["WA", "WB"] as const) {
|
||
await payViaGateway(booking.get(suffix)!);
|
||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||
}
|
||
|
||
const tons = await allocatedGrossTons(scheduleId);
|
||
expect(tons, "2 744 T of the 3 500 T base used").toBeCloseTo(2744, 0);
|
||
expect(
|
||
tons + SHAPES.WC.wagons * grossPerWagon(HEAVY_VGM),
|
||
"WC whole would not fit on weight",
|
||
).toBeGreaterThan(BASE_TONS);
|
||
expect(await allocatedWagons(scheduleId), "35 of 54 slots used").toBe(BOARDED);
|
||
// And the verdict itself: the window reads FULL with 19 slots standing
|
||
// empty, because `isExhausted` ran out of PULL, not of wagons. On the built
|
||
// trains of Group 1 the same board would still be selling space.
|
||
expect(
|
||
(await scheduleRow(scheduleId)).booking_window_status,
|
||
"FULL — declared on weight while 19 slots are free",
|
||
).toBe("FULL");
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S10 — tolerance admits a WHOLE booking over the base
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g2 s10: the overage tolerance admits the last booking whole", () => {
|
||
const DEPARTURE = departureAt(59);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
TA: { twenty: 40, wagons: 20 },
|
||
TB: { twenty: 30, wagons: 15 },
|
||
TC: { twenty: 20, wagons: 10 },
|
||
} as const;
|
||
const ORDER = ["TA", "TB", "TC"] as const;
|
||
const ALL_WAGONS = 45;
|
||
const ALL_TONS = ALL_WAGONS * grossPerWagon(HEAVY_VGM); // 3528
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 31_000;
|
||
for (const [i, suffix] of ORDER.entries()) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
twenty: SHAPES[suffix].twenty,
|
||
scheduledDate: BOOKING_DAY,
|
||
vgmTons: HEAVY_VGM,
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].twenty;
|
||
await setPriority(booking.get(suffix)!, i + 1);
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("3 528 T breaks the 3 500 T base but sits inside the 3 590 T cap", () => {
|
||
expect(ALL_TONS, "45 heavy wagons").toBeCloseTo(3528, 6);
|
||
expect(ALL_TONS, "over base").toBeGreaterThan(BASE_TONS);
|
||
expect(ALL_TONS, "within base + tolerance").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS);
|
||
});
|
||
|
||
it("TC — the booking S9 could not fit — is admitted WHOLE, not split", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
for (const suffix of ORDER) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
// The scenario's whole claim: the tolerance is spent admitting a WHOLE
|
||
// booking. A split offer here would be the bug.
|
||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||
}
|
||
});
|
||
|
||
it("the train rides over base, inside tolerance, at 45 of 54 slots", async () => {
|
||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||
for (const suffix of ORDER) {
|
||
await payViaGateway(booking.get(suffix)!);
|
||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||
}
|
||
await endPaymentPhase(scheduleId);
|
||
await pollCycleConcluded(scheduleId);
|
||
|
||
expect(await allocatedWagons(scheduleId), "45 of 54 slots").toBe(ALL_WAGONS);
|
||
const tons = await allocatedGrossTons(scheduleId);
|
||
expect(tons, "3 528 T aboard").toBeCloseTo(ALL_TONS, 0);
|
||
expect(tons, "over the 3 500 T base").toBeGreaterThan(BASE_TONS);
|
||
expect(tons, "inside the 3 590 T cap").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S11 — a split may never touch the tolerance
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g2 s11: a split is sized against base weight only", () => {
|
||
const DEPARTURE = departureAt(60);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
XA: { twenty: 40, wagons: 20 },
|
||
XB: { twenty: 30, wagons: 15 },
|
||
/** Wants 15 wagons; base room pays for a fraction of them. */
|
||
XD: { twenty: 30, wagons: 15 },
|
||
} as const;
|
||
const ORDER = ["XA", "XB", "XD"] as const;
|
||
const USED_TONS = 35 * grossPerWagon(HEAVY_VGM); // 2744
|
||
const BASE_ROOM = BASE_TONS - USED_TONS; // 756
|
||
const TOLERANCE_ROOM = BASE_ROOM + TOLERANCE_TONS; // 846
|
||
const FREE_SLOTS = SLOTS - 35; // 19
|
||
const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); // 8
|
||
/** What the tolerance would have bought if the sizer were allowed to spend it. */
|
||
const OFFER_IF_TOLERANCE_SPENT = offerWagonsFor(TOLERANCE_ROOM, SHAPES.XD.wagons, FREE_SLOTS);
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
// Deliberately the TOLERANCE pair: the 90 T is present and must go unspent.
|
||
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 32_000;
|
||
for (const [i, suffix] of ORDER.entries()) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
twenty: SHAPES[suffix].twenty,
|
||
scheduledDate: BOOKING_DAY,
|
||
vgmTons: HEAVY_VGM,
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].twenty;
|
||
await setPriority(booking.get(suffix)!, i + 1);
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("base room and tolerance room would buy different offers", () => {
|
||
expect(BASE_ROOM, "756 T of base room after 2 744 T").toBe(756);
|
||
expect(
|
||
OFFER_IF_TOLERANCE_SPENT,
|
||
"spending the 90 T would buy a bigger offer — so this is a real distinction",
|
||
).toBeGreaterThan(EXPECTED_OFFER);
|
||
});
|
||
|
||
it("XD's offer is sized from base room — the tolerance stays unspent", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
for (const suffix of ["XA", "XB"] as const) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
}
|
||
|
||
const offered = Number((await pollPartialOffer(booking.get("XD")!)).offered_wagons);
|
||
expect(offered, "split sized from BASE room only").toBe(EXPECTED_OFFER);
|
||
expect(
|
||
offered * grossPerWagon(HEAVY_VGM),
|
||
"the offered part never reaches past the base room",
|
||
).toBeLessThanOrEqual(BASE_ROOM);
|
||
});
|
||
|
||
it("the train closes on weight with 19 slots free and its tolerance unused", async () => {
|
||
await extendPayWindow(scheduleId, [booking.get("XA")!, booking.get("XB")!]);
|
||
for (const suffix of ["XA", "XB"] as const) {
|
||
await payViaGateway(booking.get(suffix)!);
|
||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||
}
|
||
expect(
|
||
await allocatedGrossTons(scheduleId),
|
||
"still inside base — no tolerance spent",
|
||
).toBeLessThanOrEqual(BASE_TONS);
|
||
expect(await allocatedWagons(scheduleId), "35 of 54 slots").toBe(35);
|
||
expect(
|
||
(await scheduleRow(scheduleId)).booking_window_status,
|
||
"FULL on pull weight, not on slots",
|
||
).toBe("FULL");
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S12 — light cargo, slots bind
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g2 s12: with light cargo the slots bind first", () => {
|
||
const DEPARTURE = departureAt(61);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
LA: { twenty: 40, wagons: 20 },
|
||
LB: { twenty: 40, wagons: 20 },
|
||
LC: { twenty: 28, wagons: 14 },
|
||
} as const;
|
||
const ORDER = ["LA", "LB", "LC"] as const;
|
||
const FULL_TONS = SLOTS * grossPerWagon(LIGHT_VGM); // 2505.6
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 33_000;
|
||
for (const suffix of ORDER) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
twenty: SHAPES[suffix].twenty,
|
||
scheduledDate: BOOKING_DAY,
|
||
vgmTons: LIGHT_VGM,
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].twenty;
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("a full consist of light wagons weighs only ~72% of the pull limit", () => {
|
||
expect(grossPerWagon(LIGHT_VGM), "2 × 12 T + 22.4 T tare").toBe(46.4);
|
||
expect(FULL_TONS, "54 × 46.4 T").toBeCloseTo(2505.6, 1);
|
||
expect(FULL_TONS / BASE_TONS, "~72% of base").toBeCloseTo(0.72, 1);
|
||
expect(
|
||
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||
"the three fill every slot",
|
||
).toBe(SLOTS);
|
||
});
|
||
|
||
it("all three board and fill the train on SLOTS, with weight to spare", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
for (const suffix of ORDER) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||
}
|
||
|
||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||
for (const suffix of ORDER) {
|
||
await payViaGateway(booking.get(suffix)!);
|
||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||
}
|
||
});
|
||
|
||
it("the verdict names SLOTS — every slot filled with ~1 000 T of pull unused", async () => {
|
||
await endPaymentPhase(scheduleId);
|
||
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "FULL");
|
||
expect(await allocatedWagons(scheduleId), "54 of 54 slots").toBe(SLOTS);
|
||
for (const suffix of ORDER) {
|
||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||
}
|
||
|
||
const tons = await allocatedGrossTons(scheduleId);
|
||
expect(tons, "2 505 T aboard").toBeCloseTo(FULL_TONS, 0);
|
||
// The opposite of S9 on identical locomotives: the train is full because it
|
||
// ran out of WAGONS, not pull.
|
||
expect(BASE_TONS - tons, "nearly 1 000 T of pull unused").toBeGreaterThan(900);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// FINDING — on a BUILT train the batch never sees the pull limit
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g2 finding: a built train's batch ignores the locomotive pull limit", () => {
|
||
const DEPARTURE = departureAt(62);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
/** 45 wagons of heavy cargo — the exact load S10's tolerance admitted. */
|
||
const TWENTY = 90;
|
||
const WAGONS = 45;
|
||
/** What the ALLOCATOR weighs: cargo, plus the tare of the WHOLE 53-wagon consist. */
|
||
const ALLOCATOR_TONS = TWENTY * HEAVY_VGM + 53 * NW5_TARE; // 3707.2
|
||
|
||
let bookingId: string;
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(STAMP, [
|
||
{ suffix: "BW", freight: "CONTAINER" as const },
|
||
]);
|
||
scheduleId = (
|
||
await createBuiltTrainSchedule({
|
||
departure: DEPARTURE,
|
||
trainCode: "TRN-G2-BASE",
|
||
})
|
||
).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
bookingId = await bookContainersReady({
|
||
contractId: contracts.get("BW")!,
|
||
runStamp: STAMP,
|
||
isoSeed: 34_000,
|
||
twenty: TWENTY,
|
||
scheduledDate: BOOKING_DAY,
|
||
vgmTons: HEAVY_VGM,
|
||
});
|
||
}, 1_800_000);
|
||
|
||
afterAll(closeDb);
|
||
|
||
it("the load is beyond what these locomotives can pull, tolerance included", () => {
|
||
expect(WAGONS * grossPerWagon(HEAVY_VGM), "3 528 T on the booking's own wagons").toBeCloseTo(
|
||
3528,
|
||
6,
|
||
);
|
||
expect(ALLOCATOR_TONS, "3 707.2 T once the whole consist's tare is charged").toBeCloseTo(
|
||
3707.2,
|
||
1,
|
||
);
|
||
expect(ALLOCATOR_TONS, "over the 3 500 T base with no tolerance on this pair").toBeGreaterThan(
|
||
BASE_TONS,
|
||
);
|
||
});
|
||
|
||
it("the batch reserves it anyway — weight is Infinity in a built train's budget", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
// `remainingBudget` swaps the locomotive limits for
|
||
// {wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}
|
||
// as soon as a schedule has a built train, so nothing weighs this booking
|
||
// until the wagons are handed out.
|
||
await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
await expectNoPartialOffer(bookingId, "BW");
|
||
});
|
||
|
||
it("the customer pays, and only THEN does allocation refuse on weight", async () => {
|
||
await extendPayWindow(scheduleId, [bookingId]);
|
||
await payViaGateway(bookingId);
|
||
|
||
// Allocation retries on every settle tick and keeps failing with
|
||
// "Train set locomotives cannot pull the gross weight … limit 3500T incl.
|
||
// tolerance". The customer is PAID with no wagons — the state this whole
|
||
// scenario group exists to surface.
|
||
await sleep(60_000);
|
||
expect(await wagonAllocationCount(bookingId), "paid, and not a single wagon").toBe(0);
|
||
expect((await bookingRow(bookingId)).status, "money taken").toBe("PAID");
|
||
expect(await allocatedWagons(scheduleId), "the train stays empty").toBe(0);
|
||
}, 300_000);
|
||
});
|