Flight queue UI: dotted map line, left-pane Queue sub-section, e2e (ISS-043 part 2) #51
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-09-20 11:28
|
||||||
|
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
|
||||||
|
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
|
||||||
|
- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport `<select>` (`queue-add-dest`) + "Add to queue" button (`queue-add` → `POST …/queue {destIcao}`) reusing the pane's already-fetched `airportOptions` (no second `/api/airports` fetch); buttons disable on `actionsBusy`, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`) unchanged
|
||||||
|
- Tests: unit — `lib/mapData.test.ts` gains a `queuedRoutesGeoJson` block mirroring the `routesGeoJson` tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length `n_legs × SEGMENTS + 1`; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new `e2e/queue.spec.ts` (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via `queue-add-dest`/`queue-add`, assert `queue-leg-0` appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered `queue-routes` segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries `planeId: plane-starter`; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (`status 'flying'` to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (308, was 303); `npx playwright test --list` = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
## v0.25.0 — 2026-09-20 10:36
|
## v0.25.0 — 2026-09-20 10:36
|
||||||
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
|
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
|
||||||
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
|
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
|
||||||
|
|||||||
@@ -416,6 +416,30 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ISS-043 (v0.26.0) — flight-queue sub-section inside on-board blocks */
|
||||||
|
.queue-subsection {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
|
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
|
||||||
.toasts {
|
.toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+56
-1
@@ -107,6 +107,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// a sensible default (first grounded plane with fuel below max).
|
// a sensible default (first grounded plane with fuel below max).
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
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
|
// 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;
|
// 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
|
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||||
@@ -200,7 +204,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
|
// 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.
|
// abandon controls, and flying planes show their current leg.
|
||||||
const clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 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' || slotsUsedFor(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
|
// 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
|
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
|
||||||
@@ -455,6 +463,53 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</div>
|
</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). */}
|
||||||
|
<div className="queue-subsection">
|
||||||
|
<span className="queue-heading">Queue</span>
|
||||||
|
{plane.queue.map((icao, i) => (
|
||||||
|
<div key={`${icao}-${i}`} data-testid={`queue-leg-${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}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
+31
-1
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
|
|||||||
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
||||||
import type { StateSnapshot } from '../lib/game/state';
|
import type { StateSnapshot } from '../lib/game/state';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
|
|||||||
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
||||||
const ROUTES_SOURCE_ID = 'routes';
|
const ROUTES_SOURCE_ID = 'routes';
|
||||||
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — a flying plane's NOT-yet-started legs
|
||||||
|
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
||||||
|
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
||||||
|
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
||||||
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
||||||
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
||||||
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
||||||
@@ -95,6 +99,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
if (routesSource && airportsIndexRef.current) {
|
if (routesSource && airportsIndexRef.current) {
|
||||||
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
||||||
}
|
}
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — same airport-index guard as the solid legs above.
|
||||||
|
const queueRoutesSource = map.getSource(QUEUE_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (queueRoutesSource && airportsIndexRef.current) {
|
||||||
|
void queueRoutesSource.setData(queuedRoutesGeoJson(st.planes, airportsIndexRef.current));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -357,6 +366,27 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
||||||
|
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
|
||||||
|
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
|
||||||
|
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
||||||
|
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: queuedRoutesGeoJson(stateRef.current?.planes ?? [], airportsIndexRef.current ?? new Map()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer(QUEUE_ROUTES_LAYER_ID)) {
|
||||||
|
map.addLayer({
|
||||||
|
id: QUEUE_ROUTES_LAYER_ID,
|
||||||
|
type: 'line',
|
||||||
|
source: QUEUE_ROUTES_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
'line-color': '#64748b', // muted slate — planned legs, not actively flying
|
||||||
|
'line-width': 3,
|
||||||
|
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!map.getSource(PLANES_SOURCE_ID)) {
|
if (!map.getSource(PLANES_SOURCE_ID)) {
|
||||||
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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 DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||||
|
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
status: 'available' | 'flying';
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
queue: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageState {
|
||||||
|
id: string;
|
||||||
|
originIcao: string;
|
||||||
|
destIcao: string;
|
||||||
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
|
planeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
packages: PackageState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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. ISS-041 also clears pause: a
|
||||||
|
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
|
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||||||
|
});
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
return (await created.json()) as PackageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||||
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (plane.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
}
|
||||||
|
if (plane.locationIcao !== HUB) {
|
||||||
|
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||||
|
const s1 = await getState(page);
|
||||||
|
for (const pkg of s1.packages) {
|
||||||
|
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAirportCount(page: Page): Promise<void> {
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered features of the given layers in the current viewport, [] until the map exists.
|
||||||
|
function renderedFeatures(
|
||||||
|
page: Page,
|
||||||
|
layers: string[],
|
||||||
|
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
|
||||||
|
return page.evaluate((ls: string[]) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) return [];
|
||||||
|
try {
|
||||||
|
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
|
||||||
|
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
|
||||||
|
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
|
||||||
|
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
properties?: unknown;
|
||||||
|
geometry?: unknown;
|
||||||
|
}>).map((f) => ({
|
||||||
|
type: f.type,
|
||||||
|
id: f.id,
|
||||||
|
properties: f.properties,
|
||||||
|
geometry: f.geometry,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, layers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
|
||||||
|
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
|
||||||
|
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const points = icaos.map((icao) => {
|
||||||
|
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||||
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
|
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||||
|
});
|
||||||
|
await page.evaluate((pts: [number, number][]) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) throw new Error('map not ready');
|
||||||
|
const lons = pts.map((p) => p[0]);
|
||||||
|
const lats = pts.map((p) => p[1]);
|
||||||
|
// duration 0 → instant camera jump, no animation to wait out.
|
||||||
|
m.fitBounds(
|
||||||
|
[
|
||||||
|
[Math.min(...lons), Math.min(...lats)],
|
||||||
|
[Math.max(...lons), Math.max(...lats)],
|
||||||
|
],
|
||||||
|
{ padding: 40, duration: 0 },
|
||||||
|
);
|
||||||
|
}, points);
|
||||||
|
// Give the renderer a beat after the camera jump before asserting on rendered features.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
|
||||||
|
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||||
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
|
return [f.properties.lon, f.properties.lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
|
||||||
|
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
|
||||||
|
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
|
||||||
|
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
|
||||||
|
// any two EU airports.
|
||||||
|
const QUEUE_ROUTE_EPS_DEG = 0.05;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
|
||||||
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
|
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
|
||||||
|
await loadBtn.click();
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// START leg 1 via the UI (fly-to button in the on-board block).
|
||||||
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
await expect(flyBtn).toBeEnabled();
|
||||||
|
await flyBtn.click();
|
||||||
|
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
|
||||||
|
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||||||
|
}, { 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();
|
||||||
|
|
||||||
|
// …and the new leg appears as queue-leg-0 with its destination.
|
||||||
|
const legRow = page.getByTestId('queue-leg-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 });
|
||||||
|
|
||||||
|
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(home.status()).toBe(200);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
}).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
||||||
|
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(load.status()).toBe(200);
|
||||||
|
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(queueRes.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||||||
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
|
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
||||||
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
|
await fitToAirports(page, [DEST, QUEUE]);
|
||||||
|
|
||||||
|
const destLL = await airportLonLat(page, DEST);
|
||||||
|
const queueLL = await airportLonLat(page, QUEUE);
|
||||||
|
const hubLL = await airportLonLat(page, HUB);
|
||||||
|
const near = (a: [number, number], b: [number, number]) =>
|
||||||
|
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
geometry?: { type: string; coordinates: [number, number][] };
|
||||||
|
}>;
|
||||||
|
// Filter to real LineString segments — keeps this poll callback total (a throw inside
|
||||||
|
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
|
||||||
|
const segs = feats.filter(
|
||||||
|
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||||
|
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||||
|
);
|
||||||
|
if (!segs.length) return false;
|
||||||
|
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
|
||||||
|
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
|
||||||
|
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
|
||||||
|
// …and some ENDS within EPS of the queued airport.
|
||||||
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLL);
|
||||||
|
});
|
||||||
|
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
|
||||||
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||||||
|
// Every rendered feature belongs to plane-starter (the only queued plane).
|
||||||
|
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||||||
|
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
|
||||||
|
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
|
||||||
|
// to arrival, then fly home.
|
||||||
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancelRes.status()).toBe(200);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(home.status()).toBe(200);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
}).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(load.status()).toBe(200);
|
||||||
|
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
|
||||||
|
expect(queueRes.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||||||
|
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
||||||
|
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
|
||||||
|
|
||||||
|
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
|
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
|
});
|
||||||
+64
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm } from './geo';
|
||||||
|
|
||||||
const AIRPORTS = new Map<string, AirportCoord>([
|
const AIRPORTS = new Map<string, AirportCoord>([
|
||||||
@@ -140,6 +140,69 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only)', () => {
|
||||||
|
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(1);
|
||||||
|
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||||||
|
const coords = fc.features[0].geometry.coordinates;
|
||||||
|
// Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points.
|
||||||
|
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1);
|
||||||
|
expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin
|
||||||
|
expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD)
|
||||||
|
expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
|
||||||
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
|
||||||
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
|
||||||
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
|
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
|
||||||
it('returns an empty collection for the no-hover "" sentinel', () => {
|
it('returns an empty collection for the no-hover "" sentinel', () => {
|
||||||
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { StateSnapshot } from './game/state';
|
import type { PlaneState, StateSnapshot } from './game/state';
|
||||||
import { formatFuel } from './format';
|
import { formatFuel } from './format';
|
||||||
import { positionAt } from './sim/movement';
|
import { positionAt } from './sim/movement';
|
||||||
|
|
||||||
@@ -164,3 +164,31 @@ export function routesGeoJson(
|
|||||||
}
|
}
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
|
||||||
|
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
|
||||||
|
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
|
||||||
|
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
|
||||||
|
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
|
||||||
|
export function queuedRoutesGeoJson(
|
||||||
|
planes: PlaneState[],
|
||||||
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
|
): FeatureCollection<MapLineFeature> {
|
||||||
|
const features: MapLineFeature[] = [];
|
||||||
|
for (const p of planes) {
|
||||||
|
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
|
||||||
|
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
||||||
|
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
||||||
|
const coords: [number, number][] = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
|
||||||
|
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
|
||||||
|
}
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'LineString', coordinates: coords },
|
||||||
|
properties: { planeId: p.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.25.0",
|
"version": "0.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user