test: harden full-core-loop against Load/Fly precondition races
CI / build + unit + e2e (pull_request) Successful in 6m32s

This commit is contained in:
2026-09-19 13:36:27 +00:00
parent 687e4f2169
commit ae1cb93a4f
2 changed files with 28 additions and 6 deletions
+1
View File
@@ -13,6 +13,7 @@
- e2e (`right-pane.spec.ts`): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". `e2e/api.spec.ts` path list unchanged - e2e (`right-pane.spec.ts`): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". `e2e/api.spec.ts` path list unchanged
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack - Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
- **Fix (CI run #40): forFlight refuel leaves an exactly-feasible tank, and the new e2e's premise corrected.** The right-pane e2e picked the dataset's absolute farthest airport from EDDF — UNNT at ~4803 km, whose leg needs ~6955 L > the 6300 L tank (max range from empty is ~4436 km) — so the full-tank fly rightly 409'd; `farthestToHub` becomes `farthestFlyableToHub`, picking the farthest airport whose leg still fits a full tank (UWUU, ~3195 km). Server hardening in the same area: forFlight now sets fuel to EXACTLY `requiredFuelFor(...)` instead of `fuel + liters` — that sum is not guaranteed ≥ R in IEEE754 (`f + fl(R − f)` can round a hair below), and flyPlane's strict comparison would then 409 on an otherwise-feasible leg; cost math unchanged (still the added liters × price). New unit regression test: forFlight on an empty-ish tank → immediate `flyPlane` to the package destination succeeds - **Fix (CI run #40): forFlight refuel leaves an exactly-feasible tank, and the new e2e's premise corrected.** The right-pane e2e picked the dataset's absolute farthest airport from EDDF — UNNT at ~4803 km, whose leg needs ~6955 L > the 6300 L tank (max range from empty is ~4436 km) — so the full-tank fly rightly 409'd; `farthestToHub` becomes `farthestFlyableToHub`, picking the farthest airport whose leg still fits a full tank (UWUU, ~3195 km). Server hardening in the same area: forFlight now sets fuel to EXACTLY `requiredFuelFor(...)` instead of `fuel + liters` — that sum is not guaranteed ≥ R in IEEE754 (`f + fl(R − f)` can round a hair below), and flyPlane's strict comparison would then 409 on an otherwise-feasible leg; cost math unchanged (still the added liters × price). New unit regression test: forFlight on an empty-ish tank → immediate `flyPlane` to the package destination succeeds
- **E2e flake hardening (runs #36/#41): full-core-loop Load/Fly precondition races** — test mechanics only, no app code changes. Two independent races in `e2e/actions.spec.ts`: (1) the Fly click fires its POST asynchronously (`runAction` is fire-and-forget from the test's view), so the immediate `getState` could race ahead of it and still see status 'available' — now an `expect.poll` waits until the server shows 'flying' with the expected destIcao before proceeding; (2) `flyHomeViaApi` made its repositioning decision from a snapshot taken BEFORE waiting for a leftover in-flight leg to land — while flying, locationIcao stays at the departure airport, so a plane that landed away from home was still believed to be home and never repositioned (run #41 retry: starter stranded at EDFH) — state is now re-read after landing; plus an explicit server-side precondition poll (starter grounded at the package origin) before clicking Load
## v0.15.1 — 2026-09-19 11:04 ## v0.15.1 — 2026-09-19 11:04
- **Right pane widened, "On board" wrapping fixed (ISS-004).** The 3-pane shell grid (`app/globals.css` `.app-shell`) moves from `300px / 1fr / 320px` to `280px / 1fr / 360px` — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed: - **Right pane widened, "On board" wrapping fixed (ISS-004).** The 3-pane shell grid (`app/globals.css` `.app-shell`) moves from `300px / 1fr / 320px` to `280px / 1fr / 360px` — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed:
+27 -6
View File
@@ -100,15 +100,21 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so // (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run. // exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> { async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page); let s = await getState(page);
const plane = s0.planes.find((p) => p.id === 'plane-starter')!; let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) { if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE); await setScale(page, SCALE);
} }
if (plane.status === 'flying') { if (plane.status === 'flying') {
// Let the in-flight leg finish before repositioning. // Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
// DEPARTURE airport, so the pre-landing snapshot can't tell us where the plane ends up — re-read
// state after landing before deciding whether a repositioning flight is needed (run #41 retry: a
// leftover EDDF→EDFH leg landed away from home but the helper still believed it was home and
// skipped the fly-home).
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
} }
if (plane.locationIcao !== HUB) { if (plane.locationIcao !== HUB) {
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
@@ -143,6 +149,14 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
expect(pkg.originIcao).toBe(HUB); expect(pkg.originIcao).toBe(HUB);
expect(pkg.destIcao).toBe(nearest.icao); expect(pkg.destIcao).toBe(nearest.icao);
// Precondition (server truth) before clicking Load: the starter must be grounded at the package's
// origin — that is exactly what makes it load-eligible, and a stale pane snapshot can't prove it.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'available' && p.locationIcao === pkg.originIcao;
}, { timeout: 30_000, message: 'waiting for plane-starter to be grounded at the package origin (self-heal incomplete?)' }).toBe(true);
// LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button. // LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button.
await page.goto('/'); await page.goto('/');
const loadBtn = page.getByTestId(`load-${pkg.id}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
@@ -159,10 +173,17 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
await expect(flyBtn).toBeEnabled(); await expect(flyBtn).toBeEnabled();
await flyBtn.click(); await flyBtn.click();
let s = await getState(page);
const planeId = 'plane-starter'; const planeId = 'plane-starter';
expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying'); // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view),
expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao); // so a getState issued immediately can race ahead of it and still see 'available' — poll until the
// fly actually took effect on the server (the run #36/#41 flake).
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === planeId)!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
let s = await getState(page);
// Fast-forward to arrival (a few seconds of real time at 1000×). // Fast-forward to arrival (a few seconds of real time at 1000×).
await setScale(page, SCALE); await setScale(page, SCALE);