Compare commits

..
7 Commits
Author SHA1 Message Date
kamilkrzywda 4e585cbed1 Merge pull request 'Queue via fly-to + Relocate; remove dedicated queue input (ISS-053, v0.32.0)' (#63) from feat/queue-via-fly-to-relocate into master
CI / build + unit + e2e (push) Successful in 9m28s
2026-09-21 02:07:45 +02:00
kamilkrzywda 89ad09a04d Queue via fly-to + Relocate; remove dedicated queue-add input (ISS-053, v0.32.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
- LeftPane fly-to button enabled for any plane state; posts to /queue when the plane is flying, else /fly
- Relocate lists ALL planes (relocatable); relocate-go posts to /queue for a flying selected plane, else /fly
- Delete queue-add select + Add-to-queue button and shared queueAddDestIcao state; leg rows/cancel kept
- e2e/queue.spec.ts test 1 adds its queued leg via the Relocate section (starter is flying)
- e2e/two-planes.spec.ts per-plane-queues test adds the starter's second leg via API POST /queue
2026-09-20 23:48:12 +00:00
kamilkrzywda bf2c46b959 Merge pull request 'Two-plane load fix + e2e coverage for 2-plane gameplay (ISS-055, v0.31.0)' (#62) from fix/two-planes-load-and-e2e into master
CI / build + unit + e2e (push) Successful in 9m42s
2026-09-21 01:08:55 +02:00
kamilkrzywda fa64e32f65 Fix two-planes e2e healPlaneHome: restore 1000× before waiting for landing (ISS-057 slows sim to 1×)
CI / build + unit + e2e (pull_request) Successful in 9m21s
2026-09-20 22:59:29 +00:00
kamilkrzywda 04b6f20a93 Fix two-planes e2e fuel 409s: refuel before return leg; healPlaneHome retries after refuel (ISS-055)
CI / build + unit + e2e (pull_request) Failing after 17m3s
2026-09-20 22:15:27 +00:00
kamilkrzywda acee806180 Fix two-planes.spec.ts load test: switch back to Jobs tab after buying via UI (tabs unmount inactive content)
CI / build + unit + e2e (pull_request) Failing after 8m26s
2026-09-20 21:56:00 +00:00
kamilkrzywda 1e3992f872 Two-plane load fix + e2e coverage (ISS-055, v0.31.0)
CI / build + unit + e2e (pull_request) Failing after 8m13s
- LoadControls preselects the first eligible plane so Load is one-click with N planes
- LeftPane queue testids scoped by plane id (queue-leg/cancel/add[-dest]-{plane.id})
  — unscoped ids duplicated in the DOM with two on-board blocks
- new POST /api/test/add-cash hook (FLIGHT_TEST_HOOKS=1, default €1M) so e2e can
  afford a second plane; returns the full state snapshot
- new serial spec e2e/two-planes.spec.ts: load, parallel fly + ISS-057 slow/resume,
  refuel, relocate, per-plane queues with auto-chain, sell (6 tests)
- e2e/queue.spec.ts locators updated to the scoped testids
2026-09-20 21:26:16 +00:00
7 changed files with 707 additions and 65 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v0.32.0 — 2026-09-20 23:47
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
## v0.31.0 — 2026-09-20 21:23
- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`)
- **New test hook: POST /api/test/add-cash (ISS-055).** Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body `{ amount?: number }` (positive finite, default €1M), gated on `FLIGHT_TEST_HOOKS=1` exactly like its siblings (`app/api/test/create-package`, `/expire-package`; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen
- **New serial e2e spec `e2e/two-planes.spec.ts` (ISS-055).** Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't `plane-starter`: **load** (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), **fly** (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves `paused === false && timeScale === 1`, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes `lastSelectedTimeScale` = 1000 in the /fly response), **refuel** (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), **relocate** (moves one plane, starter untouched), **queue** (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), **sell** (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1
- Verified: `tsc --noEmit` clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); `npx playwright test --list` = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack
## v0.30.0 — 2026-09-20 19:47
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
+58
View File
@@ -0,0 +1,58 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.31.0, ISS-055) — grants cash so the e2e suite can afford a second
// plane (ATR €200k vs the €20k starting balance). Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/create-package — same gate, same conventions.
//
// POST /api/test/add-cash
// body { amount?: number } — a positive finite number when provided; defaults to €1M.
// Adds to account.cash (rounded to cents) and returns the full state snapshot.
const DEFAULT_AMOUNT = 1_000_000;
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let amount = DEFAULT_AMOUNT;
if (input.amount !== undefined) {
if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) {
return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 });
}
amount = input.amount;
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+28 -47
View File
@@ -106,10 +106,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// a sensible default (first grounded plane with fuel below max).
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// ISS-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
const [queueAddDestIcao, setQueueAddDestIcao] = useState<string | null>(null);
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
@@ -209,17 +205,20 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
: [];
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
// the right-pane market row; with state null both fall back to null and the section isn't rendered.
const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
// market row; with state null both fall back to null and the section isn't rendered.
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
const relocatable = state ? state.planes : [];
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
const relocateDest =
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
// market row; reuses `grounded` above, and with state null falls back to null.
// market row; grounded-only, and with state null falls back to null.
const grounded = relocatable.filter((p) => p.status === 'available');
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
@@ -422,8 +421,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
disabled={actionsBusy}
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
// so "Fly to …" works from any state and the queue needs no dedicated input.
onClick={() => void runAction(`/api/planes/${plane.id}/${plane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
@@ -462,18 +463,21 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
add control reusing this pane's already-fetched airport options (no second fetch). */}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
/queue while a plane is flying, so no separate entry point is needed. */}
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two on-board blocks the
unscoped ids duplicated in the DOM and broke Playwright strict mode. */}
{plane.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${i}`} className="queue-row">
<div key={`${icao}-${i}`} data-testid={`queue-leg-${plane.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${i}`}
data-testid={`queue-cancel-${plane.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
@@ -482,32 +486,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
</button>
</div>
))}
<div className="market-row">
<select
data-testid="queue-add-dest"
aria-label="Airport to add to the flight queue"
value={queueAddDestIcao ?? ''}
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
>
<option value="">Select an airport…</option>
{airportOptions?.map((a) => (
<option key={a.icao} value={a.icao}>
{a.name}
</option>
))}
</select>
<button
type="button"
data-testid="queue-add"
className="pane-btn"
disabled={actionsBusy || queueAddDestIcao === null}
onClick={() => {
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
}}
>
Add to queue
</button>
</div>
</div>
</div>
);
@@ -519,7 +497,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
everything else about our planes, between On board and Time. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target.
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
With no planes or no loaded airport options there is nothing to preview → null. */}
<section
className="pane-section"
data-testid="relocate-section"
@@ -533,8 +511,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
onMouseLeave={() => onHover(null)}
>
<h3 className="pane-heading">Relocate</h3>
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board. */}
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board.
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
<div className="market-row market-row-relocate">
<select
data-testid="relocate-plane"
@@ -542,7 +521,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
value={relocatePlane?.id ?? ''}
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
>
{grounded.map((p) => (
{relocatable.map((p) => (
<option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
</option>
@@ -565,9 +544,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
data-testid="relocate-go"
className="pane-btn"
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
// relocation takes off when the current one lands.
onClick={() => {
if (relocatePlane && relocateDest) {
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
}
}}
>
+6 -4
View File
@@ -49,8 +49,9 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
// Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its
// selection in local state and defaults to the "Select a plane…" placeholder —
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
// selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
// first eligible plane so Load is one-click out of the box (matching the single-plane
// UX) while the picker still allows choosing a different plane.
function LoadControls({
pkg,
eligible,
@@ -63,8 +64,9 @@ function LoadControls({
onLoad: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// The user's pick while it stays eligible, else nothing (placeholder).
const effective = eligible.find((p) => p.id === selectedId) ?? null;
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
if (eligible.length === 1) {
return (
+13 -13
View File
@@ -1,7 +1,8 @@
import { test, expect, type Page } from '@playwright/test';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
@@ -216,9 +217,9 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the queue-add select — wait for it.
const addSelect = page.getByTestId('queue-add-dest');
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
@@ -232,20 +233,19 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected…
const addButton = page.getByTestId('queue-add');
await expect(addButton).toBeDisabled();
await addSelect.selectOption(QUEUE);
await expect(addButton).toBeEnabled();
await addButton.click();
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
// posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await destSelect.selectOption(QUEUE);
await page.getByTestId('relocate-go').click();
// …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-0');
const legRow = page.getByTestId('queue-leg-plane-starter-0');
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
// CANCEL it via the UI and assert it's gone.
await page.getByTestId('queue-cancel-0').click();
await expect(page.getByTestId('queue-leg-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
await page.getByTestId('queue-cancel-plane-starter-0').click();
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE);
+590
View File
@@ -0,0 +1,590 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
//
// Each test buys its own second plane via the UI (after a cash grant from the new
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
}
interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
emptyWeightKg: number;
speedKmh: number;
maxPayloadKg: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
// proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)
.slice(0, 2)
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409).
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
}
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
async function createPackage(
page: Page,
fromIcao: string,
toIcao: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
// post-grant snapshot (the route's response).
async function addCash(page: Page, amount?: number): Promise<StateBody> {
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
async function buySecondPlaneViaUi(page: Page): Promise<string> {
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible({ timeout: 15_000 });
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
await buyModel.selectOption(BUY_MODEL);
await page.getByTestId('market-buy').click();
let secondId: string | null = null;
await expect
.poll(async () => {
const s = await getState(page);
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
return secondId !== null;
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
.toBe(true);
return secondId!;
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
// carrying cargo, and clean baselines keep exact-cash assertions honest.
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
for (const pkg of s.packages) {
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
}
}
}
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
// specs, generalized to an arbitrary plane id.
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
await clearQueueViaApi(page, planeId);
let p = (await getState(page)).planes.find((x) => x.id === planeId);
if (!p) return; // already sold — nothing to heal
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
await setScale(page, SCALE);
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => {
const s = await getState(page);
const plane = s.planes.find((x) => x.id === planeId);
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
return plane?.status ?? '';
}, {
timeout: 240_000,
message: `waiting for an in-flight ${planeId} to land`,
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
}
if (p.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
await setScale(page, SCALE);
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
if (flyHome.status() === 409) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for ${planeId} to land back at the hub`,
})
.toBe(HUB);
}
}
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
// in flight) — leaves the fleet at exactly one plane.
async function sellNonStarters(page: Page): Promise<void> {
const s = await getState(page);
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
await abandonLeftoverCargo(page, p.id);
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
}
}
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
// no cargo on board — even after an interrupted previous run.
async function resetFleet(page: Page): Promise<void> {
await sellNonStarters(page);
await healPlaneHome(page, STARTER);
await abandonLeftoverCargo(page, STARTER);
}
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
test.beforeEach(async ({ page }) => {
await resetFleet(page);
});
test.afterEach(async ({ page }) => {
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
const [nearest] = await twoNearestToHub(page);
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
// The multi-plane picker lists BOTH planes…
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
await expect(picker).toBeVisible({ timeout: 15_000 });
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
const s0 = await getState(page);
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
// One-click Load binds the package to the preselected (first eligible) plane.
await loadBtn1.click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
.toBe(firstEligible.id);
// Second package — pick the OTHER plane explicitly via the dropdown.
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
await expect(picker2).toBeVisible({ timeout: 15_000 });
await picker2.selectOption(secondId);
await page.getByTestId(`load-${pkg2.id}`).click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
.toBe(secondId);
// Both planes carry cargo simultaneously — both on-board blocks render.
await expect(page.getByTestId(`onboard-${STARTER}`), 'the starter block renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`onboard-${secondId}`), 'the second plane block renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// Full tanks for both — the starter's may be partial from an earlier run.
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
const [nearest] = await twoNearestToHub(page);
const farthest = await farthestFlyableToHub(page);
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyA.status()).toBe(200);
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
expect(flyB.status()).toBe(200);
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
const s1 = (await flyB.json()) as StateBody;
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
// Fast-forward to the FIRST landing (the short hop)…
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for the first plane to land',
})
.toBe(true);
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for both planes to land',
})
.toBe(true);
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState(page);
expect(landed.simState.paused).toBe(false);
expect(landed.simState.timeScale).toBe(1);
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
expect(flyB2.status()).toBe(200);
let body = (await flyB2.json()) as StateBody;
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
// …but dispatching the LAST grounded plane restores the last manually selected speed.
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA2.status()).toBe(200);
body = (await flyA2.json()) as StateBody;
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
});
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// Partial tanks: a short hop each, to different destinations.
const [a, b] = await twoNearestToHub(page);
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for the first plane to land',
})
.toBe(true);
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for both planes to land',
})
.toBe(true);
const pre = await getState(page);
const starter = pre.planes.find((p) => p.id === STARTER)!;
const second = pre.planes.find((p) => p.id === secondId)!;
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
const refuelSelect = page.getByTestId('market-refuel-plane');
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100;
await refuelSelect.selectOption(secondId);
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
await page.getByTestId('market-refuel').click();
await expect
.poll(async () => {
const s = await getState(page);
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
.toBe(true);
const post = await getState(page);
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
});
test('relocate section moves one plane without touching the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
// The Relocate section lists BOTH grounded planes…
const relocateSelect = page.getByTestId('relocate-plane');
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
// Relocate ONLY the second plane…
await relocateSelect.selectOption(secondId);
await destSelect.selectOption(a.icao);
await page.getByTestId('relocate-go').click();
// …it is flying to the new airport while the starter stays put at EDDF.
await expect
.poll(async () => {
const s = await getState(page);
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
.toBe(true);
// Let it land — locationIcao flips to the destination on arrival.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for the relocated plane to land at ${a.icao}`,
})
.toBe(a.icao);
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
});
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
const secondId = await buySecondPlaneViaUi(page);
const [a, b] = await twoNearestToHub(page);
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination…
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true);
// …then restore speed so the chained leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
return !!p && p.status === 'available' && p.locationIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
.toBe(true);
// The second plane never moved — still grounded at EDDF with an empty queue.
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
});
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
// The Sell row lists BOTH grounded planes…
const sellSelect = page.getByTestId('market-sell-plane');
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
await sellSelect.selectOption(secondId);
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
await page.getByTestId('market-sell').click();
await expect
.poll(async () => {
const s = await getState(page);
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
.toBe(true);
// The last remaining plane can never be sold — even via the API.
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
expect(res.status()).toBe(409);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.30.0",
"version": "0.32.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",