feat: save, undo and redo direct edits
Step 5 of the map editor. EditSession becomes the single owner of the constraint set, replacing the pair of refs step 4 kept alongside it: it now holds the saved operations too, and `fragment()` assembles the constraints plus every operation they reference — the shape both preview and save send. That removes the class of bug that produced the earlier 400, because callers can no longer ship a constraint whose provenance points at nothing. Save goes through `expectedDocumentVersion`. A 409 adopts the server's version so the next attempt is checked against reality, and says which version won instead of failing silently. Undo of a saved gesture appends an inverse operation rather than rewriting persisted history; undo of an unsaved one just moves the cursor. Discard drops unsaved commands, cancels anything in flight, and returns the map to the baseline. One gesture mints one operation id, released on pointerup. Reusing an id across gestures put two operations with the same id in the document, which validateEditDocument() rejects. The API test now covers the payload the client actually sends — `constraints` + `operations` merged into the active document, rather than a whole document — and asserts that a save survives a reload as `exact`. Sending constraints without their operations is asserted to be rejected rather than written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,5 +72,52 @@ const rebased = rebaseEdits(area, checkpoint.manifest.id);
|
|||||||
assert.equal(rebased.counts.exact, 0);
|
assert.equal(rebased.counts.exact, 0);
|
||||||
assert.deepEqual(rebased.constraints, []);
|
assert.deepEqual(rebased.constraints, []);
|
||||||
|
|
||||||
|
// The map saves `constraints` + `operations`, not a whole document. That merge path
|
||||||
|
// is what the client actually exercises, and leaving the operations out is exactly
|
||||||
|
// how the first wiring produced a 400.
|
||||||
|
const edgeHandle = state.handles.handles.find((handle) => handle.kind === 'road-edge-offset');
|
||||||
|
assert.ok(edgeHandle, 'the fixture must publish a road edge handle');
|
||||||
|
const clientConstraint = {
|
||||||
|
id: `constraint:${edgeHandle.handleId}`,
|
||||||
|
kind: 'road-edge-offset',
|
||||||
|
anchor: edgeHandle.anchor,
|
||||||
|
anchorSnapshot: {
|
||||||
|
coordinate: edgeHandle.position,
|
||||||
|
tangentAzimuth: 0,
|
||||||
|
roadLengthMeters: 120,
|
||||||
|
osmNodeIds: ['1'],
|
||||||
|
},
|
||||||
|
value: { offsetMeters: 0.5, transition: 'smoothstep' },
|
||||||
|
enabled: true,
|
||||||
|
status: 'exact',
|
||||||
|
provenance: { operationId: 'operation:client:1', createdAt: '2026-08-27T00:00:00.000Z' },
|
||||||
|
};
|
||||||
|
const clientOperation = {
|
||||||
|
id: clientConstraint.provenance.operationId,
|
||||||
|
createdAt: clientConstraint.provenance.createdAt,
|
||||||
|
constraintIds: [clientConstraint.id],
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => saveEdits(area, { expectedDocumentVersion: 1, constraints: [clientConstraint], operations: [] }),
|
||||||
|
/provenance\.operationId/,
|
||||||
|
'a constraint saved without its operation must be rejected, not written',
|
||||||
|
);
|
||||||
|
const clientSaved = saveEdits(area, {
|
||||||
|
expectedDocumentVersion: 1,
|
||||||
|
constraints: [clientConstraint],
|
||||||
|
operations: [clientOperation],
|
||||||
|
});
|
||||||
|
assert.equal(clientSaved.documentVersion, 2);
|
||||||
|
|
||||||
|
// A save that does not survive a reload as `exact` was cosmetic.
|
||||||
|
const reloaded = editState(area);
|
||||||
|
assert.equal(reloaded.documentVersion, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
reloaded.document.constraints.map((item) => item.id),
|
||||||
|
[clientConstraint.id],
|
||||||
|
);
|
||||||
|
assert.equal(reloaded.constraintStates[0].status, 'exact');
|
||||||
|
assert.equal(reloaded.constraintStates[0].applied, true);
|
||||||
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
console.log('workbench edit API tests passed');
|
console.log('workbench edit API tests passed');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
|
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
|
||||||
import { api } from './lib/api';
|
import { api, isVersionConflict } from './lib/api';
|
||||||
import { MapCanvas } from './components/MapCanvas';
|
import { MapCanvas } from './components/MapCanvas';
|
||||||
import { directEditEnabled, intervalEditingSupported } from './edit/flag';
|
import { directEditEnabled, intervalEditingSupported } from './edit/flag';
|
||||||
import { PreviewRequester, type PreviewDraft } from './edit/preview-request';
|
import { PreviewRequester, type PreviewDraft } from './edit/preview-request';
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
RoadConstraint,
|
RoadConstraint,
|
||||||
RoadEditOperation,
|
RoadEditOperation,
|
||||||
RoadIntervalAnchor,
|
RoadIntervalAnchor,
|
||||||
|
VersionConflict,
|
||||||
} from './edit/types';
|
} from './edit/types';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { GeoFeature, GeoJson, Override, Road, WorkbenchState } from './types/state';
|
import type { GeoFeature, GeoJson, Override, Road, WorkbenchState } from './types/state';
|
||||||
@@ -112,10 +113,10 @@ function App() {
|
|||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (!('handles' in value)) return;
|
if (!('handles' in value)) return;
|
||||||
setManifest(value.handles);
|
setManifest(value.handles);
|
||||||
// A drag drafts on top of whatever is already saved, so keep the active
|
// The session owns the constraint set from here on, so a drag drafts on top
|
||||||
// document's constraints rather than sending a lone constraint.
|
// of the active document instead of sending a lone constraint.
|
||||||
savedConstraints.current = value.document.constraints;
|
sessionRef.current?.load(value.document.constraints, value.document.operations, value.documentVersion);
|
||||||
savedOperations.current = value.document.operations;
|
touch();
|
||||||
})
|
})
|
||||||
// A manifest failure must not take the workbench down with it; the map just
|
// A manifest failure must not take the workbench down with it; the map just
|
||||||
// shows no handles.
|
// shows no handles.
|
||||||
@@ -149,19 +150,32 @@ function App() {
|
|||||||
}, [editHandles, manifest, selected, state]);
|
}, [editHandles, manifest, selected, state]);
|
||||||
const [preview, setPreview] = useState<Partial<Record<PreviewLayerName, GeoJson | null>> | null>(null);
|
const [preview, setPreview] = useState<Partial<Record<PreviewLayerName, GeoJson | null>> | null>(null);
|
||||||
const [editDiagnostics, setEditDiagnostics] = useState<EditDiagnostic[]>([]);
|
const [editDiagnostics, setEditDiagnostics] = useState<EditDiagnostic[]>([]);
|
||||||
// The working set: the saved document plus whatever this session has drafted.
|
|
||||||
// Advanced on pointerup, not on every move, so an abandoned drag leaves nothing
|
|
||||||
// behind — and so a later range drag can re-anchor the edit a value drag made.
|
|
||||||
const savedConstraints = useRef<RoadConstraint[]>([]);
|
|
||||||
/** Their operations. A constraint without its operation is rejected as a 400. */
|
|
||||||
const savedOperations = useRef<RoadEditOperation[]>([]);
|
|
||||||
const sessionRef = useRef<EditSession | null>(null);
|
const sessionRef = useRef<EditSession | null>(null);
|
||||||
if (directEditEnabled && !sessionRef.current) sessionRef.current = new EditSession();
|
if (directEditEnabled && !sessionRef.current) sessionRef.current = new EditSession();
|
||||||
|
// The session is a mutable object in a ref, so React cannot see it change.
|
||||||
|
// Bumping this after every mutation is what keeps the buttons in step with it.
|
||||||
|
const [editRevision, setEditRevision] = useState(0);
|
||||||
|
const touch = () => setEditRevision((value) => value + 1);
|
||||||
|
const session = sessionRef.current;
|
||||||
|
/**
|
||||||
|
* One gesture, one operation id. Minted on the first draft of a drag and cleared
|
||||||
|
* on release: reusing an id across gestures would put two operations with the
|
||||||
|
* same id in the document, which `validateEditDocument()` rejects outright.
|
||||||
|
*/
|
||||||
|
const gesture = useRef<{ operationId: string; createdAt: string } | null>(null);
|
||||||
|
const gestureIdentity = () => {
|
||||||
|
if (!gesture.current)
|
||||||
|
gesture.current = {
|
||||||
|
operationId: `operation:${editRevision}:${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return gesture.current;
|
||||||
|
};
|
||||||
const requester = useMemo(() => {
|
const requester = useMemo(() => {
|
||||||
const session = sessionRef.current;
|
const owner = sessionRef.current;
|
||||||
if (!session) return null;
|
if (!owner) return null;
|
||||||
return new PreviewRequester({
|
return new PreviewRequester({
|
||||||
session,
|
session: owner,
|
||||||
send: (request, signal) => api.editPreview(request, signal),
|
send: (request, signal) => api.editPreview(request, signal),
|
||||||
onSettled: (outcome, response) => {
|
onSettled: (outcome, response) => {
|
||||||
setEditDiagnostics(response.diagnostics);
|
setEditDiagnostics(response.diagnostics);
|
||||||
@@ -175,31 +189,28 @@ function App() {
|
|||||||
onError: (error) => setStatus(`预览失败:${(error as Error).message}`),
|
onError: (error) => setStatus(`预览失败:${(error as Error).message}`),
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
/** A drag becomes one drafted constraint layered over the saved document. */
|
/** A drag becomes one drafted constraint layered over the session's document. */
|
||||||
const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => {
|
const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => {
|
||||||
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null;
|
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null;
|
||||||
const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined;
|
const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined;
|
||||||
// Without a centerline there is no snapshot to record, so drop the drag rather
|
// Without a centerline there is no snapshot to record, so drop the drag rather
|
||||||
// than send a constraint the server would have to reject.
|
// than send a constraint the server would have to reject.
|
||||||
if (!road || road.centerline.length < 2) return null;
|
if (!session || !road || road.centerline.length < 2) return null;
|
||||||
const constraint = draftConstraint(
|
const constraint = draftConstraint(
|
||||||
handle,
|
handle,
|
||||||
handle.anchor,
|
handle.anchor,
|
||||||
constraintValueFor(handle, value),
|
constraintValueFor(handle, value),
|
||||||
anchorSnapshotFor(handle, road.centerline, road.sourceNodeIds),
|
anchorSnapshotFor(handle, road.centerline, road.sourceNodeIds),
|
||||||
{
|
{ constraintId: `constraint:${handle.handleId}`, ...gestureIdentity() },
|
||||||
constraintId: `constraint:${handle.handleId}`,
|
|
||||||
operationId: `operation:${handle.handleId}`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const operation = operationFor(constraint);
|
const operation = operationFor(constraint);
|
||||||
|
const base = session.fragment();
|
||||||
// Both halves travel: validateEditDocument() rejects a constraint whose
|
// Both halves travel: validateEditDocument() rejects a constraint whose
|
||||||
// provenance.operationId is not a recorded operation, and that check covers the
|
// provenance.operationId is not a recorded operation, and that check covers the
|
||||||
// already-saved constraints too, not just the one being dragged.
|
// already-saved constraints too, not just the one being dragged.
|
||||||
return {
|
return {
|
||||||
constraints: [...savedConstraints.current.filter((item) => item.id !== constraint.id), constraint],
|
constraints: [...base.constraints.filter((item) => item.id !== constraint.id), constraint],
|
||||||
operations: [...savedOperations.current.filter((item) => item.id !== operation.id), operation],
|
operations: [...base.operations.filter((item) => item.id !== operation.id), operation],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -208,23 +219,86 @@ function App() {
|
|||||||
* constraint on it. With no constraint yet there is nothing to re-anchor.
|
* constraint on it. With no constraint yet there is nothing to re-anchor.
|
||||||
*/
|
*/
|
||||||
const rangeDraftFor = (range: IntervalRangeHandle, anchor: RoadIntervalAnchor): PreviewDraft | null => {
|
const rangeDraftFor = (range: IntervalRangeHandle, anchor: RoadIntervalAnchor): PreviewDraft | null => {
|
||||||
|
const base = session?.fragment();
|
||||||
const onRoad = (item: RoadConstraint) =>
|
const onRoad = (item: RoadConstraint) =>
|
||||||
item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId;
|
item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId;
|
||||||
if (!savedConstraints.current.some(onRoad)) return null;
|
if (!base || !base.constraints.some(onRoad)) return null;
|
||||||
return {
|
return {
|
||||||
constraints: savedConstraints.current.map((item) =>
|
constraints: base.constraints.map((item) =>
|
||||||
onRoad(item)
|
onRoad(item)
|
||||||
? { ...item, anchor: { ...item.anchor, startStation: anchor.startStation, endStation: anchor.endStation } }
|
? { ...item, anchor: { ...item.anchor, startStation: anchor.startStation, endStation: anchor.endStation } }
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
operations: savedOperations.current,
|
operations: base.operations,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** Both drag kinds commit their result here so the next drag builds on it. */
|
/**
|
||||||
const advance = (draft: PreviewDraft | null) => {
|
* Pointerup closes the gesture: one undoable command, then the id is released so
|
||||||
if (!draft) return;
|
* the next drag mints its own. An abandoned drag commits nothing.
|
||||||
savedConstraints.current = draft.constraints ?? savedConstraints.current;
|
*/
|
||||||
savedOperations.current = draft.operations ?? savedOperations.current;
|
const commitGesture = (draft: PreviewDraft | null, constraintIds: string[]) => {
|
||||||
|
if (!draft || !session) return;
|
||||||
|
requester?.flush(draft);
|
||||||
|
session.commit(draft.constraints ?? [], constraintIds, gestureIdentity());
|
||||||
|
gesture.current = null;
|
||||||
|
touch();
|
||||||
|
};
|
||||||
|
/** Re-previews whatever the session now holds, after undo/redo moved the cursor. */
|
||||||
|
const previewSession = () => {
|
||||||
|
const fragment = session?.fragment();
|
||||||
|
if (fragment) requester?.flush(fragment);
|
||||||
|
};
|
||||||
|
const undoEdit = () => {
|
||||||
|
// A saved gesture cannot be un-applied — persisted history is append-only — so
|
||||||
|
// it is reversed by an inverse operation, which needs its own identity.
|
||||||
|
const reversed = session?.undo({
|
||||||
|
operationId: `operation:undo:${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!reversed) return;
|
||||||
|
touch();
|
||||||
|
previewSession();
|
||||||
|
};
|
||||||
|
const redoEdit = () => {
|
||||||
|
if (!session?.redo()) return;
|
||||||
|
touch();
|
||||||
|
previewSession();
|
||||||
|
};
|
||||||
|
const discardEdits = () => {
|
||||||
|
if (!session) return;
|
||||||
|
requester?.cancel();
|
||||||
|
session.discard();
|
||||||
|
gesture.current = null;
|
||||||
|
touch();
|
||||||
|
setPreview(null);
|
||||||
|
setEditDiagnostics([]);
|
||||||
|
setStatus('已放弃未保存的直接编辑');
|
||||||
|
};
|
||||||
|
const saveDirectEdits = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
const fragment = session.fragment();
|
||||||
|
try {
|
||||||
|
const result = await api.saveEdits({
|
||||||
|
expectedDocumentVersion: session.documentVersion,
|
||||||
|
constraints: fragment.constraints,
|
||||||
|
operations: fragment.operations,
|
||||||
|
});
|
||||||
|
session.markSaved(result.documentVersion);
|
||||||
|
touch();
|
||||||
|
setStatus(`直接编辑已保存,文档版本 ${result.documentVersion}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isVersionConflict(error)) {
|
||||||
|
setStatus(`保存失败:${(error as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single-writer protection fired: another tab moved the document. Adopt the
|
||||||
|
// server's version so the next attempt is checked against reality, and say so
|
||||||
|
// rather than failing silently.
|
||||||
|
const current = (error.body as VersionConflict | null)?.current?.documentVersion;
|
||||||
|
if (Number.isInteger(current)) session.setDocumentVersion(current as number);
|
||||||
|
touch();
|
||||||
|
setStatus(`保存冲突:文档已被改到版本 ${current ?? '未知'},请刷新后重做本次编辑。`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const stage = (change: Override) =>
|
const stage = (change: Override) =>
|
||||||
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
|
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
|
||||||
@@ -312,6 +386,22 @@ function App() {
|
|||||||
<Button onClick={() => void compile()}>
|
<Button onClick={() => void compile()}>
|
||||||
<RefreshCw size={15} /> 保存并重新生成
|
<RefreshCw size={15} /> 保存并重新生成
|
||||||
</Button>
|
</Button>
|
||||||
|
{directEditEnabled ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => void saveDirectEdits()} disabled={!session?.dirty}>
|
||||||
|
<Save size={15} /> 保存直接编辑
|
||||||
|
</Button>
|
||||||
|
<Button onClick={undoEdit} disabled={!session?.canUndo}>
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
|
<Button onClick={redoEdit} disabled={!session?.canRedo}>
|
||||||
|
重做
|
||||||
|
</Button>
|
||||||
|
<Button onClick={discardEdits} disabled={!session?.dirty}>
|
||||||
|
放弃
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<aside className="issues">
|
<aside className="issues">
|
||||||
@@ -368,12 +458,7 @@ function App() {
|
|||||||
const draft = draftFor(handle, value);
|
const draft = draftFor(handle, value);
|
||||||
if (draft) requester?.schedule(draft);
|
if (draft) requester?.schedule(draft);
|
||||||
}}
|
}}
|
||||||
onHandleDragEnd={(handle, value) => {
|
onHandleDragEnd={(handle, value) => commitGesture(draftFor(handle, value), [`constraint:${handle.handleId}`])}
|
||||||
const draft = draftFor(handle, value);
|
|
||||||
if (!draft) return;
|
|
||||||
requester?.flush(draft);
|
|
||||||
advance(draft);
|
|
||||||
}}
|
|
||||||
onHandleBlocked={(_handle, reason) => setStatus(reason)}
|
onHandleBlocked={(_handle, reason) => setStatus(reason)}
|
||||||
onRangeDrag={(range, anchor) => {
|
onRangeDrag={(range, anchor) => {
|
||||||
const draft = rangeDraftFor(range, anchor);
|
const draft = rangeDraftFor(range, anchor);
|
||||||
@@ -385,8 +470,10 @@ function App() {
|
|||||||
setStatus('先拖动路缘、步行带或车道分隔手柄产生一次编辑,范围手柄才有可调整的区间。');
|
setStatus('先拖动路缘、步行带或车道分隔手柄产生一次编辑,范围手柄才有可调整的区间。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requester?.flush(draft);
|
const touched = (draft.constraints ?? [])
|
||||||
advance(draft);
|
.filter((item) => item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId)
|
||||||
|
.map((item) => item.id);
|
||||||
|
commitGesture(draft, touched);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Inspector
|
<Inspector
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const error: EditDiagnostic = {
|
|||||||
|
|
||||||
describe('command stack', () => {
|
describe('command stack', () => {
|
||||||
it('starts clean on the loaded baseline', () => {
|
it('starts clean on the loaded baseline', () => {
|
||||||
const session = new EditSession([constraint('c1', 1)], 3);
|
const session = new EditSession([constraint('c1', 1)], [], 3);
|
||||||
expect(session.constraints()).toHaveLength(1);
|
expect(session.constraints()).toHaveLength(1);
|
||||||
expect(session.canUndo).toBe(false);
|
expect(session.canUndo).toBe(false);
|
||||||
expect(session.canRedo).toBe(false);
|
expect(session.canRedo).toBe(false);
|
||||||
@@ -124,12 +124,77 @@ describe('saved history is append-only', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adopts the server version after a conflict', () => {
|
it('adopts the server version after a conflict', () => {
|
||||||
const session = new EditSession([], 1);
|
const session = new EditSession([], [], 1);
|
||||||
session.setDocumentVersion(7);
|
session.setDocumentVersion(7);
|
||||||
expect(session.documentVersion).toBe(7);
|
expect(session.documentVersion).toBe(7);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('document fragment', () => {
|
||||||
|
const operation = (id: string, constraintIds: string[]) => ({ id, createdAt: 'now', constraintIds });
|
||||||
|
|
||||||
|
it('carries the operations the saved constraints reference', () => {
|
||||||
|
// Sending a constraint without its operation is a 400 from
|
||||||
|
// validateEditDocument(), so the fragment has to cover the saved half too.
|
||||||
|
const session = new EditSession([constraint('c1', 1)], [operation('op', ['c1'])], 2);
|
||||||
|
expect(session.fragment()).toEqual({
|
||||||
|
constraints: [constraint('c1', 1)],
|
||||||
|
operations: [operation('op', ['c1'])],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the pending operations of unsaved gestures', () => {
|
||||||
|
const session = new EditSession([constraint('c1', 1)], [operation('op', ['c1'])], 2);
|
||||||
|
session.commit([constraint('c1', 1), constraint('c2', 3)], ['c2'], identity(1));
|
||||||
|
const fragment = session.fragment();
|
||||||
|
expect(fragment.constraints.map((item) => item.id)).toEqual(['c1', 'c2']);
|
||||||
|
expect(fragment.operations.map((item) => item.id)).toEqual(['op', 'op-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not list an operation twice when a gesture is re-committed', () => {
|
||||||
|
const session = new EditSession([]);
|
||||||
|
session.commit([constraint('c1', 1)], ['c1'], identity(1));
|
||||||
|
session.markSaved(1);
|
||||||
|
// The same operation is now in the baseline; committing again must not duplicate it.
|
||||||
|
session.commit([constraint('c1', 5)], ['c1'], identity(1));
|
||||||
|
expect(session.fragment().operations.map((item) => item.id)).toEqual(['op-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loading and discarding', () => {
|
||||||
|
it('adopts a server document as the new baseline with no history', () => {
|
||||||
|
const session = new EditSession([]);
|
||||||
|
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||||
|
session.load([constraint('c9', 4)], [{ id: 'op-9', createdAt: 'now', constraintIds: ['c9'] }], 12);
|
||||||
|
expect(session.constraints()).toEqual([constraint('c9', 4)]);
|
||||||
|
expect(session.documentVersion).toBe(12);
|
||||||
|
expect(session.canUndo).toBe(false);
|
||||||
|
expect(session.canRedo).toBe(false);
|
||||||
|
expect(session.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates in place so the preview requester keeps arbitrating', () => {
|
||||||
|
// The requester captures the session once; a swapped object would leave it
|
||||||
|
// deciding staleness for a session nobody reads.
|
||||||
|
const session = new EditSession([]);
|
||||||
|
const captured = session;
|
||||||
|
session.load([constraint('c1', 1)], [], 3);
|
||||||
|
expect(captured.constraints()).toEqual([constraint('c1', 1)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards unsaved gestures but keeps saved ones', () => {
|
||||||
|
const session = new EditSession([]);
|
||||||
|
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||||
|
session.markSaved(1);
|
||||||
|
session.commit([constraint('c1', 2), constraint('c2', 3)], ['c2'], identity(2));
|
||||||
|
expect(session.dirty).toBe(true);
|
||||||
|
session.discard();
|
||||||
|
expect(session.constraints()).toEqual([constraint('c1', 2)]);
|
||||||
|
expect(session.dirty).toBe(false);
|
||||||
|
expect(session.canRedo).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('previewSeq arbitration', () => {
|
describe('previewSeq arbitration', () => {
|
||||||
it('hands out a monotonic sequence', () => {
|
it('hands out a monotonic sequence', () => {
|
||||||
const session = new EditSession([]);
|
const session = new EditSession([]);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface PreviewOutcome {
|
|||||||
|
|
||||||
export class EditSession {
|
export class EditSession {
|
||||||
private baseline: RoadConstraint[];
|
private baseline: RoadConstraint[];
|
||||||
|
/** Operations of the saved constraints. A constraint whose operation is missing is a 400. */
|
||||||
|
private baselineOperations: RoadEditOperation[];
|
||||||
private stack: Command[] = [];
|
private stack: Command[] = [];
|
||||||
private cursor = 0;
|
private cursor = 0;
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
@@ -49,11 +51,26 @@ export class EditSession {
|
|||||||
constraintStates: ConstraintStateReport[] = [];
|
constraintStates: ConstraintStateReport[] = [];
|
||||||
degraded = false;
|
degraded = false;
|
||||||
|
|
||||||
constructor(constraints: RoadConstraint[] = [], documentVersion = 0) {
|
constructor(constraints: RoadConstraint[] = [], operations: RoadEditOperation[] = [], documentVersion = 0) {
|
||||||
this.baseline = [...constraints];
|
this.baseline = [...constraints];
|
||||||
|
this.baselineOperations = [...operations];
|
||||||
this.version = documentVersion;
|
this.version = documentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document fragment both preview and save send: the effective constraints
|
||||||
|
* plus every operation they reference. Assembling it here is what keeps callers
|
||||||
|
* from shipping constraints whose provenance points at nothing.
|
||||||
|
*/
|
||||||
|
fragment(): { constraints: RoadConstraint[]; operations: RoadEditOperation[] } {
|
||||||
|
const pending = this.pendingOperations();
|
||||||
|
const ids = new Set(pending.map((operation) => operation.id));
|
||||||
|
return {
|
||||||
|
constraints: this.constraints(),
|
||||||
|
operations: [...this.baselineOperations.filter((operation) => !ids.has(operation.id)), ...pending],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Effective constraint set: the top applied command, or the saved baseline. */
|
/** Effective constraint set: the top applied command, or the saved baseline. */
|
||||||
constraints(): RoadConstraint[] {
|
constraints(): RoadConstraint[] {
|
||||||
return this.cursor > 0 ? this.stack[this.cursor - 1].after : this.baseline;
|
return this.cursor > 0 ? this.stack[this.cursor - 1].after : this.baseline;
|
||||||
@@ -144,6 +161,7 @@ export class EditSession {
|
|||||||
|
|
||||||
/** After a successful save: current state becomes the baseline, history closes. */
|
/** After a successful save: current state becomes the baseline, history closes. */
|
||||||
markSaved(documentVersion: number): void {
|
markSaved(documentVersion: number): void {
|
||||||
|
this.baselineOperations = this.fragment().operations;
|
||||||
this.baseline = this.constraints();
|
this.baseline = this.constraints();
|
||||||
this.stack = this.stack.slice(0, this.cursor).map((command) => ({ ...command, saved: true }));
|
this.stack = this.stack.slice(0, this.cursor).map((command) => ({ ...command, saved: true }));
|
||||||
this.cursor = this.stack.length;
|
this.cursor = this.stack.length;
|
||||||
@@ -155,6 +173,27 @@ export class EditSession {
|
|||||||
this.version = documentVersion;
|
this.version = documentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a document the server handed us: new baseline, history cleared.
|
||||||
|
*
|
||||||
|
* Mutates in place rather than returning a new session because the preview
|
||||||
|
* requester holds this object; swapping it would leave the requester arbitrating
|
||||||
|
* against a session nobody reads.
|
||||||
|
*/
|
||||||
|
load(constraints: RoadConstraint[], operations: RoadEditOperation[], documentVersion: number): void {
|
||||||
|
this.baseline = [...constraints];
|
||||||
|
this.baselineOperations = [...operations];
|
||||||
|
this.stack = [];
|
||||||
|
this.cursor = 0;
|
||||||
|
this.version = documentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every unsaved command and return to the last saved baseline. */
|
||||||
|
discard(): void {
|
||||||
|
this.stack = this.stack.filter((command) => command.saved);
|
||||||
|
this.cursor = this.stack.length;
|
||||||
|
}
|
||||||
|
|
||||||
/** Monotonic per-session sequence stamped on every preview request. */
|
/** Monotonic per-session sequence stamped on every preview request. */
|
||||||
nextPreviewSeq(): number {
|
nextPreviewSeq(): number {
|
||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user