'use strict'; const assert = require('assert/strict'); const { resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver'); const model = { roads: [], endpoints: [], connections: [], diagnostics: [] }; function constraint(overrides = {}) { return { id: 'constraint-1', kind: 'road-edge-offset', anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.3, endStation: 0.7 }, anchorSnapshot: { coordinate: [113.1, 30.2], tangentAzimuth: 45, roadLengthMeters: 120, osmNodeIds: ['1', '2'], }, value: { offsetMeters: 1.5, transition: 'smoothstep' }, enabled: true, status: 'exact', provenance: { operationId: 'operation-1', createdAt: '2026-08-27T00:00:00.000Z' }, ...overrides, }; } function document(constraints) { return { schema: 'native-road-edits/v2', documentVersion: 1, base: {}, constraints, operations: [] }; } // A missing or empty document must resolve to the identity: this is what keeps // the stage insertable ahead of compileGeometry without moving any geometry. for (const empty of [null, undefined, document([])]) { const resolution = resolveDirectEditConstraints(model, empty); assert.equal(resolution.schema, 'road-edit-resolution/v1'); assert.equal(resolution.roadProfiles.size, 0); assert.equal(resolution.junctionPlans.size, 0); assert.deepEqual(resolution.constraintStates, []); assert.deepEqual(resolution.diagnostics, []); assert.equal(resolution.handles.schema, 'road-edit-handles/v1'); assert.deepEqual(resolution.handles.handles, []); assert.deepEqual(resolution.handles.reserves, []); assert.equal(resolution.handles.revisionId, null); assert.equal(resolution.handles.previewSeq, 0); } // Handle manifest context is echoed so a preview response can be matched to the // request that asked for it. const withContext = resolveDirectEditConstraints(model, null, { revisionId: 'rev-0007', previewSeq: 42 }); assert.equal(withContext.handles.revisionId, 'rev-0007'); assert.equal(withContext.handles.previewSeq, 42); // A small ordinary junction exercises the step-two manifest contract without // depending on the full OSM parser. Three approaches produce one reserve each, // all six kinds, and native feature links for the downstream map highlighter. const junctionRoads = ['a', 'b', 'c'].map((id, index) => ({ id: `road:${id}`, segmentId: `segment:${id}`, direction: 'forward', centerline: [ [113 + index * 0.001, 30], [113 + index * 0.001 + (index === 1 ? 0.001 : 0), 30 + (index === 1 ? 0.001 : 0.001)], ], sourceNodeIds: ['junction', `end-${id}`], osmWayIds: [id], widthMeters: 6, laneCount: 2, sidewalkLeft: true, sidewalkRight: true, })); const junctionModel = { roads: junctionRoads, endpoints: junctionRoads.map((road) => ({ id: `endpoint:${road.id}:start`, roadId: road.id, side: 'start', nodeId: 'junction', coordinate: road.centerline[0], })), connections: [], }; const junctionResolution = resolveDirectEditConstraints(junctionModel, document([]), { revisionId: 'rev-junction' }); assert.equal(junctionResolution.handles.reserves.length, 3); assert.equal(new Set(junctionResolution.handles.handles.map((handle) => handle.kind)).size, 6); assert.ok(junctionResolution.handles.handles.every((handle) => handle.position.every(Number.isFinite))); assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth))); assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction'))); // The left handle must sit on the geometry compiler's left. `offsetLine()` shifts a // positive offset counter-clockwise from the direction of travel and sidewalks use // `heading + (side === 'left' ? -90 : 90)`, so for the north-heading road:a that is // west. Drawing it east put it over the right kerb, and dragging the visually-left // handle then moved the right edge. const northEdges = junctionResolution.handles.handles.filter( (handle) => handle.kind === 'road-edge-offset' && handle.anchor.roadId === 'road:a', ); const leftEdge = northEdges.find((handle) => handle.anchor.side === 'left'); const rightEdge = northEdges.find((handle) => handle.anchor.side === 'right'); assert.ok(leftEdge && rightEdge, 'both edge handles must be published'); assert.ok(leftEdge.position[0] < 113, 'left edge handle must sit west of a north-heading centerline'); assert.ok(rightEdge.position[0] > 113, 'right edge handle must sit east of a north-heading centerline'); const northSidewalks = junctionResolution.handles.handles.filter( (handle) => handle.kind === 'road-sidewalk-width' && handle.anchor.roadId === 'road:a', ); assert.ok( northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113, 'the sidewalk handle must follow the same side convention as the edge handle', ); // A selection scopes the manifest to the object being edited. The whole-area // manifest is 844 KB on a 41-road workspace, 89% of it `affects` id lists, while // the map renders about six handles — so every new handle kind multiplies a // payload the client throws away. const unscoped = junctionResolution.handles.handles; const segmentScoped = resolveDirectEditConstraints(junctionModel, document([]), { selection: { type: 'segment', id: 'segment:a' }, }).handles.handles; assert.ok(segmentScoped.length > 0, 'a segment selection must still publish its road handles'); assert.ok(segmentScoped.length < unscoped.length, 'a segment selection must be smaller than the whole manifest'); assert.ok( segmentScoped.every((handle) => handle.kind.startsWith('road-')), 'junction kinds belong to JunctionTools and must not reach a segment selection', ); assert.ok( segmentScoped.every((handle) => handle.anchor.roadId === 'road:a'), 'a segment selection must only carry that segment’s handles', ); // Scoping must not invent or drop handles: it is a filter of the full manifest. assert.deepEqual( segmentScoped.map((handle) => handle.handleId).sort(), unscoped .filter((handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === 'road:a') .map((handle) => handle.handleId) .sort(), ); const junctionScoped = resolveDirectEditConstraints(junctionModel, document([]), { selection: { type: 'junction', id: 'junction' }, }).handles.handles; assert.ok(junctionScoped.length > 0, 'a junction selection must publish that node’s handles'); assert.ok( junctionScoped.every((handle) => handle.kind.startsWith('junction-') && handle.anchor.nodeId === 'junction'), 'a junction selection must only carry that node’s handles', ); // Omitting the selection keeps the full manifest, which the compiler and the // existing callers rely on. assert.equal(resolveDirectEditConstraints(junctionModel, document([]), {}).handles.handles.length, unscoped.length); const anchored = constraint({ id: 'edge-on-road-a', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' }, }); const anchoredResolution = resolveDirectEditConstraints(junctionModel, document([anchored])); const anchoredHandle = anchoredResolution.handles.handles.find( (handle) => handle.kind === 'road-edge-offset' && handle.anchor.roadId === 'road:a' && handle.anchor.side === 'left', ); assert.equal(anchoredHandle.constraintId, 'edge-on-road-a'); const allKinds = [ anchored, constraint({ id: 'sidewalk-on-road-a', kind: 'road-sidewalk-width', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' }, value: { widthMeters: 3, transition: 'linear' }, }), constraint({ id: 'divider-on-road-a', kind: 'road-lane-divider', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8 }, value: { boundaryIndex: 1, offsetMeters: 0.1, transition: 'smoothstep' }, }), constraint({ id: 'approach-width', kind: 'junction-approach-width', anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' }, value: { widthMeters: 7 }, }), constraint({ id: 'approach-cutback', kind: 'junction-cutback', anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' }, value: { cutbackMeters: 2 }, }), constraint({ id: 'corner-radius', kind: 'junction-corner-radius', anchor: { type: 'junction-corner', nodeId: 'junction', incomingRoadId: 'segment:a', outgoingRoadId: 'segment:b' }, value: { radiusMeters: 4 }, }), ]; const allKindsResolution = resolveDirectEditConstraints(junctionModel, document(allKinds)); assert.deepEqual( allKindsResolution.constraintStates.map((state) => state.applied), [true, true, true, true, true, true], ); assert.equal(allKindsResolution.roadProfiles.get('road:a').sidewalkWidths.left, 3); assert.equal(allKindsResolution.roadProfiles.get('road:a').laneDividerOffsets[1], 0.1); assert.equal( allKindsResolution.handles.handles.find((handle) => handle.kind === 'road-lane-divider').constraintId, 'divider-on-road-a', ); assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segment:a'].widthMeters, 7); assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segment:a'].cutbackMeters, 2); assert.equal(allKindsResolution.junctionPlans.get('junction').corners['segment:a->segment:b'], 4); const recheckResolution = resolveDirectEditConstraints( junctionModel, { ...document([anchored]), base: { compilerGeometryVersion: 'old-geometry' } }, { compilerGeometryVersion: 'new-geometry' }, ); assert.equal(recheckResolution.constraintStates[0].status, 'recheck'); assert.equal(recheckResolution.constraintStates[0].applied, true); const pendingResolution = resolveDirectEditConstraints( junctionModel, document([ constraint({ anchor: { type: 'road-interval', roadId: 'missing-road', startStation: 0.2, endStation: 0.8, side: 'left' }, anchorSnapshot: { ...constraint().anchorSnapshot, coordinate: [113, 30], osmNodeIds: ['junction'] }, }), ]), ); assert.equal(pendingResolution.constraintStates[0].status, 'pending'); assert.equal(pendingResolution.constraintStates[0].applied, false); const invalidDivider = resolveDirectEditConstraints( junctionModel, document([ constraint({ kind: 'road-lane-divider', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8 }, value: { boundaryIndex: 3, offsetMeters: 0 }, }), ]), ); assert.equal(invalidDivider.constraintStates[0].applied, false); assert.equal(invalidDivider.constraintStates[0].status, 'stale'); assert.equal(invalidDivider.constraintStates[0].reason, 'status-stale'); assert.equal(invalidDivider.roadProfiles.size, 0); // A disabled constraint is reported, never applied, and produces no diagnostic: // the user turned it off deliberately, so it is not a problem to report. const disabled = resolveDirectEditConstraints(model, document([constraint({ enabled: false })])); assert.deepEqual(disabled.constraintStates, [ { constraintId: 'constraint-1', kind: 'road-edge-offset', status: 'exact', applied: false, reason: 'disabled' }, ]); assert.deepEqual(disabled.diagnostics, []); // Only `exact` and `recheck` may reach the solver. The other three statuses mean // the anchor is not trustworthy, so they are skipped with the status as reason. for (const status of ['pending', 'conflicted', 'stale']) { const resolution = resolveDirectEditConstraints(model, document([constraint({ status })])); assert.deepEqual(resolution.constraintStates, [ { constraintId: 'constraint-1', kind: 'road-edge-offset', status, applied: false, reason: `status-${status}` }, ]); assert.deepEqual(resolution.diagnostics, []); } // An exact constraint whose anchor disappeared during reimport is stale and // must not enter the solver. for (const status of ['exact', 'recheck']) { const resolution = resolveDirectEditConstraints(model, document([constraint({ status })])); assert.deepEqual(resolution.constraintStates, [ { constraintId: 'constraint-1', kind: 'road-edge-offset', status: 'stale', applied: false, reason: 'status-stale', }, ]); assert.deepEqual(resolution.diagnostics, []); } // Every constraint gets exactly one state, in document order, so a caller can // report per-row status without re-deriving the mapping. const many = resolveDirectEditConstraints( model, document([ constraint({ id: 'a', enabled: false }), constraint({ id: 'b', status: 'stale' }), constraint({ id: 'c' }), ]), ); assert.deepEqual( many.constraintStates.map((state) => state.constraintId), ['a', 'b', 'c'], ); // A short road sandwiched between two junctions. `junctionReserves()` caps each // end's reserve at 0.45, so `start >= end` can never fire; what decides whether // an edit is offered is the surviving band measured in meters against the road's // own width. 0.00013 degrees of latitude is about 14.4 m, shorter than the // cutback the approaches demand at either end, so both ends hit the cap and the // band lands at ~1.4 m. function sandwichModel(shortWidthMeters, shortLaneCount) { const nodeA = [113, 30]; const nodeB = [113, 30.00013]; const road = (id, from, to, widthMeters, laneCount) => ({ id: `road:${id}`, segmentId: `segment:${id}`, direction: 'forward', centerline: [from, to], sourceNodeIds: [`node-${id}-a`, `node-${id}-b`], osmWayIds: [id], widthMeters, laneCount, sidewalkLeft: true, sidewalkRight: true, }); const endpoint = (roadId, side, nodeId, coordinate) => ({ id: `endpoint:${roadId}:${side}`, roadId, side, nodeId, coordinate, }); return { roads: [ road('short', nodeA, nodeB, shortWidthMeters, shortLaneCount), road('a1', nodeA, [112.999, 30], 6, 2), road('a2', nodeA, [113.001, 30], 6, 2), road('b1', nodeB, [112.999, 30.00013], 6, 2), road('b2', nodeB, [113.001, 30.00013], 6, 2), ], endpoints: [ endpoint('road:short', 'start', 'node-a', nodeA), endpoint('road:a1', 'start', 'node-a', nodeA), endpoint('road:a2', 'start', 'node-a', nodeA), endpoint('road:short', 'end', 'node-b', nodeB), endpoint('road:b1', 'start', 'node-b', nodeB), endpoint('road:b2', 'start', 'node-b', nodeB), ], connections: [], }; } function roadHandlesFor(resolution, roadId) { return resolution.handles.handles.filter( (handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === roadId, ); } // The short road's band is ~1.4 m against a 10 m width, so every road handle on // it is read-only and says why. The long approach at the same junction keeps its // handles, which is what proves the rule is about the band and not about being // adjacent to a junction. const sandwich = resolveDirectEditConstraints(sandwichModel(10, 4), document([]), { revisionId: 'rev-sandwich' }); const shortHandles = roadHandlesFor(sandwich, 'road:short'); const longHandles = roadHandlesFor(sandwich, 'road:a1'); assert.ok(shortHandles.length > 0, 'the blocked road must still publish handles so the map can explain them'); assert.ok(longHandles.length > 0); assert.ok(shortHandles.every((handle) => handle.editable === false)); assert.ok(shortHandles.every((handle) => handle.disabledReason.includes('JunctionTools'))); assert.ok(longHandles.every((handle) => handle.editable === true)); assert.ok(longHandles.every((handle) => handle.disabledReason === undefined)); // Junction kinds are JunctionTools' territory and must stay draggable there. assert.ok( sandwich.handles.handles .filter((handle) => handle.kind.startsWith('junction-')) .every((handle) => handle.editable === true), ); // The band is compared in meters against the road's own width. The 0.45 cap keeps // the band at ~1.4 m either way, so changing only the width crosses the boundary. assert.ok( roadHandlesFor(resolveDirectEditConstraints(sandwichModel(2, 2), document([])), 'road:short').every( (h) => !h.editable, ), ); assert.ok( roadHandlesFor(resolveDirectEditConstraints(sandwichModel(1, 2), document([])), 'road:short').every( (h) => h.editable, ), ); // Greying is a UI affordance, not a retroactive veto: an edit already saved // against this road keeps being solved, or a compiler upgrade would silently drop // it and the geometry would move underneath the user. const onBlocked = resolveDirectEditConstraints( sandwichModel(10, 4), document([ constraint({ id: 'edge-on-short', anchor: { type: 'road-interval', roadId: 'road:short', startStation: 0.45, endStation: 0.55, side: 'left' }, }), ]), ); assert.equal(onBlocked.constraintStates[0].applied, true); assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5); // The solver must be free of file and network access so preview can share it. const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8'); for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"]) assert.equal(source.includes(forbidden), false, `direct-edit-solver must not ${forbidden}`); console.log('direct edit solver tests passed');