mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: more test cases
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
572
integration/src/g2-weight.it.ts
Normal file
572
integration/src/g2-weight.it.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user