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:
2026-08-27 14:59:11 +08:00
parent e25c564bb9
commit bc4b9a9717
25 changed files with 2463 additions and 22 deletions

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