223 lines
9.1 KiB
JavaScript
223 lines
9.1 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')));
|
|
|
|
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 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 })]));
|
|
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, []);
|
|
}
|
|
|
|
// 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 })]));
|
|
assert.deepEqual(resolution.constraintStates, [
|
|
{
|
|
constraintId: 'constraint-1',
|
|
kind: 'road-edge-offset',
|
|
status,
|
|
applied: false,
|
|
reason: 'invalid',
|
|
},
|
|
]);
|
|
assert.equal(resolution.diagnostics.length, 1);
|
|
for (const item of resolution.diagnostics) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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'],
|
|
);
|
|
|
|
// 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');
|