Files
road-compiler/workbench/client/src/edit/session.test.ts
que01 38593f6f67 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>
2026-08-27 18:11:52 +08:00

256 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { EditSession } from './session';
import type { EditDiagnostic, EditPreviewResponse, RoadConstraint } from './types';
function constraint(id: string, offsetMeters: number): RoadConstraint {
return {
id,
kind: 'road-edge-offset',
anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.2, endStation: 0.8, side: 'left' },
anchorSnapshot: { coordinate: [116.397, 39.908], tangentAzimuth: 0, roadLengthMeters: 500, osmNodeIds: ['1'] },
value: { offsetMeters },
enabled: true,
status: 'exact',
provenance: { operationId: 'op', createdAt: '2026-08-27T00:00:00.000Z' },
};
}
const identity = (n: number) => ({ operationId: `op-${n}`, createdAt: `2026-08-27T00:00:0${n}.000Z` });
function preview(previewSeq: number, diagnostics: EditDiagnostic[] = []): EditPreviewResponse {
return {
ok: true,
previewSeq,
degraded: false,
revisionId: 'rev-0001',
documentVersion: 0,
constraintStates: [],
diagnostics,
handles: { schema: 'road-edit-handles/v1', revisionId: 'rev-0001', previewSeq, handles: [], reserves: [] },
layers: {},
};
}
const error: EditDiagnostic = {
id: 'd1',
message: '车道分隔调整会使相邻车道小于 2.4 米。',
rule: 'direct-edit-min-lane-width',
severity: 'error',
};
describe('command stack', () => {
it('starts clean on the loaded baseline', () => {
const session = new EditSession([constraint('c1', 1)], [], 3);
expect(session.constraints()).toHaveLength(1);
expect(session.canUndo).toBe(false);
expect(session.canRedo).toBe(false);
expect(session.dirty).toBe(false);
expect(session.documentVersion).toBe(3);
});
it('applies one gesture as one undoable command', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
expect(session.constraints()).toEqual([constraint('c1', 2)]);
expect(session.canUndo).toBe(true);
expect(session.dirty).toBe(true);
});
it('undoes and redoes an unsaved command', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.commit([constraint('c1', 5)], ['c1'], identity(2));
expect(session.undo()).toBe(true);
expect(session.constraints()).toEqual([constraint('c1', 2)]);
expect(session.undo()).toBe(true);
expect(session.constraints()).toEqual([]);
expect(session.undo()).toBe(false);
expect(session.redo()).toBe(true);
expect(session.redo()).toBe(true);
expect(session.constraints()).toEqual([constraint('c1', 5)]);
expect(session.redo()).toBe(false);
});
it('drops the redo tail once a new gesture is committed', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.commit([constraint('c1', 5)], ['c1'], identity(2));
session.undo();
session.commit([constraint('c1', 9)], ['c1'], identity(3));
expect(session.canRedo).toBe(false);
expect(session.constraints()).toEqual([constraint('c1', 9)]);
});
it('reports each unsaved gesture as one operation, oldest first', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.commit([constraint('c1', 2), constraint('c2', 1)], ['c2'], identity(2));
expect(session.pendingOperations()).toEqual([
{ id: 'op-1', createdAt: '2026-08-27T00:00:01.000Z', constraintIds: ['c1'] },
{ id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c2'] },
]);
});
});
describe('saved history is append-only', () => {
it('clears the pending list and the dirty flag on save', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.markSaved(4);
expect(session.dirty).toBe(false);
expect(session.pendingOperations()).toEqual([]);
expect(session.documentVersion).toBe(4);
});
it('undoes a saved gesture by appending an inverse, not by rewriting it', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.markSaved(4);
expect(session.undo(identity(2))).toBe(true);
expect(session.constraints()).toEqual([]);
// The original operation stays; a second one records that it was reversed.
expect(session.pendingOperations()).toEqual([
{ id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c1'], inverseOf: 'op-1' },
]);
expect(session.dirty).toBe(true);
});
it('refuses to undo saved history without an identity for the inverse', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.markSaved(4);
expect(session.undo()).toBe(false);
expect(session.constraints()).toEqual([constraint('c1', 2)]);
});
it('adopts the server version after a conflict', () => {
const session = new EditSession([], [], 1);
session.setDocumentVersion(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', () => {
it('hands out a monotonic sequence', () => {
const session = new EditSession([]);
expect([session.nextPreviewSeq(), session.nextPreviewSeq(), session.nextPreviewSeq()]).toEqual([1, 2, 3]);
});
it('discards a response that arrives after a newer one', () => {
const session = new EditSession([]);
expect(session.acceptPreview(preview(5)).applied).toBe(true);
expect(session.acceptPreview(preview(4)).applied).toBe(false);
expect(session.lastValidPreview()?.previewSeq).toBe(5);
});
it('accepts responses in order and keeps the newest', () => {
const session = new EditSession([]);
session.acceptPreview(preview(1));
session.acceptPreview(preview(2));
expect(session.lastValidPreview()?.previewSeq).toBe(2);
});
it('lets a stale response through only if nothing newer was applied', () => {
const session = new EditSession([]);
expect(session.acceptPreview(preview(0)).applied).toBe(true);
expect(session.lastValidPreview()?.previewSeq).toBe(0);
});
it('keeps the last valid preview when the draft is rejected', () => {
const session = new EditSession([]);
session.acceptPreview(preview(1));
const outcome = session.acceptPreview(preview(2, [error]));
expect(outcome).toEqual({ applied: true, blocked: true });
expect(session.lastValidPreview()?.previewSeq).toBe(1);
expect(session.diagnostics).toEqual([error]);
});
it('does not let an older valid response overwrite a rejected newer one', () => {
// The watermark advances even on rejection, so a late reply for an earlier
// drag cannot resurrect itself on top of the current diagnostics.
const session = new EditSession([]);
session.acceptPreview(preview(1));
session.acceptPreview(preview(3, [error]));
expect(session.acceptPreview(preview(2)).applied).toBe(false);
expect(session.lastValidPreview()?.previewSeq).toBe(1);
});
it('surfaces a warning without blocking', () => {
const session = new EditSession([]);
const warning: EditDiagnostic = { id: 'd2', message: '提示', rule: 'advisory', severity: 'warning' };
expect(session.acceptPreview(preview(1, [warning])).blocked).toBe(false);
expect(session.lastValidPreview()?.previewSeq).toBe(1);
});
it('reports a degraded preview so the ghost can stay pending', () => {
const session = new EditSession([]);
session.acceptPreview({ ...preview(1), degraded: true });
expect(session.degraded).toBe(true);
});
});