test(e2e): rework the suite to the queue + NEXT TURN model (#44)

This commit is contained in:
2026-10-03 00:05:18 +00:00
parent b51bde3c25
commit 1ddb8ad469
+273
View File
@@ -728,3 +728,276 @@ test('GET /api/version reports the app version as JSON', async ({ page }) => {
const body = (await res.json()) as { version: string };
expect(body.version).toMatch(/^\d+\.\d+\.\d+$/);
});
// --- The new queue + tick model scenarios (Task 10 / #44) -------------------------------
// All five start from a clean queue (drainQueue): the CI DB persists across retries, and a
// failed earlier attempt can leave an ACTIVE / PENDING row — or an in-transit JUMP — behind.
// Every duration below is read live from the queue responses (the team builder's value, Task 8),
// never hard-coded, and every tick goes through the raw POST /api/turn — the only turn bump.
// The full queue row keyed by id — the same array the Task 9 strip renders (ACTIVE block first,
// then PENDING; DONE rows are filtered out of the snapshots, so only ACTIVE/PENDING appear).
interface QueueTaskRow { id: number; type: string; status: string; remaining: number }
// World / system snapshot views carrying that full row shape (the top-level DTOs keep the minimal
// {type, remaining} subset the reworked tests read).
interface WorldStateQ { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskRow[] }
interface SystemStateQ { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskRow[] }
// One POST /api/turn (no body) — the only thing that advances the turn counter.
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;
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];
const T = world.turn;
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
// The queue POST: 200, no turn bump, no landing — the free member starts the JUMP ACTIVE at once.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.turn).toBe(T);
expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged immediately after the queue POST
const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8)
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
// N-1 ticks: still in transit — remaining 1, the origin still current.
for (let k = 0; k < n - 1; k++) await tickOnce(page);
const mid = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(mid.turn).toBe(T + n - 1);
expect(mid.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged after N-1 ticks
expect(mid.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(1);
// The Nth tick lands the jump.
await tickOnce(page);
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + n);
expect(after.systems.find((s) => s.current)?.id).toBe(targetId); // changed exactly on the Nth tick
expect(after.tasks.some((t) => t.type === 'JUMP')).toBe(false); // resolved rows are filtered out
});
test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their own durations (#44)', async ({ page }) => {
await drainQueue(page);
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).
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
if (!sys.planets.some((p) => !p.explored)) {
await jumpToFreshSystem(page);
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
}
const planet = sys.planets.find((p) => !p.explored)!;
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const targetName = world.systems.find((s) => s.id === targetId)!.name;
const T = world.turn;
// Mandated order (JC-17): the EXPLORE is queued FIRST — while a JUMP is ACTIVE no new task of any
// type may be queued, so a jump-first order would 409 the explore. Both crews are free, so both
// tasks start ACTIVE immediately and run in parallel, with no interleaving clicks.
const expRes = await page.request.post('/api/explore', { data: { planetId: planet.id, leadCrewId: crew[1].id, helperCrewIds: [] } });
expect(expRes.status()).toBe(200);
const expQ = (await expRes.json()) as SystemStateQ;
expect(expQ.turn).toBe(T);
const nExp = expQ.tasks.find((t) => t.type === 'EXPLORE')!.remaining;
expect(expQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE');
const jmpRes = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(jmpRes.status()).toBe(200); // legal even while the non-jump explore is ACTIVE
const jmpQ = (await jmpRes.json()) as WorldStateQ;
expect(jmpQ.turn).toBe(T);
const nJmp = jmpQ.tasks.find((t) => t.type === 'JUMP')!.remaining;
expect(jmpQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE'); // the explore kept running
expect(jmpQ.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
const getEvents = async () => (await (await page.request.get('/api/events')).json()) as EventDto[];
// A planet flip and a jump are both recorded by the event log (the same records the EVENT LOG test
// reads), each stamped with the tick it resolved on — turn numbers are unique within a run, so the
// (turn, message) pair pins this run's own tasks.
const exploreEventAt = async (turn: number) =>
(await getEvents()).find((e) => e.type === 'explore' && e.turn === turn && e.message.startsWith(`Explored ${planet.name} — found`));
const jumpEventAt = async (turn: number) =>
(await getEvents()).find((e) => e.type === 'jump' && e.turn === turn && e.message === `Jumped to ${targetName}`);
if (nExp < nJmp) {
// The scan resolves first (tick nExp), while the ship is still in the origin — so the flip is
// readable directly off the current system.
for (let k = 0; k < nExp; k++) await tickOnce(page);
const mid = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(mid.turn).toBe(T + nExp);
expect(mid.planets.find((p) => p.id === planet.id)?.explored).toBe(true); // the scan's effect, on its own tick
expect(mid.tasks.some((t) => t.type === 'EXPLORE')).toBe(false);
expect(await exploreEventAt(T + nExp)).toBeDefined();
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.systems.find((s) => s.current)?.id).toBe(currentId); // not landed yet
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nJmp - nExp); // nJmp - nExp ticks left
for (let k = 0; k < nJmp - nExp; k++) await tickOnce(page);
} else if (nJmp < nExp) {
// The jump lands first (tick nJmp); the origin is no longer `current` (GET /api/system is
// current-only), so the scan's flip is proven by the event it writes on its own resolution tick.
for (let k = 0; k < nJmp; k++) await tickOnce(page);
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.turn).toBe(T + nJmp);
expect(midWorld.systems.find((s) => s.current)?.id).toBe(targetId);
expect(await jumpEventAt(T + nJmp)).toBeDefined();
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE');
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - nJmp); // nExp - nJmp ticks left
for (let k = 0; k < nExp - nJmp; k++) await tickOnce(page);
} else {
// Equal durations: both decrement in lockstep and stay ACTIVE through every tick before the last.
for (let k = 1; k < nExp; k++) {
await tickOnce(page);
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k);
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k);
}
}
// Both resolved at their own durations: the jump landed, each event is stamped on its task's
// resolution tick, and the queue is clean again.
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + Math.max(nExp, nJmp));
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
expect(after.tasks.some((t) => t.type === 'JUMP' || t.type === 'EXPLORE')).toBe(false);
expect(await exploreEventAt(T + nExp)).toBeDefined();
expect(await jumpEventAt(T + nJmp)).toBeDefined();
});
test('a second task for a member on an ACTIVE one is pre-queued PENDING and auto-starts on its tick (#44)', async ({ page }) => {
await drainQueue(page);
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
// Two unexplored bodies in the current system; a fresh system always has them, so jump until the
// current one does (the bounded guard fails the test if the world ever stops producing them).
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
for (let guard = 0; sys.planets.filter((p) => !p.explored).length < 2 && guard < 10; guard++) {
await jumpToFreshSystem(page);
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
}
const unexplored = sys.planets.filter((p) => !p.explored);
expect(unexplored.length).toBeGreaterThanOrEqual(2);
const planetA = unexplored[0]!;
const planetB = unexplored[1]!;
const T = sys.turn;
// Task 1: crew[0] is free, so the scan starts ACTIVE at once.
const res1 = await page.request.post('/api/explore', { data: { planetId: planetA.id, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res1.status()).toBe(200);
const q1 = (await res1.json()) as SystemStateQ;
expect(q1.turn).toBe(T); // no turn bump on queue
const t1 = q1.tasks.find((t) => t.type === 'EXPLORE')!; // the only row — the queue was clean
expect(t1.status).toBe('ACTIVE');
const na = t1.remaining;
// Task 2, same member, a different target: a lead on an ACTIVE may chain (JC-16) — it is accepted
// (the member is on ACTIVE, not PENDING, and no jump is in transit) and starts PENDING.
const res2 = await page.request.post('/api/explore', { data: { planetId: planetB.id, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res2.status()).toBe(200);
const q2 = (await res2.json()) as SystemStateQ;
expect(q2.turn).toBe(T);
expect(q2.tasks.find((t) => t.id === t1.id)!.status).toBe('ACTIVE');
const t2 = q2.tasks.find((t) => t.id !== t1.id && t.type === 'EXPLORE')!;
expect(t2.status).toBe('PENDING'); // the second is dimmed in the strip (Task 9) until it starts
const nb = t2.remaining; // its full duration — untouched until it starts
// The snapshot orders the ACTIVE block before the PENDING block (Task 6).
expect(q2.tasks.findIndex((t) => t.id === t1.id)).toBeLessThan(q2.tasks.findIndex((t) => t.id === t2.id));
// Tick until task 1 resolves — a loop keyed on its specific id (resolveAction's find-by-type would
// grab the earliest same-type row, which is the ambiguity two same-type rows create).
for (let k = 1; k < na; k++) {
await tickOnce(page);
const snap = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(snap.tasks.find((t) => t.id === t1.id)!.remaining).toBe(na - k); // the ACTIVE row decrements
expect(snap.tasks.find((t) => t.id === t2.id)!.status).toBe('PENDING'); // the second keeps waiting
}
// The na-th tick: task 1 resolves (filtered out of the snapshot), and task 2 auto-starts on that
// same tick — ACTIVE with its FULL remaining (the first decrement lands on the next tick).
await tickOnce(page);
const done = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(done.tasks.some((t) => t.id === t1.id)).toBe(false);
expect(done.tasks.find((t) => t.id === t2.id)!.status).toBe('ACTIVE');
expect(done.tasks.find((t) => t.id === t2.id)!.remaining).toBe(nb);
// Leave the queue clean for the next scenario: tick task 2 to completion.
for (let k = 0; k < nb; k++) await tickOnce(page);
const clean = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(clean.tasks.length).toBe(0);
});
test('queueing a second JUMP while one is in transit is refused 409 (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const T = world.turn;
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.turn).toBe(T);
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); // in transit — and deliberately not ticked
// A second JUMP while the first is in transit — a different, free member, so the 409 is the
// global in-transit rule ("a jump is in transit — no new tasks can be queued" / "a jump is
// already queued"), not crew availability. (The UI variant and the full 409 matrix are
// unit-covered by Task 3 — this single API case is what e2e carries.)
const second = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[1].id, helperCrewIds: [] } });
expect(second.status()).toBe(409);
expect(await second.text()).toMatch(/jump/i);
// The first is still in transit, untouched by the refused queue.
const still = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(still.turn).toBe(T);
expect(still.systems.find((s) => s.current)?.id).toBe(currentId);
expect(still.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
// The jump is left in transit on purpose (the scenario is about the blocked queue); the next
// scenario's drainQueue ticks it out before its empty-queue precondition.
});
test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays hidden (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(world.tasks.length).toBe(0); // the precondition: nothing queued
const T = world.turn;
await page.goto('/');
// The button's live counter shows the current turn; with an empty queue the strip renders nothing.
await expect(page.getByText(`TURN ${T}`, { exact: true })).toBeVisible();
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'NEXT TURN' }).click();
await expect(page.getByText(`TURN ${T + 1}`, { exact: true })).toBeVisible(); // the click is a real tick
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0); // a free skip queues no task
// API confirms the model: the turn advanced by exactly one and the queue is still empty.
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + 1);
expect(after.tasks.length).toBe(0);
});