/** * 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(); 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(); 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(); 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(); 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); });