Files
Flight-Ops/e2e/panes.spec.ts
T
kamilkrzywda a0560ec767
CI / build + unit + e2e (pull_request) Failing after 12m10s
Fixed world clock: no player time controls (ISS-117)
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.
2026-09-22 10:50:21 +00:00

161 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).
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: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>;
}
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;
}
// 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 }) => {
await setScale(page, WORLD);
});
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('left-pane')).toBeVisible();
// Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands.
const cashEl = page.getByTestId('wallet-cash');
await expect(cashEl).toBeVisible({ timeout: 15_000 });
const cashText = (await cashEl.textContent()) ?? '';
expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0);
// Reputation present and numeric.
await expect(page.getByTestId('reputation')).toBeVisible();
expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
// Fleet: the starter ATR 72-600 — location asserted against /api/state truth, because a
// human player (or an earlier spec) may have moved it anywhere by now.
const row = page.getByTestId('fleet-list').locator('.plane-row').first();
await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
await expect(row).toContainText('ATR 72-600');
const truth = (await getState(page)).planes.find((p) => p.id === 'plane-starter') as unknown as {
status: string;
locationName: string;
destName: string | null;
};
if (truth.status === 'flying' && truth.destName) {
await expect(row).toContainText(`${truth.locationName} → ${truth.destName}`); // in-flight leg
} else {
await expect(row).toContainText(truth.locationName);
}
// v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible();
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a
// 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 at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
test.setTimeout(60_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 });
// "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'));
};
// 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);
}
});
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market');
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
});