CI / build + unit + e2e (pull_request) Failing after 6m37s
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
covering the first queued leg (same requiredFuelFor rule as /fly), the plane
immediately starts that leg from the arrival airport (stays 'flying',
departureSimTime = arrival instant, queue shortened). Short tank → grounds
safely with the queue kept intact; empty queue → unchanged grounding. Cargo
delivery for the reached airport runs first and is unaffected. Ordering kept:
delivery still runs before tickOnce's pause check, so a chained plane counts
as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
(append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
by index; active in-flight leg never affected; 400 non-integer/out-of-range,
404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
right-pane, mapData and seed tests.
v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
225 lines
11 KiB
TypeScript
225 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import type { StateSnapshot } from './game/state';
|
||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||
import { haversineKm } from './geo';
|
||
|
||
const AIRPORTS = new Map<string, AirportCoord>([
|
||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
|
||
]);
|
||
|
||
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||
return {
|
||
account: { cash: 0, reputation: 0 },
|
||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||
planes: [],
|
||
packages: [],
|
||
catalog: [
|
||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
|
||
],
|
||
fuelPricePerL: 0.8,
|
||
sellFraction: 0.5,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('planesGeoJson', () => {
|
||
it('returns an empty collection for a null snapshot', () => {
|
||
expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] });
|
||
});
|
||
|
||
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
||
const fc = planesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(fc.features).toHaveLength(2);
|
||
const grounded = fc.features[0];
|
||
expect(grounded.geometry.coordinates).toEqual([8.56, 50.03]); // [lon, lat]
|
||
expect(grounded.properties.flying).toBe(false);
|
||
expect(grounded.properties.modelLabel).toBe('ATR 72-600'); // catalog label, not raw model id
|
||
expect(grounded.properties.fuelText).toBe('6300 / 6300 L');
|
||
expect(grounded.properties.routeText).toBe('');
|
||
|
||
const flying = fc.features[1];
|
||
expect(flying.geometry.coordinates).toEqual([9.1, 51.2]); // live polled position
|
||
expect(flying.properties.flying).toBe(true);
|
||
expect(flying.properties.status).toBe('flying');
|
||
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
|
||
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport'); // from the plane's own dest fields (v0.12.0)
|
||
});
|
||
|
||
it('falls back to the raw model id when the catalog has no entry', () => {
|
||
const fc = planesGeoJson(
|
||
snapshot({
|
||
catalog: [],
|
||
planes: [
|
||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
|
||
],
|
||
}),
|
||
);
|
||
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||
});
|
||
|
||
it('leaves routeText empty for a flying plane without a destination name', () => {
|
||
const fc = planesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||
],
|
||
}),
|
||
);
|
||
expect(fc.features[0].properties.routeText).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
|
||
it('returns an empty collection for a null snapshot', () => {
|
||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||
});
|
||
|
||
it('draws nothing for grounded planes, whatever their packages', () => {
|
||
const fc = routesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ 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: [] },
|
||
],
|
||
}),
|
||
AIRPORTS,
|
||
);
|
||
expect(fc.features).toHaveLength(0);
|
||
});
|
||
|
||
it('flying plane → one line origin→dest_icao with the plane id', () => {
|
||
const fc = routesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ 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(1);
|
||
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||
const coords = fc.features[0].geometry.coordinates;
|
||
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled great-circle arc, not a 2-point chord (v0.12.2)
|
||
expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint
|
||
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
|
||
});
|
||
|
||
it('skips a flying plane whose endpoint is missing from the airport dataset', () => {
|
||
const fc = routesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||
],
|
||
}),
|
||
AIRPORTS,
|
||
);
|
||
expect(fc.features).toHaveLength(0);
|
||
});
|
||
|
||
it('one line per flying plane — multiple planes draw multiple legs', () => {
|
||
const fc = routesGeoJson(
|
||
snapshot({
|
||
planes: [
|
||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
|
||
],
|
||
}),
|
||
AIRPORTS,
|
||
);
|
||
expect(fc.features.map((f) => f.properties.planeId).sort()).toEqual(['p1', 'p2']);
|
||
});
|
||
});
|
||
|
||
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
|
||
it('returns an empty collection for the no-hover "" sentinel', () => {
|
||
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||
});
|
||
|
||
it('draws one great-circle line between two known airports with exact endpoints', () => {
|
||
const fc = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS);
|
||
expect(fc.features).toHaveLength(1);
|
||
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
|
||
const coords = fc.features[0].geometry.coordinates;
|
||
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
|
||
expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint
|
||
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
|
||
});
|
||
|
||
it('draws the same trace reversed (endpoints swapped, arc mirrored)', () => {
|
||
const ab = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS).features[0].geometry.coordinates;
|
||
const ba = hoverRouteGeoJson('EHAM', 'EDDF', AIRPORTS).features[0].geometry.coordinates;
|
||
expect(ba.length).toBe(ab.length);
|
||
// The slerp is symmetric up to floating-point rounding (1 − t ≠ (N−t)/N bit-exactly), so
|
||
// compare with a tolerance: each reversed point sits on the forward arc within ~1e-9 degrees.
|
||
for (let i = 0; i < ab.length; i++) {
|
||
expect(Math.abs(ba[i][0] - ab[ab.length - 1 - i][0])).toBeLessThan(1e-9);
|
||
expect(Math.abs(ba[i][1] - ab[ab.length - 1 - i][1])).toBeLessThan(1e-9);
|
||
}
|
||
});
|
||
|
||
it('returns an empty collection when the from airport is missing from the dataset', () => {
|
||
expect(hoverRouteGeoJson('ZZZZ', 'EDDV', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||
});
|
||
|
||
it('returns an empty collection when the to airport is missing from the dataset', () => {
|
||
expect(hoverRouteGeoJson('EDDF', 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||
});
|
||
});
|
||
|
||
describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => {
|
||
it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => {
|
||
const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport
|
||
const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS);
|
||
expect(fc.features).toHaveLength(1);
|
||
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
|
||
const coords = fc.features[0].geometry.coordinates;
|
||
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
|
||
expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint
|
||
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
|
||
});
|
||
|
||
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: [] });
|
||
});
|
||
});
|
||
|
||
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||
it('samples >2 points with the endpoints exactly at the airport coords', () => {
|
||
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
|
||
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); // 25 coordinates: 23 intermediate points
|
||
expect(arc[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat]
|
||
expect(arc[arc.length - 1]).toEqual([4.76, 52.31]); // EHAM
|
||
});
|
||
|
||
it('midpoint of a long route deviates from the straight chord (it is actually curved)', () => {
|
||
const from = AIRPORTS.get('EDDF')!;
|
||
const to = AIRPORTS.get('LEMD')!; // ~1400 km — sagitta ≈ 40 km off the chord
|
||
const arc = greatCircleArc(from, to);
|
||
const mid = arc[Math.floor(arc.length / 2)]; // t = 0.5 exactly (index SEGMENTS/2)
|
||
const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2];
|
||
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeGreaterThan(5); // km off the straight line
|
||
});
|
||
|
||
it('short routes still fine: exact endpoints and a nearly-straight arc', () => {
|
||
const from = AIRPORTS.get('EDDF')!;
|
||
const to = AIRPORTS.get('EDDV')!; // ~280 km — sagitta ≈ 1.5 km, visually near-straight
|
||
const arc = greatCircleArc(from, to);
|
||
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
|
||
expect(arc[0]).toEqual([from.lon, from.lat]);
|
||
expect(arc[arc.length - 1]).toEqual([to.lon, to.lat]);
|
||
const mid = arc[Math.floor(arc.length / 2)];
|
||
const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2];
|
||
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
|
||
});
|
||
});
|