test(e2e): rework the existing action tests to the queue + NEXT TURN model (2/3) (#44)
This commit is contained in:
+230
-52
@@ -1,12 +1,15 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { jumpToFreshSystem } from './helpers';
|
import { jumpToFreshSystem, resolveAction } from './helpers';
|
||||||
|
|
||||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
// 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).
|
||||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
// The subset of QueueDto the spec reads: the task type and its live duration (`remaining`).
|
||||||
|
interface QueueTaskDto { type: string; remaining: number }
|
||||||
|
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskDto[] }
|
||||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||||
interface InventoryItemDto { kind: string; name: string; qty: number }
|
interface InventoryItemDto { kind: string; name: string; qty: number }
|
||||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
|
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskDto[] }
|
||||||
interface FoundItem { kind: string; name: string; qty: number }
|
interface FoundItem { kind: string; name: string; qty: number }
|
||||||
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
||||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
@@ -47,6 +50,40 @@ const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][
|
|||||||
return path;
|
return path;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- PR A's UI flow (Task 8's team builder + Task 7's NEXT TURN button) -----------------
|
||||||
|
|
||||||
|
// The team-builder half of every action panel: pick the default one-person crew in the mounted
|
||||||
|
// builder — the first lead radio, the same default member (the roster is in join order) that
|
||||||
|
// resolveAction queues with, no helpers — and click DISPATCH. Returns the queue POST's response:
|
||||||
|
// 200, no turn bump, the effect not yet applied. Its `tasks` array (where the snapshot carries
|
||||||
|
// one) holds the new task's `remaining` — the team's live duration N (Task 6).
|
||||||
|
async function dispatchDefaultCrew(page: Page, path: string) {
|
||||||
|
await page.getByRole('tabpanel').locator('input[type="radio"][name="lead"]').first().check();
|
||||||
|
const [res] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.request().method() === 'POST' && r.url().endsWith(path)),
|
||||||
|
page.getByRole('button', { name: 'DISPATCH' }).click(),
|
||||||
|
]);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One NEXT TURN click (Task 7) = one POST /api/turn. The button drops a click while a tick is
|
||||||
|
// in flight, so each click waits for the button's own TURN span (the shell's ship snapshot —
|
||||||
|
// refreshed on every tick) to show the next turn before the following click.
|
||||||
|
async function tickTo(page: Page, toTurn: number) {
|
||||||
|
await page.getByRole('button', { name: 'NEXT TURN' }).click();
|
||||||
|
await expect(page.getByText(`TURN ${toTurn}`, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The N for a ship or research action, read from the crew snapshot: the queueing routes for
|
||||||
|
// /api/ship/* and /api/research return a bare ShipState / TechDto[] with no `tasks` field of
|
||||||
|
// their own (unlike the world/system snapshots), but the queue itself is the same one GET
|
||||||
|
// /api/crew exposes (JC-7). Every test ticks its actions to completion, so at most one task of
|
||||||
|
// a given type is ever in the queue when this reads it.
|
||||||
|
async function crewTasks(page: Page): Promise<QueueTaskDto[]> {
|
||||||
|
const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
@@ -142,11 +179,22 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
|||||||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Click the target's chart field — clickable nodes are role=button with their name as accessible label.
|
// Click the target's chart field — it ARMS the node: the crew team builder floats over the chart.
|
||||||
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||||
|
// Dispatch with the default one-person crew — the POST only QUEUES the JUMP: 200, no turn bump,
|
||||||
|
// and the ship has not moved yet (the origin is still `current` in the queue response).
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/jump');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queued = (await res.json()) as WorldState;
|
||||||
|
expect(queued.turn).toBe(before.turn);
|
||||||
|
expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // no instant landing
|
||||||
|
const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8)
|
||||||
|
|
||||||
|
// The tick, not the queue, advances the counter: N NEXT TURN clicks, one turn each.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
expect(after.turn).toBe(before.turn + 1);
|
expect(after.turn).toBe(before.turn + n);
|
||||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,18 +206,28 @@ test('local system shows the new current system after a jump, without a page rel
|
|||||||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab).
|
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab) — it arms
|
||||||
|
// the node and floats the crew team builder over the chart.
|
||||||
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/jump');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
// The queue response is applied to the chart — but the turn is still the origin's: no landing,
|
||||||
|
// no counter bump until the ticks.
|
||||||
|
const queued = (await res.json()) as WorldState;
|
||||||
|
expect(queued.turn).toBe(before.turn);
|
||||||
|
expect(queued.systems.find((s) => s.current)?.id).toBe(currentId);
|
||||||
|
const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining;
|
||||||
|
|
||||||
// Wait until the jump response is applied to the chart — server state is committed by then, so
|
// Wait until the ticks are done: the tick, not the queue, advances the counter (the button's
|
||||||
// the /api/world read below can't race the in-flight POST.
|
// TURN span is the shell's live counter; the last click lands the jump and shows TURN before+N).
|
||||||
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
|
for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const newCurrent = after.systems.find((s) => s.current)!;
|
const newCurrent = after.systems.find((s) => s.current)!;
|
||||||
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed
|
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed, on the Nth tick
|
||||||
|
|
||||||
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
|
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30):
|
||||||
|
// the shell re-fetches /api/system on every tick, so its header carries the final turn.
|
||||||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||||
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -194,13 +252,29 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re
|
|||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||||
|
// The row's button ARMS the planet (it flips to PICK TEAM); the crew team builder mounts above the list.
|
||||||
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
|
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
|
||||||
|
// Dispatch with the default one-person crew — the POST only QUEUES the scan: 200, no turn bump,
|
||||||
|
// the body still unexplored in the response and its `outcome` empty until the scan resolves (JC-2).
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/explore');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queued = (await res.json()) as ExploreResult;
|
||||||
|
expect(queued.turn).toBe(before.turn);
|
||||||
|
expect(queued.planets.find((p) => p.id === target.id)?.explored).toBe(false);
|
||||||
|
expect(queued.outcome).toEqual([]);
|
||||||
|
expect(queued.crewFound).toBeNull();
|
||||||
|
const n = queued.tasks.find((t) => t.type === 'EXPLORE')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: the scan resolves (explored flips, the haul lands) on the Nth one.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
expect(after.turn).toBe(before.turn + 1);
|
expect(after.turn).toBe(before.turn + n);
|
||||||
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
|
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
|
||||||
|
|
||||||
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
|
// UI reflects it after the ticks: the row badge flipped to SCANNED and its EXPLORE button is gone.
|
||||||
|
// (The SCAN RESULT block already showed the queue response's empty outcome — NOTHING FOUND — as
|
||||||
|
// soon as the scan was dispatched; the haul itself only ever lands on the tick, into the hold.)
|
||||||
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
|
||||||
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
|
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -215,18 +289,22 @@ test('exploration yields finds that accumulate in the inventory', async ({ page
|
|||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
if (unexplored.length > 0) {
|
if (unexplored.length > 0) {
|
||||||
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
|
// Queue + tick the scan (resolveAction). The queue response's `outcome` is empty until the
|
||||||
expect(res.status()).toBe(200);
|
// tick (JC-2), so the haul is read from the post-tick /api/system snapshot it returns:
|
||||||
for (const item of ((await res.json()) as ExploreResult).outcome) {
|
// the inventory delta of this scan (the queue is clean at the start of every iteration).
|
||||||
found.set(`${item.kind}|${item.name}`, item.qty);
|
const after = (await resolveAction(page, '/api/explore', { planetId: unexplored[0].id })) as SystemState;
|
||||||
|
const before = new Map(sys.inventory.map((i) => [`${i.kind}|${i.name}`, i.qty]));
|
||||||
|
for (const item of after.inventory) {
|
||||||
|
const qty = item.qty - (before.get(`${item.kind}|${item.name}`) ?? 0);
|
||||||
|
if (qty > 0) found.set(`${item.kind}|${item.name}`, qty);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Current system fully scanned — jump somewhere and keep scanning.
|
// Current system fully scanned — jump somewhere (queued, then ticked) and keep scanning.
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = world.systems.find((s) => s.current)!.id;
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
await resolveAction(page, '/api/jump', { systemId: targetId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(found.size).toBeGreaterThan(0);
|
expect(found.size).toBeGreaterThan(0);
|
||||||
@@ -240,13 +318,14 @@ test('exploration yields finds that accumulate in the inventory', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
|
test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
|
||||||
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
// GET /api/crew is the {crew, tasks} object since Task 6 (JC-18) — the roster is its crew field.
|
||||||
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
||||||
|
expect(crew.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'CREW' }).click();
|
await page.getByRole('tab', { name: 'CREW' }).click();
|
||||||
|
|
||||||
for (const member of roster) {
|
for (const member of crew) {
|
||||||
const heading = page.getByRole('heading', { name: member.name });
|
const heading = page.getByRole('heading', { name: member.name });
|
||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
||||||
@@ -260,7 +339,9 @@ 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 }) => {
|
test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
|
||||||
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
||||||
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
|
// 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
|
||||||
|
// keeps it going if a whole system is exhausted.
|
||||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
||||||
// WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by
|
// WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by
|
||||||
@@ -273,13 +354,13 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
|||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
if (unexplored.length > 0) {
|
if (unexplored.length > 0) {
|
||||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||||||
} else {
|
} else {
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = world.systems.find((s) => s.current)!.id;
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
await resolveAction(page, '/api/jump', { systemId: targetId });
|
||||||
}
|
}
|
||||||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
}
|
}
|
||||||
@@ -292,28 +373,58 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
|||||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||||
|
|
||||||
if (upgrade) {
|
if (upgrade) {
|
||||||
|
// The UPGRADE button ARMS the action — the crew team builder mounts above the grid.
|
||||||
const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]');
|
const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]');
|
||||||
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
|
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
|
||||||
await card.getByRole('button', { name: 'UPGRADE' }).click();
|
await card.getByRole('button', { name: 'UPGRADE' }).click();
|
||||||
|
|
||||||
|
// Dispatch with the default one-person crew — the POST only QUEUES the upgrade: 200, no turn
|
||||||
|
// bump, the cost (`level` resources) already deducted from the hold, the level NOT yet up.
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/ship/upgrade');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queued = (await res.json()) as ShipState;
|
||||||
|
expect(queued.turn).toBe(ship.turn);
|
||||||
|
expect(queued.systems.find((s) => s.id === upgrade.id)!.level).toBe(upgrade.level); // no instant upgrade
|
||||||
|
expect(totalQty(queued.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - upgrade.level); // cost at queue time
|
||||||
|
const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_UPGRADE')!.remaining; // N = the team's live duration (Task 6/8)
|
||||||
|
|
||||||
|
// The ticks: the level-up lands on the Nth one.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
const row = after.systems.find((s) => s.id === upgrade.id)!;
|
const row = after.systems.find((s) => s.id === upgrade.id)!;
|
||||||
expect(row.level).toBe(upgrade.level + 1);
|
expect(row.level).toBe(upgrade.level + 1);
|
||||||
expect(after.turn).toBe(ship.turn + 1);
|
expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter
|
||||||
|
|
||||||
// The card badge flipped to the new level and the left column mirrors it.
|
// The card badge flipped to the new level and the left column mirrors it — both re-fetched by
|
||||||
|
// the tick, not by the click.
|
||||||
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
||||||
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
const target = build!;
|
const target = build!;
|
||||||
|
// The BUILD NEW button ARMS the action — the crew team builder mounts above the grid.
|
||||||
const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]');
|
const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]');
|
||||||
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||||
await card.getByRole('button', { name: 'BUILD NEW' }).click();
|
await card.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||||
|
|
||||||
|
// Dispatch — the POST only QUEUES the build: 200, no turn bump, the flat 3-parts cost already
|
||||||
|
// deducted, the system NOT yet built.
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/ship/build');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queued = (await res.json()) as ShipState;
|
||||||
|
expect(queued.turn).toBe(ship.turn);
|
||||||
|
expect(queued.systems.find((s) => s.id === target.id)!.built).toBe(false); // no instant build
|
||||||
|
expect(totalQty(queued.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 3); // cost at queue time
|
||||||
|
const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: the build lands (OFFLINE → LV 1) on the Nth one.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
const row = after.systems.find((s) => s.id === target.id)!;
|
const row = after.systems.find((s) => s.id === target.id)!;
|
||||||
expect(row.built).toBe(true);
|
expect(row.built).toBe(true);
|
||||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 });
|
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 });
|
||||||
|
expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter
|
||||||
|
|
||||||
// The card badge flipped OFFLINE → LV 1 and the left column's online count went up.
|
// The card badge flipped OFFLINE → LV 1 and the left column's online count went up.
|
||||||
await expect(card.getByText('LV 1')).toBeVisible();
|
await expect(card.getByText('LV 1')).toBeVisible();
|
||||||
@@ -322,7 +433,8 @@ 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 }) => {
|
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
|
||||||
// Research costs two parts, building WEAPONS three — scan until the hold has five. Parts drop on
|
// 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.
|
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
|
||||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
@@ -331,7 +443,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
|
|||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
if (unexplored.length > 0) {
|
if (unexplored.length > 0) {
|
||||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||||||
} else {
|
} else {
|
||||||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||||
}
|
}
|
||||||
@@ -347,25 +459,51 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
|
|||||||
await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible();
|
await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible();
|
||||||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled();
|
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled();
|
||||||
|
|
||||||
// SKILL TREE tab: research WEAPONS SYSTEMS (two parts) — the card flips to RESEARCHED.
|
// SKILL TREE tab: research WEAPONS SYSTEMS (two parts). The RESEARCH button ARMS the tech — the
|
||||||
|
// crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump, the
|
||||||
|
// cost already deducted, and the card NOT yet RESEARCHED (it flips on the resolving tick).
|
||||||
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
||||||
const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]');
|
const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]');
|
||||||
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
||||||
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2
|
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2
|
||||||
const turnBefore = ship.turn;
|
const turnBefore = ship.turn;
|
||||||
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
||||||
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
const res = await dispatchDefaultCrew(page, '/api/research');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[];
|
||||||
|
expect(queuedTechs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.state).toBe('AVAILABLE'); // no instant flip
|
||||||
|
// The research response is a bare TechDto[] — the cost deduction is checked on the ship snapshot.
|
||||||
|
const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
expect(queuedShip.turn).toBe(turnBefore);
|
||||||
|
expect(totalQty(queuedShip.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 2); // cost at queue time
|
||||||
|
const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: the tech flips RESEARCHED (and unlocks the gated build) on the Nth one.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k);
|
||||||
|
await expect(techCard.getByText('RESEARCHED')).toBeVisible(); // the card flips on the resolving tick
|
||||||
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn
|
expect(afterResearch.turn).toBe(turnBefore + n); // the tick, not the click, advances the counter
|
||||||
|
|
||||||
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds.
|
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and ARMS the build.
|
||||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||||
await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click();
|
await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||||
|
|
||||||
|
// Dispatch the build — again queue, not instant: 200, no turn bump, still OFFLINE, cost deducted.
|
||||||
|
const buildRes = await dispatchDefaultCrew(page, '/api/ship/build');
|
||||||
|
expect(buildRes.status()).toBe(200);
|
||||||
|
const queuedBuild = (await buildRes.json()) as ShipState;
|
||||||
|
expect(queuedBuild.turn).toBe(afterResearch.turn);
|
||||||
|
expect(queuedBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(false); // no instant build
|
||||||
|
expect(totalQty(queuedBuild.inventory, 'part')).toBe(totalQty(afterResearch.inventory, 'part') - 3); // cost at queue time
|
||||||
|
const n2 = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: WEAPONS comes online (OFFLINE → LV 1) on the Nth one.
|
||||||
|
for (let k = 1; k <= n2; k++) await tickTo(page, afterResearch.turn + k);
|
||||||
|
|
||||||
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
|
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
|
||||||
|
expect(afterBuild.turn).toBe(afterResearch.turn + n2);
|
||||||
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,6 +513,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
|
|||||||
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
|
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
|
||||||
const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state;
|
const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state;
|
||||||
if (navAiState() !== 'RESEARCHED') {
|
if (navAiState() !== 'RESEARCHED') {
|
||||||
|
// Every scan/jump is queued with a default crew and ticked to completion (resolveAction).
|
||||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) {
|
while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) {
|
||||||
@@ -382,7 +521,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
|
|||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
if (unexplored.length > 0) {
|
if (unexplored.length > 0) {
|
||||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||||||
} else {
|
} else {
|
||||||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||||
}
|
}
|
||||||
@@ -391,12 +530,25 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
|
|||||||
expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2);
|
expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// SKILL TREE tab: research NAVIGATION AI (two resources) — the card flips to RESEARCHED.
|
// SKILL TREE tab: research NAVIGATION AI (two resources). The RESEARCH button ARMS the tech —
|
||||||
|
// the crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump,
|
||||||
|
// the cost already deducted, and the card NOT yet RESEARCHED.
|
||||||
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
||||||
const techCard = page.getByRole('heading', { name: 'NAVIGATION AI' }).locator('xpath=ancestor::div[3]');
|
const techCard = page.getByRole('heading', { name: 'NAVIGATION AI' }).locator('xpath=ancestor::div[3]');
|
||||||
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
||||||
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2
|
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2
|
||||||
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/research');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[];
|
||||||
|
expect(queuedTechs.find((t) => t.slug === 'NAV_AI')!.state).toBe('AVAILABLE'); // no instant flip
|
||||||
|
const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
expect(queuedShip.turn).toBe(ship.turn);
|
||||||
|
expect(totalQty(queuedShip.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - 2); // cost at queue time
|
||||||
|
const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: the card flips RESEARCHED on the Nth one (the unlock lands with it).
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k);
|
||||||
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,11 +578,26 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
|
|||||||
|
|
||||||
// The two-hop systems render as distinct chart fields (// JUMP ×2 sublabel) — several at once, so .first().
|
// The two-hop systems render as distinct chart fields (// JUMP ×2 sublabel) — several at once, so .first().
|
||||||
await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/).first()).toBeVisible();
|
await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/).first()).toBeVisible();
|
||||||
|
// Click the target's chart field — it ARMS the node: the crew team builder floats over the chart.
|
||||||
await panel.getByRole('button', { name: target.name.toUpperCase() }).click();
|
await panel.getByRole('button', { name: target.name.toUpperCase() }).click();
|
||||||
|
|
||||||
|
// Dispatch with the default one-person crew — the POST only QUEUES the two-hop jump: 200, no
|
||||||
|
// turn bump, no landing yet (the origin is still `current` in the queue response).
|
||||||
|
const res = await dispatchDefaultCrew(page, '/api/jump');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const queued = (await res.json()) as WorldState;
|
||||||
|
expect(queued.turn).toBe(turnBefore);
|
||||||
|
expect(queued.systems.find((s) => s.current)?.id).toBe(world.systems.find((s) => s.current)!.id); // no instant landing
|
||||||
|
// The hop count now feeds the duration model, not a flat turn delta: N is the team's live
|
||||||
|
// duration for a two-hop jump (the route puts the hop count in the queued payload).
|
||||||
|
const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining;
|
||||||
|
|
||||||
|
// The ticks: the counter advances one per tick, and the jump lands on the Nth one.
|
||||||
|
for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k);
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // world state updated in one action
|
expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // the jump landed on the Nth tick
|
||||||
expect(after.turn).toBe(turnBefore + 2); // the turn cost scales with hop count (#35)
|
expect(after.turn).toBe(turnBefore + n); // the hop count scaled the duration, not a flat +2 (#35)
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
||||||
@@ -461,14 +628,19 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
|||||||
|
|
||||||
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
|
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
|
||||||
await expect(acceptButtons.nth(idx)).toBeEnabled();
|
await expect(acceptButtons.nth(idx)).toBeEnabled();
|
||||||
|
// Accepting is the one action in the suite that does NOT queue (JC-19): no crew params, and no
|
||||||
|
// turn bump — the turn counter only ever moves on a NEXT TURN tick.
|
||||||
|
const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
await acceptButtons.nth(idx).click();
|
await acceptButtons.nth(idx).click();
|
||||||
|
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
expect(worldAfter.turn).toBe(worldBefore.turn); // JC-19 — accept is free, it does not tick
|
||||||
|
|
||||||
// UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled.
|
// UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled.
|
||||||
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||||||
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||||||
await expect(acceptButtons.nth(idx)).toBeDisabled();
|
await expect(acceptButtons.nth(idx)).toBeDisabled();
|
||||||
|
|
||||||
// API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn).
|
// API confirms it: exactly one ACTIVE mission, the picked one.
|
||||||
const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
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)!]);
|
expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]);
|
||||||
}
|
}
|
||||||
@@ -477,9 +649,11 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
|||||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
|
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
|
||||||
|
|
||||||
// Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of
|
// Complete it. Every scan/jump is queued with a default crew and ticked to completion
|
||||||
// scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest
|
// (resolveAction) — the mission hooks run on the resolving ticks. EXPLORE/FIND: scan until done,
|
||||||
// jump path to its target (edges only ever get added, so the precomputed path stays valid).
|
// 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[] = [];
|
let deliverPath: number[] = [];
|
||||||
if (picked.type === 'DELIVER') {
|
if (picked.type === 'DELIVER') {
|
||||||
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
@@ -491,12 +665,12 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
|||||||
while (state !== 'DONE' && iterations < 80) {
|
while (state !== 'DONE' && iterations < 80) {
|
||||||
iterations++;
|
iterations++;
|
||||||
if (deliverPath.length > 0) {
|
if (deliverPath.length > 0) {
|
||||||
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
|
await resolveAction(page, '/api/jump', { systemId: deliverPath.shift()! });
|
||||||
} else {
|
} else {
|
||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
if (unexplored.length > 0) {
|
if (unexplored.length > 0) {
|
||||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||||||
} else {
|
} else {
|
||||||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||||
}
|
}
|
||||||
@@ -512,9 +686,11 @@ 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 }) => {
|
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
||||||
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
|
// 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.
|
// 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
|
||||||
|
// on, not the turn of the queueing POST.
|
||||||
const afterJump = await jumpToFreshSystem(page);
|
const afterJump = await jumpToFreshSystem(page);
|
||||||
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
||||||
const jumpTurn = afterJump.turn;
|
const jumpTurn = afterJump.turn; // the resolution tick's turn
|
||||||
|
|
||||||
// Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first.
|
// Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first.
|
||||||
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
@@ -523,23 +699,25 @@ test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
|||||||
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
}
|
}
|
||||||
const planet = sys.planets.find((p) => !p.explored)!;
|
const planet = sys.planets.find((p) => !p.explored)!;
|
||||||
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
|
// The explore POST only queues — no turn bump, no event yet — so tick it to completion: the scan
|
||||||
expect(exploreRes.status()).toBe(200);
|
// event lands on the resolving tick, and the post-tick /api/system snapshot carries that turn.
|
||||||
const explored = (await exploreRes.json()) as ExploreResult;
|
const explored = (await resolveAction(page, '/api/explore', { planetId: planet.id })) as SystemState;
|
||||||
|
const exploreTurn = explored.turn;
|
||||||
|
|
||||||
// API: this run's jump + scan are in the log, and the newer one (the scan) comes first.
|
// API: this run's jump + scan are in the log, each stamped with the tick it resolved on, and the
|
||||||
|
// newer one (the scan) comes first.
|
||||||
const events = (await (await page.request.get('/api/events')).json()) as EventDto[];
|
const events = (await (await page.request.get('/api/events')).json()) as EventDto[];
|
||||||
const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore');
|
const exploreEvent = events.find((e) => e.turn === exploreTurn && e.type === 'explore');
|
||||||
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
|
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
|
||||||
const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`);
|
const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`);
|
||||||
expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump
|
expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump
|
||||||
expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
|
expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
|
||||||
|
|
||||||
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased).
|
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased) with the resolution turns.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||||
const panel = page.getByRole('tabpanel');
|
const panel = page.getByRole('tabpanel');
|
||||||
await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible();
|
await expect(panel.getByText(new RegExp(`T${exploreTurn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible();
|
||||||
await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible();
|
await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user