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); }); });