@@ -0,0 +1,594 @@
import type { Page } from '@playwright/test' ;
import { expect , test } from '@playwright/test' ;
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
//
// Each test buys its own second plane via the UI (after a cash grant from the new
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
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' ; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72' ; // cheapest catalog entry — the second plane every test buys
interface PlaneState {
id : string ;
model : string ;
status : 'available' | 'flying' ;
fuel : number ;
maxFuel : number ;
locationIcao : string ;
destIcao : string | null ;
queue : string [ ] ;
}
interface PackageState {
id : string ;
originIcao : string ;
destIcao : string ;
status : 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored' ;
planeId : string | null ;
}
interface CatalogEntry {
model : string ;
label : string ;
price : number ;
maxFuel : number ;
burnPerKm : number ;
emptyWeightKg : number ;
speedKmh : number ;
maxPayloadKg : number ;
}
interface StateBody {
account : { cash : number ; reputation : number } ;
simState : { clockMs : number ; timeScale : number ; paused : boolean } ;
planes : PlaneState [ ] ;
packages : PackageState [ ] ;
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 } ) ;
}
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
// proceeds at full speed.
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 ) ;
}
// 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 ) ) ) ;
}
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
const FUEL_DENSITY_KG_PER_L = 0.8 ; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
function requiredFuelFor ( burnPerKm : number , emptyWeightKg : number , maxFuel : number , distanceKm : number ) : number {
const k = burnPerKm / ( emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel ) ;
return ( emptyWeightKg / FUEL_DENSITY_KG_PER_L ) * ( Math . exp ( k * FUEL_DENSITY_KG_PER_L * distanceKm ) - 1 ) ;
}
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
async function twoNearestToHub ( page : Page ) : Promise < Array < { 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 ;
return fc . features
. filter ( ( f ) = > f . properties . icao !== HUB )
. map ( ( f ) = > ( { . . . f . properties , d : haversineKm ( hub . lat , hub . lon , f . properties . lat , f . properties . lon ) } ) )
. sort ( ( x , y ) = > x . d - y . d )
. slice ( 0 , 2 )
. map ( ( { icao , lat , lon } ) = > ( { icao , lat , lon } ) ) ;
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409).
async function farthestFlyableToHub ( page : Page ) : Promise < { icao : string ; lat : number ; lon : number } > {
const entry = ( await getState ( page ) ) . catalog . find ( ( e ) = > e . model === BUY_MODEL ) ! ;
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 farthest : { icao : string ; lat : number ; lon : number ; d : number } | null = null ;
for ( const f of fc . features ) {
if ( f . properties . icao === HUB ) continue ;
const d = haversineKm ( hub . lat , hub . lon , f . properties . lat , f . properties . lon ) ;
if ( requiredFuelFor ( entry . burnPerKm , entry . emptyWeightKg , entry . maxFuel , d ) > entry . maxFuel ) continue ; // beyond range
if ( ! farthest || d > farthest . d ) {
farthest = { icao : f.properties.icao , lat : f.properties.lat , lon : f.properties.lon , d } ;
}
}
if ( ! farthest ) throw new Error ( 'no full-tank-flyable airport found in the dataset' ) ;
return { icao : farthest.icao , lat : farthest.lat , lon : farthest.lon } ;
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
async function createPackage (
page : Page ,
fromIcao : string ,
toIcao : string ,
deadlineOffsetMs? : number ,
weightKg? : number ,
) : Promise < PackageState > {
const created = await page . request . post ( '/api/test/create-package' , {
data : { originIcao : fromIcao , destIcao : toIcao , deadlineOffsetMs , weightKg } ,
} ) ;
expect ( created . status ( ) ) . toBe ( 201 ) ;
return ( await created . json ( ) ) as PackageState ;
}
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
// post-grant snapshot (the route's response).
async function addCash ( page : Page , amount? : number ) : Promise < StateBody > {
const res = await page . request . post ( '/api/test/add-cash' , { data : amount === undefined ? { } : { amount } } ) ;
expect ( res . status ( ) ) . toBe ( 200 ) ;
return ( await res . json ( ) ) as StateBody ;
}
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
async function buySecondPlaneViaUi ( page : Page ) : Promise < string > {
await addCash ( page ) ; // default €1M grant — covers the ATR's €200k price regardless of prior cash
await page . goto ( '/' ) ;
const right = page . getByTestId ( 'right-pane' ) ;
await expect ( right ) . toBeVisible ( { timeout : 15_000 } ) ;
await page . getByTestId ( 'tab-planes-market' ) . click ( ) ;
const buyModel = page . getByTestId ( 'market-buy-model' ) ;
await expect ( buyModel , 'the market model picker appears' ) . toBeVisible ( { timeout : 15_000 } ) ;
await buyModel . selectOption ( BUY_MODEL ) ;
await page . getByTestId ( 'market-buy' ) . click ( ) ;
let secondId : string | null = null ;
await expect
. poll ( async ( ) = > {
const s = await getState ( page ) ;
secondId = s . planes . find ( ( p ) = > p . id !== STARTER ) ? . id ? ? null ;
return secondId !== null ;
} , { timeout : 15_000 , message : 'waiting for the bought plane to appear in /api/state' } )
. toBe ( true ) ;
return secondId ! ;
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi ( page : Page , planeId : string ) : Promise < void > {
for ( ; ; ) {
const q = ( await getState ( page ) ) . planes . find ( ( p ) = > p . id === planeId ) ? . queue ? ? [ ] ;
if ( q . length === 0 ) return ;
expect ( ( await page . request . post ( ` /api/planes/ ${ planeId } /queue/cancel ` , { data : { index : 0 } } ) ) . status ( ) ) . toBe ( 200 ) ;
}
}
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
// carrying cargo, and clean baselines keep exact-cash assertions honest.
async function abandonLeftoverCargo ( page : Page , planeId : string ) : Promise < void > {
const s = await getState ( page ) ;
for ( const pkg of s . packages ) {
if ( pkg . planeId === planeId && ( pkg . status === 'loaded' || pkg . status === 'expired' ) ) {
expect ( ( await page . request . post ( ` /api/packages/ ${ pkg . id } /abandon ` ) ) . ok ( ) ) . toBeTruthy ( ) ;
}
}
}
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
// specs, generalized to an arbitrary plane id.
async function healPlaneHome ( page : Page , planeId : string ) : Promise < void > {
await clearQueueViaApi ( page , planeId ) ;
let p = ( await getState ( page ) ) . planes . find ( ( x ) = > x . id === planeId ) ;
if ( ! p ) return ; // already sold — nothing to heal
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
await setScale ( page , SCALE ) ;
if ( p . status === 'flying' ) {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
. poll ( async ( ) = > {
const s = await getState ( page ) ;
const plane = s . planes . find ( ( x ) = > x . id === planeId ) ;
if ( plane ? . status === 'flying' && s . simState . timeScale !== SCALE ) await setScale ( page , SCALE ) ; // sibling landing re-slowed to 1×
return plane ? . status ? ? '' ;
} , {
timeout : 240_000 ,
message : ` waiting for an in-flight ${ planeId } to land ` ,
} )
. toBe ( 'available' ) ;
p = ( await getState ( page ) ) . planes . find ( ( x ) = > x . id === planeId ) ! ;
}
if ( p . locationIcao !== HUB ) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
await setScale ( page , SCALE ) ;
let flyHome = await page . request . post ( ` /api/planes/ ${ planeId } /fly ` , { data : { destIcao : HUB } } ) ;
if ( flyHome . status ( ) === 409 ) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect ( ( await page . request . post ( ` /api/planes/ ${ planeId } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
flyHome = await page . request . post ( ` /api/planes/ ${ planeId } /fly ` , { data : { destIcao : HUB } } ) ;
}
expect ( flyHome . status ( ) ) . toBe ( 200 ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . find ( ( x ) = > x . id === planeId ) ? . locationIcao ? ? '' ) , {
timeout : 240_000 ,
message : ` waiting for ${ planeId } to land back at the hub ` ,
} )
. toBe ( HUB ) ;
}
}
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
// in flight) — leaves the fleet at exactly one plane.
async function sellNonStarters ( page : Page ) : Promise < void > {
const s = await getState ( page ) ;
for ( const p of s . planes . filter ( ( x ) = > x . id !== STARTER ) ) {
await healPlaneHome ( page , p . id ) ; // grounded + at the hub before cargo can be abandoned / it sold
await abandonLeftoverCargo ( page , p . id ) ;
expect ( ( await page . request . post ( '/api/market/sell-plane' , { data : { planeId : p.id } } ) ) . status ( ) ) . toBe ( 200 ) ;
}
}
// 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 > {
await sellNonStarters ( page ) ;
await healPlaneHome ( page , STARTER ) ;
await abandonLeftoverCargo ( page , STARTER ) ;
}
test . describe . configure ( { mode : 'serial' } ) ; // shared live DB — order matters within this file
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
test . beforeEach ( async ( { page } ) = > {
await resetFleet ( page ) ;
} ) ;
test . afterEach ( async ( { page } ) = > {
await sellNonStarters ( page ) ; // a failure mid-test may have left the second plane behind (even in flight)
const res = await postSim ( page , { timeScale : 1 , paused : false } ) ;
expect ( res . status ( ) ) . toBe ( 200 ) ;
} ) ;
test ( 'two grounded planes: Load is one-click with a preselected plane; the picker routes to the other' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi ( page ) ; // both grounded at EDDF — bought planes spawn at HUB
await page . getByTestId ( 'tab-jobs' ) . click ( ) ; // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
const [ nearest ] = await twoNearestToHub ( page ) ;
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
const pkg1 = await createPackage ( page , HUB , nearest . icao , 24 * 3 _600_000 , 500 ) ;
// The multi-plane picker lists BOTH planes…
const picker = page . getByTestId ( ` load-plane- ${ pkg1 . id } ` ) ;
await expect ( picker ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( picker . locator ( ` option[value=" ${ STARTER } "] ` ) ) . toHaveCount ( 1 , { timeout : 15_000 } ) ;
await expect ( picker . locator ( ` option[value=" ${ secondId } "] ` ) ) . toHaveCount ( 1 ) ;
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
const s0 = await getState ( page ) ;
const firstEligible = s0 . planes . find ( ( p ) = > p . status === 'available' && p . locationIcao === HUB ) ! ;
await expect ( picker , 'the picker preselects the first eligible plane' ) . toHaveValue ( firstEligible . id ) ;
const loadBtn1 = page . getByTestId ( ` load- ${ pkg1 . id } ` ) ;
await expect ( loadBtn1 , 'Load is enabled without a manual pick' ) . toBeEnabled ( ) ;
// One-click Load binds the package to the preselected (first eligible) plane.
await loadBtn1 . click ( ) ;
await expect
. poll ( async ( ) = > {
const p = ( await getState ( page ) ) . packages . find ( ( x ) = > x . id === pkg1 . id ) ;
return ! ! p && p . status === 'loaded' ? p.planeId : null ;
} , { timeout : 15_000 , message : 'waiting for the package to load onto a plane' } )
. toBe ( firstEligible . id ) ;
// Second package — pick the OTHER plane explicitly via the dropdown.
const pkg2 = await createPackage ( page , HUB , nearest . icao , 24 * 3 _600_000 , 500 ) ;
const picker2 = page . getByTestId ( ` load-plane- ${ pkg2 . id } ` ) ;
await expect ( picker2 ) . toBeVisible ( { timeout : 15_000 } ) ;
await picker2 . selectOption ( secondId ) ;
await page . getByTestId ( ` load- ${ pkg2 . id } ` ) . click ( ) ;
await expect
. poll ( async ( ) = > {
const p = ( await getState ( page ) ) . packages . find ( ( x ) = > x . id === pkg2 . id ) ;
return ! ! p && p . status === 'loaded' ? p.planeId : null ;
} , { timeout : 15_000 , message : 'waiting for the second package to load onto a plane' } )
. toBe ( secondId ) ;
// Both planes carry cargo simultaneously — both on-board blocks render.
await expect ( page . getByTestId ( ` onboard- ${ STARTER } ` ) , 'the starter block renders' ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( page . getByTestId ( ` onboard- ${ secondId } ` ) , 'the second plane block renders' ) . toBeVisible ( { timeout : 15_000 } ) ;
} ) ;
test ( 'two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi ( page ) ;
// Full tanks for both — the starter's may be partial from an earlier run.
expect ( ( await page . request . post ( ` /api/planes/ ${ STARTER } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ secondId } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
const [ nearest ] = await twoNearestToHub ( page ) ;
const farthest = await farthestFlyableToHub ( page ) ;
const pkgA = await createPackage ( page , HUB , nearest . icao , 24 * 3 _600_000 , 500 ) ;
const pkgB = await createPackage ( page , HUB , farthest . icao , 24 * 3 _600_000 , 500 ) ;
expect ( ( await page . request . post ( ` /api/packages/ ${ pkgA . id } /load ` , { data : { planeId : STARTER } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/packages/ ${ pkgB . id } /load ` , { data : { planeId : secondId } } ) ) . status ( ) ) . toBe ( 200 ) ;
const flyA = await page . request . post ( ` /api/planes/ ${ STARTER } /fly ` , { data : { destIcao : nearest.icao } } ) ;
expect ( flyA . status ( ) ) . toBe ( 200 ) ;
const flyB = await page . request . post ( ` /api/planes/ ${ secondId } /fly ` , { data : { destIcao : farthest.icao } } ) ;
expect ( flyB . status ( ) ) . toBe ( 200 ) ;
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
const s1 = ( await flyB . json ( ) ) as StateBody ;
expect ( s1 . planes . find ( ( p ) = > p . id === STARTER ) ? . status ) . toBe ( 'flying' ) ;
expect ( s1 . planes . find ( ( p ) = > p . id === STARTER ) ? . destIcao ) . toBe ( nearest . icao ) ;
expect ( s1 . planes . find ( ( p ) = > p . id === secondId ) ? . status ) . toBe ( 'flying' ) ;
expect ( s1 . planes . find ( ( p ) = > p . id === secondId ) ? . destIcao ) . toBe ( farthest . icao ) ;
// Fast-forward to the FIRST landing (the short hop)…
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . some ( ( p ) = > p . status === 'available' ) ) , {
timeout : 240_000 ,
message : 'waiting for the first plane to land' ,
} )
. toBe ( true ) ;
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . every ( ( p ) = > p . status === 'available' ) ) , {
timeout : 240_000 ,
message : 'waiting for both planes to land' ,
} )
. toBe ( true ) ;
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState ( page ) ;
expect ( landed . simState . paused ) . toBe ( false ) ;
expect ( landed . simState . timeScale ) . toBe ( 1 ) ;
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
expect ( landed . packages . find ( ( p ) = > p . id === pkgA . id ) ? . status ) . toBe ( 'delivered' ) ;
expect ( landed . packages . find ( ( p ) = > p . id === pkgB . id ) ? . status ) . toBe ( 'delivered' ) ;
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
expect ( ( await page . request . post ( ` /api/planes/ ${ secondId } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
const flyB2 = await page . request . post ( ` /api/planes/ ${ secondId } /fly ` , { data : { destIcao : HUB } } ) ;
expect ( flyB2 . status ( ) ) . toBe ( 200 ) ;
let body = ( await flyB2 . json ( ) ) as StateBody ;
expect ( body . simState . timeScale , 'another plane still grounded → stays at 1× ' ) . toBe ( 1 ) ;
// …but dispatching the LAST grounded plane restores the last manually selected speed.
const flyA2 = await page . request . post ( ` /api/planes/ ${ STARTER } /fly ` , { data : { destIcao : HUB } } ) ;
expect ( flyA2 . status ( ) ) . toBe ( 200 ) ;
body = ( await flyA2 . json ( ) ) as StateBody ;
expect ( body . simState . timeScale , 'the last grounded plane resumes lastSelectedTimeScale' ) . toBe ( SCALE ) ; // setScale posted 1000
} ) ;
test ( 'refuel section lists both planes; refueling one tops only its tank' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal + two short hops at scale 1000
const secondId = await buySecondPlaneViaUi ( page ) ;
// Partial tanks: a short hop each, to different destinations.
const [ a , b ] = await twoNearestToHub ( page ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ STARTER } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ secondId } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ STARTER } /fly ` , { data : { destIcao : a.icao } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ secondId } /fly ` , { data : { destIcao : b.icao } } ) ) . status ( ) ) . toBe ( 200 ) ;
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . some ( ( p ) = > p . status === 'available' ) ) , {
timeout : 240_000 ,
message : 'waiting for the first plane to land' ,
} )
. toBe ( true ) ;
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . every ( ( p ) = > p . status === 'available' ) ) , {
timeout : 240_000 ,
message : 'waiting for both planes to land' ,
} )
. toBe ( true ) ;
const pre = await getState ( page ) ;
const starter = pre . planes . find ( ( p ) = > p . id === STARTER ) ! ;
const second = pre . planes . find ( ( p ) = > p . id === secondId ) ! ;
expect ( starter . fuel , 'the hop burned fuel' ) . toBeLessThan ( starter . maxFuel ) ;
expect ( second . fuel , 'the hop burned fuel' ) . toBeLessThan ( second . maxFuel ) ;
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
const refuelSelect = page . getByTestId ( 'market-refuel-plane' ) ;
await expect ( refuelSelect ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( refuelSelect . locator ( ` option[value=" ${ STARTER } "] ` ) ) . toHaveCount ( 1 ) ;
await expect ( refuelSelect . locator ( ` option[value=" ${ secondId } "] ` ) ) . toHaveCount ( 1 ) ;
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
const expectedCost = Math . round ( ( second . maxFuel - second . fuel ) * pre . fuelPricePerL * 100 ) / 100 ;
await refuelSelect . selectOption ( secondId ) ;
await expect ( page . getByTestId ( 'market-refuel-cost' ) ) . toContainText ( eur ( expectedCost ) ) ;
await page . getByTestId ( 'market-refuel' ) . click ( ) ;
await expect
. poll ( async ( ) = > {
const s = await getState ( page ) ;
return Math . abs ( s . account . cash - ( pre . account . cash - expectedCost ) ) < 0.001 ;
} , { timeout : 15_000 , message : 'waiting for the refuel cost to land in /api/state' } )
. toBe ( true ) ;
const post = await getState ( page ) ;
expect ( post . planes . find ( ( p ) = > p . id === secondId ) ! . fuel , 'that tank is full' ) . toBe ( second . maxFuel ) ;
expect ( post . planes . find ( ( p ) = > p . id === STARTER ) ! . fuel , 'the other tank is untouched' ) . toBe ( starter . fuel ) ;
} ) ;
test ( 'relocate section moves one plane without touching the other' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal + one short hop at scale 1000
const secondId = await buySecondPlaneViaUi ( page ) ; // both grounded at EDDF
const [ a ] = await twoNearestToHub ( page ) ; // relocation destination for the second plane
// The Relocate section lists BOTH grounded planes…
const relocateSelect = page . getByTestId ( 'relocate-plane' ) ;
await expect ( relocateSelect ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( relocateSelect . locator ( ` option[value=" ${ STARTER } "] ` ) ) . toHaveCount ( 1 ) ;
await expect ( relocateSelect . locator ( ` option[value=" ${ secondId } "] ` ) ) . toHaveCount ( 1 ) ;
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
const destSelect = page . getByTestId ( 'relocate-dest' ) ;
await expect ( destSelect . locator ( ` option[value=" ${ a . icao } "] ` ) ) . toHaveCount ( 1 , { timeout : 15_000 } ) ;
// Relocate ONLY the second plane…
await relocateSelect . selectOption ( secondId ) ;
await destSelect . selectOption ( a . icao ) ;
await page . getByTestId ( 'relocate-go' ) . click ( ) ;
// …it is flying to the new airport while the starter stays put at EDDF.
await expect
. poll ( async ( ) = > {
const s = await getState ( page ) ;
return ( s . planes . find ( ( p ) = > p . id === secondId ) ? . status ? ? '' ) === 'flying' &&
( s . planes . find ( ( p ) = > p . id === STARTER ) ? . locationIcao ? ? '' ) === HUB ;
} , { timeout : 15_000 , message : 'waiting for the relocation to take effect' } )
. toBe ( true ) ;
// Let it land — locationIcao flips to the destination on arrival.
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > ( ( await getState ( page ) ) . planes . find ( ( p ) = > p . id === secondId ) ? . locationIcao ? ? '' ) , {
timeout : 240_000 ,
message : ` waiting for the relocated plane to land at ${ a . icao } ` ,
} )
. toBe ( a . icao ) ;
const s = await getState ( page ) ;
expect ( s . planes . find ( ( p ) = > p . id === secondId ) ! . status ) . toBe ( 'available' ) ;
expect ( s . planes . find ( ( p ) = > p . id === STARTER ) ! . locationIcao , 'the starter is untouched' ) . toBe ( HUB ) ;
} ) ;
test ( 'per-plane queues are independent; landing auto-chains the next queued leg' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal + two chained legs at scale 1000
const secondId = await buySecondPlaneViaUi ( page ) ;
const [ a , b ] = await twoNearestToHub ( page ) ;
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
expect ( ( await page . request . post ( ` /api/planes/ ${ STARTER } /refuel ` ) ) . status ( ) ) . toBe ( 200 ) ;
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
expect ( ( await page . request . post ( ` /api/planes/ ${ STARTER } /queue ` , { data : { destIcao : a.icao } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` /api/planes/ ${ secondId } /queue ` , { data : { destIcao : b.icao } } ) ) . status ( ) ) . toBe ( 200 ) ;
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
const starterLeg0 = page . getByTestId ( ` queue-leg- ${ STARTER } -0 ` ) ;
await expect ( starterLeg0 , 'the starter block renders its queued leg' ) . toContainText ( a . icao , { timeout : 15_000 } ) ;
await expect ( page . getByTestId ( ` queue-leg- ${ secondId } -0 ` ) , 'the second block renders its queued leg' ) . toContainText ( b . icao ) ;
// …and ADD a second leg to the STARTER via its scoped controls only.
const starterDest = page . getByTestId ( ` queue-add-dest- ${ STARTER } ` ) ;
await expect ( starterDest . locator ( ` option[value=" ${ b . icao } "] ` ) ) . toHaveCount ( 1 , { timeout : 15_000 } ) ; // airports fetched
await starterDest . selectOption ( b . icao ) ;
const starterAdd = page . getByTestId ( ` queue-add- ${ STARTER } ` ) ;
await expect ( starterAdd ) . toBeEnabled ( ) ;
await starterAdd . click ( ) ;
await expect ( page . getByTestId ( ` queue-leg- ${ STARTER } -1 ` ) , 'the second leg is queued on the STARTER' ) . toContainText ( b . icao , { timeout : 15_000 } ) ;
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
await page . getByTestId ( ` queue-cancel- ${ secondId } -0 ` ) . click ( ) ;
await expect ( page . getByTestId ( ` queue-leg- ${ secondId } -0 ` ) , 'the cancelled leg is gone' ) . toHaveCount ( 0 , { timeout : 15_000 } ) ;
await expect ( starterLeg0 , 'the starter queue is untouched by the other plane cancel' ) . toContainText ( a . icao ) ;
// Fly the STARTER to its first queued destination…
const flyRes = await page . request . post ( ` /api/planes/ ${ STARTER } /fly ` , { data : { destIcao : a.icao } } ) ;
expect ( flyRes . status ( ) ) . toBe ( 200 ) ;
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > {
const p = ( await getState ( page ) ) . planes . find ( ( x ) = > x . id === STARTER ) ;
return ! ! p && p . status === 'flying' && p . destIcao === b . icao ;
} , { timeout : 240_000 , message : ` waiting for the auto-chained leg ${ a } → ${ b } to start ` } )
. toBe ( true ) ;
// …then restore speed so the chained leg fast-forwards too.
await setScale ( page , SCALE ) ;
await expect
. poll ( async ( ) = > {
const p = ( await getState ( page ) ) . planes . find ( ( x ) = > x . id === STARTER ) ;
return ! ! p && p . status === 'available' && p . locationIcao === b . icao ;
} , { timeout : 240_000 , message : ` waiting for the chained leg to land at ${ b } ` } )
. toBe ( true ) ;
// The second plane never moved — still grounded at EDDF with an empty queue.
const s = await getState ( page ) ;
expect ( s . planes . find ( ( p ) = > p . id === secondId ) ! . status ) . toBe ( 'available' ) ;
expect ( s . planes . find ( ( p ) = > p . id === secondId ) ! . locationIcao ) . toBe ( HUB ) ;
} ) ;
test ( 'sell section lists both planes; selling one pays out and the last plane is protected' , async ( { page } ) = > {
test . setTimeout ( 300 _000 ) ; // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi ( page ) ; // both grounded at EDDF
const pre = await getState ( page ) ; // authoritative catalog + economy constants (v0.10.0)
const entry = pre . catalog . find ( ( e ) = > e . model === BUY_MODEL ) ! ;
const payout = Math . round ( entry . price * pre . sellFraction * 100 ) / 100 ;
// The Sell row lists BOTH grounded planes…
const sellSelect = page . getByTestId ( 'market-sell-plane' ) ;
await expect ( sellSelect ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( sellSelect . locator ( ` option[value=" ${ STARTER } "] ` ) ) . toHaveCount ( 1 ) ;
await expect ( sellSelect . locator ( ` option[value=" ${ secondId } "] ` ) ) . toHaveCount ( 1 ) ;
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
await sellSelect . selectOption ( secondId ) ;
await expect ( page . getByTestId ( 'market-sell-payout' ) ) . toContainText ( eur ( payout ) ) ;
await page . getByTestId ( 'market-sell' ) . click ( ) ;
await expect
. poll ( async ( ) = > {
const s = await getState ( page ) ;
return Math . abs ( s . account . cash - ( pre . account . cash + payout ) ) < 0.001 && s . planes . length === 1 ;
} , { timeout : 15_000 , message : 'waiting for the sell to land in /api/state' } )
. toBe ( true ) ;
// The last remaining plane can never be sold — even via the API.
const res = await page . request . post ( '/api/market/sell-plane' , { data : { planeId : STARTER } } ) ;
expect ( res . status ( ) ) . toBe ( 409 ) ;
} ) ;