feat: add direct edit handles behind directEdit flag
Steps 1-3 of the main map road interval editor. EditSession keeps the command stack, undo/redo and previewSeq arbitration as pure logic with no OpenLayers reference, so all of it is unit-tested in node. Pointer displacement converts to meters through EPSG:4326 and spherical distance: treating a 3857 delta as meters desyncs the geometry from the cursor by 1/cos(latitude). Handle drags project onto the axis the manifest declares and clamp to its range, so the client never writes a coordinate into a road polygon. All of it sits behind a directEdit flag that defaults to off. With the flag off the workbench requests no manifest, creates no extra source and registers no interaction, so behaviour matches main. The ol-ext probe passed its three gates but is not adopted for road handles. Transform translates by the raw pointer delta, so a handle detaches from its clamped constraint value: a drag reading -24.1 m produced a draft of -5.4 m. Production needs the handle position derived from the constraint instead, which means owning the position update, so native OL PointerInteraction will carry the drag. ol-ext stays out of package.json; the probe is kept as a manual harness. Reserve handles are unreachable with the current solver, recorded in research/ rather than worked around. Also names the dead backend when an API response is empty, instead of surfacing "Unexpected end of JSON input" from response.json(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
workbench/client/src/edit/session.test.ts
Normal file
190
workbench/client/src/edit/session.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user