Files
Flight-Ops/app/api/state/route.test.ts
T
kamilkrzywda a0560ec767
CI / build + unit + e2e (pull_request) Failing after 12m10s
Fixed world clock: no player time controls (ISS-117)
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
  lib/timeScale.ts, LeftPane pause button + scale slider machinery,
  ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
  rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
  (sibling of set-health); all e2e setScale helpers call it, afterEach restores
  the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
  activates the world via set-health + /repair for advance/expiry tests.
2026-09-22 10:50:21 +00:00

108 lines
4.6 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route';
let dir: string;
beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never
// touches ./db/flight.db. getGameDb() reads the env var on first call, and
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
describe('GET /api/state', () => {
it('returns 200 with the full snapshot shape', async () => {
const res = await GET();
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
]);
const account = body.account as { cash: number; reputation: number };
expect(typeof account.cash).toBe('number');
expect(typeof account.reputation).toBe('number');
const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
expect(typeof sim.clockMs).toBe('number');
const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key);
}
expect(typeof planes[0].locationName).toBe('string');
// The seeded starter is grounded — no flight destination yet.
expect(planes[0].destIcao).toBeNull();
expect(planes[0].destName).toBeNull();
const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key);
}
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
// fixture airports' coordinates for every package in the snapshot.
const fc = await loadAirportsGeoJson();
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
for (const pkg of packages) {
const from = coords.get(String(pkg.originIcao));
const to = coords.get(String(pkg.destIcao));
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
}
});
it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash);
});
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{
catalog: Array<Record<string, unknown>>;
fuelPricePerL: number;
sellFraction: number;
}>;
const snap = await body;
// One entry per PLANE_TYPES model, values copied straight from the catalog.
expect(snap.catalog.map((e) => e.model).sort()).toEqual(Object.keys(PLANE_TYPES).sort());
for (const [model, type] of Object.entries(PLANE_TYPES)) {
const entry = snap.catalog.find((e) => e.model === model)!;
expect(entry.label).toBe(type.name);
expect(entry.price).toBe(type.price);
expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh);
expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
}
// Economy constants — the single source of truth stays lib/game/catalog.ts.
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION);
});
});