Files
edr-platform/e2e/freight/cypress/e2e/flows/g4_multi_schedule.cy.ts
Marshal 8ccb0e1558 Refactor booking process to use bookAndClear utility
- Replaced instances of bookContainers with bookAndClear across multiple test files to streamline booking and acceptance process.
- Updated import statements to include bookAndClear where necessary.
- Removed redundant acceptOperation calls after booking, as bookAndClear handles this internally.
- Adjusted comments and documentation to reflect changes in booking logic.
- Modified forceReservationExpiry function to ensure payment deadlines are set correctly, preventing issues with booking promotions.
2026-08-01 18:42:45 +00:00

327 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GROUP 4 · S19S21 — two trains on one import day.
*
* The import batch fills a route-DAY, not a single schedule: `topUpFill`
* re-runs the whole day pool (booking-batch.service.ts:2277, and see the
* comment at :2284 explaining why a schedule-scoped query was the bug). So a
* day with two departures is one pool with two boards, and these scenarios
* pin down how demand lands across them.
*
* S19 an oversized booking is split ACROSS the two trains (import may do
* what export cannot — see Group 3)
* S20 the batch prefers whole placements over forcing a split when a second
* train exists
* S21 the fill-first-train policy: T1 is closed out with a split before T2
* is opened up
*
* ── S20 vs S21 ARE COMPETING POLICIES ──────────────────────────────────────
*
* The original scenario document poses them as alternatives and says "pick
* one, the test asserts it". They cannot both be true of the same engine:
* S20 says a 10-wagon booking facing a 3-wagon gap on T1 goes WHOLE to T2;
* S21 says the same booking is split 3 + 7 to close T1 first.
*
* The engine's actual rule is visible in the batch loop: a booking is placed
* whole when ANY open train can take it whole, and `maybeOfferPartial` is
* reached only when `!target` — i.e. when NO train fits it
* (booking-batch.service.ts:2473-2496). That is S20. S21's fill-first policy
* is therefore NOT implemented, and its test is written `.skip` documenting
* the difference rather than asserting a behaviour that does not exist.
*
* Both trains are 53-wagon built consists (TRN-G1-1, TRN-G1-2) so the two
* boards are directly comparable.
*
* Sequential steps per scenario — retries off.
*/
import {
acceptOperation,
bookContainers,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
markPaid,
pollAllocations,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
} from "./import-utils";
import {
G1_TRAIN_2,
G1_WAGONS,
bookAndClear,
closeWindowAndRunBatch,
configureAndOpenSchedule,
expectVerdict,
wagonsFor,
} from "./g1-utils";
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/**
* Wagons allocated to one booking on one specific schedule.
*
* The schedule → consist link is `train_schedules.train_set_id` (a schedule
* points AT its set; `train_sets` has no back-reference), so every query here
* joins in that direction.
*/
function wagonsOnSchedule(suffix: string, scheduleId: string) {
return db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.bookings b ON b.id = wba.booking_id
JOIN freight.contracts ct ON ct.id = b.contract_id
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
AND ts.id = $2
AND wba.deleted_at IS NULL AND b.deleted_at IS NULL
AND ts.deleted_at IS NULL`,
[suffix, scheduleId],
).then(({ rows }) => Number(rows[0].n));
}
/** Distinct schedules a booking's wagons sit on — >1 means it spans trains. */
function schedulesFor(suffix: string) {
return db<{ id: string }>(
`SELECT DISTINCT ts.id
FROM freight.wagon_booking_allocations wba
JOIN freight.bookings b ON b.id = wba.booking_id
JOIN freight.contracts ct ON ct.id = b.contract_id
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
AND wba.deleted_at IS NULL AND b.deleted_at IS NULL
AND ts.deleted_at IS NULL`,
[suffix],
).then(({ rows }) => rows.map((r) => r.id));
}
// ───────────────────────────────────────────────────────────────────────────
// S19 — an oversized import booking spans both trains
// ───────────────────────────────────────────────────────────────────────────
describe("G4·S19: an import booking splits across two trains", { retries: 0 }, () => {
const T1 = departureAt(31);
const T2 = new Date(T1.getTime() + 3 * 3_600_000);
const BOOKING_DAY = eatDayStr(T1);
const SHAPES = {
MA: { forty: 60, wagons: 60 },
MB: { forty: 10, wagons: 10 },
MC: { forty: 5, wagons: 5 },
} as const;
const ORDER = ["MA", "MB", "MC"] as const;
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("MA is bigger than either train but fits the day's combined capacity", () => {
expect(SHAPES.MA.wagons, "60 > one train").to.be.greaterThan(G1_WAGONS);
expect(SHAPES.MA.wagons, "60 < two trains").to.be.lessThan(G1_WAGONS * 2);
expect(eatDayStr(T2), "both trains run the same EAT day").to.eq(BOOKING_DAY);
});
it("the day runs two 53-wagon built trains", () => {
ensureCorridorRoute();
resetCorridorDay(T1);
resetCorridorDay(T2);
configureAndOpenSchedule({ departure: T1 });
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
});
it("three bookings arrive, MA first in priority", () => {
let isoSeed = 17_100;
ORDER.forEach((suffix) => {
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += SHAPES[suffix].forty;
});
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("MA is placed across BOTH trains — import may span, unlike export", () => {
closeWindowAndRunBatch(T1);
ORDER.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
ORDER.forEach((suffix) => markPaid(suffix));
ORDER.forEach((suffix) => pollAllocations(suffix, 1));
// The distinguishing assertion: MA's wagons sit on two different schedules.
schedulesFor("MA").then((ids) => {
expect(ids.length, "MA spans two trains").to.eq(2);
});
withSchedule(T1, (s1) =>
wagonsOnSchedule("MA", s1.id).then((n) =>
expect(n, "MA fills T1 completely").to.eq(G1_WAGONS),
),
);
});
it("T1 departs FULL with MA alone; T2 carries the remainder plus MB and MC", () => {
withSchedule(T1, (s) => endPaymentPhase(s.id));
expectVerdict(T1, { wagons: G1_WAGONS, full: true });
// MA's overflow (7) + MB (10) + MC (5) = 22 on the second train.
const overflow = SHAPES.MA.wagons - G1_WAGONS; // 7
const onT2 = overflow + SHAPES.MB.wagons + SHAPES.MC.wagons; // 22
expect(onT2, "22 wagons on T2").to.eq(22);
expectVerdict(T2, { wagons: onT2, full: false });
});
});
// ───────────────────────────────────────────────────────────────────────────
// S20 — whole placements are preferred while a second train has room
// ───────────────────────────────────────────────────────────────────────────
describe("G4·S20: the batch prefers a whole placement over a forced split", { retries: 0 }, () => {
const T1 = departureAt(32);
const T2 = new Date(T1.getTime() + 3 * 3_600_000);
const BOOKING_DAY = eatDayStr(T1);
const SHAPES = {
WA: { forty: 30, wagons: 30 },
WB: { forty: 30, wagons: 30 },
WC: { forty: 20, wagons: 20 },
WD: { forty: 10, wagons: 10 },
} as const;
const ORDER = ["WA", "WB", "WC", "WD"] as const;
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("90 wagons of demand across two 53-wagon trains", () => {
ORDER.forEach((s) =>
expect(wagonsFor(0, SHAPES[s].forty), `${s} wagons`).to.eq(SHAPES[s].wagons),
);
expect(
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"90 wagons of demand",
).to.eq(90);
});
it("both trains run and all four book", () => {
ensureCorridorRoute();
resetCorridorDay(T1);
resetCorridorDay(T2);
configureAndOpenSchedule({ departure: T1 });
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
let isoSeed = 17_600;
ORDER.forEach((suffix) => {
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += SHAPES[suffix].forty;
});
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
closeWindowAndRunBatch(T1);
});
it("EVERY booking is placed WHOLE — none is split to close T1's gap", () => {
ORDER.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
ORDER.forEach((suffix) => markPaid(suffix));
ORDER.forEach((suffix) => pollAllocations(suffix, SHAPES[suffix].wagons));
// The policy assertion: with a second train available, `maybeOfferPartial`
// is never reached, because a whole placement always exists.
ORDER.forEach((suffix) => {
withBooking(suffix, (b) =>
expect(b.is_split, `${suffix} placed whole`).to.eq(false),
);
schedulesFor(suffix).then((ids) =>
expect(ids.length, `${suffix} rides ONE train`).to.eq(1),
);
});
});
it("neither train ends FULL — whole placements leave gaps, and that is correct", () => {
withSchedule(T1, (s) => endPaymentPhase(s.id));
withSchedule(T2, (s) => endPaymentPhase(s.id));
// 90 wagons over two 53-slot trains cannot both be full; the engine
// trades slot efficiency for keeping bookings intact.
withSchedule(T1, (s1) =>
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
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`,
[s1.id],
).then(({ rows }) => {
const onT1 = Number(rows[0].n);
expect(onT1, "T1 carries whole bookings only").to.be.at.most(G1_WAGONS);
// Whatever landed on T1, the day's total is all 90 wagons.
withSchedule(T2, (s2) =>
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
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`,
[s2.id],
).then(({ rows: r2 }) =>
expect(onT1 + Number(r2[0].n), "all 90 wagons placed across the day").to.eq(90),
),
);
}),
);
});
});
// ───────────────────────────────────────────────────────────────────────────
// S21 — the competing "fill T1 first" policy (NOT implemented)
// ───────────────────────────────────────────────────────────────────────────
describe("G4·S21: fill-first-train policy", { retries: 0 }, () => {
it("documents that the engine prefers whole placement, not fill-first", () => {
// S20 (above) asserts the implemented behaviour. Keeping this as a live,
// passing note rather than a skipped mystery: the two scenarios are
// mutually exclusive and S20 is the one that matches the code.
cy.log(
"S21 proposes closing T1 with a split before opening T2. The batch " +
"instead reaches maybeOfferPartial only when NO train fits the " +
"booking whole (booking-batch.service.ts:2473-2496), so a second " +
"train with room always wins. S20 is the asserted policy.",
);
});
// Un-skip only if the fill-first policy is deliberately implemented; it
// would change S19 and S20's outcomes too, so treat it as a product change
// rather than a test fix.
it.skip("closes T1 with a split before opening T2", () => {
// Would assert: A30 + B20 + C-split(3) fills T1 to 53/53, and C's
// remaining 7 wagons roll to T2 — i.e. a split is CHOSEN even though a
// whole placement was available on T2.
});
});
export {};