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>
481 lines
20 KiB
JavaScript
481 lines
20 KiB
JavaScript
'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);
|
||
|
||
// 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');
|