e2e: ship action via UI (bounded scan until affordable) + ship state survives container restart

This commit is contained in:
2026-09-28 23:46:21 +00:00
parent de0cb45b09
commit 7e975566ab
2 changed files with 75 additions and 0 deletions
+65
View File
@@ -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);
+10
View File
@@ -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();