fix(e2e): task-10 review fixes — Nth tick, retry-safe preambles, queue-DTO shape (#44)
CI / build + unit + e2e (pull_request) Failing after 34m10s

This commit is contained in:
2026-10-03 00:35:10 +00:00
parent 1ddb8ad469
commit a0307e4ccd
3 changed files with 42 additions and 19 deletions
+18 -15
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { jumpToFreshSystem, resolveAction } from './helpers';
import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers';
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
// Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING).
@@ -172,6 +172,7 @@ test('world map shows the generated starting system and its neighbours', async (
});
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = before.systems.find((s) => s.current)!.id;
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
@@ -199,6 +200,7 @@ test('jumping to an adjacent system updates the world state and turn counter', a
});
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = before.systems.find((s) => s.current)!.id;
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
@@ -247,6 +249,7 @@ test('local system shows the current system bodies with staggered moons and expl
});
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
const target = before.planets.find((p) => !p.explored)!;
@@ -280,6 +283,7 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re
});
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
const found = new Map<string, number>(); // kind|name -> qty
@@ -338,6 +342,7 @@ 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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
// a build costs three parts. Every scan/jump is queued with a default crew and ticked to
// completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping
@@ -433,6 +438,7 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
});
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// Research costs two parts, building WEAPONS three — scan until the hold has five. Every
// scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
@@ -508,6 +514,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
});
test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable;
// jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED.
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
@@ -601,6 +608,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
});
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
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
@@ -684,6 +692,7 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
});
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
// message), never on absolute counts — earlier attempts leave their events behind.
// The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED
@@ -748,18 +757,6 @@ const tickOnce = async (page: Page) => {
expect((await page.request.post('/api/turn')).status()).toBe(200);
};
// Tick the save's task queue until it is empty. Free skips are legal 200s and every tick
// decrements every ACTIVE row (PENDING rows auto-start), so a non-empty queue always shrinks —
// the bound only fails a pathological state, not a slow one.
async function drainQueue(page: Page): Promise<void> {
for (let ticks = 0; ; ticks++) {
const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
if (tasks.length === 0) return;
expect(ticks).toBeLessThan(60);
await tickOnce(page);
}
}
test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
@@ -799,13 +796,16 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams
// The explore's body: an unexplored planet in the current system (jump first if it is exhausted).
// The explore's body: an unexplored planet in the current system. A jump's fallback target can
// be a fully-explored system (jumpToFreshSystem prefers undiscovered, not unexplored), so loop
// like the other preambles instead of a single if.
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
if (!sys.planets.some((p) => !p.explored)) {
for (let guard = 0; !sys.planets.some((p) => !p.explored) && guard < 10; guard++) {
await jumpToFreshSystem(page);
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
}
const planet = sys.planets.find((p) => !p.explored)!;
expect(planet).toBeDefined(); // the world keeps producing fresh unscanned bodies
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
const currentId = world.systems.find((s) => s.current)!.id;
@@ -874,6 +874,9 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k);
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k);
}
// The Nth tick resolves both — the loop above covered ticks 1..N-1 only (mirrors how the other
// scenarios end on their resolving tick).
await tickOnce(page);
}
// Both resolved at their own durations: the jump landed, each event is stamped on its task's