Merge pull request 'Queue UI fixes: grayed duplicate routes, hover trace from landing airport, grounded queue lines (ISS-077, v0.41.0)' (#84) from feat/queue-ui-fixes into master
CI / build + unit + e2e (push) Successful in 9m18s
CI / build + unit + e2e (push) Successful in 9m18s
This commit was merged in pull request #84.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.41.0 — 2026-09-21 13:26
|
||||||
|
- **Queue UI fixes (ISS-077).** Three queue-related map/UI fixes, no server logic changed:
|
||||||
|
- **Would-409 controls are disabled.** The on-board "Fly to …" button (`components/LeftPane.tsx`) is additionally disabled while the plane is flying and its destination is already active or queued (title "Already in flight queue"); the Relocate section disables its GO button for the same reason AND renders the selected plane's active/queued airports as native disabled `<option>`s. Grounded planes are untouched — their clicks go to `/fly`, which has no such conflict
|
||||||
|
- **Hover trace starts at the landing airport.** Hovering an on-board package while its carrier is FLYING now traces from the carrier's LANDING airport (`destIcao`), not its live mid-flight position (`components/Map.tsx`) — the hover previews what happens AFTER this leg lands; the solid active-leg + dotted queue layers already show where it is. Grounded / no-active-leg carriers keep today's behavior exactly (live position = the plane's airport). `hoverTraceGeoJson` returns an empty collection when start == dest, so a package destined for the active leg's destination draws nothing there — the solid layer covers it
|
||||||
|
- **Grounded queues draw dotted lines.** `queuedRoutesGeoJson` (`lib/mapData.ts`) now also draws GROUNDED ('available') planes with a non-empty queue, chaining from their current airport (`locationIcao → queue[0] → …`); flying planes keep the `[destIcao, ...queue]` chain; 'repairing' planes draw nothing. Same source/layer — no new MapLibre layer
|
||||||
|
- **Tests.** Unit: `lib/mapData.test.ts` pins the grounded chain (exact endpoints), the unknown-first-point skip, the repairing skip and the start==dest empty trace; all flying-case pins stay green. E2E: `e2e/hover.spec.ts`'s mid-flight test is reworked — a package destined for a QUEUED stop beyond the active leg, hover traces from the landing airport to that stop with no segment starting at the departure airport (deterministic: paused right after dispatch); new `e2e/queue.spec.ts` test pins the grounded two-leg chain HUB → DEST → QUEUE in the `queue-routes` layer
|
||||||
|
|
||||||
## v0.40.0 — 2026-09-21 13:06
|
## v0.40.0 — 2026-09-21 13:06
|
||||||
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
||||||
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
||||||
|
|||||||
+15
-3
@@ -207,6 +207,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||||
const relocateDest =
|
const relocateDest =
|
||||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||||
|
// ISS-077 (v0.41.0): while flying, the Relocate click would POST /queue — block it when the selected
|
||||||
|
// destination is already active or queued (the server rejects that with 409). Grounded planes are unaffected.
|
||||||
|
const relocateBlocked =
|
||||||
|
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||||
|
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
// 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
|
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
||||||
@@ -408,6 +413,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
)}
|
)}
|
||||||
{cargo.map((pkg) => {
|
{cargo.map((pkg) => {
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
|
// ISS-077 (v0.41.0): while flying the click would POST /queue — disable it when the
|
||||||
|
// destination is already active or queued (the server rejects that with 409). Grounded
|
||||||
|
// clicks go to /fly, which has no such conflict.
|
||||||
|
const alreadyQueued = p.status === 'flying' && (p.destIcao === pkg.destIcao || p.queue.includes(pkg.destIcao));
|
||||||
return (
|
return (
|
||||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||||
@@ -424,7 +433,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`fly-to-${pkg.id}`}
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
className="pane-btn onboard-fly-btn"
|
className="pane-btn onboard-fly-btn"
|
||||||
disabled={actionsBusy}
|
disabled={actionsBusy || alreadyQueued}
|
||||||
|
title={alreadyQueued ? 'Already in flight queue' : undefined}
|
||||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
// 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.
|
// 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 })}
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
@@ -551,7 +561,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||||
>
|
>
|
||||||
{airportOptions?.map((a) => (
|
{airportOptions?.map((a) => (
|
||||||
<option key={a.icao} value={a.icao}>
|
// ISS-077 (v0.41.0): a flying plane's active/queued airports can't be re-selected — the
|
||||||
|
// server would reject them with 409; native disabled options, no text change.
|
||||||
|
<option key={a.icao} value={a.icao} disabled={relocatePlane?.status === 'flying' && (relocatePlane.destIcao === a.icao || relocatePlane.queue.includes(a.icao))}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -560,7 +572,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="relocate-go"
|
data-testid="relocate-go"
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
disabled={actionsBusy || relocatePlane === null || relocateDest === null || relocateBlocked}
|
||||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
// 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.
|
// relocation takes off when the current one lands.
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
+11
-1
@@ -153,10 +153,20 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
||||||
// not its origin airport — identical to before while grounded at the pickup, different once it
|
// not its origin airport — identical to before while grounded at the pickup, different once it
|
||||||
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
||||||
|
// ISS-077 (v0.41.0): a FLYING carrier's trace starts at its LANDING airport (destIcao), not the
|
||||||
|
// live mid-flight position — the hover previews what happens AFTER this leg lands, and the solid
|
||||||
|
// active-leg + dotted queue layers already show where it is now. Grounded / no active leg keeps
|
||||||
|
// today's behavior exactly (live position = the plane's airport). destIcao missing from the
|
||||||
|
// index → fall back to the live position rather than guessing.
|
||||||
const idx = airportsIndexRef.current ?? new Map();
|
const idx = airportsIndexRef.current ?? new Map();
|
||||||
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
||||||
|
const landing = carrier?.status === 'flying' && carrier.destIcao ? idx.get(carrier.destIcao) : undefined;
|
||||||
void hoverSource.setData(
|
void hoverSource.setData(
|
||||||
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
|
landing
|
||||||
|
? hoverTraceGeoJson(landing, toIcao, idx)
|
||||||
|
: carrier
|
||||||
|
? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx)
|
||||||
|
: hoverRouteGeoJson(fromIcao, toIcao, idx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
||||||
|
|||||||
+41
-32
@@ -15,6 +15,8 @@ const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights
|
|||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
|
// Amsterdam — a third airport, distinct from hub and destination (the queued stop in the ISS-077 test).
|
||||||
|
const QUEUE = 'EHAM';
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -500,7 +502,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
|
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
@@ -511,44 +513,42 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
// ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
// destination — load it, queue EHAM while grounded (pre-built routes are allowed), then start the
|
||||||
|
// HUB→DEST leg via the API (same pattern as e2e/map-interactions.spec.ts). The hover must trace
|
||||||
|
// from DEST (where this flight lands) to EHAM — NOT from the plane's live mid-flight position.
|
||||||
|
const pkg = await createPackage(page, HUB, QUEUE);
|
||||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(load.status()).toBe(200);
|
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 fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||||||
|
const flyBody = (await fly.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
|
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
|
||||||
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
|
||||||
const hubLonLat = await airportLonLat(page, HUB);
|
// airport regardless of progress, so no mid-flight position window is needed at all.
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
|
||||||
if (!p || p.status !== 'flying') return false;
|
|
||||||
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
|
|
||||||
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
|
|
||||||
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
|
|
||||||
await postSim(page, { paused: true });
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
|
// Both trace endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
await fitToAirports(page, [HUB, DEST]);
|
await fitToAirports(page, [DEST, QUEUE]);
|
||||||
|
|
||||||
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
|
||||||
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
|
|
||||||
const destLonLat = await airportLonLat(page, DEST);
|
const destLonLat = await airportLonLat(page, DEST);
|
||||||
|
const queueLonLat = await airportLonLat(page, QUEUE);
|
||||||
|
const hubLonLat = await airportLonLat(page, HUB);
|
||||||
|
|
||||||
await row.hover();
|
await row.hover();
|
||||||
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
|
// ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's
|
||||||
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
|
// destination (the queued stop EHAM), and NO segment may start near the departure airport — that
|
||||||
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
// was the old live-position behavior right after takeoff. Same per-tile clipping/quantization
|
||||||
|
// caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||||
const segs = feats.filter(
|
const segs = feats.filter(
|
||||||
@@ -558,11 +558,14 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
if (!segs.length) return false;
|
if (!segs.length) return false;
|
||||||
const near = (a: [number, number], b: [number, number]) =>
|
const near = (a: [number, number], b: [number, number]) =>
|
||||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||||
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
|
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLonLat));
|
||||||
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLonLat);
|
||||||
|
});
|
||||||
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||||||
return startsAtLive && endsAtDest && noneStartsAtHub;
|
return startsAtDest && endsAtQueue && noneStartsAtHub;
|
||||||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the landing airport (DEST) to the queued stop' }).toBe(true);
|
||||||
|
|
||||||
await unhover(page);
|
await unhover(page);
|
||||||
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||||
@@ -570,9 +573,12 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
message: 'waiting for the hover route trace to clear after mouseleave',
|
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
|
// Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain),
|
||||||
// flying home afterwards leaves the starter clean for the other specs.
|
// resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM,
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
// not DEST — it never delivered), 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); // also clears the pause above
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -580,6 +586,9 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
|||||||
@@ -350,6 +350,64 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
}).toBe(HUB);
|
}).toBe(HUB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('grounded plane with a pre-built queue draws its dotted route from its airport (ISS-077)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + map assertions only — no flight in this test
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// Pre-build a two-leg route while GROUNDED via the raw API (queuing is allowed from any state,
|
||||||
|
// ISS-043 part 1) — no package or flight needed. The dotted line must chain HUB → DEST → QUEUE.
|
||||||
|
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
|
expect(q1.status()).toBe(200);
|
||||||
|
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q2.status()).toBe(200);
|
||||||
|
|
||||||
|
// All chain endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
|
await fitToAirports(page, [HUB, DEST, QUEUE]);
|
||||||
|
|
||||||
|
const hubLL = await airportLonLat(page, HUB);
|
||||||
|
const destLL = await airportLonLat(page, DEST);
|
||||||
|
const queueLL = await airportLonLat(page, QUEUE);
|
||||||
|
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. "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 chain HUB → DEST → QUEUE may be clipped into per-tile segments — check across all of them:
|
||||||
|
// some segment STARTS within EPS of the hub (the plane's airport, NOT a flight destination)…
|
||||||
|
const startsAtHub = segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||||||
|
// …some ENDS within EPS of the last queued airport…
|
||||||
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLL);
|
||||||
|
});
|
||||||
|
// …and the chain passes THROUGH the first queued airport — an interior joint point survives in
|
||||||
|
// whichever tile contains it (not guaranteed to be a segment endpoint after clipping).
|
||||||
|
const passesThroughDest = segs.some((f) => f.geometry.coordinates.some((c) => near(c, destLL)));
|
||||||
|
// Every rendered feature belongs to plane-starter.
|
||||||
|
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||||||
|
return startsAtHub && endsAtQueue && passesThroughDest && ownedByStarter;
|
||||||
|
}, { timeout: 15_000, message: `waiting for the dotted queue line ${HUB} → ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup: cancel both queued legs (index 0 twice — each cancel shifts the rest down) so the
|
||||||
|
// starter stays grounded at HUB with an empty queue. No flight happened, nothing else to reset.
|
||||||
|
const c1 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c1.status()).toBe(200);
|
||||||
|
const c2 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c2.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
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
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|||||||
+39
-4
@@ -140,14 +140,40 @@ 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)', () => {
|
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => {
|
||||||
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
|
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
||||||
{ 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, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ 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, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
|
expect(fc.features).toHaveLength(1);
|
||||||
|
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||||||
|
const coords = fc.features[0].geometry.coordinates;
|
||||||
|
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // one arc, exact endpoints
|
||||||
|
expect(coords[0]).toEqual([8.56, 50.03]); // starts at the plane's airport (EDDF)
|
||||||
|
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // ends at the queued airport (EHAM)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +216,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
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, health: 100, repairUntilSimTime: null, queue: ['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, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||||
@@ -199,7 +225,10 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
|
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1', 'p3']); // p2 has an empty queue
|
||||||
|
const p3 = fc.features[1].geometry.coordinates;
|
||||||
|
expect(p3[0]).toEqual([9.68, 52.46]); // grounded chain starts at the plane's airport (EDDV)
|
||||||
|
expect(p3[p3.length - 1]).toEqual([-3.56, 40.47]); // …and ends at the queued airport (LEMD)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,6 +283,12 @@ describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the
|
|||||||
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
||||||
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ISS-077 (v0.41.0): a flying carrier's trace starts at its landing airport — when that IS the
|
||||||
|
// package's destination there is nothing to draw (the solid active-leg layer covers it).
|
||||||
|
it('returns an empty collection when the start point equals the dest airport', () => {
|
||||||
|
expect(hoverTraceGeoJson(AIRPORTS.get('EHAM')!, 'EHAM', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||||
|
|||||||
+18
-8
@@ -116,7 +116,10 @@ export function hoverRouteGeoJson(
|
|||||||
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
||||||
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
||||||
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
||||||
// collection when the destination is missing from the dataset — never guessed.
|
// collection when the destination is missing from the dataset — never guessed. ISS-077 (v0.41.0):
|
||||||
|
// also empty when the start point IS the destination airport itself (a flying carrier's trace now
|
||||||
|
// starts at its landing airport, which can equal the package's dest) — nothing to draw there; the
|
||||||
|
// solid active-leg layer covers that case.
|
||||||
export function hoverTraceGeoJson(
|
export function hoverTraceGeoJson(
|
||||||
from: AirportCoord,
|
from: AirportCoord,
|
||||||
toIcao: string,
|
toIcao: string,
|
||||||
@@ -124,6 +127,7 @@ export function hoverTraceGeoJson(
|
|||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const to = airportsByIcao.get(toIcao);
|
const to = airportsByIcao.get(toIcao);
|
||||||
if (!to) return EMPTY_LINES;
|
if (!to) return EMPTY_LINES;
|
||||||
|
if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: [
|
features: [
|
||||||
@@ -166,19 +170,25 @@ 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
|
// ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue,
|
||||||
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
|
// chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination
|
||||||
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
|
// (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer),
|
||||||
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
|
// and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current
|
||||||
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
|
// airport (locationIcao → queue[0] → …). 'repairing' planes draw nothing — they can't fly. Arcs are
|
||||||
|
// concatenated with the shared joint point dropped once. A plane is skipped — never guessed — when
|
||||||
|
// its first point or any queued ICAO is missing from the airport index; empty collection when
|
||||||
|
// nothing qualifies.
|
||||||
export function queuedRoutesGeoJson(
|
export function queuedRoutesGeoJson(
|
||||||
planes: PlaneState[],
|
planes: PlaneState[],
|
||||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const features: MapLineFeature[] = [];
|
const features: MapLineFeature[] = [];
|
||||||
for (const p of planes) {
|
for (const p of planes) {
|
||||||
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
|
if (p.queue.length === 0) continue;
|
||||||
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
// First point of the chain: a flying plane's landing airport, a grounded one's current airport.
|
||||||
|
const firstIcao = p.status === 'flying' ? p.destIcao : p.status === 'available' ? p.locationIcao : null;
|
||||||
|
if (!firstIcao) continue;
|
||||||
|
const pts = [firstIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
||||||
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
||||||
const coords: [number, number][] = [];
|
const coords: [number, number][] = [];
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.40.0",
|
"version": "0.41.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user