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.
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
|
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
|
|
|
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
|
|
// it is GROUNDED at the package's origin with a free slot (capacity from the
|
|
// snapshot catalog; expired packages still occupy theirs). The Load button acts
|
|
// directly on the single eligible plane, or shows a picker when several qualify.
|
|
|
|
const CATALOG = [
|
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
|
|
];
|
|
|
|
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|
return {
|
|
model: 'atr72',
|
|
status: 'available',
|
|
fuel: 100,
|
|
maxFuel: 100,
|
|
locationIcao: 'EDDF',
|
|
locationName: 'Frankfurt Main',
|
|
destIcao: null,
|
|
destName: null,
|
|
departureSimTime: null,
|
|
lat: 50,
|
|
lon: 8.5,
|
|
queue: [],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageState {
|
|
return {
|
|
originIcao: 'EDDF',
|
|
originName: 'Frankfurt Main',
|
|
destIcao: 'LEMD',
|
|
destName: 'Madrid Barajas',
|
|
reward: 1000,
|
|
cargoType: 'mail',
|
|
cargoCount: 4,
|
|
deadlineSimTime: 1_000_000,
|
|
status: 'open',
|
|
planeId: null,
|
|
createdSimTime: 0,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
|
return {
|
|
account: { cash: 0, reputation: 0 },
|
|
simState: { clockMs: 0, timeScale: 1, paused: false },
|
|
planes,
|
|
packages,
|
|
catalog: CATALOG,
|
|
fuelPricePerL: 0.8,
|
|
sellFraction: 0.5,
|
|
};
|
|
}
|
|
|
|
describe('slotsUsedFor', () => {
|
|
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
|
|
const s = snap(
|
|
[plane({ id: 'p1' })],
|
|
[
|
|
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
|
|
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
|
|
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
|
|
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
|
|
],
|
|
);
|
|
expect(slotsUsedFor(s, 'p1')).toBe(2);
|
|
});
|
|
|
|
it('is 0 for a plane with no bound cargo', () => {
|
|
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
|
it('includes only grounded planes at the package origin with a free slot', () => {
|
|
const s = snap([
|
|
plane({ id: 'at-origin' }), // eligible
|
|
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
|
plane({ id: 'in-flight', status: 'flying', destIcao: 'LEMD', destName: 'Madrid Barajas' }), // flying
|
|
]);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
|
});
|
|
|
|
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
|
|
const packages = Array.from({ length: 6 }, (_, i) =>
|
|
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
|
|
);
|
|
const s = snap([plane({ id: 'full' })], packages);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
|
});
|
|
|
|
it('a full plane with one slot freed by an expired package still counts as full', () => {
|
|
const packages = [
|
|
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
|
|
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
|
|
];
|
|
const s = snap([plane({ id: 'full' })], packages);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
|
});
|
|
|
|
it('returns several planes when several qualify (picker case)', () => {
|
|
const s = snap([plane({ id: 'a' }), plane({ id: 'b' })]);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id).sort()).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('returns nothing when no plane is at the origin (Load stays disabled)', () => {
|
|
const s = snap([plane({ id: 'far', locationIcao: 'LEMD' })]);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
|
});
|
|
|
|
it('a plane with an unknown model has no capacity → never eligible', () => {
|
|
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
|
});
|
|
});
|