From 7e975566abb6291b254f0937bb2dd9c442a36a14 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:46:21 +0000 Subject: [PATCH] e2e: ship action via UI (bounded scan until affordable) + ship state survives container restart --- e2e/smoke.spec.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ e2e/state.spec.ts | 10 ++++++++ 2 files changed, 75 insertions(+) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index a103a25..eec460c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -9,6 +9,10 @@ interface SystemState { systemId: number; systemName: string; planets: PlanetDto interface FoundItem { kind: string; name: string; qty: number } interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } +interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } +interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number } + +const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); @@ -139,6 +143,67 @@ test('CREW tab renders real roster cards, each showing both traits and an origin } }); +test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => { + // Scan bodies until something is affordable: an upgrade costs `level` resources (at least one), + // a build costs three parts. Jumping keeps it going if a whole system is exhausted. + let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; + const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level); + const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : undefined); + + let iterations = 0; + while (!upgradeTarget() && !buildTarget() && 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) { + expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + } else { + 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); + } + ship = (await (await page.request.get('/api/ship')).json()) as ShipState; + } + + const upgrade = upgradeTarget(); + const build = !upgrade ? buildTarget() : undefined; + expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on + + await page.goto('/'); + await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); + + if (upgrade) { + const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]'); + await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled(); + await card.getByRole('button', { name: 'UPGRADE' }).click(); + + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; + const row = after.systems.find((s) => s.id === upgrade.id)!; + expect(row.level).toBe(upgrade.level + 1); + expect(after.turn).toBe(ship.turn + 1); + + // The card badge flipped to the new level and the left column mirrors it. + await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible(); + await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible(); + } else { + const target = build!; + const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]'); + await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); + await card.getByRole('button', { name: 'BUILD NEW' }).click(); + + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; + const row = after.systems.find((s) => s.id === target.id)!; + expect(row.built).toBe(true); + expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 }); + + // The card badge flipped OFFLINE → LV 1 and the left column's online count went up. + await expect(card.getByText('LV 1')).toBeVisible(); + await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible(); + } +}); + test('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index f0d2a75..bdb7702 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -14,6 +14,8 @@ interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; tur 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 } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } +interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } +interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -52,6 +54,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + // Ship baseline: the systems (levels/capacities) + cargo hold must survive too. + const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -86,6 +92,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewAfter).toEqual(crewBefore); + // Ship state survived too: same systems, levels/capacities and cargo hold. + const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(shipAfter).toEqual(shipBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();