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('路口保留区'); }); });