Compare commits

..
Author SHA1 Message Date
kamilkrzywda 394d51d827 Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)
CI / build + unit + e2e (pull_request) Successful in 8m43s
2026-09-21 01:28:41 +00:00
kamilkrzywda 264778522c Merge pull request 'Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)' (#64) from feat/merge-onboard-into-fleet into master
CI / build + unit + e2e (push) Successful in 10m53s
2026-09-21 03:16:40 +02:00
kamilkrzywda e55078fb5d Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
2026-09-21 00:46:12 +00:00
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
13 changed files with 244 additions and 243 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v0.34.0 — 2026-09-21 01:26
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
## v0.33.0 — 2026-09-21 00:45
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
## 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`)
+5 -12
View File
@@ -376,17 +376,10 @@ body {
font-variant-numeric: tabular-nums;
}
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
.onboard-block {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
.onboard-block .contract-row {
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
a full-width fly-to button then refuel + abandon side by side below it; long destination names
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
.plane-row .contract-row {
background: transparent;
padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
@@ -395,7 +388,7 @@ body {
gap: 5px;
}
.onboard-block .contract-row .contract-sub {
.plane-row .contract-row .contract-sub {
margin-top: 0; /* row spacing comes from the container's gap */
}
+69 -115
View File
@@ -19,7 +19,7 @@ interface LeftPaneProps {
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
@@ -92,7 +92,7 @@ export async function retrySimPost(
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
@@ -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
@@ -199,27 +195,23 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
// abandon controls, and flying planes show their current leg.
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
const clockMs = state?.simState.clockMs ?? 0;
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? 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)) ??
@@ -341,8 +333,17 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<p className="pane-muted">No planes.</p>
) : (
<ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => (
// ISS-002: hovering a fleet row highlights the plane on the map.
{state.planes.map((p) => {
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
// On board section's cargo rows and queue legs, so all plane info lives in one place.
const cargo = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
// the whole block without flicker between sub-elements; cargo rows override with their
// own package hover (below).
<li
key={p.id}
data-testid={`plane-${p.id}`}
@@ -352,7 +353,18 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
<span className={`plane-status status-${p.status}`}>{p.status}</span>
{p.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{p.destName && (
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
{p.locationName} → {p.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{p.locationIcao}</span>
)}
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
@@ -362,48 +374,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
</li>
))}
</ul>
)}
</section>
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
@@ -415,15 +385,17 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
>
<button
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/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
@@ -434,8 +406,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
@@ -443,7 +415,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
@@ -462,66 +434,45 @@ 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.
ISS-054 (v0.33.0): rendered only when non-empty. */}
{p.queue.length > 0 && (
<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-${plane.id}-${i}`} className="queue-row">
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
duplicated in the DOM and broke Playwright strict mode. */}
{p.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.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-${plane.id}-${i}`}
data-testid={`queue-cancel-${p.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
<div className="market-row">
<select
data-testid={`queue-add-dest-${plane.id}`}
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-${plane.id}`}
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>
)}
</li>
);
})
})}
</ul>
)}
</section>
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between On board and Time. */}
everything else about our planes, between Fleet 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"
@@ -535,8 +486,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"
@@ -544,7 +496,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>
@@ -567,9 +519,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 });
}
}}
>
@@ -583,7 +537,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3>
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
+3 -3
View File
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}).toBe(0);
});
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
const block = page.getByTestId('onboard-plane-starter');
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
+3 -4
View File
@@ -70,10 +70,9 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
// its heading renders here…
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
// …and no longer in the right pane.
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
+14 -11
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,10 +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.
// ISS-055 (v0.31.0): queue testids are scoped by plane id (the e2e fleet is one plane here).
const addSelect = page.getByTestId('queue-add-dest-plane-starter');
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}`);
@@ -233,12 +233,15 @@ 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-plane-starter');
await expect(addButton).toBeDisabled();
await addSelect.selectOption(QUEUE);
await expect(addButton).toBeEnabled();
await addButton.click();
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
expect(dupQueue.status()).toBe(409);
// 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-plane-starter-0');
+2 -2
View File
@@ -268,8 +268,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible();
await expect(leg).toContainText('→');
+6 -10
View File
@@ -323,9 +323,9 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
}, { 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 });
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
@@ -519,13 +519,9 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
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 its scoped controls only.
const starterDest = page.getByTestId(`queue-add-dest-${STARTER}`);
await expect(starterDest.locator(`option[value="${b.icao}"]`)).toHaveCount(1, { timeout: 15_000 }); // airports fetched
await starterDest.selectOption(b.icao);
const starterAdd = page.getByTestId(`queue-add-${STARTER}`);
await expect(starterAdd).toBeEnabled();
await starterAdd.click();
// …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.
+31
View File
@@ -261,6 +261,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
});
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dupflying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
});
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
});
});
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
+9
View File
@@ -153,7 +153,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
throw new GameActionError(400, `unknown airport ${destIcao}`);
}
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
// leg already in the queue would only produce pointless repeated hops on landing.
if (plane.status === 'flying' && plane.destIcao === destIcao) {
throw new GameActionError(409, `already flying to ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue);
if (queue.includes(destIcao)) {
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
}
queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst);
+1 -1
View File
@@ -202,7 +202,7 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
+5
View File
@@ -260,6 +260,11 @@ export const openApiSpec = {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description:
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.31.0",
"version": "0.34.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",