feat: insert direct edit constraint solver stage
Adds resolveDirectEditConstraints between compileRoadModel and compileGeometry, per the parent design's data flow. The stage is an identity transform until the individual constraint kinds land: it returns empty road profiles, junction plans and handle manifest, so compileGeometry's geometry code is untouched and output stays byte-identical. Constraint planning is separated from solving up front. Only `exact` and `recheck` constraints may reach the solver; `disabled`, `pending`, `conflicted` and `stale` are reported as unapplied with a reason. SOLVED_KINDS starts empty and grows one entry per kind, so a partially delivered solver reports what it skipped instead of publishing half-solved geometry. diagnostic() moves to src/compile/diagnostics.js so the solver can share the compiler's diagnostic shape without depending on fs, which would break the pure-function boundary that lets preview, the full compile and the CLI export share one implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
test/direct-edit-solver.js
Normal file
116
test/direct-edit-solver.js
Normal file
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert/strict');
|
||||
const { resolveDirectEditConstraints, SOLVED_KINDS } = 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 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, []);
|
||||
}
|
||||
|
||||
// Until a kind is wired into the solver its constraints are reported as
|
||||
// unapplied with an explicit 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,
|
||||
},
|
||||
]);
|
||||
assert.equal(resolution.diagnostics.length, expected.diagnostics);
|
||||
for (const item of resolution.diagnostics) {
|
||||
assert.equal(item.severity, 'info');
|
||||
assert.equal(item.rule, 'direct-edit-kind-not-solved');
|
||||
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');
|
||||
Reference in New Issue
Block a user