Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
CI / build + unit + e2e (pull_request) Successful in 9m34s
This commit is contained in:
+5
-1
@@ -118,7 +118,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
|
||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before the steps below act on the starter, so the FAR
|
||||
// round-trip feasibility pin never sees a worn or broken plane.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
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×).
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all fourteen endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
@@ -32,6 +32,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
||||
'/api/planes/{planeId}/queue',
|
||||
'/api/planes/{planeId}/queue/cancel',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
// Real helpers (v0.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves
|
||||
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||
import { initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
|
||||
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers.
|
||||
import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog';
|
||||
|
||||
// v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in
|
||||
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short
|
||||
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts
|
||||
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate
|
||||
// passes; shortening the gate via the hook brings it back to service on the next tick.
|
||||
|
||||
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 STARTER = 'plane-starter';
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
model: string;
|
||||
burnPerKm: number;
|
||||
emptyWeightKg: number;
|
||||
maxFuel: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
catalog: CatalogEntry[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||
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);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||
async function setHealth(
|
||||
page: Page,
|
||||
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||
): Promise<StateBody> {
|
||||
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Closest airport to EDDF from /api/airports — the short fixed leg for the broken-burn flight.
|
||||
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
|
||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||
for (const f of fc.features) {
|
||||
if (f.properties.icao === HUB) continue;
|
||||
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||
nearest = f.properties;
|
||||
}
|
||||
}
|
||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||
async function healStarterHome(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
if (starter.status === 'repairing') {
|
||||
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 30_000,
|
||||
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||
})
|
||||
.toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
}
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
let flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
if (!flyHome.ok()) {
|
||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
}
|
||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||
async function restoreStarter(page: Page): Promise<void> {
|
||||
const s = await getState(page);
|
||||
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||
await setHealth(page, body);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||
// and leave a clean starter — full health, no active repair gate.
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
await restoreStarter(page);
|
||||
});
|
||||
|
||||
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait
|
||||
|
||||
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
// (a) Force health to 42 — snapshot AND UI show it.
|
||||
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||
|
||||
await page.goto('/');
|
||||
const row = page.getByTestId(`plane-${STARTER}`);
|
||||
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||
.toContainText('42%');
|
||||
|
||||
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel.
|
||||
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||
.toHaveText('broken', { timeout: 15_000 });
|
||||
|
||||
// Full tank for the flight (free no-op if already full)…
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
s = await getState(page);
|
||||
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
expect(starter.fuel).toBe(starter.maxFuel);
|
||||
|
||||
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch).
|
||||
const nearest = await nearestToHub(page);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for the broken plane to land at the nearest airport',
|
||||
})
|
||||
.toBe(nearest.icao);
|
||||
|
||||
// Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts).
|
||||
s = await getState(page);
|
||||
const landed = s.planes.find((p) => p.id === STARTER)!;
|
||||
const entry = s.catalog.find((e) => e.model === starter.model)!;
|
||||
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||
const wHub = weatherFor(HUB);
|
||||
const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon));
|
||||
expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6);
|
||||
// Wear clamped at 0 — the broken plane stays broken.
|
||||
expect(landed.health).toBe(0);
|
||||
|
||||
// (c) Repair through the UI: exact cash drop + "in repair" pill.
|
||||
const cashBefore = s.account.cash;
|
||||
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||
// checked against the exact game clock the server used (a later GET would tick further).
|
||||
const [res] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||
repairBtn.click(),
|
||||
]);
|
||||
expect(res.status()).toBe(200);
|
||||
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||
expect(repaired.status).toBe('repairing');
|
||||
expect(repaired.health).toBe(100); // restored to full…
|
||||
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||
|
||||
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||
.toHaveText('in repair', { timeout: 15_000 });
|
||||
|
||||
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
return p.status === 'available' && p.health === 100 ? true : false;
|
||||
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||
.toBe(true);
|
||||
});
|
||||
@@ -184,7 +184,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
||||
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
const s0 = await getState(page);
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
|
||||
@@ -275,8 +275,11 @@ async function sellNonStarters(page: Page): Promise<void> {
|
||||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||
// no cargo on board — even after an interrupted previous run.
|
||||
async function resetFleet(page: Page): Promise<void> {
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
await sellNonStarters(page);
|
||||
await healPlaneHome(page, STARTER);
|
||||
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||
await abandonLeftoverCargo(page, STARTER);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user