feat: v0.9.0 — single-screen 3-pane shell + left Assets pane (wallet/reputation/fleet/time controls) from a single /api/state poll; right pane placeholder

This commit is contained in:
2026-09-17 15:43:29 +00:00
parent aa70eb5286
commit 9502cd1975
11 changed files with 589 additions and 22 deletions
+27 -20
View File
@@ -51,15 +51,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 },
).toBeGreaterThan(0);
// Project UKBB (Boryspil) to screen space via the dev hook, then click it.
// Boryspil is used because with only large_airport rows remaining it is the
// most isolated marker inside the initial zoom-4 viewport: its nearest
// surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom
// (Kyiv Zhuliany, geographically closer, was a medium_airport and got cut),
// so its marker cannot be overlapped by a neighbouring circle.
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
// Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
// be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([30.894699, 50.345001]);
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
@@ -67,8 +67,8 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText('Boryspil International Airport');
await expect(popup).toContainText('UKBB');
await expect(popup).toContainText('Nantes Atlantique Airport');
await expect(popup).toContainText('LFRS');
});
test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
@@ -185,14 +185,21 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Clicking a marker after the switch opens exactly one popup (the click handler is
// re-wired idempotently — no duplicate listeners).
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
await page.mouse.click(pt.x, pt.y);
// re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
// retry the click until the popup opens instead of asserting on a single shot (the marker is
// clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners).
await expect.poll(async () => {
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
await page.mouse.click(pt.x, pt.y);
return (await page.getByTestId('airport-popup').count()) > 0;
}, { timeout: 15_000 }).toBe(true);
const popups = page.getByTestId('airport-popup');
await expect(popups.first()).toBeVisible();
await expect(popups).toHaveCount(1);
@@ -248,7 +255,7 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
@@ -256,6 +263,6 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText('Boryspil International Airport');
await expect(popup).toContainText('UKBB');
await expect(popup).toContainText('Nantes Atlantique Airport');
await expect(popup).toContainText('LFRS');
});
+87
View File
@@ -0,0 +1,87 @@
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.
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
planes: Array<Record<string, unknown>>;
contracts: 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;
}
// 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 at Frankfurt (EDDF).
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');
await expect(row).toContainText('Frankfurt');
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
});
test('time controls: pause freezes the sim and the scale selector sets timeScale=60', async ({ page }) => {
await page.goto('/');
const pauseBtn = page.getByTestId('pause-toggle');
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
// Pause via the UI button — assert against the authoritative /api/state…
await pauseBtn.click();
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
// …and in the pane itself (the refetch after POST flips the button to Resume).
await expect(pauseBtn).toHaveText('Resume');
// Scale via the UI select.
const scaleSelect = page.getByTestId('time-scale-select');
await scaleSelect.selectOption('60');
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(60);
await expect(scaleSelect).toHaveValue('60');
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(restore.status()).toBe(200);
const after = await getState(page);
expect(after.simState.timeScale).toBe(1);
expect(after.simState.paused).toBe(false);
});
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');
});