CI / build + unit + e2e (pull_request) Successful in 2m47s
The TabsList is now position:sticky top:0 with a z-index above the panel content. In wide mode it pins to the top of the OPERATIONS scroll area (#21's center-pane scroller); in narrow mode, where the body scrolls and there is no inner scroll container, it pins to the viewport. The existing opaque background keeps scrolled content from showing through.
481 lines
26 KiB
TypeScript
481 lines
26 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 });
|
|
|
|
// WORLD MAP is always tall enough to overflow (fixed-height chart) — deterministic across retries.
|
|
await page.getByRole('tab', { name: 'WORLD MAP' }).click();
|
|
|
|
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+$/);
|
|
});
|