fix: v0.10.1 — dispatch pickers independent per accepted contract (placeholder default, no first-eligible auto-fallback; stale-select fix via explicit placeholder option)
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.10.1 — 2026-09-18 06:36
|
||||||
|
- **Bugfix (reported): dispatch picker selections appeared shared across accepted contracts** — selecting a plane in one contract's dispatch picker made every other accepted contract's picker show that same plane until the page was refreshed. Root cause confirmed by code audit of `components/RightPane.tsx`: NOT a single shared state variable and NOT missing list keys (each row already had its own `useState` inside `DispatchControls` and stable `key={contract.id}`) — it was the "effective selection" fallback, which auto-tracked the **first eligible plane** in every row whenever no explicit pick existed: with one grounded plane all pickers displayed that same plane by default, so any selection looked like it propagated to the other rows; additionally there was no placeholder `<option value="">`, so once a pick became ineligible (e.g. after dispatching the only eligible plane from another contract) `value=''` matched no option and the browser kept showing the stale previously-selected option until a DOM refresh
|
||||||
|
- **Fix**: each accepted-contract row keeps its own local selection in the per-row `DispatchControls` component, now defaulting to an explicit **"Select a plane…" placeholder** (`<option value="">`) instead of preselecting the first eligible plane — rows are visibly independent and the Dispatch button stays disabled until a valid plane is chosen (prevents accidental dispatch on the wrong contract); eligibility unchanged (only planes 'available' AND at `contract.originIcao` enabled, others disabled with reason) and a poll that makes the current pick ineligible drops it back to the placeholder; stable `key={contract.id}` kept on all open + accepted rows. Pure selection rule extracted as exported `dispatchSelection(planes, contract, selectedId)` for unit testing
|
||||||
|
- **Audit of all RightPane selects**: market buy-model / sell-plane / refuel-plane pickers are single pickers each with their own independent `useState` — no shared state found, left unchanged (their first-entry fallbacks are intentional preview defaults)
|
||||||
|
- Tests: unit — new `components/right-pane.test.ts` for the extracted rule (placeholder default = NO auto-selection of first eligible plane; user pick honored while eligible; drops to placeholder when a poll makes it ineligible via flight or relocation; unknown id → placeholder). e2e — new regression test in `e2e/right-pane.spec.ts`: two hook-created EDDF contracts accepted through the UI, both dispatch pickers assert placeholder default + disabled Dispatch buttons, selecting plane-starter in contract A's picker asserts contract B's picker STILL shows the placeholder (the reported bug), then dispatching A asserts 'flying' in /api/state + in-flight indicator while B remains accepted with its independent placeholder picker; live instance restored to scale 1 + unpaused
|
||||||
|
- Verified: unit suite 166 → 171 green; `tsc --noEmit` clean; e2e 24 → 25 green against the rebuilt compose instance
|
||||||
|
|
||||||
## v0.10.0 — 2026-09-17 19:16
|
## v0.10.0 — 2026-09-17 19:16
|
||||||
- **Right Jobs & Market pane** (`components/RightPane.tsx`, `data-testid="right-pane"`) replaces the v0.9.0 placeholder in `app/page.tsx` (PLAN.md "Right pane"): open contracts render as rows — origin → destination, reward (€), cargo type × count, deadline relative to the sim clock ("in 14h 30m") — each with an Accept button (`accept-<id>`); accepted contracts form a separate group where grounded ones get dispatch controls (plane picker enabling only planes that are 'available' AND at the contract origin; others disabled with the reason, e.g. "in flight" / "at EDDF") and in-flight ones show an "in flight" badge instead
|
- **Right Jobs & Market pane** (`components/RightPane.tsx`, `data-testid="right-pane"`) replaces the v0.9.0 placeholder in `app/page.tsx` (PLAN.md "Right pane"): open contracts render as rows — origin → destination, reward (€), cargo type × count, deadline relative to the sim clock ("in 14h 30m") — each with an Accept button (`accept-<id>`); accepted contracts form a separate group where grounded ones get dispatch controls (plane picker enabling only planes that are 'available' AND at the contract origin; others disabled with the reason, e.g. "in flight" / "at EDDF") and in-flight ones show an "in flight" badge instead
|
||||||
- **Market section**: buy plane (model picker from the snapshot catalog + price preview), sell plane (grounded-plane picker + payout preview = sellFraction × price), refuel (partial-tank grounded-plane picker + cost preview = (maxFuel − fuel) × fuelPricePerL) — all previews computed client-side from the new snapshot fields, no extra endpoints
|
- **Market section**: buy plane (model picker from the snapshot catalog + price preview), sell plane (grounded-plane picker + payout preview = sellFraction × price), refuel (partial-tank grounded-plane picker + cost preview = (maxFuel − fuel) × fuelPricePerL) — all previews computed client-side from the new snapshot fields, no extra endpoints
|
||||||
|
|||||||
@@ -22,10 +22,25 @@ function dispatchReason(p: PlaneState, contract: ContractState): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The plane a dispatch picker should act on for one accepted contract: the user's explicit pick
|
||||||
|
// while it stays eligible, else NOTHING. v0.10.1 removed the "auto-track first eligible" fallback —
|
||||||
|
// it made every row display the same plane by default (selections looked shared across contracts)
|
||||||
|
// and left no way to show "nothing chosen". null = placeholder shown + Dispatch disabled; a poll
|
||||||
|
// that makes the pick ineligible (plane flew / moved away) drops back to the placeholder too.
|
||||||
|
export function dispatchSelection(
|
||||||
|
planes: PlaneState[],
|
||||||
|
contract: ContractState,
|
||||||
|
selectedId: string | null,
|
||||||
|
): PlaneState | null {
|
||||||
|
if (selectedId === null) return null;
|
||||||
|
return planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
|
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
|
||||||
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
|
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
|
||||||
// button bound to the selection. The effective selection is the user's pick while it stays
|
// button bound to the selection. Each row owns its selection in local state and defaults to the
|
||||||
// eligible, else auto-tracks the first eligible plane so a fresh contract needs one click.
|
// "Select a plane…" placeholder — never preselecting — so rows stay visibly independent and a
|
||||||
|
// dispatch can't fire on the wrong contract by accident.
|
||||||
function DispatchControls({
|
function DispatchControls({
|
||||||
contract,
|
contract,
|
||||||
planes,
|
planes,
|
||||||
@@ -38,10 +53,7 @@ function DispatchControls({
|
|||||||
onDispatch: (planeId: string) => void;
|
onDispatch: (planeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const effective =
|
const effective = dispatchSelection(planes, contract, selectedId);
|
||||||
planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ??
|
|
||||||
planes.find((p) => dispatchReason(p, contract) === null) ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dispatch-controls">
|
<div className="dispatch-controls">
|
||||||
@@ -51,6 +63,7 @@ function DispatchControls({
|
|||||||
value={effective?.id ?? ''}
|
value={effective?.id ?? ''}
|
||||||
onChange={(e) => setSelectedId(e.target.value || null)}
|
onChange={(e) => setSelectedId(e.target.value || null)}
|
||||||
>
|
>
|
||||||
|
<option value="">Select a plane…</option>
|
||||||
{planes.map((p) => {
|
{planes.map((p) => {
|
||||||
const reason = dispatchReason(p, contract);
|
const reason = dispatchReason(p, contract);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dispatchSelection } from './RightPane';
|
||||||
|
import type { ContractState, PlaneState } from '@/lib/game/state';
|
||||||
|
|
||||||
|
// v0.10.1 — the per-contract dispatch picker rule: a row's effective selection is the user's
|
||||||
|
// explicit pick while it stays eligible (available AND at the contract origin), else NOTHING
|
||||||
|
// (placeholder). The old "auto-track first eligible" fallback made every accepted-contract row
|
||||||
|
// display the same plane, so selections looked shared across contracts.
|
||||||
|
|
||||||
|
const contract: ContractState = {
|
||||||
|
id: 'contract-a',
|
||||||
|
originIcao: 'EDDF',
|
||||||
|
originName: 'Frankfurt Main',
|
||||||
|
destIcao: 'EDDM',
|
||||||
|
destName: 'Munich',
|
||||||
|
reward: 1000,
|
||||||
|
cargoType: 'mail',
|
||||||
|
cargoCount: 4,
|
||||||
|
deadlineSimTime: 1_000_000,
|
||||||
|
status: 'accepted',
|
||||||
|
planeId: null,
|
||||||
|
createdSimTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||||
|
return {
|
||||||
|
model: 'atr72',
|
||||||
|
status: 'available',
|
||||||
|
fuel: 100,
|
||||||
|
maxFuel: 100,
|
||||||
|
locationIcao: 'EDDF',
|
||||||
|
locationName: 'Frankfurt Main',
|
||||||
|
contractId: null,
|
||||||
|
departureSimTime: null,
|
||||||
|
lat: 50,
|
||||||
|
lon: 8.5,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dispatchSelection (per-contract dispatch picker rule)', () => {
|
||||||
|
it('defaults to the placeholder — no auto-selection of the first eligible plane', () => {
|
||||||
|
const planes = [plane({ id: 'p1' }), plane({ id: 'p2' })]; // both eligible at EDDF
|
||||||
|
expect(dispatchSelection(planes, contract, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the user pick while it stays eligible (available AND at the origin)', () => {
|
||||||
|
const planes = [plane({ id: 'p1' }), plane({ id: 'p2', locationIcao: 'EDDM' })];
|
||||||
|
expect(dispatchSelection(planes, contract, 'p1')?.id).toBe('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops to the placeholder when a poll makes the pick ineligible (flew away)', () => {
|
||||||
|
const planes = [plane({ id: 'p1', status: 'flying' })];
|
||||||
|
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops to the placeholder when a poll moves the pick off the origin', () => {
|
||||||
|
const planes = [plane({ id: 'p1', locationIcao: 'EDDM' })];
|
||||||
|
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown selection id resolves to the placeholder', () => {
|
||||||
|
const planes = [plane({ id: 'p1' })];
|
||||||
|
expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -261,3 +261,85 @@ test('market buy/sell render with price and payout previews from the catalog in
|
|||||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
await expect(page.getByTestId('market-sell')).toBeVisible();
|
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
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user