mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
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:
273
internal/web/static/js/openfsd/airport-editor/history.js
Normal file
273
internal/web/static/js/openfsd/airport-editor/history.js
Normal 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));
|
||||
}
|
||||
587
webjs/airport-editor/history.test.js
Normal file
587
webjs/airport-editor/history.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user