feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly
This commit is contained in:
+160
-196
@@ -1,12 +1,12 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } 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.
|
||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows
|
||||
// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same
|
||||
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
|
||||
// match the catalog + economy constants exposed in /api/state. Packages are created via
|
||||
// POST /api/test/create-package (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
|
||||
@@ -18,15 +18,18 @@ interface PlaneState {
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
}
|
||||
|
||||
interface ContractState {
|
||||
interface PackageState {
|
||||
id: string;
|
||||
originName: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
deadlineSimTime: number;
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
@@ -36,13 +39,14 @@ interface CatalogEntry {
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
speedKmh: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
contracts: ContractState[];
|
||||
packages: PackageState[];
|
||||
catalog: CatalogEntry[];
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
@@ -61,6 +65,15 @@ async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
@@ -78,6 +91,13 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
|
||||
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||
const span = deadlineSimTime - createdSimTime;
|
||||
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
|
||||
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -94,155 +114,160 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
||||
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 },
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
||||
const created = await page.request.post('/api/test/create-package', {
|
||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as ContractState;
|
||||
return (await created.json()) as PackageState;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.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);
|
||||
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||
|
||||
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 });
|
||||
// ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button.
|
||||
const rows = page.locator('li[data-testid^="open-package-"]');
|
||||
await expect(rows.first(), 'an open package 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();
|
||||
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||
|
||||
// Deterministic target: a hook-created EDDF contract, accepted through the UI.
|
||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||
const nearest = await nearestToHub(page);
|
||||
const contract = await createContract(page, HUB, nearest.icao);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao);
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||
await expect(loadBtn).toBeEnabled();
|
||||
|
||||
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…
|
||||
// Load through the UI — exactly one eligible plane, so the button acts directly.
|
||||
await loadBtn.click();
|
||||
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.
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded');
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.planeId).toBe('plane-starter');
|
||||
|
||||
// The loaded package leaves the open list and shows in the on-board section.
|
||||
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();
|
||||
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
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
|
||||
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
||||
|
||||
// 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);
|
||||
}
|
||||
const pkg = await createPackage(page, 'LEMD', HUB);
|
||||
await page.goto('/');
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||
test.setTimeout(240_000); // one short flight at scale 60 plus UI steps — typical run well under three minutes
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Hook contract on the shortest EDDF route, accepted through the UI.
|
||||
const nearest = await nearestToHub(page);
|
||||
const contract = await createContract(page, HUB, nearest.icao);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||
const loadRes = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(loadRes.status()).toBe(200);
|
||||
|
||||
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();
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 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();
|
||||
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
||||
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
||||
// making the preview deterministic instead of racing the 1 s poll loop.
|
||||
await postSim(page, { paused: true });
|
||||
const state = await getState(page);
|
||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||
|
||||
// Fly to the destination through the UI — the button is the destination's name.
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
await expect(flyBtn).toBeEnabled();
|
||||
await flyBtn.click();
|
||||
|
||||
// The on-board block now shows the plane in flight with its current leg…
|
||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||
await expect(leg).toBeVisible();
|
||||
await expect(leg).toContainText('→');
|
||||
|
||||
// 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');
|
||||
let body = await getState(page);
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||
|
||||
// Fly to arrival at scale 60 (poll the authoritative state).
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
// Resume and fast-forward to arrival (poll the authoritative state).
|
||||
await postSim(page, { paused: false });
|
||||
await setScale(page, 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)` })
|
||||
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||||
.toBe(true);
|
||||
|
||||
// Market refuel through the pane: the flight genuinely burned fuel…
|
||||
// The in-deadline package was delivered on arrival — it leaves the on-board list…
|
||||
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;
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// …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).
|
||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.status()).toBe(200);
|
||||
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' })
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.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);
|
||||
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||
|
||||
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();
|
||||
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
|
||||
|
||||
await page.goto('/');
|
||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
||||
@@ -255,91 +280,30 @@ test('market buy/sell render with price and payout previews from the catalog in
|
||||
// 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
|
||||
expect(starter.status).toBe('available');
|
||||
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();
|
||||
});
|
||||
|
||||
test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => {
|
||||
test.setTimeout(240_000); // two short flights at scale 60 plus UI steps
|
||||
// Refuel through the pane: the flight genuinely burned fuel…
|
||||
if (starter.fuel < starter.maxFuel) {
|
||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||
const cashBefore = body.account.cash;
|
||||
|
||||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
||||
// another airport (same pattern as the dispatch-loop test above).
|
||||
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 });
|
||||
// …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 () => (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',
|
||||
})
|
||||
.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);
|
||||
const after = await getState(page);
|
||||
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Two hook contracts, both originating at EDDF on the shortest route.
|
||||
const nearest = await nearestToHub(page);
|
||||
const a = await createContract(page, HUB, nearest.icao);
|
||||
const b = await createContract(page, HUB, nearest.icao);
|
||||
|
||||
await page.goto('/');
|
||||
// Accept BOTH through the UI buttons.
|
||||
for (const c of [a, b]) {
|
||||
const row = page.locator(`li[data-testid="open-contract-${c.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(`accept-${c.id}`).click();
|
||||
}
|
||||
|
||||
const accA = page.getByTestId(`accepted-contract-${a.id}`);
|
||||
const accB = page.getByTestId(`accepted-contract-${b.id}`);
|
||||
await expect(accA).toBeVisible({ timeout: 10_000 });
|
||||
await expect(accB).toBeVisible();
|
||||
|
||||
// Both dispatch pickers render with the placeholder default — nothing preselected (v0.10.1)…
|
||||
const pickerA = page.getByTestId(`dispatch-plane-${a.id}`);
|
||||
const pickerB = page.getByTestId(`dispatch-plane-${b.id}`);
|
||||
await expect(pickerA).toBeVisible();
|
||||
await expect(pickerB).toBeVisible();
|
||||
await expect(pickerA).toHaveValue('');
|
||||
await expect(pickerB).toHaveValue('');
|
||||
await expect(pickerA.locator('option[value=""]')).toHaveText(/select a plane/i);
|
||||
|
||||
// …and the Dispatch buttons stay disabled until a valid plane is chosen.
|
||||
await expect(page.getByTestId(`dispatch-${a.id}`)).toBeDisabled();
|
||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
||||
|
||||
// Select plane-starter in contract A's picker → contract B's picker must STILL show the
|
||||
// placeholder (the v0.10.1 regression: selections used to appear shared across rows).
|
||||
await pickerA.selectOption('plane-starter');
|
||||
await expect(pickerA).toHaveValue('plane-starter');
|
||||
await expect(pickerB).toHaveValue('');
|
||||
|
||||
// Dispatch A with the selected plane → it goes 'flying' and its row shows the in-flight…
|
||||
await page.getByTestId(`dispatch-${a.id}`).click();
|
||||
await expect(page.getByTestId(`contract-inflight-${a.id}`)).toBeVisible({ timeout: 10_000 });
|
||||
body = await getState(page);
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||
|
||||
// …indicator, while B remains accepted with its own independent picker on the placeholder.
|
||||
await expect(accB).toBeVisible();
|
||||
await expect(pickerB).toHaveValue('');
|
||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
||||
|
||||
// Restore: let A's flight finish and fly plane-starter home so later specs start clean.
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).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);
|
||||
await flyViaApi(page, nearest.icao, HUB);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user