1093 lines
65 KiB
TypeScript
1093 lines
65 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
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).
|
||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||
// 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 InventoryItemDto { kind: string; name: string; qty: number }
|
||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskDto[] }
|
||
interface FoundItem { kind: string; name: string; qty: number }
|
||
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
||
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
|
||
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
|
||
|
||
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||
|
||
// BFS over the undirected jump routes — [next, ..., target], empty when already there.
|
||
const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => {
|
||
const adj = new Map<number, number[]>();
|
||
for (const [a, b] of edges) {
|
||
if (!adj.has(a)) adj.set(a, []);
|
||
if (!adj.has(b)) adj.set(b, []);
|
||
adj.get(a)!.push(b);
|
||
adj.get(b)!.push(a);
|
||
}
|
||
const prev = new Map<number, number>();
|
||
const seen = new Set([fromId]);
|
||
const queue = [fromId];
|
||
while (queue.length > 0 && !seen.has(toId)) {
|
||
const cur = queue.shift()!;
|
||
for (const next of adj.get(cur) ?? []) {
|
||
if (!seen.has(next)) {
|
||
seen.add(next);
|
||
prev.set(next, cur);
|
||
queue.push(next);
|
||
}
|
||
}
|
||
}
|
||
expect(seen.has(toId)).toBe(true); // the world graph is connected by construction
|
||
const path: number[] = [];
|
||
for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) {
|
||
path.unshift(cur);
|
||
}
|
||
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 }) => {
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||
});
|
||
|
||
test('game template tabs render (scificn-ui component layer wired in)', async ({ page }) => {
|
||
await page.goto('/');
|
||
// Stat card + footer badge prove the layout shell; Radix Tabs.Trigger renders role="tab".
|
||
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
||
await expect(page.getByRole('tab', { name: 'WORLD MAP' })).toBeVisible();
|
||
await expect(page.getByRole('tab', { name: 'CREW' })).toBeVisible();
|
||
await expect(page.getByRole('tab', { name: 'SKILL TREE' })).toBeVisible();
|
||
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
||
});
|
||
|
||
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||
await page.goto('/');
|
||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||
// The placeholder feed was replaced by the real log (v0.9.0): at least one T<turn> // <MESSAGE> line
|
||
// must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries.
|
||
await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible();
|
||
});
|
||
|
||
test('active tab survives a page reload (#27)', async ({ page }) => {
|
||
await page.goto('/');
|
||
// Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP).
|
||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false');
|
||
await page.getByRole('tab', { name: 'CREW' }).click();
|
||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||
|
||
await page.reload();
|
||
// The tab is restored from localStorage on mount — no API state involved, so this holds across retries.
|
||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||
});
|
||
|
||
test('tab bar stays pinned while panel content scrolls (#31)', async ({ page }) => {
|
||
await page.goto('/');
|
||
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
|
||
await page.setViewportSize({ width: 1280, height: 560 });
|
||
|
||
// SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall
|
||
// enough to overflow the shrunk pane on any save state, deterministic across retries. (#36 made
|
||
// WORLD MAP fill its pane instead of overflowing it, so it can no longer force the scroll.)
|
||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||
await expect(page.getByRole('heading', { name: 'HULL' })).toBeVisible();
|
||
|
||
const tablist = page.getByRole('tablist');
|
||
await expect(tablist).toBeVisible();
|
||
const before = await tablist.boundingBox();
|
||
expect(before).not.toBeNull();
|
||
|
||
// Scroll the nearest scroll container above the tab list all the way down.
|
||
const scrolled = await page.evaluate(() => {
|
||
let el = document.querySelector('[role="tablist"]')?.parentElement ?? null;
|
||
while (el && !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) el = el.parentElement;
|
||
if (!el) throw new Error('no scroll container above the tab list');
|
||
el.scrollTop = el.scrollHeight;
|
||
return el.scrollTop;
|
||
});
|
||
expect(scrolled).toBeGreaterThan(50); // content really overflowed — guards against a vacuous pass
|
||
|
||
await expect(tablist).toBeVisible();
|
||
const after = await tablist.boundingBox();
|
||
expect(after).not.toBeNull();
|
||
// Pinned: the tab bar's viewport position is unchanged even though the content scrolled away (#31).
|
||
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2);
|
||
});
|
||
|
||
test('world map shows the generated starting system and its neighbours', async ({ page }) => {
|
||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
// #35 — once NAVIGATION AI is researched the chart also offers two-hop systems, so the expected
|
||
// button count depends on the save's tech state (the CI DB persists across retries).
|
||
const techs = (await (await page.request.get('/api/tech')).json()) as { slug: string; state: string }[];
|
||
const maxHops = techs.some((t) => t.slug === 'NAV_AI' && t.state === 'RESEARCHED') ? 2 : 1;
|
||
|
||
await page.goto('/');
|
||
// Live chart title carries the turn counter.
|
||
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||
// The current system is marked, and every in-range system is a clickable chart field (role=button).
|
||
const current = world.systems.find((s) => s.current)!;
|
||
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
|
||
const panel = page.getByRole('tabpanel');
|
||
// Six on a fresh save without NAV AI; the two-hop ring joins once it is researched.
|
||
const jumpable = world.systems.filter((s) => s.id !== current.id && shortestJumpPath(current.id, s.id, world.edges).length <= maxHops);
|
||
await expect(panel.getByRole('button')).toHaveCount(jumpable.length);
|
||
});
|
||
|
||
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)!;
|
||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||
|
||
await page.goto('/');
|
||
// 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();
|
||
// 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;
|
||
expect(after.turn).toBe(before.turn + n);
|
||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||
});
|
||
|
||
test('#51: jumping into undiscovered territory mints 1–4 new systems, each routed to the target, and the graph stays connected', async ({ page }) => {
|
||
await drainQueue(page);
|
||
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
|
||
const after = await jumpToFreshSystem(page);
|
||
const target = after.systems.find((s) => s.current)!;
|
||
|
||
const beforeIds = new Set(before.systems.map((s) => s.id));
|
||
const fresh = after.systems.filter((s) => !beforeIds.has(s.id));
|
||
// #51: seeded-random 1–4 new systems (the frontier guarantee ensures at least one)
|
||
expect(fresh.length).toBeGreaterThanOrEqual(1);
|
||
expect(fresh.length).toBeLessThanOrEqual(4);
|
||
// all new systems are undiscovered
|
||
expect(fresh.every((s) => !s.discovered)).toBe(true);
|
||
// each new system is directly routed to the jump target
|
||
for (const n of fresh) {
|
||
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true);
|
||
}
|
||
// the whole graph stays connected: BFS from the target reaches every system
|
||
const adj = new Map<number, number[]>();
|
||
for (const [a, b] of after.edges) {
|
||
if (!adj.has(a)) adj.set(a, []);
|
||
if (!adj.has(b)) adj.set(b, []);
|
||
adj.get(a)!.push(b);
|
||
adj.get(b)!.push(a);
|
||
}
|
||
const seen = new Set([target.id]);
|
||
const queue = [target.id];
|
||
while (queue.length > 0) {
|
||
const cur = queue.shift()!;
|
||
for (const nb of adj.get(cur) ?? []) {
|
||
if (!seen.has(nb)) {
|
||
seen.add(nb);
|
||
queue.push(nb);
|
||
}
|
||
}
|
||
}
|
||
expect(seen.size).toBe(after.systems.length); // no orphaned systems
|
||
});
|
||
|
||
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)!;
|
||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||
|
||
await page.goto('/');
|
||
// 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();
|
||
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 ticks are done: the tick, not the queue, advances the counter (the button's
|
||
// TURN span is the shell's live counter; the last click lands the jump and shows TURN before+N).
|
||
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 newCurrent = after.systems.find((s) => s.current)!;
|
||
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):
|
||
// 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 expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
||
});
|
||
|
||
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
|
||
await page.goto('/');
|
||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||
|
||
// Header carries the real system name + turn counter.
|
||
await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible();
|
||
// One EXPLORE button per unexplored body (planets and moons alike).
|
||
const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ });
|
||
await expect(exploreButtons.first()).toBeVisible();
|
||
await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length);
|
||
});
|
||
|
||
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)!;
|
||
|
||
await page.goto('/');
|
||
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();
|
||
// 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;
|
||
expect(after.turn).toBe(before.turn + n);
|
||
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
|
||
|
||
// 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.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
|
||
});
|
||
|
||
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
|
||
let iterations = 0;
|
||
while (found.size === 0 && iterations < 60) {
|
||
iterations++;
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||
if (unexplored.length > 0) {
|
||
// Queue + tick the scan (resolveAction). The queue response's `outcome` is empty until the
|
||
// tick (JC-2), so the haul is read from the post-tick /api/system snapshot it returns:
|
||
// the inventory delta of this scan (the queue is clean at the start of every iteration).
|
||
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 {
|
||
// 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 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];
|
||
await resolveAction(page, '/api/jump', { systemId: targetId });
|
||
}
|
||
}
|
||
expect(found.size).toBeGreaterThan(0);
|
||
|
||
// Every find landed in the inventory with at least that qty.
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
for (const [key, qty] of found) {
|
||
const [kind, name] = key.split('|');
|
||
expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty);
|
||
}
|
||
});
|
||
|
||
test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
|
||
// GET /api/crew is the {crew, tasks} object since Task 6 (JC-18) — the roster is its crew field.
|
||
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.getByRole('tab', { name: 'CREW' }).click();
|
||
|
||
for (const member of crew) {
|
||
const heading = page.getByRole('heading', { name: member.name });
|
||
await expect(heading).toBeVisible();
|
||
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
||
const card = heading.locator('xpath=ancestor::div[3]');
|
||
// exact: the new skill line renders 'ENGINEERING', which a loose 'ENGINEER' role search
|
||
// would also match (substring) — the role line is the only element with the role's exact text.
|
||
await expect(card.getByText(member.role, { exact: true })).toBeVisible();
|
||
await expect(card.getByText(member.traitGood)).toBeVisible();
|
||
await expect(card.getByText(member.traitBad)).toBeVisible();
|
||
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
|
||
}
|
||
});
|
||
|
||
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
|
||
// keeps it going if a whole system is exhausted.
|
||
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);
|
||
// WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by
|
||
// the research test below, so this one only targets ungated types.
|
||
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built && s.type !== 'WEAPONS') : undefined);
|
||
|
||
let iterations = 0;
|
||
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
|
||
iterations++;
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||
if (unexplored.length > 0) {
|
||
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||
} else {
|
||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
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];
|
||
await resolveAction(page, '/api/jump', { systemId: targetId });
|
||
}
|
||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
}
|
||
|
||
const upgrade = upgradeTarget();
|
||
const build = !upgrade ? buildTarget() : undefined;
|
||
expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on
|
||
|
||
await page.goto('/');
|
||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||
|
||
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]');
|
||
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
|
||
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 row = after.systems.find((s) => s.id === upgrade.id)!;
|
||
expect(row.level).toBe(upgrade.level + 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 — both re-fetched by
|
||
// the tick, not by the click.
|
||
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
||
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
||
} else {
|
||
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]');
|
||
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||
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 row = after.systems.find((s) => s.id === target.id)!;
|
||
expect(row.built).toBe(true);
|
||
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.
|
||
await expect(card.getByText('LV 1')).toBeVisible();
|
||
await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible();
|
||
}
|
||
});
|
||
|
||
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.
|
||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
let iterations = 0;
|
||
while (totalQty(ship.inventory, 'part') < 5 && iterations < 80) {
|
||
iterations++;
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||
if (unexplored.length > 0) {
|
||
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||
} else {
|
||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||
}
|
||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
}
|
||
expect(totalQty(ship.inventory, 'part')).toBeGreaterThanOrEqual(5);
|
||
|
||
await page.goto('/');
|
||
|
||
// SHIP SYSTEMS tab: BUILD NEW on WEAPONS is disabled — gated behind the unresearched tech.
|
||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||
const weaponsCard = page.getByRole('heading', { name: 'WEAPONS' }).locator('xpath=ancestor::div[3]');
|
||
await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible();
|
||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled();
|
||
|
||
// 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();
|
||
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.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2
|
||
const turnBefore = ship.turn;
|
||
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 === '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;
|
||
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 ARMS the build.
|
||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||
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;
|
||
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();
|
||
});
|
||
|
||
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 }[];
|
||
const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state;
|
||
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 iterations = 0;
|
||
while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) {
|
||
iterations++;
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||
if (unexplored.length > 0) {
|
||
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||
} else {
|
||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||
}
|
||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
}
|
||
expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2);
|
||
|
||
await page.goto('/');
|
||
// 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();
|
||
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.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2
|
||
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();
|
||
}
|
||
|
||
// Pick a system exactly two hops from the current one. A completely fresh save has none (the start
|
||
// region is only one hop deep), so jump once first — that expansion creates the two-hop territory.
|
||
let world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
const findTwoHop = () => {
|
||
const currentId = world.systems.find((s) => s.current)!.id;
|
||
return world.systems.find((s) => shortestJumpPath(currentId, s.id, world.edges).length === 2);
|
||
};
|
||
let guard = 0;
|
||
while (!findTwoHop() && guard < 5) {
|
||
await jumpToFreshSystem(page); // +1 turn — jumping into undiscovered territory always expands the chart
|
||
world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
guard++;
|
||
}
|
||
const target = findTwoHop()!;
|
||
expect(target).toBeDefined();
|
||
|
||
await page.goto('/'); // fresh chart with NAV AI researched — two-hop fields are clickable now
|
||
// #27 restores whatever tab a previous step persisted (SKILL TREE after the research above), so
|
||
// activate WORLD MAP explicitly before asserting on the chart.
|
||
await page.getByRole('tab', { name: 'WORLD MAP' }).click();
|
||
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||
const panel = page.getByRole('tabpanel');
|
||
const turnBefore = world.turn;
|
||
|
||
// 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();
|
||
// 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();
|
||
|
||
// 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;
|
||
expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // the jump landed on the Nth tick
|
||
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 }) => {
|
||
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
|
||
|
||
await page.goto('/');
|
||
await page.getByRole('tab', { name: 'MISSIONS' }).click();
|
||
const acceptButtons = page.getByRole('button', { name: 'ACCEPT' });
|
||
const panel = page.getByRole('tabpanel');
|
||
|
||
// Idempotent across retries sharing a DB: if a previous attempt already accepted a mission, it's still
|
||
// ACTIVE and every ACCEPT button is disabled — complete that one instead of accepting again.
|
||
const leftover = board.find((m) => m.state === 'ACTIVE');
|
||
let picked: MissionDto;
|
||
|
||
if (leftover) {
|
||
picked = leftover;
|
||
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||
for (const button of await acceptButtons.all()) {
|
||
await expect(button).toBeDisabled();
|
||
}
|
||
} else {
|
||
// Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered).
|
||
picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!;
|
||
const idx = board.indexOf(picked);
|
||
|
||
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
|
||
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();
|
||
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.
|
||
// 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(acceptButtons.nth(idx)).toBeDisabled();
|
||
|
||
// API confirms it: exactly one ACTIVE mission, the picked one.
|
||
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)!]);
|
||
}
|
||
|
||
// Baseline of the reward kind in the hold — scans during completion may add more, so assert >=.
|
||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
|
||
|
||
// Complete it. Every scan/jump is queued with a default crew and ticked to completion
|
||
// (resolveAction) — the mission hooks run on the resolving ticks. EXPLORE/FIND: scan until done,
|
||
// 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[] = [];
|
||
if (picked.type === 'DELIVER') {
|
||
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges);
|
||
}
|
||
|
||
let state = 'ACTIVE';
|
||
let iterations = 0;
|
||
while (state !== 'DONE' && iterations < 80) {
|
||
iterations++;
|
||
if (deliverPath.length > 0) {
|
||
await resolveAction(page, '/api/jump', { systemId: deliverPath.shift()! });
|
||
} else {
|
||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||
if (unexplored.length > 0) {
|
||
await resolveAction(page, '/api/explore', { planetId: unexplored[0].id });
|
||
} else {
|
||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||
}
|
||
}
|
||
state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE';
|
||
}
|
||
expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite
|
||
|
||
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
|
||
});
|
||
|
||
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
|
||
// on, not the turn of the queueing POST.
|
||
const afterJump = await jumpToFreshSystem(page);
|
||
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
||
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.
|
||
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
if (!sys.planets.some((p) => !p.explored)) {
|
||
await jumpToFreshSystem(page); // fresh systems always have unscanned bodies
|
||
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||
}
|
||
const planet = sys.planets.find((p) => !p.explored)!;
|
||
// The explore POST only queues — no turn bump, no event yet — so tick it to completion: the scan
|
||
// event lands on the resolving tick, and the post-tick /api/system snapshot carries that turn.
|
||
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, 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 exploreEvent = events.find((e) => e.turn === exploreTurn && e.type === 'explore');
|
||
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
|
||
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(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
|
||
|
||
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased) with the resolution turns.
|
||
await page.goto('/');
|
||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||
const panel = page.getByRole('tabpanel');
|
||
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();
|
||
});
|
||
|
||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||
const res = await page.request.get('/api/version');
|
||
expect(res.status()).toBe(200);
|
||
expect(res.headers()['content-type']).toContain('application/json');
|
||
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);
|
||
};
|
||
|
||
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. 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;
|
||
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;
|
||
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);
|
||
}
|
||
// 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
|
||
// 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);
|
||
});
|
||
|
||
test('NEXT TURN button is full-width in the right pane and not covered by the task strip (#52)', 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 { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
||
expect(crew.length).toBeGreaterThanOrEqual(2);
|
||
|
||
// Queue a one-person JUMP and deliberately do not tick it (the #44 jump scenarios' pattern,
|
||
// the in-transit 409 one leaves it that way on purpose): the strip renders from the shell's
|
||
// system snapshot — the same queue — so a non-empty queue keeps ACTIVE TASKS visible all test.
|
||
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.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
|
||
|
||
await page.goto('/');
|
||
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toBeVisible(); // the strip is up
|
||
|
||
// Two rects intersect when their x AND y ranges overlap (touching edges do not count).
|
||
type Box = { x: number; y: number; width: number; height: number };
|
||
const intersects = (a: Box, b: Box) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
||
|
||
const buttonBox = await page.getByRole('button', { name: 'NEXT TURN' }).boundingBox();
|
||
expect(buttonBox).not.toBeNull();
|
||
// The strip's root div is the parent of its 'ACTIVE TASKS' label (components/task-strip.tsx).
|
||
const stripBox = await page.getByText('ACTIVE TASKS', { exact: true }).locator('xpath=..').boundingBox();
|
||
expect(stripBox).not.toBeNull();
|
||
// The CARGO HOLD panel: the Panel root is the h3 heading's grandparent div (components/ui/panel.tsx).
|
||
const panelBox = await page.getByRole('heading', { name: 'CARGO HOLD // INVENTORY' }).locator('xpath=ancestor::div[2]').boundingBox();
|
||
expect(panelBox).not.toBeNull();
|
||
|
||
// (a) The button spans the right pane's content width — >= 280 on its own, and within ±8px of
|
||
// the CARGO HOLD panel, a sibling full-width element in the same pane column.
|
||
expect(buttonBox!.width).toBeGreaterThanOrEqual(280);
|
||
expect(Math.abs(buttonBox!.width - panelBox!.width)).toBeLessThanOrEqual(8);
|
||
|
||
// (b) The button's box does not intersect the active-tasks strip's box.
|
||
expect(intersects(buttonBox!, stripBox!)).toBe(false);
|
||
|
||
await drainQueue(page); // leave the queue clean for the next scenario
|
||
});
|