Files
edr-platform/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts

650 lines
27 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.

/**
* Segment weight & tolerance journeys — per-edge capacity on one corridor
* (Mojo Dry Port → Dire Dawa Yard → Nagad Terminal), two dedicated
* trains departing the same day (seed-segment-weight.sql):
*
* TRN-SEG-W "tolerance train" — 240T pull; one loco carries a 90T overage
* tolerance, the second has NONE CONFIGURED (the S-2026-00024 regression
* pair: the set's tolerance must stay 90, not collapse to 0).
*
* 1. 130T wheat Mojo→Djibouti → 179.6T gross (2 CW4) on both legs
* 2. intercity 2×20ft VGM 24 Mojo→Dire → 70.4T gross (1 NW5); the shared
* Mojo→Dire leg hits 250T — OVER the 240T base, inside the 330T
* ceiling. Wagon allocation must succeed (allocation is what silently
* failed on S-2026-00024: PAID + SCHEDULED, zero allocations).
* 3. a second identical ride-along → 320.4T, still inside the ceiling —
* the tolerance admits whole bookings repeatedly until spent
* 4. the schedule detail strip shows the per-leg gross vs the ceiling
*
* TRN-SEG-F "border-full train" — 200T pull, no tolerance, 4 NW5.
*
* 5. 8×20ft export boarding at DIRE (sub-corridor) commits all 4 wagons
* on the border edge (W's border edge only has 2 free, so FCFS lands
* it on F) → the window goes FULL for the trade direction
* 6. the FULL train still accepts an intercity ride-along Mojo→Dire on
* its free home leg (the old whole-train sum — 169.6 + 70.4 = 240T >
* 200T pull — rejected the accept outright; per-edge math admits it).
* Wagon ALLOCATION of that shared wagon is a known gap: physical
* pinning is slot-exclusive, so the test asserts accept + link only.
*
* Contracts are seeded FULLY_EXECUTED straight into SQL (stamped references,
* re-runnable) — contract lifecycle is covered by the other flow specs; this
* spec is about the scheduling engine. Run against a fresh e2e stack: the
* trains' capacity math assumes empty consists.
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local";
// Own corridor (…→ Nagad, not Djibouti Port): other specs schedule TRN-E2E-1
// on the Djibouti Port route, and an earlier-departing same-day train there
// would steal these FCFS bookings.
const ORIGIN_YARD = "Mojo Dry Port";
const MID_YARD = "Dire Dawa Yard";
const PORT_YARD = "Nagad Terminal, Djibouti";
const TRAIN_W = "TRN-SEG-W";
const TRAIN_F = "TRN-SEG-F";
const stamp = String(Date.now());
const isoNumber = (prefix: string, offset: number) =>
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
// A ONE_TIME contract is spent after one booking, so every run seeds a fresh
// set with stamped references. Lookups go by SUFFIX + newest row: Cypress
// re-evaluates the spec bundle on cross-origin reloads, so a module-scope
// stamp drifts between tests and must never key a lookup.
const REF = {
exportMojo: "EXP1",
exportDire: "EXP2",
ic1: "IC1",
ic2: "IC2",
ic3: "IC3",
} as const;
const stampedRef = (suffix: string) => `CTR-SEG-${stamp}-${suffix}`;
/** Both trains depart just past the 24h export lead — windows open in minutes. */
const DEPART_W = new Date(Date.now() + 24 * 3_600_000 + 4 * 60_000);
const DEPART_F = new Date(Date.now() + 24 * 3_600_000 + 7 * 60_000);
const apiUrl = () => Cypress.env("apiUrl") as string;
/** Seed one FULLY_EXECUTED ONE_TIME contract (contract + route + cargo scope). */
function seedContract(opts: {
suffix: string;
reference: string;
direction: "EXPORT" | "DOMESTIC";
freight: "CONTAINER" | "BULK";
originCode: string;
destCode: string;
}) {
cy.task("db:query", {
sql: `WITH c AS (
INSERT INTO freight.contracts
(reference, company_id, company_profile_id, contract_kind,
trade_direction, freight_type, service_type_id, payment_currency,
status, fully_executed_at, contract_valid_from,
contract_valid_until, contract_summary)
SELECT $1, comp.id,
-- bookings.company_profile_id is NOT NULL and inherits from
-- the contract: exporter profile for exports, any active
-- profile otherwise.
(SELECT p.id FROM freight.company_profiles p
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
ORDER BY CASE
WHEN $2 = 'EXPORT' AND p.type = 'exporter' THEN 0
ELSE 1
END
LIMIT 1),
'ONE_TIME', $2, $3,
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
'ETB', 'FULLY_EXECUTED', now(), now() - interval '1 day',
now() + interval '60 days', 'E2E segment-weight fixture contract'
FROM freight.companies comp
WHERE comp.tin = $4
-- before() re-runs on Cypress reloads: skip when this run
-- already seeded a fresh, still-unbooked contract for the
-- suffix, so lookups keep pointing at one stable row.
AND NOT EXISTS (
SELECT 1 FROM freight.contracts c2
WHERE c2.reference LIKE 'CTR-SEG-%-' || $7
AND c2.deleted_at IS NULL
AND c2.created_at > now() - interval '15 minutes'
AND NOT EXISTS (
SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id
)
)
RETURNING id
), r AS (
INSERT INTO freight.contract_routes
(contract_id, origin_yard_id, destination_yard_id, sort_order)
SELECT c.id, o.id, d.id, 0 FROM c
JOIN freight.yards o ON o.code = $5
JOIN freight.yards d ON d.code = $6
RETURNING id
)
INSERT INTO freight.contract_cargo_scope
(contract_id, container_size, cargo_type_id, cargo_free_text)
SELECT c.id,
CASE WHEN $3 = 'CONTAINER' THEN '20ft' END,
CASE WHEN $3 = 'BULK' THEN
(SELECT ct.id FROM freight.cargo_types ct WHERE ct.code = 'E2E_WHEAT' LIMIT 1)
END,
'E2E segment-weight cargo'
FROM c`,
params: [
opts.reference,
opts.direction,
opts.freight,
companyTin,
opts.originCode,
opts.destCode,
opts.suffix,
],
});
}
/** Newest seeded contract for a suffix — stamp-agnostic (see REF). */
function dbContractId(suffix: string) {
return cy
.task<{ rows: Array<{ id: string }> }>("db:query", {
sql: `SELECT id FROM freight.contracts
WHERE reference LIKE 'CTR-SEG-%-' || $1
ORDER BY created_at DESC LIMIT 1`,
params: [suffix],
})
.then(({ rows }) => {
expect(rows, `seeded contract *-${suffix}`).to.have.length(1);
return cy.wrap(rows[0].id, { log: false });
});
}
type BookingRow = {
id: string;
reference: string;
status: string;
scheduling_status: string;
train_schedule_id: string | null;
payment_deadline: string | null;
};
/** The (only) booking under this run's seeded contract for a suffix. */
function withBooking(suffix: string, fn: (b: BookingRow) => void) {
cy.task<{ rows: BookingRow[] }>("db:query", {
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
b.train_schedule_id, b.payment_deadline
FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-SEG-%-' || $1
ORDER BY b.created_at DESC LIMIT 1`,
params: [suffix],
}).then(({ rows }) => {
expect(rows, `booking under *-${suffix}`).to.have.length(1);
fn(rows[0]);
});
}
type ScheduleRow = {
id: string;
booking_window_status: string;
window_closes_at: string;
};
/** The live schedule riding a given built train on the NAGAD corridor. */
function dbScheduleFor(trainCode: string) {
return cy.task<{ rows: ScheduleRow[] }>("db:query", {
sql: `SELECT ts.id, ts.booking_window_status, ts.window_closes_at
FROM freight.train_schedules ts
JOIN freight.train_sets se ON se.id = ts.train_set_id
JOIN freight.trains t ON t.id = se.train_id
JOIN freight.yards d ON d.id = ts.destination_station_id
WHERE t.code = $1 AND d.code = 'NAGAD' AND ts.deleted_at IS NULL
ORDER BY ts.created_at DESC LIMIT 1`,
params: [trainCode],
});
}
function withSchedule(trainCode: string, fn: (s: ScheduleRow) => void) {
dbScheduleFor(trainCode).then(({ rows }) => {
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
fn(rows[0]);
});
}
function fill(label: string | RegExp, value: string) {
cy.contains("label", label)
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
}
/** Pick the departure day on the booking form's inline calendar. */
function pickShipmentDay(date: Date) {
cy.contains(/available day/, { timeout: 30000 }).should("exist");
const day = String(date.getDate());
cy.get("button:not(:disabled)", { timeout: 15000 })
.contains(new RegExp(`^${day}$`))
.click({ force: true });
}
/** Create one schedule from a built train, departing at the given moment. */
function createSchedule(trainCode: string, departure: Date) {
cy.visit("/dashboard/operations/train-scheduling-v2");
cy.contains("button", "New schedule", { timeout: 20000 }).click();
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
// Anchored at BOTH ends. /Nagad/ alone also matches the reverse (import)
// route, which carries a different scheduling lead; anchoring only the
// origin still collides with the other Mojo-origin corridor that runs on
// through to Djibouti Port, whose schedules this spec's lookups ignore.
cy.mantineSelect(
/^Route$/,
new RegExp(`^\\s*${ORIGIN_YARD}.*${PORT_YARD}\\s*$`),
);
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
cy.get('.mantine-Modal-content input[type="datetime-local"]')
.clear({ force: true })
.type(local, { force: true });
cy.mantineSelect(/^Train$/, new RegExp(trainCode));
cy.get(".mantine-Modal-content").contains("button", "Create").click();
cy.location("pathname", { timeout: 30000 }).should(
"match",
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
);
}
/** Ops accepts a booking's operation request from the booking-requests page. */
function acceptOperationRequest(contractRef: string) {
cy.loginBackoffice(opsStaff);
withBooking(contractRef, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
}
/** Book 2×20ft VGM 24T each under a seeded DOMESTIC contract (48T cargo, 1 NW5). */
function bookIntercityPair(contractRef: string, isoOffset: number) {
cy.loginPortal(customer);
dbContractId(contractRef).then((id) => cy.visitPortal(`/contracts/${id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
fill(/^Quantity/, "2");
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", isoOffset));
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", isoOffset + 1));
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input).clear({ force: true }).type("24", { force: true });
});
cy.contains("Shipment day").should("not.exist");
cy.fillCargoDescription();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
}
/** Accept a waiting intercity booking from a schedule's ride-along panel. */
function acceptRideAlong(trainCode: string, contractRef: string) {
cy.loginBackoffice(opsStaff);
withSchedule(trainCode, (s) =>
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
);
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
withBooking(contractRef, (b) => {
cy.contains("tr", b.reference, { timeout: 30000 })
.find('input[type="checkbox"]')
.check({ force: true });
cy.contains("button", /Accept .*onto this train/).click();
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
});
}
/** Staff mark-paid (no mounted UI button), then wait for wagon allocation. */
function markPaidAndAssertAllocated(contractRef: string) {
withBooking(contractRef, (b) => {
cy.apiLogin(opsStaff).then(({ token }) => {
cy.request({
method: "POST",
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
withBooking(contractRef, (b) => {
expect(b.status).to.eq("PAID");
expect(b.scheduling_status).to.eq("SCHEDULED");
// Wagon allocation runs async after allocate() — poll for its rows.
// Zero allocations with a PAID/SCHEDULED booking is exactly the
// S-2026-00024 failure shape this spec guards against.
const waitForAllocation = (attempt: number) => {
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n FROM freight.wagon_booking_allocations
WHERE booking_id = $1 AND deleted_at IS NULL`,
params: [b.id],
}).then(({ rows }) => {
if (Number(rows[0].n) > 0) return;
expect(attempt, `wagon allocations for ${contractRef}`).to.be.lessThan(20);
cy.wait(2000).then(() => waitForAllocation(attempt + 1));
});
};
waitForAllocation(0);
});
}
describe(
"segment weight: per-edge caps, loco tolerance, directional FULL",
{ retries: 0 },
() => {
before(() => {
cy.task("db:seedFile", "seed-intercity.sql");
cy.task("db:seedFile", "seed-export.sql");
cy.task("db:seedFile", "seed-segment-weight.sql");
seedContract({
suffix: REF.exportMojo,
reference: stampedRef(REF.exportMojo),
direction: "EXPORT",
freight: "BULK",
originCode: "MOJO",
destCode: "NAGAD",
});
seedContract({
suffix: REF.exportDire,
reference: stampedRef(REF.exportDire),
direction: "EXPORT",
freight: "CONTAINER",
originCode: "DIRE_DAWA",
destCode: "NAGAD",
});
for (const ref of [REF.ic1, REF.ic2, REF.ic3]) {
seedContract({
suffix: ref,
reference: stampedRef(ref),
direction: "DOMESTIC",
freight: "CONTAINER",
originCode: "MOJO",
destCode: "DIRE_DAWA",
});
}
});
// ── Infrastructure ───────────────────────────────────────────────────────
it("operations ensures the export route exists", () => {
cy.loginBackoffice(opsStaff);
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n
FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'NAGAD'
WHERE r.deleted_at IS NULL`,
}).then(({ rows }) => {
if (Number(rows[0].n) > 0) return;
cy.visit("/dashboard/routes");
cy.contains("button", "Add route", { timeout: 20000 }).click();
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
const pickYard = (index: number, yard: string) => {
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
.eq(index)
.click({ force: true });
cy.get('[role="option"]:visible').contains(yard).click();
};
pickYard(0, ORIGIN_YARD);
pickYard(1, MID_YARD);
pickYard(2, PORT_YARD);
cy.get(".mantine-Modal-content").contains("button", "Save").click();
});
});
it("operations schedules both segment trains — same route, same day", () => {
cy.loginBackoffice(opsStaff);
// Two different physical trains may share a route+day (the guard only
// blocks the SAME train twice); export windows are per-schedule.
dbScheduleFor(TRAIN_W).then(({ rows }) => {
if (rows.length === 0) createSchedule(TRAIN_W, DEPART_W);
});
dbScheduleFor(TRAIN_F).then(({ rows }) => {
if (rows.length === 0) createSchedule(TRAIN_F, DEPART_F);
});
// The export window opens at departure 24h CLAMPED into the booking
// desk hours (817 EAT), and the engine can take minutes to advance a
// second same-day train. Force both windows OPEN directly — the spec
// arranges window state, it does not test the window engine.
for (const trainCode of [TRAIN_W, TRAIN_F]) {
dbScheduleFor(trainCode).then(({ rows }) => {
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
cy.task("db:query", {
sql: `UPDATE freight.train_schedules
SET window_opens_at = LEAST(window_opens_at, now()),
-- The e2e rules run a 1.002-minute window duration, so the
-- CREATE-time close for a departing-today schedule is already
-- in the past — hold the close out or the next 10s tick slams
-- the window shut mid-flow.
window_closes_at = GREATEST(window_closes_at, now() + interval '45 minutes'),
window_phase = 'OPEN',
booking_window_status = 'OPEN'
WHERE id = $1 AND booking_window_status <> 'FULL'`,
params: [rows[0].id],
});
});
}
// Belt-and-braces: confirm the engine keeps them OPEN.
const waitForOpen = (trainCode: string, attempt: number) => {
dbScheduleFor(trainCode).then(({ rows }) => {
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
if (rows[0].booking_window_status === "OPEN") return;
expect(attempt, `${trainCode} window OPEN`).to.be.lessThan(60);
cy.wait(10000).then(() => waitForOpen(trainCode, attempt + 1));
});
};
waitForOpen(TRAIN_W, 0);
waitForOpen(TRAIN_F, 0);
});
// ── Tolerance train (TRN-SEG-W) ──────────────────────────────────────────
it("customer books 130T of wheat Mojo→Djibouti", () => {
cy.loginPortal(customer);
dbContractId(REF.exportMojo).then((id) =>
cy.visitPortal(`/contracts/${id}/bookings/new`),
);
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
fill(/^Quantity \(tons\)/, "130");
pickShipmentDay(DEPART_W);
cy.fillCargoDescription();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
});
it("the wheat lands on the tolerance train and allocates", () => {
acceptOperationRequest(REF.exportMojo);
// FCFS picks the earliest fitting train of the day — the W train.
withSchedule(TRAIN_W, (s) => {
withBooking(REF.exportMojo, (b) => {
expect(b.status).to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id, "reserved on the tolerance train").to.eq(s.id);
});
});
markPaidAndAssertAllocated(REF.exportMojo);
});
it("customer books the first intercity pair Mojo→Dire", () => {
bookIntercityPair(REF.ic1, 0);
});
it("the ride-along boards the shared leg through the overage tolerance", () => {
// Mojo→Dire now carries 179.6T (wheat). Adding 70.4T (2×20ft VGM 24 on
// one NW5) puts the leg at 250T — over the 240T base, inside 240+90.
// Before the tolerance fix the second loco's NULL tolerance zeroed the
// set and this exact allocation failed at "3500" scale.
acceptOperationRequest(REF.ic1);
acceptRideAlong(TRAIN_W, REF.ic1);
withSchedule(TRAIN_W, (s) => {
withBooking(REF.ic1, (b) => {
expect(b.status).to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id).to.eq(s.id);
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at).getTime(),
);
});
});
markPaidAndAssertAllocated(REF.ic1);
});
it("customer books the second intercity pair Mojo→Dire", () => {
bookIntercityPair(REF.ic2, 2);
});
it("a second ride-along still fits whole — tolerance spends per booking, not once", () => {
// 250T + 70.4T = 320.4T on Mojo→Dire — still under the 330T ceiling.
acceptOperationRequest(REF.ic2);
acceptRideAlong(TRAIN_W, REF.ic2);
markPaidAndAssertAllocated(REF.ic2);
// The border edge (Dire→Djibouti) still has room, so the tolerance
// train's window must NOT be FULL — fullness is directional.
withSchedule(TRAIN_W, (s) => {
expect(s.booking_window_status, "W window stays open").to.eq("OPEN");
});
});
it("the schedule detail strip shows per-leg gross against the tolerance ceiling", () => {
cy.loginBackoffice(opsStaff);
withSchedule(TRAIN_W, (s) =>
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
);
// Mojo→Dire: 179.6 (wheat) + 70.4 + 70.4 (ride-alongs) = 320.4T gross;
// ceiling = 240 base + 90 tolerance = 330T (the weakest CONFIGURED
// tolerance governs — the second loco has none set).
cy.contains("320.4 / 330 T gross", { timeout: 30000 }).should("exist");
// Border leg carries only the wheat.
cy.contains("179.6 / 330 T gross").should("exist");
});
// ── Border-full train (TRN-SEG-F) ────────────────────────────────────────
it("customer books an 8×20ft export from the MID yard", () => {
cy.loginPortal(customer);
dbContractId(REF.exportDire).then((id) =>
cy.visitPortal(`/contracts/${id}/bookings/new`),
);
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
fill(/^Quantity/, "8");
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 8);
for (let i = 0; i < 8; i += 1) {
cy.get('input[placeholder*="MSCU"]').eq(i).type(isoNumber("MSCU", 10 + i));
}
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input).clear({ force: true }).type("10", { force: true });
});
pickShipmentDay(DEPART_F);
cy.fillCargoDescription();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
});
it("the export fills the border edge — the window goes FULL for the direction", () => {
acceptOperationRequest(REF.exportDire);
// 8×20ft = 4 wagons. W's border edge has only 2 wagons free (the wheat
// holds the other 2), so FCFS lands this Dire→Djibouti sub-corridor
// booking on the F train — all 4 of its wagons, but only past Dire.
withSchedule(TRAIN_F, (s) => {
withBooking(REF.exportDire, (b) => {
expect(b.status).to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id, "reserved on the border-full train").to.eq(s.id);
});
});
markPaidAndAssertAllocated(REF.exportDire);
// Every wagon on the border edge is committed: the train is FULL for
// its trade direction even though Mojo→Dire runs completely empty.
const waitForFull = (attempt: number) => {
dbScheduleFor(TRAIN_F).then(({ rows }) => {
if (rows[0]?.booking_window_status === "FULL") return;
expect(attempt, "F window FULL").to.be.lessThan(20);
cy.wait(3000).then(() => waitForFull(attempt + 1));
});
};
waitForFull(0);
});
it("customer books the third intercity pair Mojo→Dire", () => {
bookIntercityPair(REF.ic3, 4);
});
it("the FULL train still accepts and allocates an intercity ride-along on its free leg", () => {
// Mojo→Dire on the F train is empty (the export boards at Dire): the
// ride-along uses the SAME wagons there and alights before they load.
// The old whole-train sum — 169.6 + 70.4 = 240T > 200T pull — rejected
// this; per-edge math sees 70.4T on Mojo→Dire and 169.6T on the border,
// both within the cap. The FULL flag closes the export window only.
acceptOperationRequest(REF.ic3);
acceptRideAlong(TRAIN_F, REF.ic3);
withSchedule(TRAIN_F, (s) => {
withBooking(REF.ic3, (b) => {
expect(b.status).to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id, "accepted onto the FULL train").to.eq(s.id);
});
});
// Mark paid: PAID + SCHEDULED + linked. Wagon allocation is asserted
// only as far as today's model supports: physical wagon pinning is
// slot-exclusive (one wagon serves ONE slot), so the same wagon cannot
// yet be pinned to the intercity's Mojo→Dire slot AND the export's
// Dire→Nagad slot even though the per-edge budget admits both. KNOWN
// GAP — when wagon↔slot pinning becomes leg-aware, restore
// markPaidAndAssertAllocated(REF.ic3) here.
withBooking(REF.ic3, (b) => {
cy.apiLogin(opsStaff).then(({ token }) => {
cy.request({
method: "POST",
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
withBooking(REF.ic3, (b) => {
expect(b.status).to.eq("PAID");
expect(b.scheduling_status).to.eq("SCHEDULED");
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND deleted_at IS NULL`,
params: [b.id],
}).then(({ rows }) => {
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
});
});
// The ride-along never reopens the export window.
withSchedule(TRAIN_F, (s) => {
expect(s.booking_window_status, "F window stays FULL").to.eq("FULL");
});
});
},
);
export {};