Files
SpaceWanderer/e2e/helpers.ts
T
kamilkrzywda ab22a7e68b save/e2e: #51 — wire generateRing attach into resolveJumpTask + spread-limit e2e
- resolveJumpTask: build existingEdgeKeys from all jump routes (both
  endpoints in the save's system set), pass all systems except the
  target as attachCandidates, insert closing routes for each attach.
- save.test.ts: jump test now expects 1–4 fresh systems (was pinned
  to exactly 4); A/B snapshot test stays green (both paths share the
  same resolveJumpTask code).
- e2e/helpers.ts: update stale comment ('4 fresh neighbours' →
  '1–4 new systems').
- e2e/smoke.spec.ts: new test verifying 1–4 mints, all undiscovered,
  each routed to the target, and BFS connectivity of the full graph.
2026-10-06 12:18:33 +00:00

91 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, type Page } from '@playwright/test';
// Shared e2e helpers — kept out of the specs so both files can reuse them.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
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
// undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
// lib/game/world.ts #51), so fresh territory is 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 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> {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id;
const adjacentIds = new Set<number>();
for (const [a, b] of world.edges) {
if (a === currentId) adjacentIds.add(b);
else if (b === currentId) adjacentIds.add(a);
}
const candidates = [...adjacentIds].map((id) => world.systems.find((s) => s.id === id)!);
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
return after;
}
// Tick the save's task queue until it is empty (free skips are legal 200s; every tick decrements
// every ACTIVE row and PENDING rows auto-start, so a non-empty queue always shrinks — the bound
// only fails a pathological state). Every spec that queues actions starts from this on retry:
// the CI DB persists across attempts, and a failed one may have left a row behind — an in-transit
// JUMP would 409 every new queue (lib/game/tasks.ts's in-transit rule).
export async function drainQueue(page: Page): Promise<void> {
for (let ticks = 0; ; ticks++) {
const body = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[]; tasks?: { id: number }[] } | CrewDto[];
const tasks = Array.isArray(body) ? [] : (body.tasks ?? []);
if (tasks.length === 0) return;
expect(ticks).toBeLessThan(60);
expect((await page.request.post('/api/turn')).status()).toBe(200);
}
}