Merge airport editor undo/redo stack into dev

This commit is contained in:
Reese Norris
2026-07-27 17:34:26 -04:00
5 changed files with 2121 additions and 14 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ Shipped. Summary of the tree as of closeout:
| Persistence | **None** durable (no disk/DB); Blob download + transient request bodies only |
| Playwright | **Cancelled** — no browser automation suite |
| Vertex drag | **Fixed** (live geometry + handles); see `docs/design/airport-editor-vertex-drag.md`. Vertex delete remains rail-only (Del/Backspace deletes the whole surface). |
| Undo / redo | **Shipped** (keyboard-only snapshot history); see `docs/design/airport-editor-undo-redo.md`. `history.js` + `main.js` wiring; clean-content hash seeded at bootstrap / successful Open / New (field names `last*DownloadHash` mean last clean content). |
Design history below is retained. Stale “Format missing / JS tests none” rows are updated in Background.
@@ -530,10 +531,12 @@ L.tileLayer(
Browser Blob + temporary `<a download>` has **no reliable completion signal** (and can be blocked by the browser). Therefore:
1. On any model mutation → set `aptDirty` / `airDirty` as appropriate.
2. Maintain `lastAptDownloadHash` / `lastAirDownloadHash` (hash of last successfully **formatted** text the user initiated a download for).
3. **Do not** clear dirty merely on click. After `format*` succeeds and the download is **initiated** (Blob URL created + click dispatched), set `last*DownloadHash` to that texts hash and set dirty = false **only if** current formatted text still equals that hash (it will at that instant). If the user edits again, dirty becomes true again.
4. Expose explicit toolbar action **“Mark clean”** (per side or both) for users who cancelled a dialog or know the file is saved elsewhere — never claim the OS saved the file.
5. Titlebar chips: `APT` / `APT*` / `AIR` / `AIR*` reflecting dirty flags; tooltip: “Unsaved changes (download to save locally)”.
2. Maintain `lastAptDownloadHash` / `lastAirDownloadHash` as the **last clean content hash** (FNV-1a of download-format text via `formatAptForDownload` / `formatAirForDownload`). Field names retain “Download” for compatibility; they are **not** download-only anchors.
3. **Seed** those hashes (and leave dirty flags false) via `seedCleanContentHashes` at: **editor bootstrap** (immediately after `createEmptyDocument`), successful **Open** APT/AIR (that side only), and **New** (both sides). **Never** leave or reset hashes to `null` for a clean state — `syncDirtyFromHash` treats null as always dirty. Open no longer leaves hashes null-as-clean.
4. **Blob download** still overwrites the baseline via `noteDownload`: after `format*` succeeds and the download is **initiated** (Blob URL created + click dispatched), set `last*DownloadHash` to that texts hash and set dirty = false **only if** current formatted text still equals that hash (it will at that instant). If the user edits again, dirty becomes true again.
5. Expose explicit toolbar action **“Mark clean”** (per side or both) for users who cancelled a dialog or know the file is saved elsewhere — never claim the OS saved the file. Mark-clean does not rewrite hashes; undo/redo recompute dirty from the live hashes.
6. Titlebar chips: `APT` / `APT*` / `AIR` / `AIR*` reflecting dirty flags; tooltip: “Unsaved changes (download to save locally)”.
7. **Undo/redo** restores content snapshots then recomputes dirty via `syncDirtyAfterHistoryApply` against the **live** clean-content hashes (hashes are not rewound by history). See `docs/design/airport-editor-undo-redo.md`.
**UX state table (dual document):**
@@ -636,6 +639,8 @@ Serialize with **16 colon fields** exactly as `ParseAIR` expects; preserve route
| `Ctrl/Cmd+O` | Focus/open the APT file input (toolbar “Open .apt”); no multi-key chord sequences |
| `Ctrl/Cmd+Shift+O` | Open AIR file input |
| `Ctrl/Cmd+S` | Download **dirty** side only; if both dirty, confirm “Download both?” then APT then AIR |
| `Ctrl/Cmd+Z` | **Undo** last document mutation (map/chrome focus only; not in fields) |
| `Ctrl/Cmd+Shift+Z`, `Ctrl/Cmd+Y` | **Redo** (map/chrome focus only; not in fields). No toolbar buttons; no `Ctrl/Cmd+X` binding |
| `Delete` / `Backspace` | Delete selection (ignored when focus is in inputs/textareas) |
| `Esc` | Cancel draw mode → select |
| `F` | Fit bounds |
@@ -649,7 +654,7 @@ Serialize with **16 colon fields** exactly as `ParseAIR` expects; preserve route
Hold has its own quick key (`5`); aircraft is `6`. No `O` then `A`/`R` chords (easy to miss and conflict with typing).
When focus is in `<input>` / `<textarea>`, letter shortcuts are disabled except Ctrl/Cmd combos.
When focus is in `<input>` / `<textarea>` / `<select>` / contenteditable, letter shortcuts are disabled. **Save/Open** remain the only `Ctrl/Cmd+*` chords that work inside fields; document undo/redo leave `Mod+Z` / redo chords to the browser (native text undo). Full history semantics: `docs/design/airport-editor-undo-redo.md`.
### Progressive enhancement matrix
@@ -795,6 +800,7 @@ sequenceDiagram
UI->>V: parseAPT(text)
V-->>UI: airport + errs
UI->>M: setAirport; aptDirty=false
UI->>M: seed lastAptDownloadHash from download-format text (clean baseline)
UI->>UI: redraw map; fit bounds
U->>UI: Edit taxi vertex
@@ -804,10 +810,12 @@ sequenceDiagram
UI->>V: formatAPT(airport)
UI->>U: Initiate Blob download (filename KBTV.apt)
Note over UI,S: No durable server write
UI->>M: lastAptDownloadHash=hash(text); aptDirty=false if still matches
UI->>M: noteDownload → lastAptDownloadHash=hash(text); aptDirty=false if still matches
```
**New document:** if dirty, confirm; clear airport to defaults (`registration=N`, pattern size 1, climbs 3000/5000 matching Go defaults), clear aircraft, reset map to world view or last center.
**Clean-content hash baseline:** at bootstrap (empty document), successful Open (opened side), and New (both sides), seed `last*DownloadHash` from download-format text so never-downloaded clean state survives undo/recompute. Failed open does not rewrite hashes. Blob download still overwrites via `noteDownload`.
**New document:** if dirty, confirm; clear airport to defaults (`registration=N`, pattern size 1, climbs 3000/5000 matching Go defaults), clear aircraft, reset map to world view or last center; seed empty clean hashes; clear undo history.
**Unload warning:** `beforeunload` when any dirty flag set (PE only).
@@ -974,6 +982,7 @@ internal/web/static/js/openfsd/airport-editor/
ui-toolbar.js # DOM
shortcuts.js
download.js # Blob helpers
history.js # pure undo/redo stack + clean-hash seed helpers
main.js # bootstrap; may read data-test-tiles=blank for e2e
```
@@ -1293,10 +1302,10 @@ bash scripts/check-webjs.sh
1. **Should Supervisors access the editor?** **Resolved for v1:** Admin-only (product default; Supervisor access deferred).
2. **Esri imagery ToS** for each deployer's traffic profile — keep optional and documented.
3. **Undo stack?** **Deferred v1.1** — not a v1 ship requirement.
3. **Undo stack?** **Shipped** — keyboard-only snapshot history; see `docs/design/airport-editor-undo-redo.md`. (Earlier “deferred v1.1” superseded.)
4. **~~Playwright CI~~** — **cancelled.** No browser automation suite.
Resolved by this rev: WIP download allowed (yes); fixture path = `pkg/twrfiles/testdata`; dirty = hash + Mark clean; Format contract frozen; API authz = 403 JSON; JS import path relative from `webjs/`.
Resolved by this rev: WIP download allowed (yes); fixture path = `pkg/twrfiles/testdata`; dirty = last clean-content hash (seeded at bootstrap/Open/New; download overwrites via `noteDownload`) + Mark clean; Format contract frozen; API authz = 403 JSON; JS import path relative from `webjs/`.
---
@@ -1323,7 +1332,7 @@ Resolved by this rev: WIP download allowed (yes); fixture path = `pkg/twrfiles/t
| 17 | **No auto-push to live sweatbox v1** | Avoid surprising production traffic injection |
| 18 | **API Admin check = 403 JSON inline** (sweatbox API style) | Never redirect API clients |
| 19 | **Normative Format contract + golden files** | Dual-language byte parity |
| 20 | **Dirty via download hash + Mark clean** | Blob has no reliable completion event |
| 20 | **Dirty via last clean-content hash + Mark clean** | Blob has no reliable completion event; field names `last*DownloadHash` mean last clean content (seeded at bootstrap/Open/New; download overwrites via `noteDownload`) |
| 21 | **Raw tab = preview + Apply** (not live two-way) | Avoid dual-write races |
| 22 | **No Playwright** — Go PE + pure Node tests + manual map smoke | House boring-web; no browser automation suite in repo |
| 23 | **`pkg/` not `internal/` for twrfiles** | protocol symmetry + cmd reuse |

View File

@@ -0,0 +1,275 @@
/**
* Pure undo/redo history stack for the airport editor.
* Snapshot-based; gesture coalescing for drags. No DOM.
*
* Dirty flags are not stored in snapshots — recompute via
* syncDirtyAfterHistoryApply after apply (K5 / K13).
*/
import { formatAptForDownload, formatAirForDownload } from './download.js';
import { hashText, syncDirtyFromHash } from './model.js';
/** @typedef {import('./model.js').EditorDocument} EditorDocument */
/** @typedef {import('./model.js').Airport} Airport */
/** @typedef {import('./model.js').Aircraft} Aircraft */
/**
* @typedef {Object} EditorSnapshot
* @property {Airport|null} airport
* @property {Aircraft[]} aircraft
* @property {{ type: string, index: number }|null} selection
* @property {number} _nextSurfaceId
*/
/**
* @typedef {Object} HistoryStack
* @property {EditorSnapshot[]} undoStack
* @property {EditorSnapshot[]} redoStack
* @property {EditorSnapshot|null} gestureBaseline
* @property {number} maxDepth
*/
export const DEFAULT_HISTORY_MAX_DEPTH = 100;
/**
* Deep-clone JSON-safe values. Prefer structuredClone; fallback JSON.
* @template T
* @param {T} value
* @returns {T}
*/
function deepClone(value) {
if (value === null || value === undefined) return value;
if (typeof structuredClone === 'function') {
try {
return structuredClone(value);
} catch {
/* fall through to JSON */
}
}
return JSON.parse(JSON.stringify(value));
}
/**
* @param {{ maxDepth?: number }} [opts]
* @returns {HistoryStack}
*/
export function createHistory(opts = {}) {
const n = Math.floor(Number(opts.maxDepth));
const maxDepth =
Number.isFinite(n) && n > 0 ? n : DEFAULT_HISTORY_MAX_DEPTH;
return {
undoStack: [],
redoStack: [],
gestureBaseline: null,
maxDepth,
};
}
/**
* Deep-clone document content fields into a snapshot.
* Mutating live `doc` after capture must not affect the returned snap.
* @param {EditorDocument} doc
* @returns {EditorSnapshot}
*/
export function captureSnapshot(doc) {
return {
airport: deepClone(doc.airport ?? null),
aircraft: deepClone(Array.isArray(doc.aircraft) ? doc.aircraft : []),
selection: deepClone(doc.selection ?? null),
_nextSurfaceId:
typeof doc._nextSurfaceId === 'number' && Number.isFinite(doc._nextSurfaceId)
? doc._nextSurfaceId
: 1,
};
}
/**
* Apply snapshot into live doc (mutates doc). Does NOT touch dirty hashes,
* filenames, mode, or serverValidation.
* Deep-clones snap into doc so stack and live doc never share identity (K9).
* @param {EditorDocument} doc
* @param {EditorSnapshot} snap
*/
export function applySnapshot(doc, snap) {
doc.airport = deepClone(snap.airport ?? null);
doc.aircraft = deepClone(Array.isArray(snap.aircraft) ? snap.aircraft : []);
doc.selection = deepClone(snap.selection ?? null);
doc._nextSurfaceId =
typeof snap._nextSurfaceId === 'number' && Number.isFinite(snap._nextSurfaceId)
? snap._nextSurfaceId
: 1;
}
/**
* Push a pre-mutation snapshot onto undo; clear redo; trim maxDepth.
* Always pushes — no equality skip on this path (v1).
* @param {HistoryStack} h
* @param {EditorSnapshot} snap
*/
export function pushUndo(h, snap) {
h.undoStack.push(snap);
h.redoStack.length = 0;
while (h.undoStack.length > h.maxDepth) {
h.undoStack.shift();
}
}
/**
* Record current doc as undo entry (convenience).
* Must be called BEFORE mutating doc for a discrete edit.
* @param {HistoryStack} h
* @param {EditorDocument} doc
*/
export function recordBeforeMutation(h, doc) {
pushUndo(h, captureSnapshot(doc));
}
/**
* Start a multi-event gesture (vertex/aircraft drag).
* Captures baseline once; ignores nested begins.
* @param {HistoryStack} h
* @param {EditorDocument} doc
*/
export function beginGesture(h, doc) {
if (h.gestureBaseline) return;
h.gestureBaseline = captureSnapshot(doc);
}
/**
* End gesture: if live doc content differs from baseline, push baseline to undo.
* Always clears gestureBaseline. Returns whether an entry was pushed.
* @param {HistoryStack} h
* @param {EditorDocument} doc
* @returns {boolean}
*/
export function commitGesture(h, doc) {
const base = h.gestureBaseline;
h.gestureBaseline = null;
if (!base) return false;
if (contentEquals(base, captureSnapshot(doc))) return false;
pushUndo(h, base);
return true;
}
/**
* Abort gesture without stacking. Does not restore doc.
* @param {HistoryStack} h
*/
export function discardGesture(h) {
h.gestureBaseline = null;
}
/**
* Undo: push current doc onto redo, apply top of undo onto doc.
* @param {HistoryStack} h
* @param {EditorDocument} doc
* @returns {{ ok: true, snap: EditorSnapshot } | { ok: false, reason: string }}
*/
export function undo(h, doc) {
if (h.gestureBaseline) {
return { ok: false, reason: 'gesture-active' };
}
if (!h.undoStack.length) return { ok: false, reason: 'empty' };
const current = captureSnapshot(doc);
const prev = h.undoStack.pop();
h.redoStack.push(current);
applySnapshot(doc, prev);
return { ok: true, snap: prev };
}
/**
* Redo: inverse of undo.
* @param {HistoryStack} h
* @param {EditorDocument} doc
* @returns {{ ok: true, snap: EditorSnapshot } | { ok: false, reason: string }}
*/
export function redo(h, doc) {
if (h.gestureBaseline) {
return { ok: false, reason: 'gesture-active' };
}
if (!h.redoStack.length) return { ok: false, reason: 'empty' };
const current = captureSnapshot(doc);
const next = h.redoStack.pop();
h.undoStack.push(current);
// Trim undo if redo branch grew past max (symmetric with pushUndo trim).
while (h.undoStack.length > h.maxDepth) {
h.undoStack.shift();
}
applySnapshot(doc, next);
return { ok: true, snap: next };
}
/**
* @param {HistoryStack} h
* @returns {boolean}
*/
export function canUndo(h) {
return h.undoStack.length > 0 && !h.gestureBaseline;
}
/**
* @param {HistoryStack} h
* @returns {boolean}
*/
export function canRedo(h) {
return h.redoStack.length > 0 && !h.gestureBaseline;
}
/**
* Empty both stacks and clear active gesture.
* @param {HistoryStack} h
*/
export function clearHistory(h) {
h.undoStack.length = 0;
h.redoStack.length = 0;
h.gestureBaseline = null;
}
/**
* Stable content key for airport + aircraft only (ignore selection).
* @param {EditorSnapshot} snap
* @returns {string}
*/
export function snapshotContentKey(snap) {
return JSON.stringify({ airport: snap.airport, aircraft: snap.aircraft });
}
/**
* Structural content equality for gesture no-op detection.
* Compares airport + aircraft only (selection ignored).
* @param {EditorSnapshot} a
* @param {EditorSnapshot} b
* @returns {boolean}
*/
export function contentEquals(a, b) {
return snapshotContentKey(a) === snapshotContentKey(b);
}
/**
* Seed last*DownloadHash from download-format text and force that side clean.
* K13: clean baseline must leave dirty false — markDirty:false on setAirport
* only skips setting dirty true and does not clear a prior true flag.
* Never sets hashes to null.
* @param {EditorDocument} doc
* @param {'apt'|'air'|'both'} [side='both']
*/
export function seedCleanContentHashes(doc, side = 'both') {
if (side === 'apt' || side === 'both') {
doc.lastAptDownloadHash = hashText(formatAptForDownload(doc));
doc.aptDirty = false;
}
if (side === 'air' || side === 'both') {
doc.lastAirDownloadHash = hashText(formatAirForDownload(doc));
doc.airDirty = false;
}
}
/**
* Recompute aptDirty/airDirty from live last*DownloadHash vs download-format text.
* Does not mutate hashes. Safe for null airport / empty aircraft.
* @param {EditorDocument} doc
*/
export function syncDirtyAfterHistoryApply(doc) {
syncDirtyFromHash(doc, 'apt', formatAptForDownload(doc));
syncDirtyFromHash(doc, 'air', formatAirForDownload(doc));
}

View File

@@ -1,7 +1,8 @@
/**
* Airport editor bootstrap — full edit loop (PR7).
* Airport editor bootstrap — full edit loop.
*
* Modes, draw, vertex/aircraft drag, Blob download, dirty hash,
* undo/redo history (keyboard: Mod+Z / Mod+Shift+Z / Mod+Y when !inField),
* beforeunload, replace confirms, Raw Apply, shortcuts 16 / Del / Esc.
*
* Requires Leaflet (global L) + this module. Map authoring is JS-primary
@@ -61,6 +62,20 @@ import {
isPolylineMode,
modeToKind,
} from './draw-tools.js';
import {
createHistory,
DEFAULT_HISTORY_MAX_DEPTH,
captureSnapshot,
pushUndo,
recordBeforeMutation,
beginGesture,
commitGesture,
undo,
redo,
clearHistory,
seedCleanContentHashes,
syncDirtyAfterHistoryApply,
} from './history.js';
/**
* @returns {void}
@@ -83,6 +98,9 @@ function main() {
/** @type {import('./model.js').EditorDocument} */
const doc = createEmptyDocument();
// K13: seed clean-content hashes at bootstrap so load→draw→undo stays clean.
seedCleanContentHashes(doc, 'both');
const history = createHistory({ maxDepth: DEFAULT_HISTORY_MAX_DEPTH });
const draw = createDrawSession();
const blankTiles = root.getAttribute('data-test-tiles') === 'blank';
@@ -118,19 +136,23 @@ function main() {
}
},
onVertexDrag(si, vi, lat, lon) {
beginGesture(history, doc); // first mid-drag captures baseline
setVertex(doc, si, vi, { lat, lon });
// Intentionally no refresh — OverlayController live-updates the surface layer.
// Hard invariant: never refresh/render while OverlayController._dragging.
},
onVertexDragEnd(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
commitGesture(history, doc); // one undo step if content changed
afterAptMutation({ fit: false }); // full refresh — _dragging already false
},
onAircraftDrag(index, lat, lon) {
beginGesture(history, doc);
updateAircraft(doc, index, { lat, lon });
},
onAircraftDragEnd(index, lat, lon) {
updateAircraft(doc, index, { lat, lon });
commitGesture(history, doc);
afterAirMutation();
},
onMapClick(lat, lon, originalEvent) {
@@ -147,28 +169,34 @@ function main() {
}
return;
}
// Draw path: snapshot before handleMapClick; push only on successful place.
const before = captureSnapshot(doc);
const result = handleMapClick(doc, draw, { lat, lon });
if (result.action === 'error') {
showStatus(result.error || 'Draw error', true);
return;
}
if (result.action === 'placed-park') {
pushUndo(history, before);
afterAptMutation({ fit: false });
rail.setTab('surfaces');
showStatus(result.message || 'Parking added.', false);
return;
}
if (result.action === 'placed-aircraft') {
pushUndo(history, before);
afterAirMutation({ fit: false });
rail.setTab('aircraft');
showStatus(result.message || 'Aircraft placed.', false);
return;
}
if (result.action === 'vertex' || result.action === 'need-more') {
// In-progress polyline: no history push.
overlays.setDrawPreview(draw.points, modeToKind(doc.mode) || SurfaceTaxiway);
showStatus(result.message || '', false);
return;
}
// cancel / none: no history push (ensureAirport shell side effect is pre-existing).
void originalEvent;
},
onMapDblClick() {
@@ -196,10 +224,12 @@ function main() {
syncSurfaceSelectTip();
},
onAirportPatch(patch) {
recordBeforeMutation(history, doc);
updateAirportHeaders(doc, patch);
afterAptMutation();
},
onSurfacePatch(index, patch) {
recordBeforeMutation(history, doc);
// Rebuild runway name when ends change.
const s = doc.airport?.surfaces?.[index];
if (s && s.kind === SurfaceRunway) {
@@ -221,6 +251,7 @@ function main() {
if (s && (s.points?.length ?? 0) > 1) {
if (!window.confirm(`Delete surface ${label}?`)) return;
}
recordBeforeMutation(history, doc);
deleteSurface(doc, index);
afterAptMutation();
showStatus(`Deleted ${label}.`, false);
@@ -231,18 +262,23 @@ function main() {
const a = s.points[Math.max(0, afterIndex)] || s.points[0];
const b = s.points[Math.min(s.points.length - 1, afterIndex + 1)] || a;
const mid = { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
recordBeforeMutation(history, doc);
insertVertex(doc, index, afterIndex, mid);
afterAptMutation();
},
onDeleteVertex(index, vertexIndex) {
// Capture before; push only on success so a failed delete does not clear redo.
const before = captureSnapshot(doc);
const res = deleteVertex(doc, index, vertexIndex);
if (!res.ok) {
showStatus(res.reason || 'Cannot delete vertex', true);
return;
}
pushUndo(history, before);
afterAptMutation();
},
onAircraftPatch(index, patch) {
recordBeforeMutation(history, doc);
updateAircraft(doc, index, patch);
afterAirMutation();
},
@@ -250,21 +286,26 @@ function main() {
const ac = doc.aircraft?.[index];
const cs = ac?.callsign || 'aircraft';
if (!window.confirm(`Delete aircraft ${cs}?`)) return;
recordBeforeMutation(history, doc);
deleteAircraft(doc, index);
afterAirMutation();
showStatus(`Deleted ${cs}.`, false);
},
onSnap(acIndex, parkIdx) {
// Capture before; push only on success so a failed snap does not clear redo.
const before = captureSnapshot(doc);
if (!snapAircraftToParking(doc, acIndex, parkIdx)) {
showStatus('Snap failed — check parking selection.', true);
return;
}
pushUndo(history, before);
afterAirMutation();
showStatus('Snapped aircraft to parking.', false);
},
onApplyRaw(side, text) {
if (side === 'apt') {
const { airport, errors } = parseAPT(text);
recordBeforeMutation(history, doc);
setAirport(doc, airport, { errors, markDirty: true });
doc.selection = null;
validateDocument(doc);
@@ -280,6 +321,7 @@ function main() {
);
} else {
const { aircraft, errors } = parseAIR(text);
recordBeforeMutation(history, doc);
setAircraft(doc, aircraft, { errors, markDirty: true });
doc.selection = null;
validateDocument(doc);
@@ -314,7 +356,10 @@ function main() {
errors,
markDirty: false,
});
doc.lastAptDownloadHash = null;
// K13: seed apt clean hash + force aptDirty false (markDirty:false only skips).
// K6: clear history after successful open.
seedCleanContentHashes(doc, 'apt');
clearHistory(history);
doc.serverValidation = null;
validateDocument(doc);
doc.selection = null;
@@ -327,6 +372,7 @@ function main() {
errors.length > 0,
);
} catch (err) {
// Failed open: do not clearHistory or rewrite hashes.
showStatus(`Failed to open .apt: ${errMessage(err)}`, true);
}
},
@@ -344,7 +390,9 @@ function main() {
errors,
markDirty: false,
});
doc.lastAirDownloadHash = null;
// K13: seed air clean hash + force airDirty false. K6: clear history.
seedCleanContentHashes(doc, 'air');
clearHistory(history);
doc.serverValidation = null;
validateDocument(doc);
doc.selection = null;
@@ -357,6 +405,7 @@ function main() {
errors.length > 0,
);
} catch (err) {
// Failed open: do not clearHistory or rewrite hashes.
showStatus(`Failed to open .air: ${errMessage(err)}`, true);
}
},
@@ -409,6 +458,9 @@ function main() {
if (!window.confirm('Discard unsaved APT/AIR changes and start new?')) return;
}
resetDocument(doc);
// K13 + K6: seed empty clean hashes and clear history after New.
seedCleanContentHashes(doc, 'both');
clearHistory(history);
doc.serverValidation = null;
cancelDraw(draw);
overlays.clearDrawPreview();
@@ -478,7 +530,8 @@ function main() {
t.tagName === 'SELECT' ||
t.isContentEditable);
// Ctrl/Cmd combos work even in fields.
// Ctrl/Cmd combos: Save/Open work in fields; undo/redo only when !inField (K14).
// Never bind Mod+X (cut) to history (K3).
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
if (ev.key === 's' || ev.key === 'S') {
ev.preventDefault();
@@ -497,6 +550,19 @@ function main() {
ev.preventDefault();
return;
}
if (!inField) {
if (ev.key === 'z' || ev.key === 'Z') {
ev.preventDefault();
if (ev.shiftKey) performRedo();
else performUndo();
return;
}
if (ev.key === 'y' || ev.key === 'Y') {
ev.preventDefault();
performRedo();
return;
}
}
}
if (inField) return;
@@ -550,6 +616,7 @@ function main() {
const s = doc.airport?.surfaces?.[sel.index];
const label = s?.name || 'surface';
if (!window.confirm(`Delete surface ${label}?`)) return;
recordBeforeMutation(history, doc);
deleteSurface(doc, sel.index);
afterAptMutation();
showStatus(`Deleted ${label}.`, false);
@@ -557,6 +624,7 @@ function main() {
const ac = doc.aircraft?.[sel.index];
const cs = ac?.callsign || 'aircraft';
if (!window.confirm(`Delete aircraft ${cs}?`)) return;
recordBeforeMutation(history, doc);
deleteAircraft(doc, sel.index);
afterAirMutation();
showStatus(`Deleted ${cs}.`, false);
@@ -575,6 +643,8 @@ function main() {
}
function doFinishDraw() {
// Snapshot before finishDraw; push only on successful finished.
const before = captureSnapshot(doc);
const result = finishDraw(doc, draw);
overlays.clearDrawPreview();
if (result.action === 'error') {
@@ -585,12 +655,59 @@ function main() {
if (result.message) showStatus(result.message, false);
return;
}
if (result.action === 'finished') {
pushUndo(history, before);
}
toolbar.setMode(MODE_SELECT);
afterAptMutation();
rail.setTab('surfaces');
showStatus(result.message || 'Surface added.', false);
}
function performUndo() {
const res = undo(history, doc);
if (!res.ok) {
showStatus(
res.reason === 'empty'
? 'Nothing to undo.'
: res.reason === 'gesture-active'
? 'Cannot undo now.'
: 'Cannot undo now.',
false,
);
return;
}
afterHistoryApply();
showStatus('Undid.', false);
}
function performRedo() {
const res = redo(history, doc);
if (!res.ok) {
showStatus(
res.reason === 'empty' ? 'Nothing to redo.' : 'Cannot redo now.',
false,
);
return;
}
afterHistoryApply();
showStatus('Redid.', false);
}
function afterHistoryApply() {
cancelDraw(draw);
overlays.clearDrawPreview();
// K11: leave mode; re-arm draw session so next map click continues without
// an extra mode toggle after undo cancelled in-progress vertices.
if (isPolylineMode(doc.mode) || doc.mode === MODE_PARK || doc.mode === MODE_AIRCRAFT) {
beginDraw(draw, doc.mode);
}
syncDirtyAfterHistoryApply(doc);
validateDocument(doc);
markServerValidationStale(doc);
refresh({ fit: false });
}
function downloadDirtySides() {
const aptD = doc.aptDirty && doc.airport;
const airD = doc.airDirty && doc.aircraft?.length;

View File

@@ -0,0 +1,635 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_HISTORY_MAX_DEPTH,
createHistory,
captureSnapshot,
applySnapshot,
pushUndo,
recordBeforeMutation,
beginGesture,
commitGesture,
discardGesture,
undo,
redo,
canUndo,
canRedo,
clearHistory,
contentEquals,
snapshotContentKey,
seedCleanContentHashes,
syncDirtyAfterHistoryApply,
} from '../../internal/web/static/js/openfsd/airport-editor/history.js';
import {
createEmptyDocument,
createEmptyAirport,
setAirport,
setAircraft,
addSurface,
setVertex,
deleteSurface,
addAircraft,
updateAircraft,
noteDownload,
markClean,
hashText,
SurfaceParking,
SurfaceTaxiway,
createDefaultAircraft,
} from '../../internal/web/static/js/openfsd/airport-editor/model.js';
import {
formatAptForDownload,
formatAirForDownload,
} from '../../internal/web/static/js/openfsd/airport-editor/download.js';
/** Build a small APT with one parking and one taxiway for mutations. */
function seedDocWithGeometry() {
const doc = createEmptyDocument();
const apt = createEmptyAirport();
apt.icao = 'KBTV';
apt.fieldElev = 335;
setAirport(doc, apt, { markDirty: false });
addSurface(doc, {
kind: SurfaceParking,
name: 'G1',
points: [{ lat: 44.47, lon: -73.15 }],
});
addSurface(doc, {
kind: SurfaceTaxiway,
name: 'A',
points: [
{ lat: 44.47, lon: -73.15 },
{ lat: 44.48, lon: -73.14 },
],
});
doc.aptDirty = false;
doc.selection = { type: 'surface', index: 1 };
return doc;
}
// ---------------------------------------------------------------------------
// createHistory / constants
// ---------------------------------------------------------------------------
test('DEFAULT_HISTORY_MAX_DEPTH is 100', () => {
assert.equal(DEFAULT_HISTORY_MAX_DEPTH, 100);
});
test('createHistory defaults and custom maxDepth', () => {
const h = createHistory();
assert.deepEqual(h.undoStack, []);
assert.deepEqual(h.redoStack, []);
assert.equal(h.gestureBaseline, null);
assert.equal(h.maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
const h2 = createHistory({ maxDepth: 3 });
assert.equal(h2.maxDepth, 3);
// Invalid / non-positive / non-finite → default
assert.equal(createHistory({ maxDepth: 0 }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
assert.equal(createHistory({ maxDepth: 0.5 }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
assert.equal(createHistory({ maxDepth: -2 }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
assert.equal(createHistory({ maxDepth: Infinity }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
assert.equal(createHistory({ maxDepth: NaN }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
assert.equal(createHistory({ maxDepth: 'nope' }).maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
// Floor then accept: 2.9 → 2
assert.equal(createHistory({ maxDepth: 2.9 }).maxDepth, 2);
});
// ---------------------------------------------------------------------------
// capture / apply isolation (K9)
// ---------------------------------------------------------------------------
test('captureSnapshot deep-clones; live mutation does not affect snap', () => {
const doc = seedDocWithGeometry();
const snap = captureSnapshot(doc);
assert.equal(snap.airport.icao, 'KBTV');
assert.equal(snap.aircraft.length, 0);
assert.deepEqual(snap.selection, { type: 'surface', index: 1 });
assert.equal(snap._nextSurfaceId, doc._nextSurfaceId);
// Mutate live doc
doc.airport.icao = 'XXXX';
doc.airport.surfaces[1].points[0].lat = 99;
doc.selection = null;
doc._nextSurfaceId = 999;
doc.aircraft.push({ callsign: 'N1' });
assert.equal(snap.airport.icao, 'KBTV');
assert.equal(snap.airport.surfaces[1].points[0].lat, 44.47);
assert.deepEqual(snap.selection, { type: 'surface', index: 1 });
assert.notEqual(snap._nextSurfaceId, 999);
assert.equal(snap.aircraft.length, 0);
});
test('applySnapshot deep-clones into doc; stack snap remains isolated', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
const before = captureSnapshot(doc);
pushUndo(h, before);
// Mutate to a different state
doc.airport.icao = 'KXXX';
setVertex(doc, 1, 0, { lat: 50, lon: -70 });
doc.selection = { type: 'surface', index: 0 };
const res = undo(h, doc);
assert.equal(res.ok, true);
assert.equal(doc.airport.icao, 'KBTV');
assert.equal(doc.airport.surfaces[1].points[0].lat, 44.47);
// Mutate live after apply — must not alter redo stack snaps (K9)
doc.airport.icao = 'MUTATED';
doc.airport.surfaces[0].points[0].lat = 1;
const stacked = h.redoStack[0];
assert.equal(stacked.airport.icao, 'KXXX');
assert.equal(stacked.airport.surfaces[1].points[0].lat, 50);
assert.equal(stacked.airport.surfaces[0].points[0].lat, 44.47);
// Mutating live doc must not alter the originally captured `before` snap either
// (before was pushed to undo then popped — still held by test via `before`)
assert.equal(before.airport.icao, 'KBTV');
assert.equal(before.airport.surfaces[1].points[0].lat, 44.47);
// Redo restores KXXX from stack clone; further live mutation does not corrupt it
const r2 = redo(h, doc);
assert.equal(r2.ok, true);
assert.equal(doc.airport.icao, 'KXXX');
// undo stack now has capture of pre-redo live state (MUTATED)
const u = h.undoStack[h.undoStack.length - 1];
assert.equal(u.airport.icao, 'MUTATED');
doc.airport.icao = 'AGAIN';
doc.airport.surfaces[0].name = 'ZZZ';
assert.equal(u.airport.icao, 'MUTATED');
assert.equal(u.airport.surfaces[0].name, 'G1');
// redo stack empty; original `before` still pristine
assert.equal(before.airport.icao, 'KBTV');
assert.equal(before.airport.surfaces[0].name, 'G1');
});
test('applySnapshot does not touch dirty hashes, filenames, mode', () => {
const doc = seedDocWithGeometry();
doc.aptDirty = true;
doc.airDirty = true;
doc.lastAptDownloadHash = 'deadbeef';
doc.lastAirDownloadHash = 'cafebabe';
doc.aptFilename = 'keep.apt';
doc.airFilename = 'keep.air';
doc.mode = 'taxi';
doc.serverValidation = { loading: false, stale: false };
const empty = captureSnapshot(createEmptyDocument());
applySnapshot(doc, empty);
assert.equal(doc.airport, null);
assert.equal(doc.aptDirty, true);
assert.equal(doc.airDirty, true);
assert.equal(doc.lastAptDownloadHash, 'deadbeef');
assert.equal(doc.lastAirDownloadHash, 'cafebabe');
assert.equal(doc.aptFilename, 'keep.apt');
assert.equal(doc.airFilename, 'keep.air');
assert.equal(doc.mode, 'taxi');
assert.deepEqual(doc.serverValidation, { loading: false, stale: false });
});
// ---------------------------------------------------------------------------
// push / record / undo / redo
// ---------------------------------------------------------------------------
test('recordBefore + mutate + undo restores; redo restores post-state', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
const icaoBefore = doc.airport.icao;
recordBeforeMutation(h, doc);
doc.airport.icao = 'KXXX';
doc.aptDirty = true;
assert.equal(canUndo(h), true);
assert.equal(canRedo(h), false);
const u = undo(h, doc);
assert.equal(u.ok, true);
assert.equal(doc.airport.icao, icaoBefore);
assert.equal(canRedo(h), true);
const r = redo(h, doc);
assert.equal(r.ok, true);
assert.equal(doc.airport.icao, 'KXXX');
});
test('pushUndo always grows stack (no identical-top elision)', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
const a = captureSnapshot(doc);
const b = captureSnapshot(doc);
pushUndo(h, a);
pushUndo(h, b);
assert.equal(h.undoStack.length, 2);
assert.ok(contentEquals(h.undoStack[0], h.undoStack[1]));
});
test('redo cleared on new pushUndo after undo branch', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
recordBeforeMutation(h, doc);
doc.airport.icao = 'A1';
undo(h, doc);
assert.equal(canRedo(h), true);
recordBeforeMutation(h, doc);
doc.airport.icao = 'A2';
assert.equal(canRedo(h), false);
assert.equal(h.redoStack.length, 0);
});
test('undo/redo empty and gesture-active reasons', () => {
const doc = createEmptyDocument();
const h = createHistory();
assert.deepEqual(undo(h, doc), { ok: false, reason: 'empty' });
assert.deepEqual(redo(h, doc), { ok: false, reason: 'empty' });
beginGesture(h, doc);
assert.deepEqual(undo(h, doc), { ok: false, reason: 'gesture-active' });
assert.deepEqual(redo(h, doc), { ok: false, reason: 'gesture-active' });
});
// ---------------------------------------------------------------------------
// maxDepth
// ---------------------------------------------------------------------------
test('maxDepth drops oldest on overflow', () => {
const h = createHistory({ maxDepth: 3 });
const doc = createEmptyDocument();
for (let i = 0; i < 5; i++) {
doc._nextSurfaceId = i + 1;
pushUndo(h, captureSnapshot(doc));
}
assert.equal(h.undoStack.length, 3);
// Oldest remaining should be from when _nextSurfaceId was 3 (0-based loop: i=2 → id 3)
assert.equal(h.undoStack[0]._nextSurfaceId, 3);
assert.equal(h.undoStack[2]._nextSurfaceId, 5);
});
test('maxDepth trims undo on redo path when stack would exceed max', () => {
// Normal undo/redo from a max-sized stack never exceeds max (undo shrinks
// undo as it grows redo). Cover the defensive trim in redo by pre-filling
// undo to maxDepth with a pending redo entry so redo would push past max.
const h = createHistory({ maxDepth: 2 });
const doc = createEmptyDocument();
doc._nextSurfaceId = 1;
h.undoStack.push(captureSnapshot(doc));
doc._nextSurfaceId = 2;
h.undoStack.push(captureSnapshot(doc));
doc._nextSurfaceId = 99;
h.redoStack.push(captureSnapshot(doc));
doc._nextSurfaceId = 3; // live present; redo captures this onto undo
const r = redo(h, doc);
assert.equal(r.ok, true);
assert.equal(doc._nextSurfaceId, 99);
assert.equal(h.undoStack.length, 2); // not 3 — oldest dropped
assert.equal(h.undoStack[0]._nextSurfaceId, 2);
assert.equal(h.undoStack[1]._nextSurfaceId, 3);
assert.equal(h.redoStack.length, 0);
});
// ---------------------------------------------------------------------------
// gestures
// ---------------------------------------------------------------------------
test('beginGesture / commitGesture coalesces multi-move into one undo entry', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
const lat0 = doc.airport.surfaces[1].points[0].lat;
beginGesture(h, doc);
setVertex(doc, 1, 0, { lat: lat0 + 0.01, lon: -73.15 });
beginGesture(h, doc); // nested no-op
setVertex(doc, 1, 0, { lat: lat0 + 0.02, lon: -73.14 });
const pushed = commitGesture(h, doc);
assert.equal(pushed, true);
assert.equal(h.undoStack.length, 1);
assert.equal(h.gestureBaseline, null);
undo(h, doc);
assert.equal(doc.airport.surfaces[1].points[0].lat, lat0);
});
test('commitGesture no-op when content unchanged (selection-only ignored)', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
beginGesture(h, doc);
doc.selection = { type: 'surface', index: 0 }; // selection change only
const pushed = commitGesture(h, doc);
assert.equal(pushed, false);
assert.equal(h.undoStack.length, 0);
});
test('commitGesture with no begin returns false', () => {
const doc = createEmptyDocument();
const h = createHistory();
assert.equal(commitGesture(h, doc), false);
});
test('discardGesture clears baseline without stacking', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
beginGesture(h, doc);
setVertex(doc, 1, 0, { lat: 99, lon: 0 });
discardGesture(h);
assert.equal(h.gestureBaseline, null);
assert.equal(h.undoStack.length, 0);
});
test('canUndo / canRedo false during active gesture', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
recordBeforeMutation(h, doc);
doc.airport.icao = 'X';
undo(h, doc); // leave something on redo
assert.equal(canUndo(h), false); // empty undo after single undo
// put one undo back
recordBeforeMutation(h, doc);
assert.equal(canUndo(h), true);
assert.equal(canRedo(h), false);
beginGesture(h, doc);
assert.equal(canUndo(h), false);
assert.equal(canRedo(h), false);
discardGesture(h);
assert.equal(canUndo(h), true);
});
// ---------------------------------------------------------------------------
// clearHistory
// ---------------------------------------------------------------------------
test('clearHistory empties stacks and gesture', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
recordBeforeMutation(h, doc);
beginGesture(h, doc);
// after begin, push another path: force redo empty, undo has 1, gesture set
clearHistory(h);
assert.equal(h.undoStack.length, 0);
assert.equal(h.redoStack.length, 0);
assert.equal(h.gestureBaseline, null);
});
// ---------------------------------------------------------------------------
// contentEquals / selection + _nextSurfaceId
// ---------------------------------------------------------------------------
test('contentEquals true for deep-equal geometry, false when vertex moves', () => {
const doc = seedDocWithGeometry();
const a = captureSnapshot(doc);
const b = captureSnapshot(doc);
assert.equal(contentEquals(a, b), true);
b.selection = { type: 'aircraft', index: 0 };
assert.equal(contentEquals(a, b), true); // selection ignored
b.airport.surfaces[1].points[0].lat = 0;
assert.equal(contentEquals(a, b), false);
assert.equal(
snapshotContentKey(a),
JSON.stringify({ airport: a.airport, aircraft: a.aircraft }),
);
});
test('selection and _nextSurfaceId restore after undo of delete / add surface', () => {
const doc = seedDocWithGeometry();
const h = createHistory();
const nextIdBefore = doc._nextSurfaceId;
doc.selection = { type: 'surface', index: 0 };
recordBeforeMutation(h, doc);
deleteSurface(doc, 0);
assert.equal(doc.selection, null); // deleteSurface clears selection at index
undo(h, doc);
assert.deepEqual(doc.selection, { type: 'surface', index: 0 });
assert.equal(doc.airport.surfaces.length, 2);
assert.equal(doc.airport.surfaces[0].name, 'G1');
assert.equal(doc._nextSurfaceId, nextIdBefore);
// add surface path
recordBeforeMutation(h, doc);
const idBeforeAdd = doc._nextSurfaceId;
addSurface(doc, {
kind: SurfaceParking,
name: 'G2',
points: [{ lat: 1, lon: 2 }],
});
assert.ok(doc._nextSurfaceId > idBeforeAdd || doc.airport.surfaces.length === 3);
undo(h, doc);
assert.equal(doc.airport.surfaces.length, 2);
assert.equal(doc._nextSurfaceId, idBeforeAdd);
});
test('null airport baseline: place then undo restores airport null', () => {
const doc = createEmptyDocument();
const h = createHistory();
const before = captureSnapshot(doc);
assert.equal(before.airport, null);
pushUndo(h, before);
setAirport(doc, createEmptyAirport(), { markDirty: true });
addSurface(doc, {
kind: SurfaceParking,
name: 'G1',
points: [{ lat: 1, lon: 2 }],
});
undo(h, doc);
assert.equal(doc.airport, null);
});
// ---------------------------------------------------------------------------
// Dirty / baseline helpers (K13)
// ---------------------------------------------------------------------------
test('bootstrap / empty seed: mutate then undo → clean via syncDirtyAfterHistoryApply', () => {
const doc = createEmptyDocument();
seedCleanContentHashes(doc, 'both');
assert.ok(doc.lastAptDownloadHash);
assert.ok(doc.lastAirDownloadHash);
assert.equal(doc.aptDirty, false);
const h = createHistory();
recordBeforeMutation(h, doc);
setAirport(doc, createEmptyAirport(), { markDirty: true });
addSurface(doc, {
kind: SurfaceParking,
name: 'G1',
points: [{ lat: 44.47, lon: -73.15 }],
});
assert.equal(doc.aptDirty, true);
undo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport, null);
assert.equal(doc.aptDirty, false);
assert.equal(doc.airDirty, false);
});
test('open-equivalent seed: set airport + seed apt → mutate → undo → clean', () => {
const doc = createEmptyDocument();
const apt = createEmptyAirport();
apt.icao = 'KBTV';
setAirport(doc, apt, { markDirty: false });
seedCleanContentHashes(doc, 'apt');
doc.aptDirty = false;
const h = createHistory();
recordBeforeMutation(h, doc);
doc.airport.icao = 'KXXX';
doc.aptDirty = true;
undo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport.icao, 'KBTV');
assert.equal(doc.aptDirty, false);
});
test('download then undo/redo dirty tracking', () => {
const doc = seedDocWithGeometry();
seedCleanContentHashes(doc, 'both');
doc.aptDirty = false;
const h = createHistory();
recordBeforeMutation(h, doc);
doc.airport.icao = 'KXXX';
doc.aptDirty = true;
// Download current (edited) content — marks clean with hash of KXXX
noteDownload(doc, 'apt', formatAptForDownload(doc));
assert.equal(doc.aptDirty, false);
undo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport.icao, 'KBTV');
assert.equal(doc.aptDirty, true); // hash(A) ≠ hash(B)
redo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport.icao, 'KXXX');
assert.equal(doc.aptDirty, false);
});
test('mark-clean then undo may re-dirty via recompute', () => {
const doc = seedDocWithGeometry();
seedCleanContentHashes(doc, 'both');
doc.aptDirty = false;
const h = createHistory();
recordBeforeMutation(h, doc);
doc.airport.icao = 'KXXX';
doc.aptDirty = true;
// Mark clean without changing hash (hash still seed of KBTV content)
markClean(doc, 'apt');
assert.equal(doc.aptDirty, false);
// Current content is KXXX; recompute would dirty — but we only recompute after history apply
// Undo to KBTV which matches seed → clean
undo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport.icao, 'KBTV');
assert.equal(doc.aptDirty, false);
// Redo to KXXX vs seed KBTV → dirty
redo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport.icao, 'KXXX');
assert.equal(doc.aptDirty, true);
});
test('null airport dirty symmetry when hash is non-null from prior download', () => {
const doc = seedDocWithGeometry();
const text = formatAptForDownload(doc);
noteDownload(doc, 'apt', text);
assert.equal(doc.aptDirty, false);
assert.ok(doc.lastAptDownloadHash);
const emptySnap = captureSnapshot(createEmptyDocument());
applySnapshot(doc, emptySnap);
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.airport, null);
assert.equal(doc.aptDirty, true);
assert.notEqual(hashText(''), doc.lastAptDownloadHash);
});
test('seed recipe unity: seed and syncDirty use download-format helpers', () => {
const doc = createEmptyDocument();
seedCleanContentHashes(doc, 'both');
const expectedApt = hashText(formatAptForDownload(doc));
const expectedAir = hashText(formatAirForDownload(doc));
assert.equal(doc.lastAptDownloadHash, expectedApt);
assert.equal(doc.lastAirDownloadHash, expectedAir);
// empty download format is ''
assert.equal(formatAptForDownload(doc), '');
assert.equal(formatAirForDownload(doc), '');
assert.equal(expectedApt, hashText(''));
assert.equal(expectedAir, hashText(''));
syncDirtyAfterHistoryApply(doc);
assert.equal(doc.aptDirty, false);
assert.equal(doc.airDirty, false);
});
test('seedCleanContentHashes side=apt leaves air hash alone', () => {
const doc = createEmptyDocument();
doc.lastAirDownloadHash = 'keepme!!';
seedCleanContentHashes(doc, 'apt');
assert.equal(doc.lastAirDownloadHash, 'keepme!!');
assert.equal(doc.lastAptDownloadHash, hashText(formatAptForDownload(doc)));
});
test('seedCleanContentHashes forces dirty false for seeded side (Open after dirty)', () => {
// setAirport({ markDirty: false }) only skips setting dirty true — does not clear.
const doc = createEmptyDocument();
doc.aptDirty = true;
doc.airDirty = true;
seedCleanContentHashes(doc, 'apt');
assert.equal(doc.aptDirty, false);
assert.equal(doc.airDirty, true); // air side not seeded
seedCleanContentHashes(doc, 'air');
assert.equal(doc.airDirty, false);
doc.aptDirty = true;
doc.airDirty = true;
seedCleanContentHashes(doc, 'both');
assert.equal(doc.aptDirty, false);
assert.equal(doc.airDirty, false);
});
test('air side: record / undo / seed clean for aircraft', () => {
const doc = createEmptyDocument();
const apt = createEmptyAirport();
apt.icao = 'KBTV';
setAirport(doc, apt, { markDirty: false });
setAircraft(doc, [createDefaultAircraft(apt, { lat: 44.47, lon: -73.15 })], {
markDirty: false,
});
seedCleanContentHashes(doc, 'both');
doc.airDirty = false;
const h = createHistory();
recordBeforeMutation(h, doc);
updateAircraft(doc, 0, { callsign: 'CHANGED' });
undo(h, doc);
syncDirtyAfterHistoryApply(doc);
assert.notEqual(doc.aircraft[0].callsign, 'CHANGED');
assert.equal(doc.airDirty, false);
});