test(e2e): mission accept -> complete + board survives restart

smoke: pick an AVAILABLE contract (non-DELIVER preferred), accept it through
the UI (card flips ACTIVE, button disables), complete via API — scan loop for
EXPLORE/FIND, BFS jump path for DELIVER — assert DONE and the reward landed in
the hold. state: mission board deep-equal across the mid-run container restart.
This commit is contained in:
2026-09-29 01:55:49 +00:00
parent 684a126a5b
commit 70f95145ae
2 changed files with 101 additions and 0 deletions
+91
View File
@@ -12,9 +12,40 @@ interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: u
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 }
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
// BFS over the undirected jump routes — [next, ..., target], empty when already there.
const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => {
const adj = new Map<number, number[]>();
for (const [a, b] of edges) {
if (!adj.has(a)) adj.set(a, []);
if (!adj.has(b)) adj.set(b, []);
adj.get(a)!.push(b);
adj.get(b)!.push(a);
}
const prev = new Map<number, number>();
const seen = new Set([fromId]);
const queue = [fromId];
while (queue.length > 0 && !seen.has(toId)) {
const cur = queue.shift()!;
for (const next of adj.get(cur) ?? []) {
if (!seen.has(next)) {
seen.add(next);
prev.set(next, cur);
queue.push(next);
}
}
}
expect(seen.has(toId)).toBe(true); // the world graph is connected by construction
const path: number[] = [];
for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) {
path.unshift(cur);
}
return path;
};
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
@@ -255,6 +286,66 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
});
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
// Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered).
const picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!;
const idx = board.indexOf(picked);
// Baseline of the reward kind in the hold — scans during completion may add more, so assert >=.
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
await page.goto('/');
await page.getByRole('tab', { name: 'MISSIONS' }).click();
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
const acceptButtons = page.getByRole('button', { name: 'ACCEPT' });
await expect(acceptButtons.nth(idx)).toBeEnabled();
await acceptButtons.nth(idx).click();
// UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled.
const panel = page.getByRole('tabpanel');
await expect(panel.getByText('ACTIVE', { exact: true })).toBeVisible();
await expect(acceptButtons.nth(idx)).toBeDisabled();
// API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn).
const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]);
// Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of
// scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest
// jump path to its target (edges only ever get added, so the precomputed path stays valid).
let deliverPath: number[] = [];
if (picked.type === 'DELIVER') {
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges);
}
let state = 'ACTIVE';
let iterations = 0;
while (state !== 'DONE' && iterations < 80) {
iterations++;
if (deliverPath.length > 0) {
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
} else {
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 {
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
}
}
state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE';
}
expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
});
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
@@ -16,6 +16,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t
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 TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
test.setTimeout(180_000);
@@ -53,6 +54,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded
// Mission board baseline: contracts, states and progress must survive too. The GET tops the board
// up first — a no-op here because every action leaves at least MIN_AVAILABLE AVAILABLE contracts.
const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game
// Restart the app container mid-run — the named DB volume must survive it.
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
@@ -95,6 +101,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
expect(techAfter).toEqual(techBefore);
// Mission board survived too: same contracts, states and progress (no re-seed or re-top-up on restart).
const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(missionsAfter).toEqual(missionsBefore);
// The page itself serves again with the landing title.
await page.goto('/');
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();