test(e2e): rework shared helpers + state spec to the queue + NEXT TURN model (1/3) (#44)
This commit is contained in:
+49
-4
@@ -5,11 +5,56 @@ import { expect, type Page } from '@playwright/test';
|
|||||||
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 }
|
||||||
|
|
||||||
|
// Minimal crew member — GET /api/crew is the {crew, tasks} object (Task 6 / JC-7); the full
|
||||||
|
// member shape lives in e2e/state.spec.ts.
|
||||||
|
interface CrewDto { id: number; role: string }
|
||||||
|
|
||||||
|
// The real TaskType strings (lib/game/tasks.ts) keyed by the action POST path — there is no
|
||||||
|
// bare SHIP type, ship actions split into UPGRADE / EXPAND / BUILD.
|
||||||
|
const TASK_TYPE: Record<string, string> = {
|
||||||
|
'/api/jump': 'JUMP',
|
||||||
|
'/api/explore': 'EXPLORE',
|
||||||
|
'/api/research': 'RESEARCH',
|
||||||
|
'/api/ship/upgrade': 'SHIP_UPGRADE',
|
||||||
|
'/api/ship/expand': 'SHIP_EXPAND',
|
||||||
|
'/api/ship/build': 'SHIP_BUILD',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The GET endpoint that serves each action's final snapshot (the action paths are POST-only).
|
||||||
|
const FINAL_SNAPSHOT: Record<string, string> = {
|
||||||
|
'/api/jump': '/api/world',
|
||||||
|
'/api/explore': '/api/system',
|
||||||
|
'/api/research': '/api/tech',
|
||||||
|
'/api/ship/upgrade': '/api/ship',
|
||||||
|
'/api/ship/expand': '/api/ship',
|
||||||
|
'/api/ship/build': '/api/ship',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Queue an action POST with a default one-person crew, then tick until that task resolves.
|
||||||
|
// The POST only queues (200, no turn bump, effect not yet visible); the effect appears once the
|
||||||
|
// task hits 0 on a tick — a task that starts ACTIVE with remaining N resolves on the Nth tick
|
||||||
|
// (lib/game/tasks.ts tickTurn) — so N (the full duration) is read from the queue response's
|
||||||
|
// tasks array and POST /api/turn is sent exactly N times. Returns the final snapshot for the
|
||||||
|
// path. A 409 on the queue POST is a busy / in-transit rejection (Task 3), not a tick-count bug.
|
||||||
|
export async function resolveAction(page: Page, path: string, data: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[];
|
||||||
|
const members: CrewDto[] = Array.isArray(roster) ? roster : (roster.crew ?? []); // {crew, tasks} object (JC-7); the array guard keeps the helper shape-tolerant
|
||||||
|
expect(members.length).toBeGreaterThan(0); // starting crew is always seeded
|
||||||
|
const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] };
|
||||||
|
const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1;
|
||||||
|
for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||||||
|
return (await page.request.get(FINAL_SNAPSHOT[path])).json();
|
||||||
|
}
|
||||||
|
|
||||||
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||||||
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
||||||
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
||||||
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
||||||
// led here. Falls back to any non-current adjacent system if none is undiscovered.
|
// led here. Falls back to any non-current adjacent system if none is undiscovered. The jump is
|
||||||
|
// queued with a default one-person crew and ticked to completion (resolveAction) before this
|
||||||
|
// returns, so the caller is already standing in the target system, not merely en route to it.
|
||||||
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = world.systems.find((s) => s.current)!.id;
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
@@ -23,7 +68,7 @@ export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
|||||||
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
|
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
|
||||||
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
|
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
|
||||||
|
|
||||||
const res = await page.request.post('/api/jump', { data: { systemId: target.id } });
|
const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
|
||||||
expect(res.status()).toBe(200);
|
expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
|
||||||
return (await res.json()) as WorldState;
|
return after;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-8
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { jumpToFreshSystem } from './helpers';
|
import { jumpToFreshSystem, resolveAction } from './helpers';
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
@@ -13,6 +13,10 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
|||||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
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 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 CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
|
// Mirrors the task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks}
|
||||||
|
// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered.
|
||||||
|
interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }
|
||||||
|
interface CrewState { crew: CrewDto[]; tasks: TaskDto[] }
|
||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
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 }
|
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||||
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
|
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
|
||||||
@@ -39,13 +43,15 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
}
|
}
|
||||||
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
||||||
const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } });
|
// The explore POST only queues — no turn bump, explored not yet flipped — so tick it to
|
||||||
expect(exploreRes.status()).toBe(200);
|
// completion and re-derive the baseline from the post-tick /api/system snapshot: the turn it
|
||||||
const sysExplored = (await exploreRes.json()) as SystemState;
|
// pins (compared after the restart) must be the resolved one, not the queue's.
|
||||||
|
const sysExplored = (await resolveAction(page, '/api/explore', { planetId: targetPlanet.id })) as SystemState;
|
||||||
|
expect(sysExplored.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // the scan resolved before the baseline is pinned
|
||||||
|
|
||||||
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
||||||
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
||||||
expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
expect(crewBefore.crew.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||||
|
|
||||||
// Ship baseline: the systems (levels/capacities) + cargo hold must survive too.
|
// Ship baseline: the systems (levels/capacities) + cargo hold must survive too.
|
||||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
@@ -95,8 +101,9 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
expect(sysAfter.turn).toBe(sysExplored.turn);
|
expect(sysAfter.turn).toBe(sysExplored.turn);
|
||||||
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
|
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
|
||||||
|
|
||||||
// Crew roster survived too: same members, traits and origins (no re-seed on restart).
|
// Crew roster survived too: same members, traits and origins (no re-seed on restart). The
|
||||||
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
// {crew, tasks} object shape (JC-7) means the still-queued task rows survive with it too.
|
||||||
|
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
||||||
expect(crewAfter).toEqual(crewBefore);
|
expect(crewAfter).toEqual(crewBefore);
|
||||||
|
|
||||||
// Ship state survived too: same systems, levels/capacities and cargo hold.
|
// Ship state survived too: same systems, levels/capacities and cargo hold.
|
||||||
|
|||||||
Reference in New Issue
Block a user