mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
/**
|
||
* GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs.
|
||
*
|
||
* A 3×40ft = 3 wagons
|
||
* B 20×20ft + 10×40ft = 20 wagons
|
||
* C 30×40ft = 30 wagons
|
||
* ─────────
|
||
* 53 = the whole BUILT train → all three reserved
|
||
* D 6×20ft = 3 wagons → no room → WAITING LIST
|
||
*
|
||
* B and C pay through the real gateway. A never does: its deadline passes, A
|
||
* EXPIRES, and its 3 wagons return to the day's pool. The top-up pass then
|
||
* promotes D — an exact fit — and D pays.
|
||
*
|
||
* Final consist: B 20 + C 30 + D 3 = 53/53, FULL.
|
||
* A is recoverable: its contract is untouched, so it can rebook a later day
|
||
* with no re-approval.
|
||
*
|
||
* The 53 slots are the BUILT train's coupled consist (seed-g1-train.sql), not a
|
||
* locomotive-length figure — see {@link createBuiltTrainSchedule}.
|
||
*/
|
||
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,
|
||
expectContractStillBookable,
|
||
extendPayWindow,
|
||
forceReservationExpiry,
|
||
forceWindowOpen,
|
||
linkedBookings,
|
||
payViaGateway,
|
||
pollAllocations,
|
||
pollBookingStatus,
|
||
pollWindow,
|
||
releaseUnpaidHolds,
|
||
resetCorridorDay,
|
||
seedTenantContracts,
|
||
setPriority,
|
||
} from "./flows";
|
||
|
||
const DEPARTURE = departureAt(50);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const STAMP = String(Date.now());
|
||
|
||
/** Booking order is also PRIORITY order: A must be INSIDE the batch, because
|
||
* its expiry is what frees the space D needs. */
|
||
const SHAPES = {
|
||
A: { twenty: 0, forty: 3, wagons: 3 },
|
||
B: { twenty: 20, forty: 10, wagons: 20 },
|
||
C: { twenty: 0, forty: 30, wagons: 30 },
|
||
D: { twenty: 6, forty: 0, wagons: 3 },
|
||
} as const;
|
||
const ORDER = ["A", "B", "C", "D"] as const;
|
||
const IN_BATCH = ["A", "B", "C"] as const;
|
||
|
||
describe("g1 s1: expiry frees exactly the waiting list's space", () => {
|
||
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 = 20_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("the wagon math is exactly one trainload, and D fits exactly A's share", () => {
|
||
for (const suffix of ORDER) {
|
||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||
SHAPES[suffix].wagons,
|
||
);
|
||
}
|
||
const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
|
||
expect(booked, "A+B+C fill the train exactly").toBe(G1_WAGONS);
|
||
expect(SHAPES.D.wagons, "D fits exactly the space A frees").toBe(SHAPES.A.wagons);
|
||
});
|
||
|
||
it("the batch reserves A, B and C; D holds a place in line rather than being rejected", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
|
||
for (const suffix of IN_BATCH) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
expect(
|
||
(await bookingRow(booking.get(suffix)!)).payment_deadline,
|
||
`${suffix} got a pay window`,
|
||
).toBeTruthy();
|
||
}
|
||
const d = await bookingRow(booking.get("D")!);
|
||
expect(d.status, "D waitlisted").toBe("FULLY_EXECUTED");
|
||
expect(d.train_schedule_id, "D holds no seat").toBeNull();
|
||
});
|
||
|
||
it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", async () => {
|
||
// A is deliberately left out: the next assertion is about its expiry.
|
||
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")!);
|
||
expect((await bookingRow(booking.get("A")!)).status, "A expired unpaid").toBe("EXPIRED");
|
||
});
|
||
|
||
it("the freed 3 wagons promote D — an exact fit — and D pays", async () => {
|
||
// fillFromWaitingList runs on the tick that follows the expiry; no second
|
||
// staff action is needed.
|
||
const promoted = await pollBookingStatus(
|
||
booking.get("D")!,
|
||
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"],
|
||
40,
|
||
);
|
||
expect(promoted.status, "D promoted off the waiting list").not.toBe("FULLY_EXECUTED");
|
||
expect((await bookingRow(booking.get("D")!)).payment_deadline, "D got a pay window").toBeTruthy();
|
||
|
||
await extendPayWindow(scheduleId, [booking.get("D")!]);
|
||
await payViaGateway(booking.get("D")!);
|
||
await pollAllocations(booking.get("D")!, SHAPES.D.wagons);
|
||
});
|
||
|
||
it("the train departs FULL at 53/53 — B 20 + C 30 + D 3, and A holds no seat", async () => {
|
||
await endPaymentPhase(scheduleId);
|
||
await pollWindow(
|
||
scheduleId,
|
||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||
"FULL + DONE",
|
||
);
|
||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||
expect(await linkedBookings(scheduleId), "3 bookings linked").toBe(3);
|
||
|
||
for (const suffix of ["B", "C", "D"] as const) {
|
||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||
}
|
||
const a = await bookingRow(booking.get("A")!);
|
||
expect(a.status, "A does not ride").toBe("EXPIRED");
|
||
expect(a.train_schedule_id, "A holds no seat").toBeNull();
|
||
});
|
||
|
||
it("A is recoverable — its contract needs no re-approval to rebook a later day", async () => {
|
||
await expectContractStillBookable(booking.get("A")!);
|
||
});
|
||
});
|