e2e: local system smoke tests (staggered bodies, explore row state, inventory accumulation) + explored-state persistence across restart

This commit is contained in:
2026-09-28 22:17:03 +00:00
parent 82d41b87be
commit 1a50ffb2f9
2 changed files with 98 additions and 6 deletions
+71 -1
View File
@@ -1,8 +1,13 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained. // Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean } interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number } interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface InventoryItemDto { kind: string; name: string; qty: number }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
interface FoundItem { kind: string; name: string; qty: number }
interface ExploreResult extends SystemState { outcome: FoundItem[] }
test('landing page loads with the SpaceWanderer title', async ({ page }) => { test('landing page loads with the SpaceWanderer title', async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -49,6 +54,71 @@ test('jumping to an adjacent system updates the world state and turn counter', a
expect(after.systems.find((s) => s.current)?.id).toBe(targetId); expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
}); });
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
// Header carries the real system name + turn counter.
await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible();
// One EXPLORE button per unexplored body (planets and moons alike).
const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ });
await expect(exploreButtons.first()).toBeVisible();
await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length);
});
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
const target = before.planets.find((p) => !p.explored)!;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(after.turn).toBe(before.turn + 1);
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
});
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
const found = new Map<string, number>(); // kind|name -> qty
let iterations = 0;
while (found.size === 0 && iterations < 60) {
iterations++;
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
const unexplored = sys.planets.filter((p) => !p.explored);
if (unexplored.length > 0) {
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
expect(res.status()).toBe(200);
for (const item of ((await res.json()) as ExploreResult).outcome) {
found.set(`${item.kind}|${item.name}`, item.qty);
}
} else {
// Current system fully scanned — jump somewhere and keep scanning.
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
}
expect(found.size).toBeGreaterThan(0);
// Every find landed in the inventory with at least that qty.
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
for (const [key, qty] of found) {
const [kind, name] = key.split('|');
expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty);
}
});
test('GET /api/version reports the app version as JSON', async ({ page }) => { test('GET /api/version reports the app version as JSON', async ({ page }) => {
const res = await page.request.get('/api/version'); const res = await page.request.get('/api/version');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
+27 -5
View File
@@ -8,9 +8,11 @@ import { expect, test } from '@playwright/test';
const repoRoot = path.join(__dirname, '..'); const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained. // Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean } interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number } interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
@@ -28,7 +30,22 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
const targetId = edge[0] === currentId ? edge[1] : edge[0]; const targetId = edge[0] === currentId ? edge[1] : edge[0];
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } }); const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
expect(jumpRes.status()).toBe(200); expect(jumpRes.status()).toBe(200);
const worldJumped = (await jumpRes.json()) as WorldState;
// Exploration baseline: scan one body so explored state + inventory must survive too.
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const curId = world.systems.find((s) => s.current)!.id;
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
const tId = e[0] === curId ? e[1] : e[0];
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
}
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } });
expect(exploreRes.status()).toBe(200);
const sysExplored = (await exploreRes.json()) as SystemState;
// Restart the app container mid-run — the named DB volume must survive it. // Restart the app container mid-run — the named DB volume must survive it.
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
@@ -50,10 +67,15 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
expect(after).toBeDefined(); expect(after).toBeDefined();
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
// Game state survived: same turn counter and jumped-to current system as before the restart. // Game state survived: same turn counter and current system as before the restart.
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState; const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(worldAfter.turn).toBe(worldJumped.turn); expect(worldAfter.turn).toBe(sysExplored.turn);
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(targetId); expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
// Exploration state survived too: the scanned body is still explored.
const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(sysAfter.turn).toBe(sysExplored.turn);
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
// The page itself serves again with the landing title. // The page itself serves again with the landing title.
await page.goto('/'); await page.goto('/');