@@ -141,10 +145,33 @@
role="tabpanel" aria-labelledby="apted-tab-validate">
-
Load .apt / .air to see parse issues.
+
+ No APT/AIR loaded. Open files or draw on the map to see live parse
+ errors and soft cross-file warnings.
+
+
+
Server confirm
+
+ Optional Go ParseAPT / ParseAIR check via Admin API (does not save).
+
+
+
+
+
+
+
+ When ready: Download files, then load in
+ Sweatbox.
+
+
diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js
index 8e9ae79..10d12ec 100644
--- a/webjs/airport-editor/map-layers.test.js
+++ b/webjs/airport-editor/map-layers.test.js
@@ -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', () => {
diff --git a/webjs/airport-editor/server-validate.test.js b/webjs/airport-editor/server-validate.test.js
new file mode 100644
index 0000000..655c5a8
--- /dev/null
+++ b/webjs/airport-editor/server-validate.test.js
@@ -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