Files
edr-platform/integration/src/g2-weight.it.ts
2026-08-03 12:57:32 +00:00

573 lines
23 KiB
TypeScript
Raw Permalink 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.

/**
* GROUP 2 · S9S12 — 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);
});