Three things, kept in one commit because they touch overlapping hunks of the same two files and this environment has no interactive hunk staging. Splitting them by file would have drawn boundaries that misrepresent what changed. 1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a drag into a clamped constraint value, the ghost source shows it immediately, and the solver's answer replaces a parallel set of preview layers while the baseline layers are hidden rather than overwritten. Preview requests debounce at 80 ms, pointerup flushes without waiting, and a newer request aborts the one in flight; EditSession decides which answers count. Handle positions come from the clamped value, so a handle stops at its limit instead of following the cursor. Three of the four drag capabilities are live: edge offset, sidewalk width, lane divider. 2. Road edge handles were drawn on the wrong side. offsetLine() offsets counter-clockwise from the direction of travel and sidewalks use `heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`; makeRoadHandles() placed the left handle at `tangent + 90`, over the right kerb. Dragging the visually-left handle moved the right edge. Fixed on both sides of the wire, with regression tests that name the sides geographically rather than by axis sign. 3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub between two junctions was offered a 1.5 m editable band with no room for the transitions a road-interval constraint needs. Greying only affects the manifest: constraints already saved against such a road keep being solved, so the geometry output is unchanged and the fixture baselines do not move. Range handles are built and unit-tested but hidden behind `intervalEditingSupported`: compileGeometry() reads neither profile.interval nor profile.transitions, so every edit applies to the whole road and the control would have had no effect. Recorded in research/interval-not-applied.md, which also blocks one PRD acceptance criterion. The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import type { Coordinate } from './meters';
|
||
import { disabledReasonOf, handlesForSegment, intervalRangeHandles, 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('intervalRangeHandles', () => {
|
||
// Heads due north, about 1112 m long.
|
||
const centerline: Coordinate[] = [
|
||
[116.397, 39.9],
|
||
[116.397, 39.91],
|
||
];
|
||
const reserves = manifest.reserves;
|
||
const parent = handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward'));
|
||
|
||
it('produces one handle per end of the affected interval', () => {
|
||
const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
|
||
expect(ends.map((item) => item.end)).toEqual(['start', 'end']);
|
||
expect(ends.map((item) => item.station)).toEqual([0.15, 0.85]);
|
||
});
|
||
|
||
it('derives ids from the parent so the pair stays traceable', () => {
|
||
const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
|
||
expect(ends.map((item) => item.handleId)).toEqual(['h:edge:a:range:start', 'h:edge:a:range:end']);
|
||
expect(ends.every((item) => item.parentHandleId === 'h:edge:a')).toBe(true);
|
||
});
|
||
|
||
it('carries the reserve-free window, not the whole road', () => {
|
||
const [start] = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
|
||
// The two reserves on seg:1 leave 0.15..0.85, which is exactly the interval
|
||
// the solver anchored — a range drag may shrink it but never grow past this.
|
||
expect(start.window).toEqual({ minStation: 0.15, maxStation: 0.85 });
|
||
});
|
||
|
||
it('places the ends apart, along the road', () => {
|
||
const [start, end] = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
|
||
expect(start.position[1]).toBeLessThan(end.position[1]);
|
||
expect(start.position[0]).toBeCloseTo(116.397, 6);
|
||
// Drag axis is the road direction, which is due north here.
|
||
expect(start.tangentAzimuth).toBeCloseTo(0, 3);
|
||
expect(start.roadLengthMeters).toBeCloseTo(end.roadLengthMeters, 6);
|
||
expect(start.roadLengthMeters).toBeGreaterThan(1000);
|
||
});
|
||
|
||
it('offers nothing for a handle that cannot be dragged', () => {
|
||
const blocked = handle('h:blocked', 'road-edge-offset', roadInterval('road:way/1:forward'), false);
|
||
expect(intervalRangeHandles(blocked, centerline, reserves, 'seg:1')).toEqual([]);
|
||
});
|
||
|
||
it('offers nothing for a junction anchor', () => {
|
||
const approach = handle('h:approach', 'junction-approach-width', {
|
||
type: 'junction-approach',
|
||
nodeId: '9',
|
||
segmentId: 'seg:1',
|
||
});
|
||
expect(intervalRangeHandles(approach, centerline, reserves, 'seg:1')).toEqual([]);
|
||
});
|
||
|
||
it('offers nothing for a road with no length', () => {
|
||
expect(intervalRangeHandles(parent, [[116.397, 39.9]], reserves, 'seg:1')).toEqual([]);
|
||
expect(
|
||
intervalRangeHandles(
|
||
parent,
|
||
[
|
||
[116.397, 39.9],
|
||
[116.397, 39.9],
|
||
],
|
||
reserves,
|
||
'seg:1',
|
||
),
|
||
).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('路口保留区');
|
||
});
|
||
});
|