mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
Merge airport editor undo/redo stack into dev
This commit is contained in:
1071
docs/design/airport-editor-undo-redo.md
Normal file
1071
docs/design/airport-editor-undo-redo.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 text’s 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 text’s 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 |
|
||||
|
||||
275
internal/web/static/js/openfsd/airport-editor/history.js
Normal file
275
internal/web/static/js/openfsd/airport-editor/history.js
Normal 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));
|
||||
}
|
||||
@@ -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 1–6 / 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;
|
||||
|
||||
635
webjs/airport-editor/history.test.js
Normal file
635
webjs/airport-editor/history.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user