mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
web: airport-editor validation UX and docs polish
Live client + server confirm validation panel, empty states, sweatbox handoff copy, and residual docs.
This commit is contained in:
15
README.md
15
README.md
@@ -23,6 +23,7 @@ As of May 2025, FSD is still used to facilitate over 140,000 active members conn
|
||||
cmd/openfsd/ # Binary entrypoint (FSD + web; image CMD is /openfsd)
|
||||
pkg/protocol/ # Pure wire format (parse/marshal; no I/O)
|
||||
pkg/fsdclient/ # Mock/real FSD client for e2e and tools
|
||||
pkg/twrfiles/ # Pure .apt/.air parse + format (sweatbox + editor)
|
||||
internal/server/ # TCP accept, login, handlers, service HTTP
|
||||
internal/session/ # Per-connection state + outbound send worker
|
||||
internal/postoffice/ # Callsign registry + geospatial index
|
||||
@@ -48,6 +49,19 @@ go run ./cmd/aptdat2apt -download -out generated-apt -quiet
|
||||
Sources, licensing (Gateway / Global Airports GPLv2), and packaging rules:
|
||||
[docs/xplane-airport-data.md](docs/xplane-airport-data.md).
|
||||
|
||||
### Airport editor (web)
|
||||
|
||||
Administrators can author paired **`.apt`** (geometry) and **`.air`** (scenario
|
||||
aircraft) files in the browser at **`/airport-editor`**:
|
||||
|
||||
- Map-first editing (Leaflet; JS required for the map; text paste + echo-download works without JS)
|
||||
- **No server persistence** of APT/AIR — open/download only (Blob download with JS; form echo-download without)
|
||||
- Validate tab: live client parse + soft cross-file warnings; optional **Confirm with server**
|
||||
(`POST /api/v1/editor/validate-apt` and `validate-air`)
|
||||
- Workflow: download files → load them on **`/sweatbox`** (editor never pushes into a live session)
|
||||
|
||||
Design notes: [docs/design/apt-air-editor.md](docs/design/apt-air-editor.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```bash
|
||||
@@ -130,6 +144,7 @@ docker compose down
|
||||
```bash
|
||||
go test -race ./...
|
||||
bash scripts/check-coverage.sh 80 # overall ≥80%; pure-pkg floors (see AGENTS.md)
|
||||
bash scripts/check-webjs.sh # Node ≥20 unit tests for airport-editor JS modules
|
||||
go test -bench=. -benchmem ./internal/postoffice/ ./pkg/protocol/
|
||||
go test -tags=stress -count=1 -timeout=120s ./internal/server/ -run TestStress -v
|
||||
```
|
||||
|
||||
@@ -18,10 +18,17 @@ JSON under `/api/v1` for external tools and map polling. First-party UI is a pro
|
||||
|
||||
JSON under `/api/v1` remains for external consumers and map polling. Admin mutations work with **cookie + CSRF only** (no `Authorization` header required).
|
||||
|
||||
### Airport editor validation
|
||||
|
||||
- **Live client:** JS `parseAPT` / `parseAIR` + soft cross-file warnings (dep ICAO, aircraft far from field) on the Validate tab.
|
||||
- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (Admin, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB.
|
||||
- **Handoff:** download `.apt`/`.air`, then load on `/sweatbox` (no automatic push from editor → live session).
|
||||
- Design: `docs/design/apt-air-editor.md`. JS unit tests: `webjs/` + `bash scripts/check-webjs.sh`.
|
||||
|
||||
### JS budget / map exception
|
||||
First-party openfsd modules stay small and vanilla (no jQuery). The **dashboard route** may load **Leaflet** (vendor) + `dashboard.js` as a documented exception to the 30–50 KB compressed first-party budget. Failure mode: map is absent; connection summary HTML still works.
|
||||
|
||||
The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules, landed in later PRs). Essential data path without JS: paste `apt_text` / `air_text` + CSRF form echo-download. Map region is inert when JS is off. Download handlers never write APT/AIR to disk or DB.
|
||||
The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules). Essential data path without JS: paste `apt_text` / `air_text` + CSRF form echo-download. Map region is inert when JS is off. Download handlers never write APT/AIR to disk or DB.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -95,6 +95,16 @@ func TestAirportEditorAdminShell(t *testing.T) {
|
||||
`data-mode="select"`,
|
||||
`data-mode="park"`,
|
||||
`data-mode="aircraft"`,
|
||||
// PR8: validation UX hooks + sweatbox handoff
|
||||
`data-validate-apt="/api/v1/editor/validate-apt"`,
|
||||
`data-validate-air="/api/v1/editor/validate-air"`,
|
||||
`data-js="chip-issues"`,
|
||||
`data-action="confirm-server"`,
|
||||
`data-js="confirm-server"`,
|
||||
`data-js="panel-validate"`,
|
||||
`data-js="sweatbox-handoff"`,
|
||||
`href="/sweatbox"`,
|
||||
`Confirm with server`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected body to contain %q, body=%s", want, clip(body, 800))
|
||||
|
||||
@@ -104,6 +104,58 @@
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.apted-chip-issues.is-ok {
|
||||
border-color: #a3cfbb;
|
||||
background: #d1e7dd;
|
||||
color: var(--apted-ok);
|
||||
}
|
||||
|
||||
.apted-chip-issues.is-err {
|
||||
border-color: #f1aeb5;
|
||||
background: #f8d7da;
|
||||
color: var(--apted-danger);
|
||||
}
|
||||
|
||||
.apted-chip-issues.is-warn {
|
||||
border-color: #c9a227;
|
||||
background: var(--apted-warn-bg);
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.apted-chip-issues.is-empty {
|
||||
color: var(--apted-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.apted-tab-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 2px;
|
||||
font-family: var(--apted-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
vertical-align: middle;
|
||||
background: #e9ecef;
|
||||
color: var(--apted-muted);
|
||||
}
|
||||
|
||||
.apted-tab-badge.is-ok {
|
||||
background: #d1e7dd;
|
||||
color: var(--apted-ok);
|
||||
}
|
||||
|
||||
.apted-tab-badge.is-err {
|
||||
background: #f8d7da;
|
||||
color: var(--apted-danger);
|
||||
}
|
||||
|
||||
.apted-tab-badge.is-warn {
|
||||
background: var(--apted-warn-bg);
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.apted-flash {
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.6rem;
|
||||
@@ -525,6 +577,57 @@
|
||||
|
||||
.apted-err-list-warn {
|
||||
color: #664d03;
|
||||
background: var(--apted-warn-bg);
|
||||
border: 1px solid #e6d28a;
|
||||
border-radius: 2px;
|
||||
padding: 0.35rem 0.5rem 0.35rem 1.25rem;
|
||||
}
|
||||
|
||||
.apted-soft-hd {
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.apted-soft-note {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.apted-validate-summary {
|
||||
margin: 0 0 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: var(--apted-fs-sm);
|
||||
}
|
||||
|
||||
.apted-server-validate {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--apted-border);
|
||||
}
|
||||
|
||||
.apted-server-side {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.apted-server-ok {
|
||||
color: var(--apted-ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apted-server-stale {
|
||||
color: #664d03;
|
||||
background: var(--apted-warn-bg);
|
||||
border: 1px solid #e6d28a;
|
||||
border-radius: 2px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.apted-handoff {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--apted-border);
|
||||
}
|
||||
|
||||
.apted-handoff a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apted-mini-table {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
updateAircraft,
|
||||
updateAirportHeaders,
|
||||
snapAircraftToParking,
|
||||
markServerValidationStale,
|
||||
MODE_SELECT,
|
||||
MODE_PARK,
|
||||
MODE_TAXI,
|
||||
@@ -40,6 +41,7 @@ import { parseAIR } from './parse-air.js';
|
||||
import { formatAPT } from './format-apt.js';
|
||||
import { formatAIR } from './format-air.js';
|
||||
import { validateDocument } from './validate.js';
|
||||
import { postServerValidate } from './server-validate.js';
|
||||
import {
|
||||
createMap,
|
||||
OverlayController,
|
||||
@@ -273,6 +275,9 @@ function main() {
|
||||
);
|
||||
}
|
||||
},
|
||||
onConfirmServer() {
|
||||
void confirmWithServer();
|
||||
},
|
||||
});
|
||||
|
||||
const toolbar = mountToolbar(root, {
|
||||
@@ -291,6 +296,7 @@ function main() {
|
||||
markDirty: false,
|
||||
});
|
||||
doc.lastAptDownloadHash = null;
|
||||
doc.serverValidation = null;
|
||||
syncFallbackTextareas();
|
||||
validateDocument(doc);
|
||||
doc.selection = null;
|
||||
@@ -321,6 +327,7 @@ function main() {
|
||||
markDirty: false,
|
||||
});
|
||||
doc.lastAirDownloadHash = null;
|
||||
doc.serverValidation = null;
|
||||
syncFallbackTextareas();
|
||||
validateDocument(doc);
|
||||
doc.selection = null;
|
||||
@@ -386,6 +393,7 @@ function main() {
|
||||
if (!window.confirm('Discard unsaved APT/AIR changes and start new?')) return;
|
||||
}
|
||||
resetDocument(doc);
|
||||
doc.serverValidation = null;
|
||||
cancelDraw(draw);
|
||||
overlays.clearDrawPreview();
|
||||
toolbar.setMode(MODE_SELECT);
|
||||
@@ -591,6 +599,7 @@ function main() {
|
||||
*/
|
||||
function afterAptMutation(opts = {}) {
|
||||
validateDocument(doc);
|
||||
markServerValidationStale(doc);
|
||||
syncFallbackTextareas();
|
||||
refresh(opts);
|
||||
}
|
||||
@@ -600,10 +609,109 @@ function main() {
|
||||
*/
|
||||
function afterAirMutation(opts = {}) {
|
||||
validateDocument(doc);
|
||||
markServerValidationStale(doc);
|
||||
syncFallbackTextareas();
|
||||
refresh(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST formatted document text to Admin validate-apt / validate-air APIs.
|
||||
*/
|
||||
async function confirmWithServer() {
|
||||
if (doc.serverValidation?.loading) return;
|
||||
|
||||
const aptURL =
|
||||
root.getAttribute('data-validate-apt') || '/api/v1/editor/validate-apt';
|
||||
const airURL =
|
||||
root.getAttribute('data-validate-air') || '/api/v1/editor/validate-air';
|
||||
|
||||
const hasApt = !!doc.airport;
|
||||
const hasAir = Array.isArray(doc.aircraft) && doc.aircraft.length > 0;
|
||||
if (!hasApt && !hasAir) {
|
||||
doc.serverValidation = {
|
||||
loading: false,
|
||||
stale: false,
|
||||
message: 'Nothing to confirm — load or draw APT/AIR first.',
|
||||
};
|
||||
refresh();
|
||||
showStatus('Nothing to confirm with server.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
doc.serverValidation = {
|
||||
loading: true,
|
||||
stale: false,
|
||||
apt: null,
|
||||
air: null,
|
||||
};
|
||||
refresh();
|
||||
showStatus('Confirming with server…', false);
|
||||
|
||||
/** @type {import('./model.js').ServerValidationSide|null} */
|
||||
let aptResult = null;
|
||||
/** @type {import('./model.js').ServerValidationSide|null} */
|
||||
let airResult = null;
|
||||
let transportErr = null;
|
||||
|
||||
try {
|
||||
if (hasApt) {
|
||||
const text = formatAPT(doc.airport);
|
||||
const r = await postServerValidate(aptURL, text, { side: 'apt' });
|
||||
aptResult = {
|
||||
ok: r.ok && r.httpOk,
|
||||
error: r.httpOk ? r.error : r.error || `HTTP ${r.status}`,
|
||||
errors: r.errors || [],
|
||||
summary: r.summary || {},
|
||||
};
|
||||
}
|
||||
if (hasAir) {
|
||||
const text = formatAIR(doc.aircraft);
|
||||
const r = await postServerValidate(airURL, text, { side: 'air' });
|
||||
airResult = {
|
||||
ok: r.ok && r.httpOk,
|
||||
error: r.httpOk ? r.error : r.error || `HTTP ${r.status}`,
|
||||
errors: r.errors || [],
|
||||
summary: r.summary || {},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
transportErr = errMessage(err);
|
||||
}
|
||||
|
||||
doc.serverValidation = {
|
||||
loading: false,
|
||||
stale: false,
|
||||
apt: aptResult,
|
||||
air: airResult,
|
||||
message: transportErr || undefined,
|
||||
};
|
||||
refresh();
|
||||
|
||||
if (transportErr) {
|
||||
showStatus(`Server confirm failed: ${transportErr}`, true);
|
||||
return;
|
||||
}
|
||||
const aptN = aptResult?.errors?.length || 0;
|
||||
const airN = airResult?.errors?.length || 0;
|
||||
const aptFail = aptResult && !aptResult.ok;
|
||||
const airFail = airResult && !airResult.ok;
|
||||
if (aptFail || airFail) {
|
||||
showStatus(
|
||||
`Server confirm request error${aptFail && aptResult?.error ? `: ${aptResult.error}` : airFail && airResult?.error ? `: ${airResult.error}` : ''}.`,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const total = aptN + airN;
|
||||
showStatus(
|
||||
total
|
||||
? `Server confirm: ${total} error(s) (APT ${aptN}, AIR ${airN}).`
|
||||
: 'Server confirm: OK.',
|
||||
total > 0,
|
||||
);
|
||||
rail.setTab('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fit?: boolean }} [opts]
|
||||
*/
|
||||
|
||||
@@ -131,7 +131,7 @@ export function countSurfacesByKind(surfaces) {
|
||||
/**
|
||||
* Titlebar chip strings from document (pure).
|
||||
* @param {import('./model.js').EditorDocument} doc
|
||||
* @returns {{ icao: string, apt: string, air: string, counts: string }}
|
||||
* @returns {{ icao: string, apt: string, air: string, counts: string, issues: string, issuesTone: 'ok'|'err'|'warn'|'empty' }}
|
||||
*/
|
||||
export function titlebarChips(doc) {
|
||||
const icao = doc.airport?.icao ? String(doc.airport.icao).toUpperCase() : '—';
|
||||
@@ -145,7 +145,30 @@ export function titlebarChips(doc) {
|
||||
if (sc.taxi) parts.push(`${sc.taxi} taxi`);
|
||||
if (sc.hold) parts.push(`${sc.hold} hold`);
|
||||
if (acN) parts.push(`${acN} ac`);
|
||||
return { icao, apt, air, counts: parts.join(' · ') };
|
||||
|
||||
const aptN = Array.isArray(doc.aptErrors) ? doc.aptErrors.length : 0;
|
||||
const airN = Array.isArray(doc.airErrors) ? doc.airErrors.length : 0;
|
||||
const softN = Array.isArray(doc.softWarnings) ? doc.softWarnings.length : 0;
|
||||
const total = aptN + airN + softN;
|
||||
const hasContent = !!doc.airport || acN > 0;
|
||||
/** @type {'ok'|'err'|'warn'|'empty'} */
|
||||
let issuesTone = 'empty';
|
||||
let issues = '—';
|
||||
if (!hasContent && total === 0) {
|
||||
issues = '—';
|
||||
issuesTone = 'empty';
|
||||
} else if (total === 0) {
|
||||
issues = 'OK';
|
||||
issuesTone = 'ok';
|
||||
} else if (aptN + airN > 0) {
|
||||
issues = `${total} issue${total === 1 ? '' : 's'}`;
|
||||
issuesTone = 'err';
|
||||
} else {
|
||||
issues = `${softN} warn${softN === 1 ? '' : 's'}`;
|
||||
issuesTone = 'warn';
|
||||
}
|
||||
|
||||
return { icao, apt, air, counts: parts.join(' · '), issues, issuesTone };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,24 @@
|
||||
* @property {string|null} lastAptDownloadHash
|
||||
* @property {string|null} lastAirDownloadHash
|
||||
* @property {number} _nextSurfaceId
|
||||
* @property {ServerValidationState|null} [serverValidation] - last server confirm result (UI)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServerValidationSide
|
||||
* @property {boolean} ok
|
||||
* @property {string|null} error
|
||||
* @property {string[]} errors
|
||||
* @property {{ icao?: string, surface_count?: number, aircraft_count?: number }} summary
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServerValidationState
|
||||
* @property {boolean} loading
|
||||
* @property {boolean} stale - true after local edits since last confirm
|
||||
* @property {ServerValidationSide|null} [apt]
|
||||
* @property {ServerValidationSide|null} [air]
|
||||
* @property {string} [message] - top-level status (e.g. nothing to send)
|
||||
*/
|
||||
|
||||
// Surface kind identifiers (TWRTrainer section types).
|
||||
@@ -178,9 +196,20 @@ export function createEmptyDocument() {
|
||||
lastAptDownloadHash: null,
|
||||
lastAirDownloadHash: null,
|
||||
_nextSurfaceId: 1,
|
||||
serverValidation: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark last server confirm result as stale after local edits (if any).
|
||||
* @param {EditorDocument} doc
|
||||
*/
|
||||
export function markServerValidationStale(doc) {
|
||||
if (doc?.serverValidation && !doc.serverValidation.loading) {
|
||||
doc.serverValidation = { ...doc.serverValidation, stale: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable string hash for dirty tracking (FNV-1a 32-bit hex).
|
||||
* Pure — Node-testable.
|
||||
|
||||
249
internal/web/static/js/openfsd/airport-editor/server-validate.js
Normal file
249
internal/web/static/js/openfsd/airport-editor/server-validate.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Server validate helpers for the airport editor.
|
||||
* POST /api/v1/editor/validate-apt|validate-air with JSON {"text":"…"}.
|
||||
* Pure parsing + CSRF-aware fetch (same-origin cookie session).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} text
|
||||
* @returns {{ text: string }}
|
||||
*/
|
||||
export function buildValidateRequest(text) {
|
||||
return { text: text == null ? '' : String(text) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read CSRF token from meta tag or openfsd_csrf cookie (double-submit).
|
||||
* @param {Document|null|undefined} [doc]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getCSRFToken(doc) {
|
||||
const d = doc ?? (typeof document !== 'undefined' ? document : null);
|
||||
if (!d) return '';
|
||||
const meta = d.querySelector?.('meta[name="csrf-token"]');
|
||||
if (meta && 'content' in meta && meta.content) {
|
||||
return String(meta.content);
|
||||
}
|
||||
const cookie = typeof d.cookie === 'string' ? d.cookie : '';
|
||||
const match = cookie.match(/(?:^|;\s*)openfsd_csrf=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize APIV1 validate-apt/air response body.
|
||||
* Soft parse issues live in data.errors (HTTP 200); envelope err is for failures.
|
||||
*
|
||||
* @param {*} body
|
||||
* @param {'apt'|'air'} [side='apt']
|
||||
* @returns {{
|
||||
* ok: boolean,
|
||||
* error: string|null,
|
||||
* errors: string[],
|
||||
* summary: { icao?: string, surface_count?: number, aircraft_count?: number }
|
||||
* }}
|
||||
*/
|
||||
export function parseValidateAPIResponse(body, side = 'apt') {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { ok: false, error: 'invalid response', errors: [], summary: {} };
|
||||
}
|
||||
if (body.err != null && body.err !== '') {
|
||||
return { ok: false, error: String(body.err), errors: [], summary: {} };
|
||||
}
|
||||
const data = body.data && typeof body.data === 'object' ? body.data : {};
|
||||
const rawErrs = Array.isArray(data.errors) ? data.errors : [];
|
||||
const errors = rawErrs.map((e) => String(e));
|
||||
/** @type {{ icao?: string, surface_count?: number, aircraft_count?: number }} */
|
||||
const summary = {};
|
||||
if (side === 'air') {
|
||||
summary.aircraft_count = Number.isFinite(Number(data.aircraft_count))
|
||||
? Number(data.aircraft_count)
|
||||
: 0;
|
||||
} else {
|
||||
summary.icao = data.icao != null ? String(data.icao) : '';
|
||||
summary.surface_count = Number.isFinite(Number(data.surface_count))
|
||||
? Number(data.surface_count)
|
||||
: 0;
|
||||
}
|
||||
return { ok: true, error: null, errors, summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST text to a validate endpoint. credentials: 'same-origin'; CSRF on mutation.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} text
|
||||
* @param {{
|
||||
* fetchImpl?: typeof fetch,
|
||||
* csrfToken?: string,
|
||||
* doc?: Document|null,
|
||||
* side?: 'apt'|'air'
|
||||
* }} [opts]
|
||||
* @returns {Promise<{
|
||||
* httpOk: boolean,
|
||||
* status: number,
|
||||
* ok: boolean,
|
||||
* error: string|null,
|
||||
* errors: string[],
|
||||
* summary: { icao?: string, surface_count?: number, aircraft_count?: number }
|
||||
* }>}
|
||||
*/
|
||||
export async function postServerValidate(url, text, opts = {}) {
|
||||
const fetchImpl = opts.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : null);
|
||||
if (!fetchImpl) {
|
||||
return {
|
||||
httpOk: false,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: 'fetch unavailable',
|
||||
errors: [],
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
const side = opts.side === 'air' ? 'air' : 'apt';
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const csrf = opts.csrfToken != null ? opts.csrfToken : getCSRFToken(opts.doc);
|
||||
if (csrf) {
|
||||
headers['X-CSRF-Token'] = csrf;
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(buildValidateRequest(text)),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err && typeof err === 'object' && 'message' in err
|
||||
? String(/** @type {{ message: unknown }} */ (err).message)
|
||||
: 'network error';
|
||||
return {
|
||||
httpOk: false,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: msg,
|
||||
errors: [],
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
let body = null;
|
||||
const ct = res.headers?.get?.('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseValidateAPIResponse(body, side);
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
parsed.error ||
|
||||
(body && body.err) ||
|
||||
res.statusText ||
|
||||
`HTTP ${res.status}`;
|
||||
return {
|
||||
httpOk: false,
|
||||
status: res.status,
|
||||
ok: false,
|
||||
error: String(errMsg),
|
||||
errors: [],
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpOk: true,
|
||||
status: res.status,
|
||||
ok: parsed.ok,
|
||||
error: parsed.error,
|
||||
errors: parsed.errors,
|
||||
summary: parsed.summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate client-side issue counts for badge / summary.
|
||||
* @param {{ aptErrors?: string[], airErrors?: string[], softWarnings?: string[], airport?: *, aircraft?: *[] }|null|undefined} doc
|
||||
* @returns {{ apt: number, air: number, soft: number, total: number, hasContent: boolean }}
|
||||
*/
|
||||
export function countClientIssues(doc) {
|
||||
const apt = Array.isArray(doc?.aptErrors) ? doc.aptErrors.length : 0;
|
||||
const air = Array.isArray(doc?.airErrors) ? doc.airErrors.length : 0;
|
||||
const soft = Array.isArray(doc?.softWarnings) ? doc.softWarnings.length : 0;
|
||||
const hasAirport = !!doc?.airport;
|
||||
const hasAircraft = Array.isArray(doc?.aircraft) && doc.aircraft.length > 0;
|
||||
return {
|
||||
apt,
|
||||
air,
|
||||
soft,
|
||||
total: apt + air + soft,
|
||||
hasContent: hasAirport || hasAircraft,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Titlebar / tab badge text for live client issues.
|
||||
* @param {{ total: number, hasContent: boolean, soft?: number }} counts
|
||||
* @returns {string}
|
||||
*/
|
||||
export function issueBadgeText(counts) {
|
||||
if (!counts) return '—';
|
||||
if (!counts.hasContent && counts.total === 0) return '—';
|
||||
if (counts.total === 0) return 'OK';
|
||||
const n = counts.total;
|
||||
const soft = counts.soft || 0;
|
||||
if (soft === n) {
|
||||
return `${n} warn${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${n} issue${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human summary line for the Validate tab header.
|
||||
* @param {{ apt: number, air: number, soft: number, total: number, hasContent: boolean }} counts
|
||||
* @returns {string}
|
||||
*/
|
||||
export function clientSummaryLine(counts) {
|
||||
if (!counts.hasContent && counts.total === 0) {
|
||||
return 'No document loaded';
|
||||
}
|
||||
if (counts.total === 0) {
|
||||
return 'Client: no parse errors or soft warnings';
|
||||
}
|
||||
const parts = [];
|
||||
if (counts.apt) parts.push(`${counts.apt} APT`);
|
||||
if (counts.air) parts.push(`${counts.air} AIR`);
|
||||
if (counts.soft) parts.push(`${counts.soft} soft`);
|
||||
return `Client: ${parts.join(' · ')} (${counts.total} total)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a one-line server result summary.
|
||||
* @param {'apt'|'air'} side
|
||||
* @param {{ ok: boolean, error: string|null, errors: string[], summary: object }|null|undefined} result
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serverResultSummary(side, result) {
|
||||
if (!result) return '';
|
||||
if (!result.ok || result.error) {
|
||||
return `Server ${side.toUpperCase()}: ${result.error || 'failed'}`;
|
||||
}
|
||||
const n = result.errors?.length || 0;
|
||||
if (side === 'air') {
|
||||
const ac = result.summary?.aircraft_count ?? 0;
|
||||
return n
|
||||
? `Server AIR: ${n} error(s), ${ac} aircraft`
|
||||
: `Server AIR: OK (${ac} aircraft)`;
|
||||
}
|
||||
const icao = result.summary?.icao || '—';
|
||||
const sc = result.summary?.surface_count ?? 0;
|
||||
return n
|
||||
? `Server APT: ${n} error(s), ICAO ${icao}, ${sc} surfaces`
|
||||
: `Server APT: OK (ICAO ${icao}, ${sc} surfaces)`;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const RAIL_TABS = ['airport', 'surfaces', 'aircraft', 'validate', 'raw'];
|
||||
* @property {(index: number) => void} [onDeleteAircraft]
|
||||
* @property {(acIndex: number, parkingSurfaceIndex: number) => void} [onSnap]
|
||||
* @property {(side: 'apt'|'air', text: string) => void} [onApplyRaw]
|
||||
* @property {() => void} [onConfirmServer] - POST current text to validate-apt/air
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -194,6 +195,10 @@ export function mountRail(root, handlers) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'confirm-server' && handlers.onConfirmServer) {
|
||||
handlers.onConfirmServer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,17 +487,43 @@ function renderValidatePanel(root, doc) {
|
||||
const el = root.querySelector('[data-js="panel-validate"]');
|
||||
if (!el) return;
|
||||
clearEl(el);
|
||||
|
||||
const aptErrs = doc.aptErrors || [];
|
||||
const airErrs = doc.airErrors || [];
|
||||
const soft = doc.softWarnings || [];
|
||||
if (!aptErrs.length && !airErrs.length && !soft.length) {
|
||||
if (!doc.airport && !(doc.aircraft && doc.aircraft.length)) {
|
||||
el.appendChild(hint('Load or draw geometry / aircraft to see issues.'));
|
||||
} else {
|
||||
el.appendChild(hint('No parse errors or soft warnings.'));
|
||||
}
|
||||
return;
|
||||
const hasAirport = !!doc.airport;
|
||||
const hasAircraft = Array.isArray(doc.aircraft) && doc.aircraft.length > 0;
|
||||
const empty = !hasAirport && !hasAircraft;
|
||||
const clientTotal = aptErrs.length + airErrs.length + soft.length;
|
||||
const loading = !!doc.serverValidation?.loading;
|
||||
|
||||
// Live summary
|
||||
const summary = document.createElement('p');
|
||||
summary.className = 'apted-validate-summary';
|
||||
summary.setAttribute('data-js', 'validate-summary');
|
||||
if (empty) {
|
||||
summary.textContent = 'No APT/AIR loaded.';
|
||||
} else if (clientTotal === 0) {
|
||||
summary.textContent = 'Client: no parse errors or soft warnings.';
|
||||
} else {
|
||||
const parts = [];
|
||||
if (aptErrs.length) parts.push(`${aptErrs.length} APT error${aptErrs.length === 1 ? '' : 's'}`);
|
||||
if (airErrs.length) parts.push(`${airErrs.length} AIR error${airErrs.length === 1 ? '' : 's'}`);
|
||||
if (soft.length) parts.push(`${soft.length} soft warning${soft.length === 1 ? '' : 's'}`);
|
||||
summary.textContent = `Client: ${parts.join(' · ')}.`;
|
||||
}
|
||||
el.appendChild(summary);
|
||||
|
||||
if (empty) {
|
||||
el.appendChild(
|
||||
hint(
|
||||
'No airport geometry or aircraft loaded. Open a .apt / .air file, paste via Raw Apply, or draw on the map (Park / Taxi / Rwy / Hold / Aircraft modes). Live parse issues and soft cross-file warnings appear here.',
|
||||
),
|
||||
);
|
||||
} else if (clientTotal === 0) {
|
||||
el.appendChild(hint('Live client parse matches the in-memory document. Soft cross-file checks (dep ICAO, aircraft distance) also look clean.'));
|
||||
}
|
||||
|
||||
if (aptErrs.length) {
|
||||
el.appendChild(sectionTitle(`APT errors (${aptErrs.length})`));
|
||||
el.appendChild(errorList(aptErrs));
|
||||
@@ -502,9 +533,118 @@ function renderValidatePanel(root, doc) {
|
||||
el.appendChild(errorList(airErrs));
|
||||
}
|
||||
if (soft.length) {
|
||||
el.appendChild(sectionTitle(`Soft warnings (${soft.length})`));
|
||||
const softHd = sectionTitle(`Soft warnings (${soft.length})`);
|
||||
softHd.classList.add('apted-soft-hd');
|
||||
el.appendChild(softHd);
|
||||
const softNote = document.createElement('p');
|
||||
softNote.className = 'apted-hint apted-soft-note';
|
||||
softNote.textContent =
|
||||
'Not parse failures — dep ICAO ≠ airport ICAO, or aircraft farther than ~50 NM from the field.';
|
||||
el.appendChild(softNote);
|
||||
el.appendChild(errorList(soft, 'warn'));
|
||||
}
|
||||
|
||||
// Server confirm
|
||||
const serverBox = document.createElement('div');
|
||||
serverBox.className = 'apted-server-validate';
|
||||
serverBox.setAttribute('data-js', 'server-validate');
|
||||
|
||||
const serverHd = sectionTitle('Server confirm');
|
||||
serverBox.appendChild(serverHd);
|
||||
|
||||
const serverHint = document.createElement('p');
|
||||
serverHint.className = 'apted-hint';
|
||||
serverHint.textContent =
|
||||
'Optional: re-check formatted text with the Go ParseAPT / ParseAIR path (Admin API). Does not save files.';
|
||||
serverBox.appendChild(serverHint);
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'apted-btn-row';
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
confirmBtn.setAttribute('data-action', 'confirm-server');
|
||||
confirmBtn.setAttribute('data-js', 'confirm-server');
|
||||
confirmBtn.textContent = loading ? 'Confirming…' : 'Confirm with server';
|
||||
confirmBtn.disabled = loading || empty;
|
||||
if (empty) confirmBtn.title = 'Load or draw APT/AIR first';
|
||||
btnRow.appendChild(confirmBtn);
|
||||
serverBox.appendChild(btnRow);
|
||||
|
||||
const sv = doc.serverValidation;
|
||||
if (sv?.message && !sv.apt && !sv.air) {
|
||||
serverBox.appendChild(hint(sv.message));
|
||||
}
|
||||
if (sv?.stale && (sv.apt || sv.air) && !loading) {
|
||||
const stale = document.createElement('p');
|
||||
stale.className = 'apted-hint apted-server-stale';
|
||||
stale.setAttribute('data-js', 'server-stale');
|
||||
stale.textContent = 'Document changed since last server confirm — results may be stale.';
|
||||
serverBox.appendChild(stale);
|
||||
}
|
||||
if (sv?.apt) {
|
||||
serverBox.appendChild(serverSideBlock('APT', sv.apt));
|
||||
}
|
||||
if (sv?.air) {
|
||||
serverBox.appendChild(serverSideBlock('AIR', sv.air));
|
||||
}
|
||||
el.appendChild(serverBox);
|
||||
|
||||
// Sweatbox handoff
|
||||
const handoff = document.createElement('div');
|
||||
handoff.className = 'apted-handoff';
|
||||
handoff.setAttribute('data-js', 'sweatbox-handoff');
|
||||
const handoffP = document.createElement('p');
|
||||
handoffP.className = 'apted-hint';
|
||||
handoffP.appendChild(document.createTextNode('When ready: Download files, then load in '));
|
||||
const link = document.createElement('a');
|
||||
link.href = '/sweatbox';
|
||||
link.textContent = 'Sweatbox';
|
||||
link.setAttribute('data-js', 'sweatbox-link');
|
||||
handoffP.appendChild(link);
|
||||
handoffP.appendChild(document.createTextNode('. The editor never pushes into a live session.'));
|
||||
handoff.appendChild(handoffP);
|
||||
el.appendChild(handoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'APT'|'AIR'} label
|
||||
* @param {import('./model.js').ServerValidationSide} side
|
||||
*/
|
||||
function serverSideBlock(label, side) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'apted-server-side';
|
||||
wrap.setAttribute('data-js', `server-${label.toLowerCase()}`);
|
||||
|
||||
if (!side.ok || side.error) {
|
||||
wrap.appendChild(sectionTitle(`Server ${label} — failed`));
|
||||
wrap.appendChild(hint(side.error || 'Request failed'));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const n = side.errors?.length || 0;
|
||||
let meta = '';
|
||||
if (label === 'APT') {
|
||||
meta = `ICAO ${side.summary?.icao || '—'} · ${side.summary?.surface_count ?? 0} surfaces`;
|
||||
} else {
|
||||
meta = `${side.summary?.aircraft_count ?? 0} aircraft`;
|
||||
}
|
||||
|
||||
if (n === 0) {
|
||||
const ok = document.createElement('p');
|
||||
ok.className = 'apted-hint apted-server-ok';
|
||||
ok.textContent = `Server ${label}: OK — ${meta}`;
|
||||
wrap.appendChild(ok);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(sectionTitle(`Server ${label} errors (${n})`));
|
||||
const metaP = document.createElement('p');
|
||||
metaP.className = 'apted-hint';
|
||||
metaP.textContent = meta;
|
||||
wrap.appendChild(metaP);
|
||||
wrap.appendChild(errorList(side.errors));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -696,13 +836,33 @@ function renderInspector(root, doc) {
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {{ icao: string, apt: string, air: string, counts: string }} chips
|
||||
* @param {{ icao: string, apt: string, air: string, counts: string, issues?: string, issuesTone?: 'ok'|'err'|'warn'|'empty' }} chips
|
||||
*/
|
||||
export function applyTitlebarChips(root, chips) {
|
||||
setText(root.querySelector('[data-js="chip-icao"]'), chips.icao);
|
||||
setText(root.querySelector('[data-js="chip-apt"]'), chips.apt);
|
||||
setText(root.querySelector('[data-js="chip-air"]'), chips.air);
|
||||
setText(root.querySelector('[data-js="chip-counts"]'), chips.counts);
|
||||
const issuesEl = root.querySelector('[data-js="chip-issues"]');
|
||||
if (issuesEl) {
|
||||
setText(issuesEl, chips.issues != null ? chips.issues : '—');
|
||||
issuesEl.classList.remove('is-ok', 'is-err', 'is-warn', 'is-empty');
|
||||
const tone = chips.issuesTone || 'empty';
|
||||
if (tone === 'ok') issuesEl.classList.add('is-ok');
|
||||
else if (tone === 'err') issuesEl.classList.add('is-err');
|
||||
else if (tone === 'warn') issuesEl.classList.add('is-warn');
|
||||
else issuesEl.classList.add('is-empty');
|
||||
issuesEl.setAttribute(
|
||||
'title',
|
||||
tone === 'ok'
|
||||
? 'No client parse errors or soft warnings'
|
||||
: tone === 'err'
|
||||
? 'Client parse errors and/or soft warnings — see Validate tab'
|
||||
: tone === 'warn'
|
||||
? 'Soft cross-file warnings only — see Validate tab'
|
||||
: 'Load APT/AIR to validate',
|
||||
);
|
||||
}
|
||||
const aptChip = root.querySelector('[data-js="chip-apt"]');
|
||||
const airChip = root.querySelector('[data-js="chip-air"]');
|
||||
if (aptChip) {
|
||||
@@ -723,6 +883,29 @@ export function applyTitlebarChips(root, chips) {
|
||||
: 'Scenario aircraft',
|
||||
);
|
||||
}
|
||||
// Validate tab badge (issue count on the tab button)
|
||||
const valTab = root.querySelector('[data-tab="validate"]');
|
||||
if (valTab && chips.issues != null) {
|
||||
let badge = valTab.querySelector('[data-js="tab-issues-badge"]');
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'apted-tab-badge';
|
||||
badge.setAttribute('data-js', 'tab-issues-badge');
|
||||
valTab.appendChild(badge);
|
||||
}
|
||||
const tone = chips.issuesTone || 'empty';
|
||||
if (tone === 'empty' || chips.issues === '—' || chips.issues === 'OK') {
|
||||
badge.hidden = tone === 'empty' || chips.issues === '—';
|
||||
badge.textContent = chips.issues === 'OK' ? 'OK' : '';
|
||||
if (chips.issues === 'OK') badge.hidden = false;
|
||||
} else {
|
||||
badge.hidden = false;
|
||||
badge.textContent = chips.issues;
|
||||
}
|
||||
badge.classList.toggle('is-ok', tone === 'ok');
|
||||
badge.classList.toggle('is-err', tone === 'err');
|
||||
badge.classList.toggle('is-warn', tone === 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM helpers (textContent only for user-derived values) ---
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
<link href="/static/css/openfsd/airport-editor.css" rel="stylesheet">
|
||||
|
||||
<main id="apted-root" class="apted container-fluid"
|
||||
data-js="airport-editor">
|
||||
data-js="airport-editor"
|
||||
data-validate-apt="/api/v1/editor/validate-apt"
|
||||
data-validate-air="/api/v1/editor/validate-air">
|
||||
|
||||
<header class="apted-titlebar">
|
||||
<h1>Airport Editor</h1>
|
||||
@@ -24,6 +26,8 @@
|
||||
<span class="apted-chip apted-chip-icao" data-js="chip-icao">—</span>
|
||||
<span class="apted-chip" data-js="chip-apt" title="Airport geometry">APT</span>
|
||||
<span class="apted-chip" data-js="chip-air" title="Scenario aircraft">AIR</span>
|
||||
<span class="apted-chip apted-chip-issues is-empty" data-js="chip-issues"
|
||||
title="Load APT/AIR to validate">—</span>
|
||||
<span class="apted-hint" data-js="chip-counts"></span>
|
||||
</div>
|
||||
<p class="apted-hint apted-title-hint">
|
||||
@@ -141,10 +145,33 @@
|
||||
role="tabpanel" aria-labelledby="apted-tab-validate">
|
||||
<div class="apted-panel-hd">
|
||||
<h2>Validate</h2>
|
||||
<span class="apted-hint">Parse errors</span>
|
||||
<span class="apted-hint">Client + server</span>
|
||||
</div>
|
||||
<div class="apted-panel-bd" data-js="panel-validate">
|
||||
<p class="apted-hint">Load .apt / .air to see parse issues.</p>
|
||||
<p class="apted-hint apted-validate-summary" data-js="validate-summary">
|
||||
No APT/AIR loaded. Open files or draw on the map to see live parse
|
||||
errors and soft cross-file warnings.
|
||||
</p>
|
||||
<div class="apted-server-validate" data-js="server-validate">
|
||||
<h3 class="apted-subhd">Server confirm</h3>
|
||||
<p class="apted-hint">
|
||||
Optional Go ParseAPT / ParseAIR check via Admin API (does not save).
|
||||
</p>
|
||||
<div class="apted-btn-row">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-action="confirm-server" data-js="confirm-server"
|
||||
disabled
|
||||
title="Requires JavaScript and a loaded document">
|
||||
Confirm with server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apted-handoff" data-js="sweatbox-handoff">
|
||||
<p class="apted-hint">
|
||||
When ready: Download files, then load in
|
||||
<a href="/sweatbox" data-js="sweatbox-link">Sweatbox</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ test('titlebarChips', () => {
|
||||
assert.equal(chips.apt, 'APT');
|
||||
assert.equal(chips.air, 'AIR');
|
||||
assert.equal(chips.counts, '');
|
||||
assert.equal(chips.issues, '—');
|
||||
assert.equal(chips.issuesTone, 'empty');
|
||||
|
||||
doc.airport = {
|
||||
icao: 'kbtv',
|
||||
@@ -112,6 +114,19 @@ test('titlebarChips', () => {
|
||||
assert.match(chips.counts, /1 park/);
|
||||
assert.match(chips.counts, /1 rwy/);
|
||||
assert.match(chips.counts, /2 ac/);
|
||||
assert.equal(chips.issues, 'OK');
|
||||
assert.equal(chips.issuesTone, 'ok');
|
||||
|
||||
doc.aptErrors = ['bad header'];
|
||||
doc.softWarnings = ['dep mismatch'];
|
||||
chips = titlebarChips(doc);
|
||||
assert.equal(chips.issuesTone, 'err');
|
||||
assert.match(chips.issues, /2 issue/);
|
||||
|
||||
doc.aptErrors = [];
|
||||
chips = titlebarChips(doc);
|
||||
assert.equal(chips.issuesTone, 'warn');
|
||||
assert.match(chips.issues, /1 warn/);
|
||||
});
|
||||
|
||||
test('normalizeSelection', () => {
|
||||
|
||||
238
webjs/airport-editor/server-validate.test.js
Normal file
238
webjs/airport-editor/server-validate.test.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildValidateRequest,
|
||||
parseValidateAPIResponse,
|
||||
getCSRFToken,
|
||||
postServerValidate,
|
||||
countClientIssues,
|
||||
issueBadgeText,
|
||||
clientSummaryLine,
|
||||
serverResultSummary,
|
||||
} from '../../internal/web/static/js/openfsd/airport-editor/server-validate.js';
|
||||
import { createEmptyDocument } from '../../internal/web/static/js/openfsd/airport-editor/model.js';
|
||||
import { markServerValidationStale } from '../../internal/web/static/js/openfsd/airport-editor/model.js';
|
||||
|
||||
test('buildValidateRequest', () => {
|
||||
assert.deepEqual(buildValidateRequest('icao=KBTV\n'), { text: 'icao=KBTV\n' });
|
||||
assert.deepEqual(buildValidateRequest(null), { text: '' });
|
||||
assert.deepEqual(buildValidateRequest(undefined), { text: '' });
|
||||
});
|
||||
|
||||
test('parseValidateAPIResponse apt success with errors', () => {
|
||||
const r = parseValidateAPIResponse(
|
||||
{
|
||||
version: 'v1',
|
||||
err: null,
|
||||
data: {
|
||||
errors: ['Parking area G1 has no waypoint defined.'],
|
||||
icao: 'KBTV',
|
||||
surface_count: 12,
|
||||
},
|
||||
},
|
||||
'apt',
|
||||
);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.error, null);
|
||||
assert.equal(r.errors.length, 1);
|
||||
assert.match(r.errors[0], /G1/);
|
||||
assert.equal(r.summary.icao, 'KBTV');
|
||||
assert.equal(r.summary.surface_count, 12);
|
||||
});
|
||||
|
||||
test('parseValidateAPIResponse air success empty errors', () => {
|
||||
const r = parseValidateAPIResponse(
|
||||
{
|
||||
version: 'v1',
|
||||
err: null,
|
||||
data: { errors: [], aircraft_count: 3 },
|
||||
},
|
||||
'air',
|
||||
);
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(r.summary.aircraft_count, 3);
|
||||
});
|
||||
|
||||
test('parseValidateAPIResponse envelope error', () => {
|
||||
const r = parseValidateAPIResponse({ version: 'v1', err: 'forbidden', data: null }, 'apt');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.error, 'forbidden');
|
||||
assert.deepEqual(r.errors, []);
|
||||
});
|
||||
|
||||
test('parseValidateAPIResponse invalid body', () => {
|
||||
const r = parseValidateAPIResponse(null, 'apt');
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error || '', /invalid/);
|
||||
});
|
||||
|
||||
test('getCSRFToken from meta and cookie', () => {
|
||||
const metaDoc = {
|
||||
querySelector(sel) {
|
||||
if (sel === 'meta[name="csrf-token"]') return { content: 'meta-tok' };
|
||||
return null;
|
||||
},
|
||||
cookie: '',
|
||||
};
|
||||
assert.equal(getCSRFToken(metaDoc), 'meta-tok');
|
||||
|
||||
const cookieDoc = {
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
cookie: 'openfsd_session=abc; openfsd_csrf=cookie%2Dtok; other=1',
|
||||
};
|
||||
assert.equal(getCSRFToken(cookieDoc), 'cookie-tok');
|
||||
|
||||
assert.equal(getCSRFToken(null), '');
|
||||
});
|
||||
|
||||
test('postServerValidate sends CSRF and same-origin credentials', async () => {
|
||||
/** @type {RequestInit|undefined} */
|
||||
let seen;
|
||||
const fetchImpl = async (url, opts) => {
|
||||
seen = opts;
|
||||
assert.equal(url, '/api/v1/editor/validate-apt');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
version: 'v1',
|
||||
err: null,
|
||||
data: { errors: [], icao: 'KBTV', surface_count: 1 },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const r = await postServerValidate('/api/v1/editor/validate-apt', 'icao=KBTV\n', {
|
||||
fetchImpl,
|
||||
csrfToken: 'tok123',
|
||||
side: 'apt',
|
||||
});
|
||||
assert.equal(r.httpOk, true);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.summary.icao, 'KBTV');
|
||||
assert.equal(seen?.credentials, 'same-origin');
|
||||
assert.equal(seen?.method, 'POST');
|
||||
assert.equal(/** @type {Record<string,string>} */ (seen?.headers)['X-CSRF-Token'], 'tok123');
|
||||
assert.equal(
|
||||
/** @type {Record<string,string>} */ (seen?.headers)['Content-Type'],
|
||||
'application/json',
|
||||
);
|
||||
assert.equal(seen?.body, JSON.stringify({ text: 'icao=KBTV\n' }));
|
||||
});
|
||||
|
||||
test('postServerValidate HTTP error surfaces body.err', async () => {
|
||||
const fetchImpl = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ version: 'v1', err: 'forbidden', data: null }),
|
||||
});
|
||||
const r = await postServerValidate('/api/v1/editor/validate-air', 'x', {
|
||||
fetchImpl,
|
||||
csrfToken: '',
|
||||
side: 'air',
|
||||
});
|
||||
assert.equal(r.httpOk, false);
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.error, 'forbidden');
|
||||
});
|
||||
|
||||
test('postServerValidate network failure', async () => {
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
const r = await postServerValidate('/x', 't', { fetchImpl, side: 'apt' });
|
||||
assert.equal(r.httpOk, false);
|
||||
assert.equal(r.error, 'offline');
|
||||
});
|
||||
|
||||
test('countClientIssues and issueBadgeText', () => {
|
||||
const empty = countClientIssues(createEmptyDocument());
|
||||
assert.equal(empty.total, 0);
|
||||
assert.equal(empty.hasContent, false);
|
||||
assert.equal(issueBadgeText(empty), '—');
|
||||
|
||||
const ok = countClientIssues({
|
||||
airport: { icao: 'KBTV' },
|
||||
aircraft: [],
|
||||
aptErrors: [],
|
||||
airErrors: [],
|
||||
softWarnings: [],
|
||||
});
|
||||
assert.equal(ok.hasContent, true);
|
||||
assert.equal(issueBadgeText(ok), 'OK');
|
||||
|
||||
const softOnly = countClientIssues({
|
||||
airport: { icao: 'KBTV' },
|
||||
aircraft: [{}],
|
||||
aptErrors: [],
|
||||
airErrors: [],
|
||||
softWarnings: ['dep mismatch'],
|
||||
});
|
||||
assert.equal(softOnly.total, 1);
|
||||
assert.equal(issueBadgeText(softOnly), '1 warn');
|
||||
|
||||
const mixed = countClientIssues({
|
||||
airport: { icao: 'KBTV' },
|
||||
aptErrors: ['a', 'b'],
|
||||
airErrors: ['c'],
|
||||
softWarnings: ['d'],
|
||||
});
|
||||
assert.equal(mixed.total, 4);
|
||||
assert.equal(issueBadgeText(mixed), '4 issues');
|
||||
assert.match(clientSummaryLine(mixed), /2 APT/);
|
||||
assert.match(clientSummaryLine(mixed), /1 AIR/);
|
||||
assert.match(clientSummaryLine(mixed), /1 soft/);
|
||||
});
|
||||
|
||||
test('serverResultSummary', () => {
|
||||
assert.equal(serverResultSummary('apt', null), '');
|
||||
assert.match(
|
||||
serverResultSummary('apt', {
|
||||
ok: true,
|
||||
error: null,
|
||||
errors: [],
|
||||
summary: { icao: 'KBTV', surface_count: 2 },
|
||||
}),
|
||||
/OK.*KBTV/,
|
||||
);
|
||||
assert.match(
|
||||
serverResultSummary('air', {
|
||||
ok: true,
|
||||
error: null,
|
||||
errors: ['bad row'],
|
||||
summary: { aircraft_count: 1 },
|
||||
}),
|
||||
/1 error/,
|
||||
);
|
||||
assert.match(
|
||||
serverResultSummary('apt', { ok: false, error: 'forbidden', errors: [], summary: {} }),
|
||||
/forbidden/,
|
||||
);
|
||||
});
|
||||
|
||||
test('markServerValidationStale', () => {
|
||||
const doc = createEmptyDocument();
|
||||
markServerValidationStale(doc);
|
||||
assert.equal(doc.serverValidation, null);
|
||||
|
||||
doc.serverValidation = {
|
||||
loading: false,
|
||||
stale: false,
|
||||
apt: { ok: true, error: null, errors: [], summary: {} },
|
||||
};
|
||||
markServerValidationStale(doc);
|
||||
assert.equal(doc.serverValidation.stale, true);
|
||||
|
||||
doc.serverValidation = { loading: true, stale: false };
|
||||
markServerValidationStale(doc);
|
||||
assert.equal(doc.serverValidation.stale, false);
|
||||
assert.equal(doc.serverValidation.loading, true);
|
||||
});
|
||||
@@ -13,6 +13,13 @@ Operator documentation for [openfsd](https://github.com/renorris/openfsd).
|
||||
| [Client Connection](Client-Connection.md) | VRC, Euroscope, Swift, vPilot, xPilot |
|
||||
| [Migrating from PostgreSQL](Migrating-from-PostgreSQL.md) | Convert an existing Postgres DB to SQLite |
|
||||
|
||||
## Admin web tools (in the product UI)
|
||||
|
||||
| URL | Who | Notes |
|
||||
|-----|-----|--------|
|
||||
| `/sweatbox` | Administrator | Live ground/taxi simulator control (needs FSD + sweatbox enabled) |
|
||||
| `/airport-editor` | Administrator | Map-first `.apt` / `.air` authoring; **download only** (no server save). Validate tab has live client checks + optional server confirm. Download files, then load them on `/sweatbox`. Design: repo `docs/design/apt-air-editor.md` |
|
||||
|
||||
## Quick links
|
||||
|
||||
- Images: `ghcr.io/renorris/openfsd` (`:latest`, `:dev`, `sha-*`)
|
||||
|
||||
Reference in New Issue
Block a user