From 664906b8ce04de664ae460a364d31877d637ce1a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 27 Sep 2026 20:18:56 +0000 Subject: [PATCH] =?UTF-8?q?e2e:=20per-test=20timescale=20speedup=20?= =?UTF-8?q?=E2=80=94=20FAST/CHAIN=20rates=20for=20pure=20arrival=20waits?= =?UTF-8?q?=20(v1.8.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/hover/two-planes: FAST=10_000 for expiry fast-forwards, landing waits and fly-home cleanups; queue: + CHAIN=4_000 for auto-chain observation, refuel test at explicit 2_000 with relaxed fuel bound - map-interactions: FAST=50_000 chain hops (~3 min -> ~15 s per direction); crossing leg stays at SCALE and now sets it BEFORE dispatch (a leftover fast rate would let the dispatch tick consume most of the leg) - hover fleet-row test asserts flying from the fly endpoint's own snapshot and freezes immediately instead of speeding up then freezing mid-leg - two-planes parallel-flight: both-grounded poll after cleanup dispatches so afterEach heal no longer pays the range-edge return leg at SCALE --- CHANGELOG.md | 7 ++++++ e2e/actions.spec.ts | 11 +++++----- e2e/hover.spec.ts | 40 ++++++++++++++++++++--------------- e2e/map-interactions.spec.ts | 36 ++++++++++++++++++------------- e2e/queue.spec.ts | 41 +++++++++++++++++++++++------------- e2e/two-planes.spec.ts | 18 ++++++++++++---- package.json | 2 +- 7 files changed, 98 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6272a..6db5115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.8.0 — 2026-09-27 20:13 +- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg). +- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits. +- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path). +- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions. +- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE. + ## v1.7.0 — 2026-09-26 20:24 - **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it). - **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts. diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 867d5be..053db56 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -20,6 +20,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); }); // penalty. Mirrors lib/game/penalty.ts for exact-value assertions. const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time +const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) @@ -334,7 +335,7 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page }); test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { - test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait + test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait await flyHomeViaApi(page); // A: load, let it expire while loaded, then FLY to its destination — arrival must @@ -350,12 +351,12 @@ test('expired packages deliver and abandon with no reward and no penalty', async } // A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry - // window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why + // window so scale 10_000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why // the cash/rep baseline below is captured AFTER deactivateWorld, not before it. await activateWorld(page); try { // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). - await setScale(page, SCALE); + await setScale(page, FAST); let s = await getState(page); await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired'); s = await getState(page); @@ -387,9 +388,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing - // Restore: top up + fly home at SCALE. + // Restore: top up + fly home at FAST (pure arrival wait). await setFuelFull(page, 'plane-starter'); - await setScale(page, SCALE); + await setScale(page, FAST); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(homeRes.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 0b4e438..cc77cff 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -17,6 +17,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); }); // additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time +const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. @@ -516,7 +517,7 @@ test('hovering the Relocate section previews the planned route and target airpor }); test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => { - test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup + test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup await flyHomeViaApi(page); // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). @@ -525,23 +526,26 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p await page.goto('/'); await waitForAirportCount(page); - // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up. + // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN + // post-action snapshot already shows the plane flying — no poll race. const pkg = await createPackage(page, HUB, DEST); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(load.status()).toBe(200); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); expect(fly.status()).toBe(200); - await setScale(page, SCALE); + const flyBody = (await fly.json()) as StateBody; + expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying'); + + // Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and + // empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state + // for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover + // test below). + await setScale(page, 1); // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054). const block = page.getByTestId('plane-plane-starter'); await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); - // Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as - // e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and - // the route line stays drawn while frozen. - await setScale(page, 1); - await block.hover(); await expect.poll(async () => { const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record }>; @@ -554,9 +558,10 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); - // Resume and fast-forward to arrival, then fly home so the live instance stays in its - // onboarding state (starter grounded at EDDF) for the other specs. - await setScale(page, SCALE); + // Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance + // stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure + // arrivals → FAST (~1 s each instead of ~10 s). + await setScale(page, FAST); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); @@ -564,8 +569,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) .toBe(true); - // Re-set SCALE for the return leg. - await setScale(page, SCALE); + // Re-set FAST for the return leg. + await setScale(page, FAST); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect @@ -577,7 +582,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p }); test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => { - test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup + test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup await flyHomeViaApi(page); // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). @@ -651,7 +656,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t // not DEST — it never delivered), then fly home. const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(cancelRes.status()).toBe(200); - await setScale(page, SCALE); + // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST. + await setScale(page, FAST); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); @@ -662,8 +668,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it - // Re-set SCALE for the return leg. - await setScale(page, SCALE); + // Re-set FAST for the return leg (~1 s instead of ~10 s). + await setScale(page, FAST); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 3b20e3d..e177165 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -20,6 +20,9 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts // the 6300 L tank, so a full-tank departure always makes it home. const DEST = 'LEMD'; const SCALE = 1000; // test rate for fast-forwarding legs +// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish +// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate. +const FAST = 50_000; const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts interface PlaneState { @@ -106,15 +109,15 @@ async function setFuelFull(page: Page, planeId: string): Promise { expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } -// Load a package onto plane-starter and fly it from → to via the API, then poll at -// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts. +// Load a package onto plane-starter and fly it from → to via the API, then fast-forward at FAST +// (pure arrival wait — no mid-leg observation) until the plane lands at `to`. Same pattern as e2e/actions.spec.ts. async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise { const pkg = await createPackage(page, fromIcao, toIcao); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(load.status()).toBe(200); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } }); expect(fly.status()).toBe(200); - await setScale(page, SCALE); + await setScale(page, FAST); await expect .poll(async () => { const body = await getState(page); @@ -204,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async ( }); test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => { - test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup + test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup await page.goto('/'); await waitForAirportCount(page); @@ -215,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route', const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter'); expect(foundStarter).toBeDefined(); if (foundStarter!.status === 'flying') { - await setScale(page, SCALE); + await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 240_000, @@ -348,8 +351,8 @@ test('plane markers move while flying and the plane popup shows status + route', // The popup's route line is the plane's own leg: origin name → destination name. await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`); - // Restore SCALE before step 5 — the landing wait needs a fast clock. - await setScale(page, SCALE); + // Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s. + await setScale(page, FAST); // 5) Let the flight finish, then fly home so the live instance stays in its // onboarding state (starter grounded at EDDF) for the other specs. The loaded @@ -478,7 +481,7 @@ async function fitToAirports(page: Page, icaos: string[]): Promise { } test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { - test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin + test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin await page.goto('/'); await waitForAirportCount(page); @@ -489,7 +492,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' // Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain. expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') { - await setScale(page, SCALE); + await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); } // Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end @@ -500,7 +503,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' for (const next of await chainTo(page, loc, ORIGIN)) { await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); - await setScale(page, SCALE); + await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); } } @@ -521,9 +524,12 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' const pkg = await createPackage(page, ORIGIN, CROSS_DEST); expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); + // The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the + // >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST + // rate would let the dispatch tick itself consume most of the leg before this setScale lands. + await setScale(page, SCALE); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } }); if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`); - await setScale(page, SCALE); console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); // querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this @@ -591,16 +597,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180 expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window } finally { - // Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical - // state. The package was delivered on arrival (generous deadline). - await setScale(page, SCALE); + // Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other + // specs start from the canonical state. The package was delivered on arrival (generous deadline). + await setScale(page, FAST); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; if (endLoc !== HUB) { for (const next of await chainTo(page, endLoc, HUB)) { await setFuelFull(page, 'plane-starter'); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); - await setScale(page, SCALE); + await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); } } diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 152cb84..2955043 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -20,6 +20,12 @@ test.beforeEach(async ({ page }) => { await authenticate(page); }); // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time +// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale — +// a lagged tick can only finish the leg sooner, never skip an observed state. +const FAST = 10_000; +// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's +// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain. +const CHAIN = 4_000; const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000) @@ -285,14 +291,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => { await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. - await setScale(page, SCALE); + // Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here. + await setScale(page, FAST); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === DEST; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); - // Re-set SCALE for the return leg. - await setScale(page, SCALE); + // Re-set FAST for the return leg. + await setScale(page, FAST); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { @@ -366,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as // to arrival, then fly home. const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(cancelRes.status()).toBe(200); - await setScale(page, SCALE); + // Pure arrival waits — FAST is safe and ~10× quicker than SCALE here. + await setScale(page, FAST); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === DEST; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); - // Re-set SCALE for the return leg. - await setScale(page, SCALE); + // Re-set FAST for the return leg. + await setScale(page, FAST); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { @@ -457,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async expect(flyRes.status()).toBe(200); // Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued - // return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a - // poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. - await setScale(page, SCALE); + // return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at + // CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. + await setScale(page, CHAIN); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'flying' && p.destIcao === HUB; @@ -566,8 +574,10 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; - // Landing at DEST auto-chains QUEUE with the top-up… - await setScale(page, SCALE); + // Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must + // stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the + // snapshot window below is sized for this rate. + await setScale(page, 2_000); await expect.poll(async () => { const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); return !!x && x.status === 'flying' && x.destIcao === QUEUE; @@ -577,9 +587,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou // Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below. const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel; expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure… - // 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn; - // a wrong top-up level (≤ required2, or none at all) still lands far below this bound. - expect(fuel).toBeGreaterThan(1.3 * required2); + // > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind + // load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound. + expect(fuel).toBeGreaterThan(required2); expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost // Let leg 2 finish so the next test starts from a known airport. @@ -603,7 +613,8 @@ test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0) const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(cancel.status()).toBe(200); - await setScale(page, SCALE); + // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST. + await setScale(page, FAST); await expect.poll(async () => { const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); return !!x && x.status === 'available' && x.locationIcao === DEST; diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts index 2c93346..305608a 100644 --- a/e2e/two-planes.spec.ts +++ b/e2e/two-planes.spec.ts @@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); }); // guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after. const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time +const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts) @@ -349,7 +350,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke }); test('two planes fly in parallel and both deliver their packages', async ({ page }) => { - test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000 + test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000× const secondId = await buySecondPlaneViaUi(page); // Full tanks for both — the starter's may be partial from an earlier run. @@ -379,8 +380,9 @@ test('two planes fly in parallel and both deliver their packages', async ({ page expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying'); expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao); - // Fast-forward to the FIRST landing (the short hop)… - await setScale(page, SCALE); + // Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a + // lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate. + await setScale(page, FAST); await expect .poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), { timeout: 240_000, @@ -403,9 +405,17 @@ test('two planes fly in parallel and both deliver their packages', async ({ page // The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook). await setFuelFull(page, secondId); - // Cleanup: fly both home so the live instance stays in its onboarding state for later specs. + // Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs + // dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the + // range-edge return leg at SCALE (~24 s). expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); + await expect + .poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), { + timeout: 120_000, + message: 'waiting for both planes to land back at the hub', + }) + .toBe(true); }); // ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI — diff --git a/package.json b/package.json index f00f6e6..ed54820 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.7.0", + "version": "1.8.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0