Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e18d39edd0 | ||
|
|
a7150a1062 | ||
|
|
8eeef401ae | ||
|
|
f273d385c2 | ||
|
|
3b02c7feac | ||
|
|
1ba70ebbf3 | ||
|
|
08a819e44c | ||
|
|
50a9147665 |
+2
-1
@@ -1 +1,2 @@
|
|||||||
# No environment variables required yet.
|
# No environment variables required yet.
|
||||||
|
GITEA_TOKEN=gitea-api-token
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.23.0 — 2026-09-20 08:25
|
||||||
|
- **Refuel-to-full row moves to the left pane (ISS-035).** Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The `.market-row` JSX (`market-refuel-none` "All tanks full." span / `market-refuel-plane` select of grounded planes labeled `{planeName} — {fuel}/{maxFuel} L`, `market-refuel-cost` preview, `market-refuel` button → `POST /api/planes/{id}/refuel`) moved verbatim from the Market section in `components/RightPane.tsx` into a new "Refuel" `<section data-testid="refuel-section">` in `components/LeftPane.tsx`, inserted between Relocate and Time. Its state (`refuelPlaneId`) + derived values (`refuelable`, effective-selection `refuelPlane`, `refuelCost`) moved with it, reusing LeftPane's existing `grounded` list; busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (`refuel-for-{pkg.id}`, mode 'forFlight') is a different feature and stays put; exported helpers `slotsUsedFor` / `loadEligiblePlanes` untouched
|
||||||
|
- Tests: e2e — `right-pane.spec.ts`: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside `[data-testid="left-pane"]` (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. `panes.spec.ts`: first test gains structural assertions — Refuel heading + `market-refuel` visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.22.2 — 2026-09-20 07:32
|
||||||
|
- Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes
|
||||||
|
|
||||||
|
## v0.22.1 — 2026-09-20 06:57
|
||||||
|
- Hygiene: gitignore the local-only ORCHESTRATOR.md successor-agent manual (it references host-specific setup and must never be version-controlled); no code changes
|
||||||
|
|
||||||
## v0.22.0 — 2026-09-19 21:42
|
## v0.22.0 — 2026-09-19 21:42
|
||||||
- **Hover route trace on job-list rows (ISS-022).** Hovering an open package in the right pane now draws the ENTIRE great-circle trace between origin and destination, not just the two endpoint rings. Zero pane edits — the rows already sent `{kind:'package', originIcao, destIcao}`; `components/Map.tsx` now derives from/to for any hover target carrying two ICAOs and pushes a new pure builder's output (`hoverRouteGeoJson(fromIcao, toIcao, airportsByIcao)` in `lib/mapData.ts`: one LineString via the existing `greatCircleArc` slerp, empty FeatureCollection when either endpoint is missing from the airport index or the `''` no-hover sentinel) into a NEW dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer (paint: `#facc15` matching the airports-highlight ring, width 4, `line-dasharray [2,2]`). The shared `routes` source can't carry this — it only holds FLYING legs, so an open package's or a grounded relocation's planned route was never in it. The layer is added in `ensureLayers` after the highlight layers so style switches keep it; `applyHoverRef` re-pushes data on every hover
|
- **Hover route trace on job-list rows (ISS-022).** Hovering an open package in the right pane now draws the ENTIRE great-circle trace between origin and destination, not just the two endpoint rings. Zero pane edits — the rows already sent `{kind:'package', originIcao, destIcao}`; `components/Map.tsx` now derives from/to for any hover target carrying two ICAOs and pushes a new pure builder's output (`hoverRouteGeoJson(fromIcao, toIcao, airportsByIcao)` in `lib/mapData.ts`: one LineString via the existing `greatCircleArc` slerp, empty FeatureCollection when either endpoint is missing from the airport index or the `''` no-hover sentinel) into a NEW dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer (paint: `#facc15` matching the airports-highlight ring, width 4, `line-dasharray [2,2]`). The shared `routes` source can't carry this — it only holds FLYING legs, so an open package's or a grounded relocation's planned route was never in it. The layer is added in `ensureLayers` after the highlight layers so style switches keep it; `applyHoverRef` re-pushes data on every hover
|
||||||
- **Same trace on On-board cargo rows (ISS-025).** Each `onboard-package-{id}` row in the left pane's On board section now reports `{kind:'package', originIcao, destIcao, planeId: plane.id}` on mouseenter — same full trace as job-list rows PLUS the carrying plane's halo stays lit via the new optional `planeId` field (Map uses `h.planeId ?? ''` for the planes/routes-highlight filters). Mouseleave restores the block's plain `{kind:'plane', id}` highlight; leaving the whole block still ends at null because React fires the child's mouseleave before the parent's
|
- **Same trace on On-board cargo rows (ISS-025).** Each `onboard-package-{id}` row in the left pane's On board section now reports `{kind:'package', originIcao, destIcao, planeId: plane.id}` on mouseenter — same full trace as job-list rows PLUS the carrying plane's halo stays lit via the new optional `planeId` field (Map uses `h.planeId ?? ''` for the planes/routes-highlight filters). Mouseleave restores the block's plain `{kind:'plane', id}` highlight; leaving the whole block still ends at null because React fires the child's mouseleave before the parent's
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ORCHESTRATOR.md — Operating Manual for Successor Agents
|
||||||
|
|
||||||
|
You are taking over the autonomous development loop on **Flight-Ops** (browser game: run an airplane transport company in Europe; server-authoritative sim, MapLibre GL JS map). This document captures operational knowledge that is NOT written down in AGENTS.md. Read AGENTS.md first — it defines the mandatory change process and this file complements it.
|
||||||
|
|
||||||
|
Credentials are deliberately NOT stored in this file — see §1 for where they live. Never paste token values into any tracked file.
|
||||||
|
|
||||||
|
## 1. Project identity
|
||||||
|
- Repo: Gitea at https://git.kkhost.pl → org **OpenCode**, repo **Flight-Ops** (full name `OpenCode/Flight-Ops`). Every Gitea API/MCP call uses owner=`OpenCode`, repo=`Flight-Ops`.
|
||||||
|
- Local workdir + prod deploy dir: `/home/krzyw/projects/sandbox/flight` (git checkout; `docker compose up -d --build` deploys from here).
|
||||||
|
- Prod URL: https://flight.dev.kkhost.pl (Traefik → docker-compose stack on this host). `GET /api/version` reports the running version.
|
||||||
|
- Gitea API access for git.kkhost.pl: prefer the gitea MCP tools — they are already configured with a token. For raw REST/curl, read the current token from the local .env file at repo root (variable `GITEA_TOKEN`; .env is gitignored and must never be committed): `source .env && curl -s -H "Authorization: token $GITEA_TOKEN" https://git.kkhost.pl/api/v1/user`. (Incident note, Sept 2026: an early draft of this doc committed a live token to the repo; that commit was purged from all refs and the token has since been rotated — never repeat this.)
|
||||||
|
- Current user: kamilkrzywda (id 1).
|
||||||
|
|
||||||
|
## 2. The loop (per issue)
|
||||||
|
1. Small incremental change on a feature branch off `master`; connected issues may share one branch/PR with a single version bump.
|
||||||
|
2. Bump `package.json` version + add CHANGELOG.md entry at top: `## v<version> — YYYY-MM-DD HH:MM` then bullet points of what changed (docs-only changes get patch bumps too — precedent v0.19.1/v0.19.2).
|
||||||
|
3. Local verification ONLY (there are NO local e2e runs): `npx tsc --noEmit`, `npx vitest run` (the whole unit suite must pass — the test count grows over time, so compare against the previous green run, never a hardcoded number), `npx playwright test --list` (all specs compile and list).
|
||||||
|
4. Push → CI (~7 min) → **auto-merge on green** (PRs have auto-merge configured; no manual merge needed — verify via `GET /pulls/{n}` that state=closed and merged_at is set). Note: if a PR was created via API rather than create-on-push, double-check auto-merge actually fires.
|
||||||
|
5. Tag the version on master: `git tag v<version> origin/master && git push origin v<version>` (after `git fetch origin master`).
|
||||||
|
6. Deploy prod in a PTY (long task): `cd /home/krzyw/projects/sandbox/flight && git checkout master && git reset --hard origin/master && docker compose up -d --build`; then verify `curl https://flight.dev.kkhost.pl/api/version` returns the new version and the root page is HTTP 200.
|
||||||
|
7. Close each issue with a comment: what changed, version, PR number, merge commit SHA, deployed URL.
|
||||||
|
8. Repeat for the next open issue: `GET /issues?state=open&type=issues` — note Gitea lists pull requests in this endpoint too; filter out entries with a `pull_request` key.
|
||||||
|
|
||||||
|
## 3. Gitea API + MCP quirks (learned the hard way)
|
||||||
|
- The gitea MCP tools work for issues/PRs/files/tags, and Actions endpoints work IF you pass owner=`OpenCode`, repo=`Flight-Ops`. A wrong owner/repo makes EVERY call return `{"message":"not found","url":"https://git.kkhost.pl/api/swagger"}` — that swagger URL inside a 404 is the signature of "wrong path", not "Gitea is down".
|
||||||
|
- MCP merge endpoint 405s → don't bother; auto-merge handles merging. Manual fallback if ever needed: `POST /repos/OpenCode/Flight-Ops/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge"}`.
|
||||||
|
- Gitea REST shapes (NOT GitHub's): list runs → `{workflow_runs:[...]}`; run jobs → `/actions/runs/{id}/jobs` returns `{"jobs":[...]}`; single job log via MCP `get_job_log_preview` (param tail_lines) or `download_job_log` to a file, then grep.
|
||||||
|
- Failure anatomy in CI logs: the Playwright summary at the end lists "N failed" + test titles; each failure block has a code frame with line numbers plus an "Error Context:" path. The e2e step is the only one that can fail flakily; unit failures are real regressions.
|
||||||
|
- CI watcher pattern that works: `run_background_process` (notify=true) running a loop that polls `GET /actions/runs/{id}` every 60 s and exits when status=completed, printing status+conclusion each tick. Don't poll in the foreground; don't sleep-loop on pty_read.
|
||||||
|
|
||||||
|
## 4. CI facts (.gitea/workflows/ci.yml + docker-compose.ci.yml)
|
||||||
|
- Single job "build + unit + e2e" on runner gitea-runner (docker-in-docker). Fresh DB per run (volume removed); compose project `flight-ci`; app container on a static IP → `E2E_BASE_URL=http://172.39.0.10:3000`; `PW_REMOTE_BROWSER=0` (CI runs local chromium — the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind); `E2E_COMPOSE_ARGS="-f docker-compose.ci.yml -p flight-ci"` is read by e2e/state.spec.ts for its mid-run `docker compose restart flight`.
|
||||||
|
- Flaky uplink → retry loops in ci.yml; an occasional "flaky" test (failed once, passed on retry) does NOT fail the run.
|
||||||
|
- Branch protection requires status check `CI / build + unit + e2e (pull request)` — PRs cannot merge until green; CI never deploys to production.
|
||||||
|
|
||||||
|
## 5. E2E conventions (e2e/*.spec.ts) — read before touching tests
|
||||||
|
- Sim scale in tests: SCALE=1000; hub `HUB='EDDF'` (Frankfurt); typical destination LEMD.
|
||||||
|
- `flyHomeViaApi(page)` self-heal at test start — interrupted runs can leave planes mid-flight; landing waits up to 240 s.
|
||||||
|
- Pause-sim pattern for deterministic marker clicks: pause, act, assert, resume.
|
||||||
|
- Dev hook: `window.__flightMap` exposes the live MapLibre map instance in dev builds (also `__flightLastSnapshot`).
|
||||||
|
- `queryRenderedFeatures` is viewport-scoped → fitBounds/fitToAirports before asserting on far features; give the renderer ~300 ms after a camera jump.
|
||||||
|
- **expect.poll callbacks must be total** — a throw inside fails the test instantly instead of retrying. Guard/filter everything you dereference.
|
||||||
|
|
||||||
|
## 6. ⚠️ The big one: Playwright evaluate serialization drops prototype getters
|
||||||
|
MapLibre's `queryRenderedFeatures` returns live `GeoJSONFeature` class instances whose `geometry` is a **lazy PROTOTYPE getter** (own properties are only type/properties/id/source/state plus private fields). Playwright serializes evaluate results by copying **own enumerable properties only**, so every feature arriving in test context has NO geometry — silently.
|
||||||
|
- Verified empirically: `page.evaluate(() => { class A{constructor(){this.own=1} get geo(){return 42}} return [new A()] })` → `[{"own":1}]`.
|
||||||
|
- Symptom matrix (how it masqueraded as "the line isn't drawn" while the trace rendered fine on screen, across three consecutive failed CI runs in Sept 2026): strict length guard → timeout; direct `.geometry.coordinates` access → `TypeError: Cannot read properties of undefined (reading 'coordinates')`; defensive filter → clean 15 s timeout.
|
||||||
|
- **Rule: never return maplibre feature instances across page.evaluate — always map to plain objects in-page** (`{type, id, properties, geometry: f.geometry}` forces the getter to evaluate). Canonical pattern: `renderedFeatures()` in e2e/hover.spec.ts. Other specs that touch `.geometry` do so inside evaluate (safe) or only read array length (safe).
|
||||||
|
|
||||||
|
## 7. MapLibre rendering facts (for map assertions)
|
||||||
|
- GeoJSON geometry is stored per-tile in INTEGER grids; long lines are clipped → `queryRenderedFeatures` returns ONE FEATURE PER TILE the line crosses (the EDDF→LEMD leg ≈ 4–5 segments at z=4/5).
|
||||||
|
- Coordinates are quantized to the grid: error up to ~half a grid step (~0.001° observed at z≥4, worst ~0.02° at z=0 with extent 8192). Endpoint assertions must use an EPS sized for this (the hover tests define one in `expectHoverRoute` in e2e/hover.spec.ts — ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports) and check endpoints ACROSS ALL SEGMENTS: some segment must START within EPS of origin AND some must END within EPS of destination. Never strict equality on a single feature.
|
||||||
|
- The true line endpoints survive clipping verbatim in whichever tile contains them.
|
||||||
|
|
||||||
|
## 8. App architecture quick map
|
||||||
|
- `components/Map.tsx` — the MapLibre layer: sources/layers created in ensureLayers (dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer, plus an `airports-target-highlight` ring layer added last so it sits above the line at its endpoint — current colors/widths live in Map.tsx, don't trust this doc for exact paint values); applyHoverRef re-pushes data on every hover; `window.__flightMap` dev hook.
|
||||||
|
- `lib/mapData.ts` — pure GeoJSON builders (`hoverRouteGeoJson`, `routesGeoJson`, `greatCircleArc` slerp) + unit tests in lib/mapData.test.ts.
|
||||||
|
- Hover protocol: panes report a HoverTarget via mouseenter/leave (`components/hoverTypes.ts`): `{kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}`; the `''` sentinel means no hover.
|
||||||
|
- Server: sim ticks server-side, client polls /api/state; OpenAPI spec generated from lib/openapi.ts and served at /api/openapi — update it whenever an endpoint changes.
|
||||||
|
|
||||||
|
## 9. Finding current state (this file keeps no snapshots)
|
||||||
|
This manual is meant to stay valid long-term, so it deliberately contains NO version or issue snapshots — they rot. To orient yourself when you take over: `git log --oneline -10` + CHANGELOG.md for what has shipped; `GET /issues?state=open&type=issues` (filter out entries with a `pull_request` key) for what is left to do; PLAN.md's "Later" section for the roadmap backlog.
|
||||||
|
|
||||||
|
## 10. Standing user instruction & etiquette
|
||||||
|
- The user's standing order for this project: work through the issue list autonomously — implement → PR → CI green → merge → tag → deploy prod → close with comment → next — until told to stop.
|
||||||
|
- Small incremental changes; each part fully tested (unit + e2e) before moving on.
|
||||||
|
- Long terminal tasks → pty_* tools (deploys, builds). Background watchers → run_background_process with notify=true.
|
||||||
|
- Never push directly to master; never deploy from a feature branch; CI never touches prod — production deploys are manual only (`docker compose up -d --build` after tagging).
|
||||||
|
- **Never commit credentials.** Tokens/secrets live in the MCP server config or with the user, never in tracked files (see incident note in §1).
|
||||||
@@ -102,6 +102,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
||||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
|
||||||
|
// "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
|
||||||
|
// a sensible default (first grounded plane with fuel below max).
|
||||||
|
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||||
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||||
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||||
@@ -205,6 +210,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const relocateDest =
|
const relocateDest =
|
||||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||||
|
|
||||||
|
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
||||||
|
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
||||||
|
// market row; reuses `grounded` above, and with state null falls back to null.
|
||||||
|
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
||||||
|
const refuelPlane =
|
||||||
|
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
||||||
|
refuelable[0] ??
|
||||||
|
null;
|
||||||
|
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
||||||
|
|
||||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
||||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
||||||
// user releases the slider (browsers fire a native 'change' on release). The server's
|
// user releases the slider (browsers fire a native 'change' on release). The server's
|
||||||
@@ -507,6 +522,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
|
||||||
|
about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
|
||||||
|
<section className="pane-section" data-testid="refuel-section">
|
||||||
|
<h3 className="pane-heading">Refuel</h3>
|
||||||
|
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
||||||
|
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
|
||||||
|
<div className="market-row">
|
||||||
|
{refuelable.length === 0 ? (
|
||||||
|
<span data-testid="market-refuel-none" className="pane-muted">
|
||||||
|
All tanks full.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
data-testid="market-refuel-plane"
|
||||||
|
aria-label="Plane to refuel"
|
||||||
|
value={refuelPlane?.id ?? ''}
|
||||||
|
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
{refuelable.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span data-testid="market-refuel-cost" className="market-preview">
|
||||||
|
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="market-refuel"
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy || refuelPlane === null}
|
||||||
|
onClick={() => {
|
||||||
|
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refuel to full
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Time</h3>
|
<h3 className="pane-heading">Time</h3>
|
||||||
<p data-testid="sim-clock" className="sim-clock">
|
<p data-testid="sim-clock" className="sim-clock">
|
||||||
|
|||||||
@@ -116,10 +116,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
|
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
|
||||||
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
||||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
||||||
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
||||||
|
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
|
||||||
|
// left pane's "Refuel" section next to Relocate.
|
||||||
|
|
||||||
if (loading && !state) {
|
if (loading && !state) {
|
||||||
return (
|
return (
|
||||||
@@ -139,15 +140,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
|
|
||||||
const grounded = state.planes.filter((p) => p.status === 'available');
|
const grounded = state.planes.filter((p) => p.status === 'available');
|
||||||
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
|
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
|
||||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
|
||||||
const refuelPlane =
|
|
||||||
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
|
|
||||||
refuelable[0] ??
|
|
||||||
null;
|
|
||||||
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
||||||
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
||||||
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
|
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
|
||||||
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside data-testid="right-pane" className="pane right-pane">
|
<aside data-testid="right-pane" className="pane right-pane">
|
||||||
@@ -262,44 +257,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="market-row">
|
|
||||||
{refuelable.length === 0 ? (
|
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
|
||||||
All tanks full.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
data-testid="market-refuel-plane"
|
|
||||||
aria-label="Plane to refuel"
|
|
||||||
value={refuelPlane?.id ?? ''}
|
|
||||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
{refuelable.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span data-testid="market-refuel-cost" className="market-preview">
|
|
||||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="market-refuel"
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={busy || refuelPlane === null}
|
|
||||||
onClick={() => {
|
|
||||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refuel to full
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||||||
|
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
||||||
// …and no longer in the right pane.
|
// …and no longer in the right pane.
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
||||||
|
// heading and button render here…
|
||||||
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
|
||||||
|
// …and neither the heading nor the row remains in the right pane.
|
||||||
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||||
// abandon buttons (penalty preview from the same
|
// abandon buttons (penalty preview from the same
|
||||||
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
|
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
||||||
// match the catalog + economy constants exposed in /api/state. Packages are created via
|
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
|
||||||
|
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
|
||||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
@@ -300,7 +301,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
|
test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
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)
|
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||||
|
|
||||||
@@ -325,6 +326,9 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
|
|||||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
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-payout')).toContainText(eur(payout));
|
||||||
|
|
||||||
|
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
|
||||||
|
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Refuel through the pane: the flight genuinely burned fuel…
|
// Refuel through the pane: the flight genuinely burned fuel…
|
||||||
if (starter.fuel < starter.maxFuel) {
|
if (starter.fuel < starter.maxFuel) {
|
||||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||||
@@ -348,7 +352,7 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => {
|
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
@@ -403,7 +407,7 @@ test('on-board row Refuel tops up just enough for the package leg; market button
|
|||||||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
||||||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
||||||
|
|
||||||
// Market row: the fill-up button is labeled "Refuel to full" (testid unchanged).
|
// Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
|
||||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
||||||
|
|
||||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.22.0",
|
"version": "0.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user