890 lines
40 KiB
TypeScript
890 lines
40 KiB
TypeScript
import fs from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { eq } from 'drizzle-orm';
|
||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||
import * as schema from '@/lib/db/schema';
|
||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||
import { weatherFor } from '@/lib/weather';
|
||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||
import {
|
||
BROKEN_BURN_MULTIPLIER,
|
||
FUEL_DISCOUNT_PER_REP,
|
||
FUEL_PRICE_PER_L,
|
||
MAX_FUEL_DISCOUNT,
|
||
MAX_PLANE_DISCOUNT,
|
||
PLANE_DISCOUNT_PER_REP,
|
||
PLANE_TYPES,
|
||
REPAIR_COST,
|
||
REPAIR_DURATION_MS,
|
||
repRate,
|
||
SELL_FRACTION,
|
||
} from './catalog';
|
||
import { HUB_ICAO } from './seed';
|
||
import {
|
||
abandonPackage,
|
||
buyPlane,
|
||
cancelQueuedLeg,
|
||
flyPlane,
|
||
GameActionError,
|
||
ignorePackage,
|
||
loadPackage,
|
||
queueLeg,
|
||
refuelPlane,
|
||
repairPlane,
|
||
sellPlane,
|
||
} from './actions';
|
||
|
||
// Action tests run against throwaway DB files (openGameDb) with fake timers so
|
||
// the seeded sim clock is a known constant. Actions are called directly (no tick —
|
||
// engine.runAction() wraps them in production; its lock/tick behavior is covered
|
||
// by the route tests and e2e).
|
||
|
||
let dir: string;
|
||
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
||
|
||
beforeAll(async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(T0);
|
||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-actions-'));
|
||
});
|
||
|
||
afterAll(async () => {
|
||
vi.useRealTimers();
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
async function freshDb(name: string): Promise<GameDb> {
|
||
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
|
||
return openGameDb(path.join(dir, name));
|
||
}
|
||
|
||
function expectActionError(promise: Promise<unknown>, status: number, messagePart?: RegExp) {
|
||
return promise.then(
|
||
() => {
|
||
throw new Error(`expected GameActionError(${status}) but the action succeeded`);
|
||
},
|
||
(err) => {
|
||
expect(err).toBeInstanceOf(GameActionError);
|
||
expect((err as GameActionError).status).toBe(status);
|
||
if (messagePart) expect((err as GameActionError).message).toMatch(messagePart);
|
||
},
|
||
);
|
||
}
|
||
|
||
// Insert a known OPEN package EDDF → LEMD for load tests.
|
||
async function insertOpenPackage(inst: GameDb, id = 'p-open', over: Partial<schema.PackageRow> = {}): Promise<void> {
|
||
await inst.db.insert(schema.packages).values({
|
||
id,
|
||
originIcao: HUB_ICAO,
|
||
destIcao: 'LEMD',
|
||
reward: 1_000,
|
||
cargoType: 'Mail',
|
||
cargoCount: 4,
|
||
deadlineSimTime: T0 + 24 * 3_600_000,
|
||
status: 'open',
|
||
planeId: null,
|
||
createdSimTime: T0,
|
||
...over,
|
||
});
|
||
}
|
||
|
||
describe('loadPackage', () => {
|
||
it('binds an open package to a grounded plane at its origin', async () => {
|
||
const inst = await freshDb('load-ok.db');
|
||
await insertOpenPackage(inst);
|
||
|
||
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
||
|
||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||
expect(row.status).toBe('loaded');
|
||
expect(row.planeId).toBe('plane-starter');
|
||
});
|
||
|
||
it.each(['loaded', 'delivered', 'expired', 'abandoned'] as const)(
|
||
'rejects a %s package with 409 (must be open)',
|
||
async (status) => {
|
||
const inst = await freshDb(`load-${status}.db`);
|
||
await insertOpenPackage(inst, 'p-x', { status });
|
||
|
||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
||
},
|
||
);
|
||
|
||
it('rejects an unknown package id with 404', async () => {
|
||
const inst = await freshDb('load-punknown.db');
|
||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
||
});
|
||
|
||
it('rejects an unknown plane id with 404', async () => {
|
||
const inst = await freshDb('load-ghost.db');
|
||
await insertOpenPackage(inst);
|
||
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
||
});
|
||
|
||
it('rejects a flying plane with 409', async () => {
|
||
const inst = await freshDb('load-flying.db');
|
||
await insertOpenPackage(inst);
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
||
});
|
||
|
||
it('rejects a plane that is not at the origin with 409', async () => {
|
||
const inst = await freshDb('load-notatorigin.db');
|
||
await insertOpenPackage(inst); // originates at EDDF
|
||
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
||
});
|
||
|
||
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
||
const inst = await freshDb('load-full.db');
|
||
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
|
||
// Fill past maxPayloadKg with loaded cargo…
|
||
const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
|
||
for (let i = 0; i < 3; i++) {
|
||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
||
}
|
||
|
||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||
});
|
||
|
||
it('counts expired packages as occupying their payload too', async () => {
|
||
const inst = await freshDb('load-expiredslot.db');
|
||
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
|
||
// Half + half (expired) leaves no room — each alone would fit.
|
||
const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
|
||
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||
|
||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||
});
|
||
});
|
||
|
||
describe('flyPlane', () => {
|
||
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||
const inst = await freshDb('fly-ok.db');
|
||
|
||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.status).toBe('flying');
|
||
expect(plane.destIcao).toBe('LEMD');
|
||
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
||
const fc = await loadAirportsGeoJson();
|
||
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||
expect(plane.lat).toBe(origin.lat);
|
||
expect(plane.lon).toBe(origin.lon);
|
||
// Fuel is frozen at the departure value while flying (live fuel is analytic).
|
||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||
});
|
||
|
||
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
|
||
const inst = await freshDb('fly-resume.db');
|
||
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
|
||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
|
||
|
||
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
|
||
|
||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
|
||
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
|
||
});
|
||
|
||
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
|
||
const inst = await freshDb('fly-stayslow.db');
|
||
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
|
||
// A second grounded plane — the starter taking off leaves it on the ground.
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||
});
|
||
|
||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||
|
||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||
expect(sim.paused).toBe(false);
|
||
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
|
||
});
|
||
|
||
it('rejects a flying plane with 409', async () => {
|
||
const inst = await freshDb('fly-flying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
||
});
|
||
|
||
it('rejects an unknown plane id with 404', async () => {
|
||
const inst = await freshDb('fly-ghost.db');
|
||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
||
});
|
||
|
||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||
const inst = await freshDb('fly-unknownicao.db');
|
||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||
});
|
||
|
||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
||
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
|
||
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||
});
|
||
|
||
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', async () => {
|
||
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||
const fc = await loadAirportsGeoJson();
|
||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||
const type = PLANE_TYPES.atr72;
|
||
const w = weatherFor(HUB_ICAO);
|
||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||
// The tank holds EXACTLY the healthy requirement — a broken plane needs more than that.
|
||
await inst.db.update(schema.planes)
|
||
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||
|
||
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||
const inst2 = await freshDb('fly-broken-healthy.db');
|
||
await inst2.db.update(schema.planes)
|
||
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
|
||
});
|
||
});
|
||
|
||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||
const inst = await freshDb('qleg-ok.db');
|
||
|
||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||
|
||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
||
plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||
});
|
||
|
||
it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => {
|
||
const inst = await freshDb('qleg-flying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.status).toBe('flying'); // still on its active leg…
|
||
expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight
|
||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']);
|
||
});
|
||
|
||
it('rejects an unknown plane id with 404', async () => {
|
||
const inst = await freshDb('qleg-ghost.db');
|
||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
||
});
|
||
|
||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||
const inst = await freshDb('qleg-unknownicao.db');
|
||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||
});
|
||
|
||
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||
const inst = await freshDb('qleg-dup.db');
|
||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||
|
||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||
});
|
||
|
||
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
|
||
const inst = await freshDb('qleg-dupflying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||
});
|
||
|
||
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||
const inst = await freshDb('qleg-dupother.db');
|
||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||
});
|
||
});
|
||
|
||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||
const inst = await freshDb('qcancel-ok.db');
|
||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
||
|
||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
||
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||
});
|
||
|
||
it('rejects an out-of-range index with 400', async () => {
|
||
const inst = await freshDb('qcancel-oob.db');
|
||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
||
});
|
||
|
||
it('rejects cancelling from an empty queue with 400', async () => {
|
||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
||
});
|
||
|
||
it('rejects an unknown plane id with 404', async () => {
|
||
const inst = await freshDb('qcancel-ghost.db');
|
||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
||
});
|
||
});
|
||
|
||
describe('abandonPackage', () => {
|
||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
||
const REWARD = 1_000;
|
||
const WINDOW_MS = 10 * 3_600_000;
|
||
|
||
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
||
const inst = await freshDb(name);
|
||
// Always insert OPEN and load it through the real action…
|
||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
|
||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||
if (status === 'expired') {
|
||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||
}
|
||
return inst;
|
||
}
|
||
|
||
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
|
||
const inst = await setupLoaded('ab-start.db');
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||
|
||
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||
expect(row.status).toBe('abandoned');
|
||
expect(row.planeId).toBeNull(); // slot freed
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
|
||
});
|
||
|
||
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||
const inst = await setupLoaded('ab-mid.db');
|
||
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||
|
||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||
|
||
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||
});
|
||
|
||
it('caps at exactly 60% at and past the deadline', async () => {
|
||
for (const [name, clock] of [
|
||
['ab-deadline.db', T0 + WINDOW_MS],
|
||
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||
] as const) {
|
||
const inst = await setupLoaded(name);
|
||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||
|
||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||
}
|
||
});
|
||
|
||
it('is free for an already-expired package', async () => {
|
||
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||
|
||
expect(penalty).toBe(0);
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||
expect(row.status).toBe('abandoned');
|
||
});
|
||
|
||
it('floors cash at €0 — no debt', async () => {
|
||
const inst = await setupLoaded('ab-floor.db');
|
||
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
|
||
|
||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||
|
||
expect(penalty).toBe(150);
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
|
||
});
|
||
|
||
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||
const inst = await setupLoaded('ab-flying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
||
});
|
||
|
||
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||
const inst = await freshDb(`ab-${status}.db`);
|
||
await insertOpenPackage(inst, 'p-x', { status });
|
||
|
||
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
||
});
|
||
|
||
it('rejects an unknown id with 404', async () => {
|
||
const inst = await freshDb('ab-unknown.db');
|
||
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
||
});
|
||
});
|
||
|
||
describe('ignorePackage', () => {
|
||
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
|
||
const inst = await freshDb('ig-open.db');
|
||
await insertOpenPackage(inst, 'p-ig');
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
await ignorePackage(inst, 'p-ig');
|
||
|
||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
|
||
expect(row.status).toBe('ignored');
|
||
expect(row.planeId).toBeNull(); // was never bound
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
|
||
});
|
||
|
||
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
|
||
'rejects a %s package with 409 (must be open)',
|
||
async (status) => {
|
||
const inst = await freshDb(`ig-${status}.db`);
|
||
await insertOpenPackage(inst, 'p-x', { status });
|
||
|
||
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
|
||
},
|
||
);
|
||
|
||
it('rejects an unknown id with 404', async () => {
|
||
const inst = await freshDb('ig-unknown.db');
|
||
await expectActionError(ignorePackage(inst, 'nope'), 404);
|
||
});
|
||
});
|
||
|
||
describe('buyPlane', () => {
|
||
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
||
const inst = await freshDb('buy-ok.db');
|
||
await inst.db.update(schema.account).set({ cash: 500_000 });
|
||
|
||
const before = (await inst.db.select().from(schema.planes)).length;
|
||
const { planeId } = await buyPlane(inst, 'atr72');
|
||
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||
|
||
const planes = await inst.db.select().from(schema.planes);
|
||
expect(planes.length).toBe(before + 1);
|
||
const bought = planes.find((p) => p.id === planeId)!;
|
||
expect(bought.model).toBe('atr72');
|
||
expect(bought.status).toBe('available');
|
||
expect(bought.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // full tank on delivery
|
||
expect(bought.maxFuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||
expect(bought.locationIcao).toBe(HUB_ICAO);
|
||
const fc = await loadAirportsGeoJson();
|
||
const hub = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||
expect(bought.lat).toBe(hub.lat);
|
||
expect(bought.lon).toBe(hub.lon);
|
||
});
|
||
|
||
it('rejects an unknown catalog model with 400', async () => {
|
||
const inst = await freshDb('buy-unknown.db');
|
||
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
|
||
});
|
||
|
||
it('rejects the purchase when cash is below the price with 409', async () => {
|
||
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
||
});
|
||
|
||
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||
const inst = await freshDb('buy-rep.db');
|
||
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
|
||
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
|
||
|
||
const before = (await inst.db.select().from(schema.planes)).length;
|
||
await buyPlane(inst, 'atr72');
|
||
|
||
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
|
||
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
|
||
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
|
||
});
|
||
|
||
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
|
||
const inst = await freshDb('buy-boundary.db');
|
||
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
|
||
|
||
const { planeId } = await buyPlane(inst, 'atr72');
|
||
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||
|
||
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
|
||
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
|
||
});
|
||
});
|
||
|
||
describe('sellPlane', () => {
|
||
it('pays out SELL_FRACTION of the catalog price and deletes the row', async () => {
|
||
const inst = await freshDb('sell-ok.db');
|
||
// A second plane so the starter is not "the last remaining one".
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||
});
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { payout } = await sellPlane(inst, 'plane-extra');
|
||
|
||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
|
||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||
expect(ids).not.toContain('plane-extra'); // row deleted
|
||
expect(ids).toContain('plane-starter');
|
||
});
|
||
|
||
it('rejects selling a flying plane with 409', async () => {
|
||
const inst = await freshDb('sell-flying.db');
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||
});
|
||
|
||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
||
});
|
||
|
||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||
const inst = await freshDb('sell-cargo.db');
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||
});
|
||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||
|
||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||
});
|
||
|
||
it('rejects selling the last remaining plane with 409', async () => {
|
||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||
});
|
||
|
||
it('rejects an unknown id with 404', async () => {
|
||
const inst = await freshDb('sell-unknown.db');
|
||
await expectActionError(sellPlane(inst, 'ghost'), 404);
|
||
});
|
||
});
|
||
|
||
describe('refuelPlane', () => {
|
||
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
||
const inst = await freshDb('refuel-ok.db');
|
||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||
|
||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||
});
|
||
|
||
it('is a free no-op when the tank is already full', async () => {
|
||
const inst = await freshDb('refuel-full.db'); // seeded starter is full
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||
|
||
expect(cost).toBe(0);
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||
});
|
||
|
||
it('rejects a flying plane with 409', async () => {
|
||
const inst = await freshDb('refuel-flying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
|
||
});
|
||
|
||
it('rejects the fill-up when cash is below the cost with 409', async () => {
|
||
const inst = await freshDb('refuel-poor.db');
|
||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
||
|
||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||
});
|
||
|
||
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
|
||
const inst = await freshDb('refuel-rep.db');
|
||
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
|
||
await inst.db.update(schema.account).set({ reputation: 30 });
|
||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||
expect(rate).toBeGreaterThan(0);
|
||
|
||
// 'full': exact discounted cost, tank math untouched.
|
||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
|
||
|
||
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
|
||
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||
await loadPackage(inst, 'p-ff-r', 'plane-starter');
|
||
const required = await adjustedRequiredForHubToLemd();
|
||
const startFuel = Math.round(required / 2);
|
||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
|
||
|
||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||
const plane2 = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
|
||
});
|
||
|
||
it('rejects an unknown id with 404', async () => {
|
||
const inst = await freshDb('refuel-unknown.db');
|
||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
||
});
|
||
|
||
// --- forFlight mode (v0.16.0) ---
|
||
|
||
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
|
||
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
|
||
async function adjustedRequiredForHubToLemd() {
|
||
const fc = await loadAirportsGeoJson();
|
||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||
const type = PLANE_TYPES.atr72;
|
||
const w = weatherFor(HUB_ICAO);
|
||
return requiredFuelFor(
|
||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||
type.emptyWeightKg,
|
||
type.maxFuel,
|
||
km,
|
||
);
|
||
}
|
||
|
||
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
|
||
const inst = await freshDb('refuel-forflight-ok.db');
|
||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
||
|
||
const required = await adjustedRequiredForHubToLemd();
|
||
|
||
// Partial tank below the requirement so L > 0.
|
||
const startFuel = Math.round(required / 2);
|
||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
||
|
||
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
|
||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||
});
|
||
|
||
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
|
||
const inst = await freshDb('refuel-ff-flyafter.db');
|
||
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||
await loadPackage(inst, 'p-fa', 'plane-starter');
|
||
|
||
const required = await adjustedRequiredForHubToLemd();
|
||
|
||
// Empty-ish tank: well below the leg requirement.
|
||
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
||
|
||
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
|
||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.status).toBe('flying');
|
||
expect(plane.destIcao).toBe('LEMD');
|
||
});
|
||
|
||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
||
|
||
expect(cost).toBe(0);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||
});
|
||
|
||
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
|
||
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
|
||
await insertOpenPackage(inst, 'p-x');
|
||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
|
||
|
||
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
|
||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
||
});
|
||
|
||
it('forFlight rejects an expired package with 409', async () => {
|
||
const inst = await freshDb('refuel-ff-expired.db');
|
||
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
|
||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
|
||
});
|
||
|
||
it('forFlight rejects an unknown package id with 404', async () => {
|
||
const inst = await freshDb('refuel-ff-unknown.db');
|
||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
|
||
});
|
||
|
||
it('forFlight rejects a missing packageId with 400', async () => {
|
||
const inst = await freshDb('refuel-ff-nopkg.db');
|
||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
|
||
});
|
||
|
||
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
|
||
const inst = await freshDb('refuel-ff-nofit.db');
|
||
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
|
||
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
|
||
await loadPackage(inst, 'p-far', 'plane-starter');
|
||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(
|
||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
||
409,
|
||
/does not fit/i,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
|
||
const inst = await freshDb('repair-ok.db');
|
||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||
|
||
expect(cost).toBe(REPAIR_COST);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.status).toBe('repairing');
|
||
expect(plane.health).toBe(100); // restored to full…
|
||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||
});
|
||
|
||
it('is a free no-op when health is already full', async () => {
|
||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||
|
||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||
|
||
expect(cost).toBe(0);
|
||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||
expect(plane.status).toBe('available'); // not grounded…
|
||
expect(plane.health).toBe(100);
|
||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||
});
|
||
|
||
it('rejects a flying plane with 409', async () => {
|
||
const inst = await freshDb('repair-flying.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||
});
|
||
|
||
it('rejects a plane that is already repairing with 409', async () => {
|
||
const inst = await freshDb('repair-busy.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||
});
|
||
|
||
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
|
||
const inst = await freshDb('repair-blocks.db');
|
||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
await repairPlane(inst, 'plane-starter');
|
||
|
||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
|
||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||
});
|
||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||
});
|
||
|
||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||
const inst = await freshDb('repair-poor.db');
|
||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||
|
||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||
});
|
||
|
||
it('rejects an unknown id with 404', async () => {
|
||
const inst = await freshDb('repair-unknown.db');
|
||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||
});
|
||
});
|