diff --git a/CHANGELOG.md b/CHANGELOG.md index 7719f42..54ad9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 ` ))} @@ -560,7 +572,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh type="button" data-testid="relocate-go" 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 // relocation takes off when the current one lands. onClick={() => { diff --git a/components/Map.tsx b/components/Map.tsx index 51ce84b..c360eab 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -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, // 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. + // 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 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( - 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 — diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index f8a1bf9..b95c827 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -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 // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. 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 { id: string; @@ -500,7 +502,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p .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 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 waitForAirportCount(page); - // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up. - const pkg = await createPackage(page, HUB, DEST); + // ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's + // 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' } }); 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 } }); 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 - // both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at - // the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window. - const hubLonLat = await airportLonLat(page, HUB); - 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. + // Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and + // auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused, + // every /api/state snapshot reports the same state — and the new rule's start point is the LANDING + // airport regardless of progress, so no mid-flight position window is needed at all. await postSim(page, { paused: true }); 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 }); - // Both endpoints must be in-viewport for queryRenderedFeatures to see the trace. - await fitToAirports(page, [HUB, DEST]); + // Both trace endpoints must be in-viewport for queryRenderedFeatures to see them. + 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 queueLonLat = await airportLonLat(page, QUEUE); + const hubLonLat = await airportLonLat(page, HUB); await row.hover(); - // ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination, - // and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile - // clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS. + // ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's + // destination (the queued stop EHAM), and NO segment may start near the departure airport — that + // 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 () => { const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; 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; 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; - const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat)); - const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat)); + const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], 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)); - return startsAtLive && endsAtDest && noneStartsAtHub; - }, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true); + return startsAtDest && endsAtQueue && noneStartsAtHub; + }, { 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 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', }).toBe(0); - // Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so - // flying home afterwards leaves the starter clean for the other specs. - await postSim(page, { timeScale: SCALE, paused: false }); + // Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain), + // resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM, + // 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 .poll(async () => { 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}` }) .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). await setScale(page, SCALE); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 64df588..c9e3edb 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -350,6 +350,64 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as }).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; + 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; 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.setTimeout(300_000); // self-heal + two chained legs at scale 1000 await flyHomeViaApi(page); diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index a419917..4527941 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -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)', () => { - it('draws nothing for a grounded plane, even with a non-empty queue', () => { +describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => { + it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => { 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'] }, ], 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); }); @@ -190,7 +216,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q 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( [ { 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, ); - 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', () => { 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)', () => { diff --git a/lib/mapData.ts b/lib/mapData.ts index 4cab195..7f0e46b 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -116,7 +116,10 @@ export function hoverRouteGeoJson( // 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 // 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( from: AirportCoord, toIcao: string, @@ -124,6 +127,7 @@ export function hoverTraceGeoJson( ): FeatureCollection { const to = airportsByIcao.get(toIcao); if (!to) return EMPTY_LINES; + if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace return { type: 'FeatureCollection', features: [ @@ -166,19 +170,25 @@ export function routesGeoJson( 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. +// ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue, +// chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination +// (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer), +// and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current +// 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( planes: PlaneState[], airportsByIcao: ReadonlyMap, ): FeatureCollection { 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 (p.queue.length === 0) continue; + // 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 const coords: [number, number][] = []; for (let i = 0; i < pts.length - 1; i++) { diff --git a/package.json b/package.json index 2b73086..83c86bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.40.0", + "version": "0.41.0", "private": true, "scripts": { "dev": "next dev -p 3000",