Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
This commit is contained in:
+13
-23
@@ -14,7 +14,8 @@ import { windBurnFactor } from '../lib/sim/movement';
|
||||
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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)
|
||||
|
||||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
||||
@@ -39,7 +40,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||
packages: Array<{
|
||||
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
||||
@@ -69,11 +70,6 @@ async function createPackage(
|
||||
return (await res.json()) as StateBody['packages'][number];
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: body });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
@@ -82,15 +78,13 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
||||
@@ -110,10 +104,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||
}
|
||||
|
||||
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
|
||||
// Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
|
||||
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await postSim(page, { timeScale: 1, paused: false });
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
@@ -142,8 +136,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -255,8 +248,7 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
||||
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
||||
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
||||
|
||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
@@ -345,8 +337,7 @@ 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. ISS-041: the LEMD arrival above paused the sim, so
|
||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
||||
// Restore: top up + fly home at SCALE.
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
@@ -386,8 +377,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||
expect(res.status()).toBe(409);
|
||||
|
||||
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
|
||||
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
// Fly home from the hop so the fuel section starts at the hub, at SCALE.
|
||||
await setScale(page, SCALE);
|
||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
+1
-2
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
@@ -32,7 +32,6 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
||||
'/api/planes/{planeId}/queue',
|
||||
'/api/planes/{planeId}/queue/cancel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
]);
|
||||
|
||||
+21
-26
@@ -11,7 +11,8 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
|
||||
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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.
|
||||
const DEST = 'LEMD';
|
||||
@@ -41,7 +42,7 @@ interface PackageState {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
}
|
||||
@@ -52,24 +53,18 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
@@ -106,8 +101,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -472,8 +466,9 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
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): the route line stays drawn while paused.
|
||||
await postSim(page, { paused: true });
|
||||
// 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 () => {
|
||||
@@ -489,7 +484,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
|
||||
// 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 postSim(page, { timeScale: SCALE, paused: false });
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
@@ -497,7 +492,7 @@ 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);
|
||||
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
@@ -535,10 +530,10 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
||||
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
|
||||
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
|
||||
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
|
||||
// airport regardless of progress, so no mid-flight position window is needed at all.
|
||||
await postSim(page, { paused: true });
|
||||
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. At
|
||||
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
|
||||
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
|
||||
await setScale(page, 1);
|
||||
|
||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||
@@ -584,7 +579,7 @@ 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); // also clears the pause above
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
@@ -595,7 +590,7 @@ 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
|
||||
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
|
||||
+10
-10
@@ -10,7 +10,8 @@ import { REPAIR_COST } from '../lib/game/catalog';
|
||||
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
||||
// passes.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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';
|
||||
|
||||
@@ -35,7 +36,7 @@ interface CatalogEntry {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
catalog: CatalogEntry[];
|
||||
}
|
||||
@@ -46,13 +47,13 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// a silent reset would otherwise surface as an unexplained wait timeout.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||
@@ -125,7 +126,7 @@ async function healStarterHome(page: Page): Promise<void> {
|
||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||
// Make sure the world rate is back at SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||
// leg when cash allows — no top-up retry needed.
|
||||
@@ -152,11 +153,10 @@ async function restoreStarter(page: Page): Promise<void> {
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||
// On exit (success or failure): restore the fixed world rate for the other specs (house convention)
|
||||
// and leave a clean starter — full health, no active repair gate.
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
await restoreStarter(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
|
||||
// the 6300 L tank, so a full-tank departure always makes it home.
|
||||
const DEST = 'LEMD';
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top)
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
@@ -40,7 +41,7 @@ interface PackageState {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
}
|
||||
@@ -64,24 +65,18 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
@@ -235,7 +230,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(fly.status()).toBe(200);
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
await setScale(page, SCALE);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -258,10 +253,11 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
|
||||
|
||||
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
|
||||
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered
|
||||
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift
|
||||
// on this leg. Pausing makes the position static; we resume right after the popup checks.
|
||||
await postSim(page, { paused: true });
|
||||
// Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
|
||||
// our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
|
||||
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
|
||||
// we restore SCALE right after the popup checks.
|
||||
await setScale(page, 1);
|
||||
let prevPos: { lat: number; lon: number } | null = null;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -270,11 +266,11 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
|
||||
prevPos = { lat: p.lat, lon: p.lon };
|
||||
return false;
|
||||
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' })
|
||||
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to settle the plane position' })
|
||||
.toBe(true);
|
||||
|
||||
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused
|
||||
// above the server's analytic position is static — but the map renders from its own ~1 s
|
||||
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
|
||||
// above the server's analytic position barely moves — but the map renders from its own ~1 s
|
||||
// poll, so right after the freeze confirmation the last rendered frame can still be up to
|
||||
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
|
||||
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
|
||||
@@ -314,8 +310,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}`);
|
||||
|
||||
// Resume before step 5 — the landing wait needs a running clock.
|
||||
await postSim(page, { timeScale: SCALE, paused: false });
|
||||
// Restore SCALE before step 5 — the landing wait needs a fast clock.
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// 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
|
||||
|
||||
+73
-72
@@ -1,20 +1,14 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
|
||||
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
|
||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
|
||||
interface SimState {
|
||||
clockMs: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const STARTER = 'plane-starter';
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: SimState;
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: Array<Record<string, unknown>>;
|
||||
packages: Array<Record<string, unknown>>;
|
||||
}
|
||||
@@ -25,11 +19,42 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
async function activateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||
async function deactivateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
|
||||
timeout: 30_000,
|
||||
message: 'waiting for the starter to flip back to available',
|
||||
})
|
||||
.toBe('available');
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
|
||||
// pattern as e2e/sim.spec.ts.
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
||||
@@ -79,71 +104,47 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
// Refuel heading or row anymore.
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||
|
||||
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
|
||||
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
|
||||
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Fastest scale so game seconds move visibly inside the sampling window.
|
||||
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
|
||||
expect(post.status()).toBe(200);
|
||||
|
||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
|
||||
const sample = async () => {
|
||||
const text = ((await clock.textContent()) ?? '').trim();
|
||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||
};
|
||||
|
||||
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
|
||||
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
|
||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||
const t0 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t1 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t2 = await sample();
|
||||
|
||||
expect(t1).toBeGreaterThan(t0);
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
|
||||
// still catching a frozen or wildly mis-scaled clock.
|
||||
const total = t2 - t0;
|
||||
expect(total).toBeGreaterThanOrEqual(1_000_000);
|
||||
expect(total).toBeLessThanOrEqual(6_000_000);
|
||||
});
|
||||
|
||||
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
|
||||
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto('/');
|
||||
const slider = page.getByTestId('time-scale-slider');
|
||||
await expect(slider).toBeVisible({ timeout: 15_000 });
|
||||
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Commit to max via keyboard (input + change → immediate commitNow POST)…
|
||||
await slider.focus();
|
||||
await page.keyboard.press('End');
|
||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
|
||||
const sample = async () => {
|
||||
const text = ((await clock.textContent()) ?? '').trim();
|
||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||
};
|
||||
|
||||
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
|
||||
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
|
||||
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
|
||||
const seen: string[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
|
||||
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
|
||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||
const t0 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t1 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t2 = await sample();
|
||||
|
||||
expect(t1).toBeGreaterThan(t0);
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
|
||||
// still catching a frozen or wildly mis-scaled clock.
|
||||
const total = t2 - t0;
|
||||
expect(total).toBeGreaterThanOrEqual(50_000);
|
||||
expect(total).toBeLessThanOrEqual(200_000);
|
||||
} finally {
|
||||
await deactivateWorld(page);
|
||||
}
|
||||
expect(
|
||||
seen.every((v) => v === '1000×'),
|
||||
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
|
||||
).toBe(true);
|
||||
|
||||
// …and the server confirms it.
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
||||
|
||||
// Restore to min for later tests (afterEach restores scale anyway).
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
||||
|
||||
+14
-19
@@ -14,7 +14,8 @@ import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/move
|
||||
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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)
|
||||
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||||
@@ -40,7 +41,7 @@ interface PackageState {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
}
|
||||
@@ -51,23 +52,17 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
@@ -119,7 +114,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -259,7 +254,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
||||
await setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
|
||||
|
||||
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||
@@ -286,7 +281,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
@@ -316,8 +311,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
||||
const flyBody = (await flyRes.json()) as StateBody;
|
||||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||
|
||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
||||
await postSim(page, { paused: true });
|
||||
// Freeze mid-flight (scale 1) so the ~10 s leg can't land under us while we assert.
|
||||
await setScale(page, 1);
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
@@ -367,7 +362,7 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
|
||||
+9
-16
@@ -9,7 +9,8 @@ import { expect, test } from '@playwright/test';
|
||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
||||
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)
|
||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
|
||||
// away at the initial zoom, so a click on its projected center cannot hit another circle.
|
||||
@@ -33,20 +34,13 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: body });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
|
||||
@@ -83,8 +77,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -93,10 +86,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
|
||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
||||
// fast-forward + reposition home here; then restore the fixed world rate for later specs.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await flyHomeViaApi(page);
|
||||
await postSim(page, { timeScale: 1, paused: false });
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
+22
-24
@@ -18,7 +18,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
|
||||
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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
|
||||
|
||||
interface PlaneState {
|
||||
@@ -57,7 +58,7 @@ interface CatalogEntry {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
catalog: CatalogEntry[];
|
||||
@@ -74,24 +75,18 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
@@ -168,9 +163,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
|
||||
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
|
||||
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||
await setScale(page, SCALE);
|
||||
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -274,9 +268,15 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
||||
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
||||
// making the preview deterministic instead of racing the 1 s poll loop.
|
||||
await postSim(page, { paused: true });
|
||||
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
|
||||
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
|
||||
const frozenClock = (await getState(page)).simState.clockMs;
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||||
expect(
|
||||
(await getState(page)).simState.clockMs,
|
||||
'world should be frozen while the fleet is idle (ISS-117)',
|
||||
).toBe(frozenClock);
|
||||
const state = await getState(page);
|
||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||
@@ -297,8 +297,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||
|
||||
// Resume and fast-forward to arrival (poll the authoritative state).
|
||||
await postSim(page, { paused: false });
|
||||
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -313,8 +312,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
// Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.status()).toBe(200);
|
||||
|
||||
+123
-103
@@ -1,157 +1,177 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
||||
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
|
||||
// hook enabled in docker-compose.yml), and market top-up.
|
||||
// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
|
||||
// fleet, background-tick advance at a raised rate while something is active, package expiry via
|
||||
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
|
||||
|
||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
|
||||
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
||||
const MARKET_TARGET = 10;
|
||||
|
||||
interface SimState {
|
||||
clockMs: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
const STARTER = 'plane-starter';
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: SimState;
|
||||
planes: Array<Record<string, unknown>>;
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: Array<{ id: string; status: string }>;
|
||||
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
async function activateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||
async function deactivateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
|
||||
timeout: 30_000,
|
||||
message: 'waiting for the starter to flip back to available',
|
||||
})
|
||||
.toBe('available');
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
||||
const post = await postSim(page, { timeScale: SCALE });
|
||||
expect(post.status()).toBe(200);
|
||||
const s1 = (await post.json()) as SimState;
|
||||
expect(s1.timeScale).toBe(SCALE);
|
||||
expect(s1.paused).toBe(false);
|
||||
|
||||
let prev = s1.clockMs;
|
||||
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
|
||||
const s = await getState(page);
|
||||
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||
const frozen = s.simState.clockMs;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
||||
const body = await getState(page);
|
||||
const now = body.simState.clockMs;
|
||||
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
|
||||
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
||||
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
||||
prev = now;
|
||||
await sleep(500);
|
||||
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
|
||||
}
|
||||
});
|
||||
|
||||
test('paused freezes the clock exactly across polls', async ({ page }) => {
|
||||
const post = await postSim(page, { timeScale: SCALE, paused: true });
|
||||
expect(post.status()).toBe(200);
|
||||
const s1 = (await post.json()) as SimState;
|
||||
expect(s1.paused).toBe(true);
|
||||
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
||||
await activateWorld(page);
|
||||
try {
|
||||
await setScale(page, SCALE);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(500);
|
||||
const body = await getState(page);
|
||||
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused
|
||||
let prev = (await getState(page)).simState.clockMs;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
||||
const now = (await getState(page)).simState.clockMs;
|
||||
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
|
||||
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
||||
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
||||
prev = now;
|
||||
}
|
||||
} finally {
|
||||
await deactivateWorld(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await activateWorld(page);
|
||||
try {
|
||||
const before = await getState(page);
|
||||
const victim = before.packages.find((p) => p.status === 'open');
|
||||
expect(victim).toBeDefined(); // steady state always has open packages
|
||||
|
||||
const before = await getState(page);
|
||||
const victim = before.packages.find((p) => p.status === 'open');
|
||||
expect(victim).toBeDefined(); // steady state always has open packages
|
||||
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
||||
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
|
||||
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||
expect(hook.status()).toBe(200);
|
||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
||||
expect(moved.id).toBe(victim!.id);
|
||||
|
||||
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
||||
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
|
||||
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||
expect(hook.status()).toBe(200);
|
||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
||||
expect(moved.id).toBe(victim!.id);
|
||||
await setScale(page, SCALE);
|
||||
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
let expiredBody: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
let expiredBody: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
const after = expiredBody!;
|
||||
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
||||
// Expired packages leave the available set…
|
||||
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
||||
// …and spawn tops the market back up to the target — never below it.
|
||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
||||
const after = expiredBody!;
|
||||
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
||||
// Expired packages leave the available set…
|
||||
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
||||
// …and spawn tops the market back up to the target — never below it.
|
||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
||||
} finally {
|
||||
await deactivateWorld(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await activateWorld(page);
|
||||
try {
|
||||
const created = await page.request.post('/api/test/expire-package');
|
||||
expect(created.status()).toBe(201);
|
||||
const c = (await created.json()) as { id: string; status: string };
|
||||
expect(c.status).toBe('open');
|
||||
|
||||
const created = await page.request.post('/api/test/expire-package');
|
||||
expect(created.status()).toBe(201);
|
||||
const c = (await created.json()) as { id: string; status: string };
|
||||
expect(c.status).toBe('open');
|
||||
await setScale(page, SCALE);
|
||||
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
let expiredBody: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
let expiredBody: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
||||
} finally {
|
||||
await deactivateWorld(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/sim validates and clamps its input', async ({ page }) => {
|
||||
const clampHigh = await postSim(page, { timeScale: 2000 });
|
||||
expect(clampHigh.status()).toBe(200);
|
||||
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0)
|
||||
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
|
||||
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
|
||||
expect(ok.status()).toBe(200);
|
||||
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
|
||||
|
||||
const inRange = await postSim(page, { timeScale: 500 });
|
||||
expect(inRange.status()).toBe(200);
|
||||
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
|
||||
|
||||
const clampLow = await postSim(page, { timeScale: 0.1 });
|
||||
expect(clampLow.status()).toBe(200);
|
||||
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
|
||||
|
||||
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
|
||||
const res = await postSim(page, body);
|
||||
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: body });
|
||||
expect(res.status()).toBe(400);
|
||||
}
|
||||
const notJson = await page.request.post('/api/sim', { data: 'nope{' });
|
||||
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
|
||||
expect(notJson.status()).toBe(400);
|
||||
|
||||
// Empty body is a valid no-op (just ticks + returns current state).
|
||||
const empty = await postSim(page, {});
|
||||
expect(empty.status()).toBe(200);
|
||||
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
|
||||
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
|
||||
expect(empty.status()).toBe(400);
|
||||
});
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
|
||||
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
|
||||
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
|
||||
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
|
||||
interface SimState {
|
||||
clockMs: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
simState: SimState;
|
||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: body });
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
|
||||
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as SimState;
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
}
|
||||
|
||||
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
|
||||
async function nearestToHub(page: Page): Promise<{ icao: string }> {
|
||||
const res = await page.request.get('/api/airports');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||
for (const f of fc.features) {
|
||||
if (f.properties.icao === HUB) continue;
|
||||
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
||||
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
||||
if (d < dn) nearest = f.properties;
|
||||
}
|
||||
return { icao: nearest.icao };
|
||||
}
|
||||
|
||||
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
|
||||
// reposition plane-starter home via the API so every test starts from the canonical state even
|
||||
// after an interrupted previous run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (plane.status === 'flying') {
|
||||
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
|
||||
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
|
||||
// flight is needed.
|
||||
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) {
|
||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||
// leg when cash allows — no top-up retry needed.
|
||||
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
|
||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await flyHomeViaApi(page);
|
||||
await postSim(page, { timeScale: 1, paused: false });
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
|
||||
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
// Wait for the landing…
|
||||
let landedBody: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await getState(page);
|
||||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
|
||||
return !!landedBody;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
||||
.toBe(true);
|
||||
|
||||
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
|
||||
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
|
||||
expect(landedBody!.simState.paused).toBe(false);
|
||||
expect(landedBody!.simState.timeScale).toBe(1);
|
||||
|
||||
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
|
||||
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
|
||||
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
|
||||
// resumes it.
|
||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(fly2.status()).toBe(200);
|
||||
const resumedBody = (await fly2.json()) as StateBody;
|
||||
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
|
||||
|
||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
||||
});
|
||||
+16
-13
@@ -10,7 +10,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: Array<Record<string, unknown>>;
|
||||
packages: Array<Record<string, unknown>>;
|
||||
}
|
||||
@@ -24,9 +24,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
expect(typeof body.account.cash).toBe('number');
|
||||
expect(typeof body.account.reputation).toBe('number');
|
||||
|
||||
expect(typeof body.simState.clockMs).toBe('number');
|
||||
expect(typeof body.simState.timeScale).toBe('number');
|
||||
expect(body.simState.paused).toBe(false);
|
||||
expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
|
||||
|
||||
expect(Array.isArray(body.planes)).toBe(true);
|
||||
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -44,6 +42,17 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
|
||||
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() !== 200) return 'down';
|
||||
const body = (await res.json()) as StateBody;
|
||||
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
|
||||
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
|
||||
.toBe('idle');
|
||||
|
||||
const resBefore = await page.request.get('/api/state');
|
||||
expect(resBefore.status()).toBe(200);
|
||||
const before = (await resBefore.json()) as StateBody;
|
||||
@@ -68,15 +77,9 @@ test('state survives a container restart (sim clock + account persisted)', async
|
||||
const a = after!;
|
||||
|
||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
|
||||
// persisted across sessions"): account and entity ids are byte-identical.
|
||||
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one
|
||||
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the
|
||||
// post-restart clock may be ahead — but never by more than that bound.
|
||||
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
|
||||
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
|
||||
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
|
||||
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
|
||||
);
|
||||
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
|
||||
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
||||
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
||||
expect(a.account.cash).toBe(before.account.cash);
|
||||
expect(a.account.reputation).toBe(before.account.reputation);
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
|
||||
+21
-45
@@ -10,14 +10,15 @@ import { windBurnFactor } from '../lib/sim/movement';
|
||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
||||
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
|
||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
|
||||
// per-plane queues (scoped testids) and sell.
|
||||
//
|
||||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
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)
|
||||
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||||
@@ -54,7 +55,7 @@ interface CatalogEntry {
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
catalog: CatalogEntry[];
|
||||
@@ -71,18 +72,12 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
|
||||
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
|
||||
// proceeds at full speed.
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
@@ -230,17 +225,14 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||||
await clearQueueViaApi(page, planeId);
|
||||
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||||
if (!p) return; // already sold — nothing to heal
|
||||
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
|
||||
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
|
||||
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
|
||||
await setScale(page, SCALE);
|
||||
if (p.status === 'flying') {
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
const plane = s.planes.find((x) => x.id === planeId);
|
||||
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
|
||||
return plane?.status ?? '';
|
||||
return s.planes.find((x) => x.id === planeId)?.status ?? '';
|
||||
}, {
|
||||
timeout: 240_000,
|
||||
message: `waiting for an in-flight ${planeId} to land`,
|
||||
@@ -249,9 +241,8 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||||
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||
}
|
||||
if (p.locationIcao !== HUB) {
|
||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||
// leg when cash allows — no top-up retry needed.
|
||||
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
|
||||
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||
await setScale(page, SCALE);
|
||||
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyHome.status()).toBe(200);
|
||||
@@ -295,8 +286,7 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||||
@@ -348,7 +338,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
|
||||
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
||||
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
|
||||
const secondId = await buySecondPlaneViaUi(page);
|
||||
|
||||
@@ -358,7 +348,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
||||
|
||||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
|
||||
// the same tick (a shared-tick arrival would race the first-landing assert).
|
||||
const [nearest] = await twoNearestToHub(page);
|
||||
const farthest = await farthestFlyableToHub(page);
|
||||
|
||||
@@ -388,8 +378,6 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
@@ -397,10 +385,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
|
||||
const landed = await getState(page);
|
||||
expect(landed.simState.paused).toBe(false);
|
||||
expect(landed.simState.timeScale).toBe(1);
|
||||
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||
@@ -408,17 +393,9 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
||||
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||
await setFuelFull(page, secondId);
|
||||
|
||||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyB2.status()).toBe(200);
|
||||
let body = (await flyB2.json()) as StateBody;
|
||||
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
|
||||
|
||||
// …but dispatching the LAST grounded plane restores the last manually selected speed.
|
||||
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyA2.status()).toBe(200);
|
||||
body = (await flyA2.json()) as StateBody;
|
||||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||||
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
|
||||
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);
|
||||
});
|
||||
|
||||
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||
@@ -503,9 +480,8 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
||||
const flyBody = (await flyRes.json()) as StateBody;
|
||||
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||
|
||||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||||
await setScale(page, SCALE);
|
||||
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
|
||||
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
|
||||
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -519,7 +495,7 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
||||
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||
|
||||
// …then restore speed so the chained leg fast-forwards too.
|
||||
// …fast-forward the chained leg to its landing.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user