airport-editor: add pure undo/redo history stack module

Pure history.js with snapshot stack, gesture coalescing, and dirty
baseline helpers. Node unit tests only; no main.js wiring.
This commit is contained in:
Reese Norris
2026-07-27 16:28:52 -04:00
parent 87b9a508f3
commit 7be290f709
2 changed files with 860 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
/**
* 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 maxDepth =
typeof opts.maxDepth === 'number' && opts.maxDepth > 0
? Math.floor(opts.maxDepth)
: 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. Does not set dirty true.
* Leave dirty flags false when seeding a clean baseline (caller responsibility).
* 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));
}
if (side === 'air' || side === 'both') {
doc.lastAirDownloadHash = hashText(formatAirForDownload(doc));
}
}
/**
* 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

@@ -0,0 +1,587 @@
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);
const h3 = createHistory({ maxDepth: 0 });
assert.equal(h3.maxDepth, DEFAULT_HISTORY_MAX_DEPTH);
});
// ---------------------------------------------------------------------------
// 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);
});
// ---------------------------------------------------------------------------
// 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('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);
});