264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
// v0.10.0 — right Jobs & Market pane against the live origin: open contracts render with
|
||
// names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created
|
||
// EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight"
|
||
// in the UI AND /api/state; market refuel through the pane deducts exactly
|
||
// (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog +
|
||
// economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract
|
||
// (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||
|
||
const SCALE = 60;
|
||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||
|
||
interface PlaneState {
|
||
id: string;
|
||
model: string;
|
||
status: 'available' | 'flying';
|
||
fuel: number;
|
||
maxFuel: number;
|
||
locationIcao: string;
|
||
}
|
||
|
||
interface ContractState {
|
||
id: string;
|
||
originName: string;
|
||
destName: string;
|
||
reward: number;
|
||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||
planeId: string | null;
|
||
}
|
||
|
||
interface CatalogEntry {
|
||
model: string;
|
||
label: string;
|
||
price: number;
|
||
maxFuel: number;
|
||
burnPerKm: number;
|
||
speedKmh: number;
|
||
}
|
||
|
||
interface StateBody {
|
||
account: { cash: number; reputation: number };
|
||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||
planes: PlaneState[];
|
||
contracts: ContractState[];
|
||
catalog: CatalogEntry[];
|
||
fuelPricePerL: number;
|
||
sellFraction: number;
|
||
}
|
||
|
||
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
|
||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||
|
||
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;
|
||
}
|
||
|
||
async function postSim(page: Page, body: unknown) {
|
||
return page.request.post('/api/sim', { data: body });
|
||
}
|
||
|
||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||
test.afterEach(async ({ page }) => {
|
||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||
expect(res.status()).toBe(200);
|
||
});
|
||
|
||
// 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 → shortest possible arrival time at scale 60.
|
||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: 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 nearest;
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
|
||
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
||
const created = await page.request.post('/api/test/create-contract', {
|
||
data: { originIcao: fromIcao, destIcao: toIcao },
|
||
});
|
||
expect(created.status()).toBe(201);
|
||
return (await created.json()) as ContractState;
|
||
}
|
||
|
||
// Create + accept + dispatch a contract on plane-starter via the API, then wait for arrival.
|
||
async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
||
const contract = await createContract(page, fromIcao, toIcao);
|
||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
||
expect(acc.status()).toBe(200);
|
||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
||
expect(disp.status()).toBe(200);
|
||
await postSim(page, { timeScale: SCALE });
|
||
await expect
|
||
.poll(async () => {
|
||
const body = await getState(page);
|
||
const p = body.planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
||
}, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
||
.toBe(true);
|
||
}
|
||
|
||
test('open contracts render with names, reward and deadline; Accept moves the contract into the accepted group', async ({ page }) => {
|
||
test.setTimeout(90_000);
|
||
await page.goto('/');
|
||
const right = page.getByTestId('right-pane');
|
||
await expect(right).toBeVisible({ timeout: 15_000 });
|
||
|
||
// ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button.
|
||
const rows = page.locator('li[data-testid^="open-contract-"]');
|
||
await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 });
|
||
await expect(rows.first()).toContainText(/→/);
|
||
await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€');
|
||
await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible();
|
||
|
||
// Deterministic target: a hook-created EDDF contract, accepted through the UI.
|
||
const nearest = await nearestToHub(page);
|
||
const contract = await createContract(page, HUB, nearest.icao);
|
||
|
||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
||
await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up
|
||
await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible();
|
||
await page.getByTestId(`accept-${contract.id}`).click();
|
||
|
||
// Server truth first…
|
||
const body = await getState(page);
|
||
expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
|
||
// …and the UI: gone from open, present in the accepted group with dispatch controls.
|
||
await expect(row).not.toBeVisible();
|
||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
||
await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible();
|
||
});
|
||
|
||
test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => {
|
||
test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes
|
||
|
||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
||
// another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts).
|
||
let body = await getState(page);
|
||
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
|
||
expect(starter0).toBeDefined();
|
||
if (starter0!.status === 'flying') {
|
||
await postSim(page, { timeScale: SCALE });
|
||
await expect
|
||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||
timeout: 90_000,
|
||
message: 'waiting for an in-flight plane-starter to land',
|
||
})
|
||
.toBe(true);
|
||
body = await getState(page);
|
||
}
|
||
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
|
||
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||
await flyViaApi(page, from, HUB);
|
||
}
|
||
|
||
// Hook contract on the shortest EDDF route, accepted through the UI.
|
||
const nearest = await nearestToHub(page);
|
||
const contract = await createContract(page, HUB, nearest.icao);
|
||
|
||
await page.goto('/');
|
||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||
await page.getByTestId(`accept-${contract.id}`).click();
|
||
|
||
// Dispatch controls appear in the accepted group; pick plane-starter and dispatch.
|
||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
||
const picker = page.getByTestId(`dispatch-plane-${contract.id}`);
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option
|
||
await page.getByTestId(`dispatch-${contract.id}`).click();
|
||
|
||
// UI shows the in-flight indicator…
|
||
await expect(page.getByTestId(`contract-inflight-${contract.id}`)).toBeVisible({ timeout: 10_000 });
|
||
// …and /api/state agrees.
|
||
body = await getState(page);
|
||
let starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||
expect(starter.status).toBe('flying');
|
||
|
||
// Fly to arrival at scale 60 (poll the authoritative state).
|
||
await postSim(page, { timeScale: SCALE });
|
||
await expect
|
||
.poll(async () => {
|
||
const b = await getState(page);
|
||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||
.toBe(true);
|
||
|
||
// Market refuel through the pane: the flight genuinely burned fuel…
|
||
body = await getState(page);
|
||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||
expect(starter.fuel).toBeLessThan(starter.maxFuel);
|
||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||
const cashBefore = body.account.cash;
|
||
|
||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||
await page.getByTestId('market-refuel').click();
|
||
|
||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
||
await expect
|
||
.poll(async () => {
|
||
const b = await getState(page);
|
||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||
.toBe(true);
|
||
body = await getState(page);
|
||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||
expect(starter.fuel).toBe(starter.maxFuel);
|
||
|
||
// Fly home so the live instance stays in its onboarding state for the next run.
|
||
await flyViaApi(page, nearest.icao, HUB);
|
||
});
|
||
|
||
test('market buy/sell render with price and payout previews from the catalog in /api/state', async ({ page }) => {
|
||
test.setTimeout(60_000);
|
||
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||
expect(body.catalog.length).toBeGreaterThan(0);
|
||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||
expect(entry).toBeDefined();
|
||
|
||
await page.goto('/');
|
||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
||
const buyModel = page.getByTestId('market-buy-model');
|
||
await expect(buyModel).toBeVisible({ timeout: 15_000 });
|
||
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
|
||
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
|
||
await expect(page.getByTestId('market-buy')).toBeVisible();
|
||
|
||
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
||
// sellFraction × price, both from the snapshot's catalog/constants.
|
||
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||
expect(starter.status).toBe('available'); // test order: it flew home at the end of the previous test
|
||
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||
await expect(page.getByTestId('market-sell')).toBeVisible();
|
||
});
|