mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
/**
|
|
* GROUP 1 · S5 — one expiry cascades into a second promotion.
|
|
*
|
|
* In the batch: A 10w + B 20w + C 23w = 53/53
|
|
* Waiting: D 8w, E 5w (priority order D before E)
|
|
*
|
|
* A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH
|
|
* waiting bookings in the same pass: D is reserved whole (8w) and E, which no
|
|
* longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2.
|
|
* That is the assertion the scenario exists for — fillFromWaitingList loops
|
|
* until a pass places nothing, so a single-pass top-up would leave E untouched
|
|
* until the next window cycle.
|
|
*
|
|
* D then expires too, freeing 8 more wagons — and E's offer is NOT resized.
|
|
* FINDING (the same one bulk-b1 pins): a refill never supersedes an open
|
|
* partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons
|
|
* while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53.
|
|
*
|
|
* Every expiry must also leave an audit trail — a terminal EXPIRED booking with
|
|
* its invoice closed out, never a silent disappearance. An open invoice on an
|
|
* expired seat is money the customer could still pay for a train they are no
|
|
* longer on.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, gateway } from "./client";
|
|
import {
|
|
G1_WAGONS,
|
|
allocatedWagons,
|
|
bookContainersReady,
|
|
bookingRow,
|
|
closeBookingWindow,
|
|
completeDocReview,
|
|
containerWagons,
|
|
createBuiltTrainSchedule,
|
|
departureAt,
|
|
eatDayStr,
|
|
endPaymentPhase,
|
|
ensureCorridorRoute,
|
|
extendPayWindow,
|
|
forceReservationExpiry,
|
|
forceWindowOpen,
|
|
livePayableInvoices,
|
|
payViaGateway,
|
|
pollAllocations,
|
|
pollBookingStatus,
|
|
pollCycleConcluded,
|
|
pollPartialOffer,
|
|
releaseUnpaidHolds,
|
|
resetCorridorDay,
|
|
scheduleRow,
|
|
seedTenantContracts,
|
|
setPriority,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(54);
|
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
|
const STAMP = String(Date.now());
|
|
|
|
const SHAPES = {
|
|
A: { twenty: 20, forty: 0, wagons: 10 },
|
|
B: { twenty: 40, forty: 0, wagons: 20 },
|
|
C: { twenty: 0, forty: 23, wagons: 23 },
|
|
D: { twenty: 16, forty: 0, wagons: 8 },
|
|
E: { twenty: 10, forty: 0, wagons: 5 },
|
|
} as const;
|
|
const ORDER = ["A", "B", "C", "D", "E"] as const;
|
|
const IN_BATCH = ["A", "B", "C"] as const;
|
|
const WAITING = ["D", "E"] as const;
|
|
/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */
|
|
const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2
|
|
const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45
|
|
|
|
describe("g1 s5: expiry cascades into a second promotion", () => {
|
|
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 createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
|
await forceWindowOpen(scheduleId, 60);
|
|
|
|
let isoSeed = 24_000;
|
|
for (const [i, suffix] of ORDER.entries()) {
|
|
const shape = SHAPES[suffix];
|
|
booking.set(
|
|
suffix,
|
|
await bookContainersReady({
|
|
contractId: contracts.get(suffix)!,
|
|
runStamp: STAMP,
|
|
isoSeed,
|
|
twenty: shape.twenty,
|
|
forty: shape.forty,
|
|
scheduledDate: BOOKING_DAY,
|
|
}),
|
|
);
|
|
isoSeed += shape.twenty + shape.forty;
|
|
await setPriority(booking.get(suffix)!, i + 1);
|
|
}
|
|
}, 1_800_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => {
|
|
for (const suffix of ORDER) {
|
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
|
SHAPES[suffix].wagons,
|
|
);
|
|
}
|
|
expect(
|
|
IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
|
"A+B+C fill the train exactly",
|
|
).toBe(G1_WAGONS);
|
|
expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons);
|
|
expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons);
|
|
});
|
|
|
|
it("the batch reserves A, B and C; D and E wait in priority order", async () => {
|
|
await closeBookingWindow(scheduleId);
|
|
await completeDocReview(scheduleId);
|
|
for (const suffix of IN_BATCH) {
|
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
|
}
|
|
for (const suffix of WAITING) {
|
|
const row = await bookingRow(booking.get(suffix)!);
|
|
expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED");
|
|
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => {
|
|
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
|
|
await payViaGateway(booking.get("B")!);
|
|
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
|
await payViaGateway(booking.get("C")!);
|
|
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
|
|
|
|
await forceReservationExpiry(booking.get("A")!);
|
|
// D fits A's hole whole…
|
|
await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
|
// …and the same pass keeps going: E no longer fits in the 2 wagons left, so
|
|
// it is offered exactly those rather than being left for the next cycle.
|
|
const offer = await pollPartialOffer(booking.get("E")!);
|
|
expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER);
|
|
});
|
|
|
|
it("D expires too — but E's stale 2-wagon offer is never resized", async () => {
|
|
await forceReservationExpiry(booking.get("D")!);
|
|
// FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that
|
|
// already holds an OFFER without re-sizing it, so the 8 wagons D just freed
|
|
// stay unsold and E ships 2 of the 5 it asked for. Assert what the engine
|
|
// really does, so a fix to the offer path fails loudly here.
|
|
await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
|
expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy();
|
|
|
|
await extendPayWindow(scheduleId, [booking.get("E")!]);
|
|
await payViaGateway(booking.get("E")!);
|
|
await pollAllocations(booking.get("E")!, E_OFFER);
|
|
expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true);
|
|
});
|
|
|
|
it("both expiries are terminal and auditable — nothing vanished silently", async () => {
|
|
for (const suffix of ["A", "D"] as const) {
|
|
const row = await bookingRow(booking.get(suffix)!);
|
|
expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED");
|
|
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
|
expect(
|
|
await livePayableInvoices(booking.get(suffix)!),
|
|
`${suffix} has no live payable invoice`,
|
|
).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => {
|
|
await endPaymentPhase(scheduleId);
|
|
await pollCycleConcluded(scheduleId);
|
|
expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING);
|
|
expect(
|
|
(await scheduleRow(scheduleId)).booking_window_status,
|
|
"window not FULL at 45/53",
|
|
).not.toBe("FULL");
|
|
});
|
|
});
|