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:
115
workbench/client/src/edit/selection.test.ts
Normal file
115
workbench/client/src/edit/selection.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { disabledReasonOf, handlesForSegment, reservesForSegment } from './selection';
|
||||
import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types';
|
||||
|
||||
function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle {
|
||||
return {
|
||||
handleId,
|
||||
kind,
|
||||
anchor,
|
||||
position: [116.397, 39.908],
|
||||
axisAzimuth: 90,
|
||||
value: { current: 0, min: -5, max: 5, unit: 'meter' },
|
||||
affects: [],
|
||||
editable,
|
||||
...(editable ? {} : { disabledReason: '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' }),
|
||||
};
|
||||
}
|
||||
|
||||
const roadInterval = (roadId: string): SemanticAnchor => ({
|
||||
type: 'road-interval',
|
||||
roadId,
|
||||
startStation: 0.15,
|
||||
endStation: 0.85,
|
||||
side: 'left',
|
||||
});
|
||||
|
||||
// Two segments, plus a junction handle the main map must never render.
|
||||
const manifest: HandleManifest = {
|
||||
schema: 'road-edit-handles/v1',
|
||||
revisionId: 'rev-0001',
|
||||
previewSeq: 0,
|
||||
handles: [
|
||||
handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward')),
|
||||
handle('h:sidewalk:a', 'road-sidewalk-width', roadInterval('road:way/1:forward')),
|
||||
handle('h:edge:b', 'road-edge-offset', roadInterval('road:way/2:forward')),
|
||||
handle('h:blocked:a', 'road-lane-divider', roadInterval('road:way/1:forward'), false),
|
||||
handle('h:approach', 'junction-approach-width', { type: 'junction-approach', nodeId: '9', segmentId: 'seg:1' }),
|
||||
handle('h:corner', 'junction-corner-radius', {
|
||||
type: 'junction-corner',
|
||||
nodeId: '9',
|
||||
incomingRoadId: 'seg:1',
|
||||
outgoingRoadId: 'seg:2',
|
||||
}),
|
||||
],
|
||||
reserves: [
|
||||
{ nodeId: '9', roadId: 'seg:1', fromStation: 0, toStation: 0.15 },
|
||||
{ nodeId: '10', roadId: 'seg:1', fromStation: 0.85, toStation: 1 },
|
||||
{ nodeId: '11', roadId: 'seg:2', fromStation: 0, toStation: 0.3 },
|
||||
],
|
||||
};
|
||||
|
||||
const segmentOf = (roadId: string) =>
|
||||
({ 'road:way/1:forward': 'seg:1', 'road:way/2:forward': 'seg:2' })[roadId] ?? undefined;
|
||||
|
||||
describe('handlesForSegment', () => {
|
||||
it('keeps only the selected segment', () => {
|
||||
expect(handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.handleId)).toEqual([
|
||||
'h:edge:a',
|
||||
'h:sidewalk:a',
|
||||
'h:blocked:a',
|
||||
]);
|
||||
expect(handlesForSegment(manifest, 'seg:2', segmentOf).map((item) => item.handleId)).toEqual(['h:edge:b']);
|
||||
});
|
||||
|
||||
it('never renders junction kinds on the main map', () => {
|
||||
// Reserve interiors belong to JunctionTools; offering a second way to edit
|
||||
// them here is exactly the ownership split design.md forbids.
|
||||
const kinds = handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.kind);
|
||||
expect(kinds).not.toContain('junction-approach-width');
|
||||
expect(kinds).not.toContain('junction-corner-radius');
|
||||
});
|
||||
|
||||
it('keeps disabled handles so the boundary stays explainable', () => {
|
||||
const blocked = handlesForSegment(manifest, 'seg:1', segmentOf).find((item) => !item.editable);
|
||||
expect(blocked?.handleId).toBe('h:blocked:a');
|
||||
});
|
||||
|
||||
it('returns nothing without a manifest or a selection', () => {
|
||||
expect(handlesForSegment(null, 'seg:1', segmentOf)).toEqual([]);
|
||||
expect(handlesForSegment(manifest, null, segmentOf)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops handles whose road no longer resolves to a segment', () => {
|
||||
expect(handlesForSegment(manifest, 'seg:1', () => undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reservesForSegment', () => {
|
||||
it('returns only that segment’s reserves', () => {
|
||||
expect(reservesForSegment(manifest, 'seg:1').map((item) => item.nodeId)).toEqual(['9', '10']);
|
||||
expect(reservesForSegment(manifest, 'seg:2').map((item) => item.nodeId)).toEqual(['11']);
|
||||
});
|
||||
|
||||
it('returns nothing without a manifest or a selection', () => {
|
||||
expect(reservesForSegment(null, 'seg:1')).toEqual([]);
|
||||
expect(reservesForSegment(manifest, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabledReasonOf', () => {
|
||||
it('says nothing for an editable handle', () => {
|
||||
expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the server reason through', () => {
|
||||
const blocked = handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false);
|
||||
expect(disabledReasonOf(blocked)).toContain('JunctionTools');
|
||||
});
|
||||
|
||||
it('always gives some reason, even when the server omitted one', () => {
|
||||
const blocked = { ...handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false) };
|
||||
delete blocked.disabledReason;
|
||||
expect(disabledReasonOf(blocked)).toContain('路口保留区');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user