From 0eb404d8f72b4fd6388dd53e94f7d82872615494 Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 27 Aug 2026 12:36:35 +0800 Subject: [PATCH] feat: solve direct edit constraints --- src/compile/direct-edit-solver.js | 265 +++++++++++++++++++++++++++--- src/compile/native-road.js | 90 +++++++++- test/direct-edit-solver.js | 82 +++++++-- 3 files changed, 390 insertions(+), 47 deletions(-) diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index ac56ed4..9047ebd 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -16,7 +16,14 @@ const { const RESOLUTION_SCHEMA = 'road-edit-resolution/v1'; const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1'; const ACTIVE_STATUSES = new Set(['exact', 'recheck']); -const SOLVED_KINDS = new Set(); +const SOLVED_KINDS = new Set([ + 'road-edge-offset', + 'road-sidewalk-width', + 'road-lane-divider', + 'junction-approach-width', + 'junction-cutback', + 'junction-corner-radius', +]); const KINDS = [ 'road-edge-offset', 'road-sidewalk-width', @@ -41,28 +48,6 @@ function constraintState(constraint, applied, reason) { return { constraintId: constraint.id, kind: constraint.kind, status: constraint.status, applied, reason }; } -function planConstraints(constraints, diagnostics) { - return constraints.map((constraint) => { - if (!constraint.enabled) return constraintState(constraint, false, 'disabled'); - if (!ACTIVE_STATUSES.has(constraint.status)) - return constraintState(constraint, false, `status-${constraint.status}`); - if (!SOLVED_KINDS.has(constraint.kind)) { - diagnostics.push( - diagnostic( - 'info', - `constraint:${constraint.id}`, - [], - 'direct-edit-kind-not-solved', - `直接编辑约束 ${constraint.kind} 尚未接入求解器,本次编译按基线几何输出。`, - null, - ), - ); - return constraintState(constraint, false, 'kind-not-solved'); - } - return constraintState(constraint, true, null); - }); -} - function coordinateAt(line, station) { if (!Array.isArray(line) || line.length === 0) return null; if (line.length === 1) return [...line[0]]; @@ -112,7 +97,7 @@ function constraintFor(constraints, kind, anchor) { item.anchor.type === 'road-interval' && item.anchor.roadId === anchor.roadId && (item.anchor.side || null) === (anchor.side || null) && - (kind !== 'road-lane-divider' || item.anchor.boundaryIndex === anchor.boundaryIndex) + (kind !== 'road-lane-divider' || item.value?.boundaryIndex === anchor.boundaryIndex) ); } if (kind === 'junction-corner-radius') @@ -178,6 +163,231 @@ function junctionReserves(model) { return { reserves, approaches }; } +function roadForAnchor(model, roadId) { + return (model.roads || []).find( + (road) => road.id === roadId || road.sourceRoadId === roadId || road.segmentId === roadId, + ); +} + +function blocking(diagnostics, constraint, rule, message, coordinate = null) { + diagnostics.push(diagnostic('error', `constraint:${constraint.id}`, [], rule, message, coordinate)); +} + +function profileFor(profiles, road) { + if (!profiles.has(road.id)) + profiles.set(road.id, { + roadId: road.id, + segmentId: road.segmentId, + interval: null, + edgeOffsets: { left: 0, right: 0 }, + sidewalkWidths: { + left: road.sidewalkLeft ? 2 : 0, + right: road.sidewalkRight ? 2 : 0, + }, + laneDividerOffsets: {}, + transitions: {}, + widthMeters: road.widthMeters, + }); + return profiles.get(road.id); +} + +function applyRoadConstraint(model, constraint, profiles, diagnostics) { + const road = roadForAnchor(model, constraint.anchor?.roadId); + if (!road) { + blocking(diagnostics, constraint, 'direct-edit-road-anchor-missing', '直接编辑约束的道路锚点不存在。'); + return false; + } + const anchor = constraint.anchor; + if ( + anchor.type !== 'road-interval' || + !Number.isFinite(anchor.startStation) || + !Number.isFinite(anchor.endStation) || + anchor.startStation < 0 || + anchor.endStation > 1 || + anchor.startStation >= anchor.endStation + ) { + blocking( + diagnostics, + constraint, + 'direct-edit-invalid-interval', + '道路编辑区间必须按 0..1 的归一化 station 严格递增。', + road.centerline?.[0], + ); + return false; + } + const profile = profileFor(profiles, road); + profile.interval = profile.interval + ? { + startStation: Math.min(profile.interval.startStation, anchor.startStation), + endStation: Math.max(profile.interval.endStation, anchor.endStation), + } + : { startStation: anchor.startStation, endStation: anchor.endStation }; + const sides = anchor.side ? [anchor.side] : ['left', 'right']; + if (sides.some((side) => !['left', 'right'].includes(side))) { + blocking(diagnostics, constraint, 'direct-edit-invalid-side', '道路约束 side 必须是 left 或 right。'); + return false; + } + if (constraint.kind === 'road-edge-offset') { + const offset = Number(constraint.value?.offsetMeters); + if (!Number.isFinite(offset)) { + blocking(diagnostics, constraint, 'direct-edit-invalid-value', '外缘偏移必须是有限米数。'); + return false; + } + for (const side of sides) { + profile.edgeOffsets[side] = offset; + profile.transitions[`road-edge-offset:${side}`] = constraint.value?.transition || 'smoothstep'; + } + } else if (constraint.kind === 'road-sidewalk-width') { + const width = Number(constraint.value?.widthMeters); + if (!Number.isFinite(width) || width < 0) { + blocking(diagnostics, constraint, 'direct-edit-invalid-value', '步行带宽度必须是非负米数。'); + return false; + } + for (const side of sides) { + profile.sidewalkWidths[side] = width; + profile.transitions[`road-sidewalk-width:${side}`] = constraint.value?.transition || 'smoothstep'; + } + } else if (constraint.kind === 'road-lane-divider') { + const boundaryIndex = constraint.value?.boundaryIndex; + const offset = Number(constraint.value?.offsetMeters); + if (!Number.isInteger(boundaryIndex) || boundaryIndex < 1 || boundaryIndex >= road.laneCount) { + blocking(diagnostics, constraint, 'direct-edit-lane-boundary-stale', '车道分隔 boundaryIndex 已超出当前车道数。'); + return false; + } + if (!Number.isFinite(offset)) { + blocking(diagnostics, constraint, 'direct-edit-invalid-value', '车道分隔偏移必须是有限米数。'); + return false; + } + const laneWidth = road.widthMeters / road.laneCount; + if (laneWidth - Math.abs(offset) < 2.4) { + blocking(diagnostics, constraint, 'direct-edit-min-lane-width', '车道分隔调整会使相邻车道小于 2.4 米。'); + return false; + } + profile.laneDividerOffsets[boundaryIndex] = offset; + profile.transitions[`road-lane-divider:${boundaryIndex}`] = constraint.value?.transition || 'smoothstep'; + } + profile.widthMeters = road.widthMeters + profile.edgeOffsets.left + profile.edgeOffsets.right; + if ( + Math.abs(profile.edgeOffsets.left) > road.widthMeters / 2 || + Math.abs(profile.edgeOffsets.right) > road.widthMeters / 2 || + profile.widthMeters < road.laneCount * 2.4 + ) { + blocking(diagnostics, constraint, 'direct-edit-outer-edges-cross', '左右外缘调整后无法容纳最小车道宽度。'); + return false; + } + return true; +} + +function applyJunctionConstraint(model, constraint, junctionPlans, junctionData, diagnostics) { + const anchor = constraint.anchor || {}; + const approaches = junctionData.approaches.get(String(anchor.nodeId)); + const approach = approaches?.find((item) => item.segmentId === anchor.segmentId); + if (constraint.kind !== 'junction-corner-radius' && !approach) { + blocking(diagnostics, constraint, 'direct-edit-junction-anchor-missing', '直接编辑约束的路口进口锚点不存在。'); + return false; + } + if (constraint.kind === 'junction-corner-radius') { + const incoming = approaches?.find((item) => item.segmentId === anchor.incomingRoadId); + const outgoing = approaches?.find((item) => item.segmentId === anchor.outgoingRoadId); + const radius = Number(constraint.value?.radiusMeters); + if (!incoming || !outgoing) { + blocking(diagnostics, constraint, 'direct-edit-junction-corner-missing', '直接编辑约束的路口角部锚点不存在。'); + return false; + } + if (!Number.isFinite(radius) || radius < 0.5 || radius > 30) { + blocking(diagnostics, constraint, 'direct-edit-corner-radius-invalid', '路口角部半径必须在 0.5 到 30 米之间。'); + return false; + } + if (!junctionPlans.has(String(anchor.nodeId))) + junctionPlans.set(String(anchor.nodeId), { nodeId: String(anchor.nodeId), approaches: {}, corners: {} }); + const plan = junctionPlans.get(String(anchor.nodeId)); + plan.corners[`${anchor.incomingRoadId}->${anchor.outgoingRoadId}`] = radius; + return true; + } + if (!junctionPlans.has(String(anchor.nodeId))) + junctionPlans.set(String(anchor.nodeId), { nodeId: String(anchor.nodeId), approaches: {}, corners: {} }); + const plan = junctionPlans.get(String(anchor.nodeId)); + const entry = (plan.approaches[anchor.segmentId] ||= { + widthMeters: approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0), + cutbackMeters: approach.cutback, + }); + if (constraint.kind === 'junction-approach-width') { + const width = Number(constraint.value?.widthMeters); + if (!Number.isFinite(width) || width < 2.4) { + blocking(diagnostics, constraint, 'direct-edit-min-approach-width', '路口进口宽度不能小于 2.4 米。'); + return false; + } + entry.widthMeters = width; + } else { + const cutback = Number(constraint.value?.cutbackMeters); + if (!Number.isFinite(cutback) || cutback < 1 || cutback > approach.length * 0.45) { + blocking( + diagnostics, + constraint, + 'direct-edit-cutback-invalid', + '路口 cutback 必须落在进口可用长度的 45% 以内。', + ); + return false; + } + entry.cutbackMeters = cutback; + } + return true; +} + +function solveConstraints(model, constraints, junctionData, diagnostics) { + const roadProfiles = new Map(); + const junctionPlans = new Map(); + const states = []; + for (const constraint of constraints) { + if (!constraint.enabled) { + states.push(constraintState(constraint, false, 'disabled')); + continue; + } + if (!ACTIVE_STATUSES.has(constraint.status)) { + states.push(constraintState(constraint, false, `status-${constraint.status}`)); + continue; + } + if (!SOLVED_KINDS.has(constraint.kind)) { + states.push(constraintState(constraint, false, 'kind-not-solved')); + continue; + } + const roadSnapshot = new Map( + [...roadProfiles].map(([key, profile]) => [ + key, + { + ...profile, + edgeOffsets: { ...profile.edgeOffsets }, + sidewalkWidths: { ...profile.sidewalkWidths }, + laneDividerOffsets: { ...profile.laneDividerOffsets }, + transitions: { ...profile.transitions }, + interval: profile.interval && { ...profile.interval }, + }, + ]), + ); + const junctionSnapshot = new Map( + [...junctionPlans].map(([key, plan]) => [ + key, + { + ...plan, + approaches: Object.fromEntries(Object.entries(plan.approaches).map(([id, entry]) => [id, { ...entry }])), + corners: { ...plan.corners }, + }, + ]), + ); + const applied = constraint.kind.startsWith('road-') + ? applyRoadConstraint(model, constraint, roadProfiles, diagnostics) + : applyJunctionConstraint(model, constraint, junctionPlans, junctionData, diagnostics); + if (!applied) { + roadProfiles.clear(); + for (const [key, profile] of roadSnapshot) roadProfiles.set(key, profile); + junctionPlans.clear(); + for (const [key, plan] of junctionSnapshot) junctionPlans.set(key, plan); + } + states.push(constraintState(constraint, applied, applied ? null : 'invalid')); + } + return { roadProfiles, junctionPlans, states }; +} + function roadAffects(roads, model, side, boundaryIndex) { const ids = []; for (const road of roads) { @@ -406,6 +616,7 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {}) const handles = emptyHandleManifest(context); const groups = roadGroups(model || {}); const junctionData = junctionReserves(model || {}); + const solved = solveConstraints(model || {}, constraints, junctionData, diagnostics); handles.reserves = junctionData.reserves; handles.handles = [ ...makeRoadHandles(model || {}, groups, handles.reserves, constraints), @@ -413,10 +624,10 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {}) ]; return { schema: RESOLUTION_SCHEMA, - roadProfiles: new Map(), - junctionPlans: new Map(), + roadProfiles: solved.roadProfiles, + junctionPlans: solved.junctionPlans, handles, - constraintStates: planConstraints(constraints, diagnostics), + constraintStates: solved.states, diagnostics, }; } diff --git a/src/compile/native-road.js b/src/compile/native-road.js index 8aeeca7..f508bec 100644 --- a/src/compile/native-road.js +++ b/src/compile/native-road.js @@ -608,8 +608,10 @@ function detectComplexJunctionCandidates(model, junctionPlans, options, diagnost } function compileGeometry(model, overrides = { overrides: [] }, options = {}) { + model = modelWithDirectEditProfiles(model, options.directEdit); const diagnostics = [...model.diagnostics]; const junctionPlans = compileJunctionPlans(model, options, diagnostics); + applyDirectJunctionPlans(junctionPlans, options.directEdit); detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics); const features = []; const activeClusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; @@ -806,6 +808,76 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) { }; } +function modelWithDirectEditProfiles(model, directEdit) { + if (!directEdit?.roadProfiles?.size && !directEdit?.junctionPlans?.size) return model; + const approachWidths = new Map(); + for (const plan of directEdit.junctionPlans?.values?.() || []) + for (const [segmentId, entry] of Object.entries(plan.approaches || {})) + if (Number.isFinite(entry.widthMeters)) approachWidths.set(segmentId, entry.widthMeters); + const roads = model.roads.map((road) => { + const profile = directEdit.roadProfiles.get(road.id); + const approachWidth = approachWidths.get(road.segmentId); + const siblingWidth = model.roads + .filter((item) => item.segmentId === road.segmentId) + .reduce((sum, item) => sum + item.widthMeters, 0); + const approachScale = Number.isFinite(approachWidth) && siblingWidth > 0 ? approachWidth / siblingWidth : 1; + if (!profile && approachScale === 1) return road; + const centerlineShift = profile ? ((profile.edgeOffsets?.left || 0) - (profile.edgeOffsets?.right || 0)) / 2 : 0; + const shiftedCenterline = + Math.abs(centerlineShift) > 1e-9 ? offsetLine(road.centerline, centerlineShift) : road.centerline; + return { + ...road, + centerline: shiftedCenterline || road.centerline, + widthMeters: Number.isFinite(profile?.widthMeters) + ? profile.widthMeters * approachScale + : road.widthMeters * approachScale, + sidewalkLeft: profile ? profile.sidewalkWidths?.left > 0 : road.sidewalkLeft, + sidewalkRight: profile ? profile.sidewalkWidths?.right > 0 : road.sidewalkRight, + directEditProfile: profile || null, + }; + }); + return { ...model, roads }; +} + +function applyDirectJunctionPlans(plans, directEdit) { + if (!directEdit?.junctionPlans?.size) return; + for (const [nodeId, directPlan] of directEdit.junctionPlans) { + const plan = plans.get(String(nodeId)); + if (!plan) continue; + let changed = false; + for (const approach of plan.approaches) { + const override = directPlan.approaches?.[approach.segmentId]; + if (!override) continue; + if (Number.isFinite(override.widthMeters)) { + approach.widthMeters = override.widthMeters; + changed = true; + } + if (Number.isFinite(override.cutbackMeters)) { + approach.cutbackMeters = override.cutbackMeters; + changed = true; + } + } + const cutbacks = Object.values(directPlan.approaches || {}) + .map((entry) => entry.cutbackMeters) + .filter(Number.isFinite); + if (cutbacks.length) { + plan.cutbackMeters = Math.max(...cutbacks); + changed = true; + } + const requestedRadii = Object.values(directPlan.corners || {}).filter(Number.isFinite); + if (requestedRadii.length) changed = true; + if (!changed) continue; + const referenceWidth = Math.max(...plan.approaches.map((approach) => approach.widthMeters), 1); + const cornerRadiusMultiplier = requestedRadii.length + ? Math.max(0.1, Math.min(3, requestedRadii[0] / referenceWidth)) + : 1; + const boundary = junctionBoundary(plan.approaches, plan.node, plan.cutbackMeters, cornerRadiusMultiplier, 1); + plan.boundary = boundary.points; + plan.boundaryMode = boundary.mode; + plan.boundaryFallbacks = boundary.fallbacks; + } +} + function compileComplexPreviewArrows(features, stopLines) { const result = []; for (const feature of features) { @@ -1590,16 +1662,13 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {} const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null); const center = cluster ? clusterCenter(cluster, junctionPlans) : null; for (const [side, enabled] of sides) { - if (!enabled) continue; + const profile = forward.directEditProfile; + const sidewalkWidth = profile?.sidewalkWidths?.[side] ?? DEFAULT_SIDEWALK_WIDTH_METERS; + if (!enabled || sidewalkWidth <= 0) continue; const centerline = cluster ? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center) : trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); - const ring = sidewalkRing( - centerline, - totalWidth / 2, - totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, - side === 'left' ? 1 : -1, - ); + const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + sidewalkWidth, side === 'left' ? 1 : -1); if (!ring) { diagnostics.push( diagnostic( @@ -1622,7 +1691,7 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {} osm_way_ids: forward.osmWayIds.join(','), source_road_id: forward.sourceRoadId, side, - width_m: DEFAULT_SIDEWALK_WIDTH_METERS, + width_m: sidewalkWidth, directional_road_ids: directions.map((road) => road.id).join(','), provenance: 'native-road-sidewalk/v1', override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(','), @@ -1874,7 +1943,10 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, for (let index = 0; index < road.laneCount; index += 1) { // OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the // driver's left so tag positions and generated lane IDs have one meaning. - const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)); + const dividerAdjustment = Object.entries(road.directEditProfile?.laneDividerOffsets || {}) + .filter(([boundary]) => index + 1 > Number(boundary)) + .reduce((sum, [, value]) => sum + Number(value) / Math.max(road.laneCount, 1), 0); + const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)) + dividerAdjustment; const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset); const publishedCoordinates = offsetLine(clippedRoadLine, offset); if (!coordinates || !publishedCoordinates) { diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index a3345b3..9c89610 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('assert/strict'); -const { resolveDirectEditConstraints, SOLVED_KINDS } = require('../src/compile/direct-edit-solver'); +const { resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver'); const model = { roads: [], endpoints: [], connections: [], diagnostics: [] }; @@ -96,6 +96,69 @@ const anchoredHandle = anchoredResolution.handles.handles.find( ); 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 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].reason, 'invalid'); +assert.equal(invalidDivider.diagnostics[0].severity, 'error'); +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 })])); @@ -114,26 +177,23 @@ for (const status of ['pending', 'conflicted', 'stale']) { assert.deepEqual(resolution.diagnostics, []); } -// Until a kind is wired into the solver its constraints are reported as -// unapplied with an explicit diagnostic, rather than approximated or ignored. +// A solved kind with a missing road anchor is rejected as a blocking +// constraint diagnostic rather than approximated or ignored. for (const status of ['exact', 'recheck']) { const resolution = resolveDirectEditConstraints(model, document([constraint({ status })])); - const expected = SOLVED_KINDS.has('road-edge-offset') - ? { applied: true, reason: null, diagnostics: 0 } - : { applied: false, reason: 'kind-not-solved', diagnostics: 1 }; assert.deepEqual(resolution.constraintStates, [ { constraintId: 'constraint-1', kind: 'road-edge-offset', status, - applied: expected.applied, - reason: expected.reason, + applied: false, + reason: 'invalid', }, ]); - assert.equal(resolution.diagnostics.length, expected.diagnostics); + assert.equal(resolution.diagnostics.length, 1); for (const item of resolution.diagnostics) { - assert.equal(item.severity, 'info'); - assert.equal(item.rule, 'direct-edit-kind-not-solved'); + assert.equal(item.severity, 'error'); + assert.equal(item.rule, 'direct-edit-road-anchor-missing'); assert.equal(item.subjectId, 'constraint:constraint-1'); assert.equal(item.geometry, null); }