Files
road-compiler/test/direct-edit-solver.js
que01 c7425f5ed4 feat: make crosswalk and stop-line offsets solvable
Step 1 of the control-marking task, and deliberately server-only: no handle is
drawn yet. This project already shipped a range handle for `profile.interval`,
which compileGeometry ignores, so the control dragged and changed nothing. The
consumer comes first now.

Two kinds join the taxonomy — `junction-crosswalk-inset` and
`junction-stop-line-offset`, both on the existing `junction-approach` anchor. The
solver writes them onto the approach entry, `applyDirectJunctionPlans` carries
them onto the compiled approach, and `compileControlMarkings` reads them in place
of the module constants it used for every junction. They move markings without
reshaping the junction, so unlike width and cutback they deliberately do not
trigger a boundary recompute.

`applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had
meant every kind that was not approach-width fell through to the cutback
validator, so a new kind would have been silently validated and written as a
cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed
the same way, and now throws for an unnamed kind rather than answering with a
corner radius.

design.md's taxonomy is updated with it — a test asserts the two cannot drift,
which is what caught the omission.

Measured on a 41-road workspace with 8 crossings: both constraints change their
marking geometry, neither drags the other, and out-of-range blocks instead of
clamping. That measurement is not in the suite: the synthetic junction resolves
`junction_inset_m` to 0 because its crossing never binds to a plan, and the
committed OSM fixture has no crossings at all. The tests assert the wiring the
handles will depend on — values reaching the approach entry, distinct branches,
blocking diagnostics — and the gap is recorded in the test itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:01:36 +08:00

481 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 segments 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 nodes handles');
assert.ok(
junctionScoped.every((handle) => handle.kind.startsWith('junction-') && handle.anchor.nodeId === 'junction'),
'a junction selection must only carry that nodes 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);
// Control-marking offsets must reach the geometry before any handle is drawn for
// them. This project already shipped a range handle for `profile.interval`, which
// compileGeometry ignores, so the control dragged and changed nothing — see
// 08-26-direct-edit-map-editor/research/interval-not-applied.md. The consumer is
// asserted first.
const controlRoads = ['a', 'b', 'c'].map((id, index) => ({
id: `road:${id}`,
segmentId: `segment:${id}`,
direction: 'forward',
tags: {},
highway: 'residential',
// Roads *arrive* at the junction: `crossingJunctionInset()` resolves the junction
// from the road's last node, so a fixture whose roads leave the junction finds no
// plan and silently insets by zero.
centerline: [
[113 + index * 0.001 + (index === 1 ? 0.001 : 0), 30 + (index === 1 ? 0.001 : 0.001)],
[113 + index * 0.001, 30],
],
sourceNodeIds: [`end-${id}`, 'junction'],
osmWayIds: [id],
widthMeters: 6,
laneCount: 2,
sidewalkLeft: true,
sidewalkRight: true,
}));
const controlModel = {
roads: controlRoads,
endpoints: controlRoads.map((road) => ({
id: `endpoint:${road.id}:end`,
roadId: road.id,
side: 'end',
nodeId: 'junction',
coordinate: road.centerline.at(-1),
})),
connections: [],
diagnostics: [],
crossings: [{ id: 'x1', coordinate: [113, 30.0004], tags: { highway: 'crossing' }, osmWayIds: ['a'] }],
};
const controlConstraint = (kind, value) =>
constraint({
id: `c-${kind}`,
kind,
anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' },
value,
});
// NOTE: the geometry-level proof (a crosswalk inset actually moving the zebra) was
// measured on a real 41-road workspace with 8 crossings, not asserted here: this
// synthetic junction resolves `junction_inset_m` to 0 because the crossing never
// binds to a junction plan, and the committed OSM fixture has no crossings at all.
// Closing that gap needs an OSM fixture with a crossing on a road that arrives at a
// junction. What is asserted below is the wiring the handles will depend on.
// Both new kinds land their value on the approach entry the compiler reads.
const approachEntry = (kind, value) =>
resolveDirectEditConstraints(controlModel, document([controlConstraint(kind, value)]), {}).junctionPlans.get(
'junction',
).approaches['segment:a'];
assert.equal(approachEntry('junction-crosswalk-inset', { insetMeters: 3 }).crosswalkInsetMeters, 3);
assert.equal(approachEntry('junction-stop-line-offset', { offsetMeters: 4 }).stopLineOffsetMeters, 4);
// Out of range blocks rather than clamping, and writes nothing to the plan.
const absurd = resolveDirectEditConstraints(
controlModel,
document([controlConstraint('junction-crosswalk-inset', { insetMeters: 9999 })]),
{},
);
assert.equal(absurd.constraintStates[0].applied, false);
assert.ok(
absurd.diagnostics.some((item) => item.rule === 'direct-edit-crosswalk-inset-invalid'),
'an out-of-range inset must produce a blocking diagnostic, not a silent clamp',
);
// The kinds are distinct branches: a stop-line offset must not be validated or
// written as a cutback, which the previous `else` fallthrough would have done.
const stopOnly = resolveDirectEditConstraints(
controlModel,
document([controlConstraint('junction-stop-line-offset', { offsetMeters: 4 })]),
{},
).junctionPlans.get('junction').approaches['segment:a'];
assert.equal(stopOnly.cutbackMeters !== 4, true, 'a stop-line offset must not be written as a cutback');
// 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');