test(e2e): rework the suite to the queue + NEXT TURN model (#44)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user