chore: more test cases

This commit is contained in:
Nathnael
2026-08-03 12:57:32 +00:00
parent 72532f36cd
commit 3b45990023
4 changed files with 627 additions and 0 deletions

View File

@@ -75,6 +75,7 @@ gateway-mock-it`) — the code is a read-only mount, not baked into an image.
| `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder |
| `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed |
| `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers |
| `src/g2-weight.it.ts` | weight before slots: base pull, overage tolerance, split sized on base only, light cargo |
| `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` |
## Findings pinned by these tests
@@ -101,6 +102,20 @@ what it actually does and says so in a comment, so a fix fails loudly:
priority config.
- **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider, with no short-payment guard.
- **A BUILT train's batch is blind to the pull limit** (`g2-weight`, last
describe). `remainingBudget` replaces the locomotive limits with
`{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the
moment a schedule has a built train, so the batch reserves — and invoices —
a load the locomotives cannot pull. The only check left is at wagon
allocation, which then fails every tick with "Train set locomotives cannot
pull the gross weight … limit 3500T incl. tolerance". The customer is PAID
with zero wagons. Group 2 therefore runs its real weight scenarios on
locomotive PAIRS, where the limits survive.
- **The allocator weighs the whole consist, the batch weighs the booking.**
Allocation charges the tare of every wagon in the train set (53 × 22.4 T on
the G2 consist), while `needFor` charges only the tare of the wagons the
booking occupies — so the same 45-wagon load reads 3 528 T at reservation and
3 707.2 T at allocation. Two capacity models, one train.
- **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable`
rejects a schedule whose `booking_window_status` is FULL before any budget or
victim is considered, and `refreshWindowStatus` re-derives that flag from live
@@ -143,6 +158,12 @@ what it actually does and says so in a comment, so a fix fails loudly:
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
because a company may hold only one unpaid reservation at a time; staff book
and pay on their behalf, which is also the real Path B flow.
- **The weight axis is GROSS, but the column is not.**
`wagon_booking_allocations.allocated_weight_tons` holds CARGO only;
`allocatedGrossTons` adds each wagon type's tare, because the pull limit is
spent on both. Two 20ft at 28 T ride one wagon at 78.4 T gross — 35 of those
spend a 3 500 T locomotive pair, and reading the raw column would report
1 960 T and hide it.
- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a
cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here)
every fill pass. A built train's coupled consist wins outright, so

View File

@@ -1492,6 +1492,38 @@ export async function expectContainersPlaced(scheduleId: string, containers: num
}
}
/**
* GROSS tonnage riding a schedule — cargo PLUS the tare of every wagon it
* occupies, because a locomotive hauls the wagon as well as what is in it.
*
* `allocated_weight_tons` holds the CARGO alone, so the tare of each allocated
* wagon type is added here. Reading the column raw understates a loaded consist
* by 22.4 T a wagon and makes a weight-bound train look half empty.
*/
export async function allocatedGrossTons(scheduleId: string): Promise<number> {
const [row] = await db<{ tons: string | null }>(
`SELECT COALESCE(sum(wba.allocated_weight_tons + wt.tare_weight_tons), 0)::text AS tons
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[scheduleId],
);
return Number(row.tons ?? 0);
}
/** Wagons a single booking currently holds. */
export async function wagonAllocationCount(bookingId: string): Promise<number> {
const [row] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
return Number(row.n);
}
/** Bookings still linked to a schedule — the seats actually held. */
export async function linkedBookings(scheduleId: string): Promise<number> {
const [row] = await db<{ n: string }>(

View File

@@ -0,0 +1,572 @@
/**
* 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);
});

View File

@@ -7,6 +7,7 @@
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
* seed-g1-train.sql (the 53-wagon BUILT container train)
* seed-g2-weight.sql (the two 3 500 T weight-bound trains)
* seed-government.sql (the kind='government' company)
* seed-company-b.sql (user2@gmail.com's company — this suite)
* seed-customs-service-type.sql (a service type that bundles customs)
@@ -30,6 +31,7 @@ const SEEDS: Array<[dir: string, file: string]> = [
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
[CYPRESS_FIXTURES, "seed-government.sql"],
[OWN_FIXTURES, "seed-company-b.sql"],
[OWN_FIXTURES, "seed-customs-service-type.sql"],