484 lines
27 KiB
TypeScript
484 lines
27 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { jumpToFreshSystem } from './helpers';
|
|
|
|
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
|
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
|
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 }
|
|
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;
|
|
};
|
|
|
|
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;
|
|
|
|
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 adjacent 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');
|
|
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
|
|
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
|
|
});
|
|
|
|
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
|
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 — clickable nodes are role=button with their name as accessible label.
|
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
|
|
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
|
expect(after.turn).toBe(before.turn + 1);
|
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
|
});
|
|
|
|
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
|
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).
|
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
|
|
|
// Wait until the jump response is applied to the chart — server state is committed by then, so
|
|
// the /api/world read below can't race the in-flight POST.
|
|
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
|
|
|
|
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
|
|
|
|
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
|
|
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 }) => {
|
|
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();
|
|
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
|
|
|
|
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
|
|
expect(after.turn).toBe(before.turn + 1);
|
|
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.
|
|
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 }) => {
|
|
// 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) {
|
|
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
|
|
expect(res.status()).toBe(200);
|
|
for (const item of ((await res.json()) as ExploreResult).outcome) {
|
|
found.set(`${item.kind}|${item.name}`, item.qty);
|
|
}
|
|
} else {
|
|
// Current system fully scanned — jump somewhere 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];
|
|
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
|
}
|
|
}
|
|
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 }) => {
|
|
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
|
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
|
|
|
await page.goto('/');
|
|
await page.getByRole('tab', { name: 'CREW' }).click();
|
|
|
|
for (const member of roster) {
|
|
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]');
|
|
await expect(card.getByText(member.role)).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 }) => {
|
|
// 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.
|
|
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) {
|
|
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
|
} 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];
|
|
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
|
}
|
|
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) {
|
|
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();
|
|
|
|
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 + 1);
|
|
|
|
// The card badge flipped to the new level and the left column mirrors it.
|
|
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
|
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
|
} else {
|
|
const target = build!;
|
|
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();
|
|
|
|
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 });
|
|
|
|
// 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 }) => {
|
|
// Research costs two parts, building WEAPONS three — scan until the hold has five. 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) {
|
|
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
|
} 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 card flips to RESEARCHED.
|
|
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();
|
|
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
|
|
|
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
|
expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn
|
|
|
|
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds.
|
|
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();
|
|
|
|
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
|
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
|
|
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
|
});
|
|
|
|
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
|
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();
|
|
await acceptButtons.nth(idx).click();
|
|
|
|
// 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 (accepting cost a turn).
|
|
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. 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) {
|
|
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
|
|
} 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) {
|
|
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
|
} 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 }) => {
|
|
// 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.
|
|
const afterJump = await jumpToFreshSystem(page);
|
|
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
|
const jumpTurn = afterJump.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)!;
|
|
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
|
|
expect(exploreRes.status()).toBe(200);
|
|
const explored = (await exploreRes.json()) as ExploreResult;
|
|
|
|
// API: this run's jump + scan are in the log, 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 === explored.turn && 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).
|
|
await page.goto('/');
|
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
|
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${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+$/);
|
|
});
|