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. 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; } interface StateBody { account: { cash: number; reputation: number }; simState: SimState; planes: Array>; packages: Array>; } async function getState(page: Page): Promise { 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) — 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); }); 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-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section // heading and button render here… await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible(); await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible(); // …and neither the heading nor the row remains in the right pane. await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).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.setTimeout(60_000); await page.goto('/'); const slider = page.getByTestId('time-scale-slider'); await expect(slider).toBeVisible({ timeout: 15_000 }); // Commit to max via keyboard (input + change → immediate commitNow POST)… await slider.focus(); await page.keyboard.press('End'); // …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)); } 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 }) => { 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'); });