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>
235 lines
9.5 KiB
JavaScript
235 lines
9.5 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { nativeRoadEdits } = require('../src');
|
|
|
|
const { EDITS_SCHEMA, CONSTRAINT_ANCHORS, CONSTRAINT_KINDS, emptyEditDocument, validateEditDocument } = nativeRoadEdits;
|
|
const { loadEditDocument, saveEditDocument } = nativeRoadEdits;
|
|
|
|
const DIGEST = 'a'.repeat(64);
|
|
|
|
function snapshot() {
|
|
return {
|
|
coordinate: [114.1, 22.5],
|
|
tangentAzimuth: 87.5,
|
|
roadLengthMeters: 240.75,
|
|
osmNodeIds: ['node/1', 'node/2'],
|
|
};
|
|
}
|
|
|
|
function anchorFor(kind) {
|
|
const type = CONSTRAINT_ANCHORS[kind];
|
|
if (type === 'road-interval')
|
|
return { type, roadId: 'road:way/1:forward', startStation: 0.25, endStation: 0.75, side: 'left' };
|
|
if (type === 'junction-approach') return { type, nodeId: 'node/9', segmentId: 'road:way/1:segment/0' };
|
|
return { type, nodeId: 'node/9', incomingRoadId: 'road:way/1:forward', outgoingRoadId: 'road:way/2:forward' };
|
|
}
|
|
|
|
// One branch per kind. The trailing `return { radiusMeters: 6 }` this replaces
|
|
// silently answered for every kind it did not name, so a new kind was built with a
|
|
// corner-radius value and only failed later, in the validator.
|
|
function valueFor(kind) {
|
|
switch (kind) {
|
|
case 'road-edge-offset':
|
|
return { offsetMeters: 0.8, transition: 'smoothstep' };
|
|
case 'road-sidewalk-width':
|
|
return { widthMeters: 2.5, transition: 'linear' };
|
|
case 'road-lane-divider':
|
|
return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
|
|
case 'junction-approach-width':
|
|
return { widthMeters: 12.5 };
|
|
case 'junction-cutback':
|
|
return { cutbackMeters: 4 };
|
|
case 'junction-corner-radius':
|
|
return { radiusMeters: 6 };
|
|
case 'junction-crosswalk-inset':
|
|
return { insetMeters: 1.5 };
|
|
case 'junction-stop-line-offset':
|
|
return { offsetMeters: 2.7 };
|
|
default:
|
|
throw new Error(`valueFor has no case for ${kind}; the taxonomy grew without this fixture`);
|
|
}
|
|
}
|
|
|
|
function constraintFor(kind, id = `c-${kind}`) {
|
|
return {
|
|
id,
|
|
kind,
|
|
anchor: anchorFor(kind),
|
|
anchorSnapshot: snapshot(),
|
|
value: valueFor(kind),
|
|
enabled: true,
|
|
status: 'exact',
|
|
provenance: { operationId: 'op-1', createdAt: '2026-08-26T10:00:00.000Z', author: 'dingkang' },
|
|
};
|
|
}
|
|
|
|
// A document exercising all six kinds at once, so a taxonomy drift breaks here.
|
|
function fullDocument() {
|
|
const document = emptyEditDocument({
|
|
osmSha256: DIGEST,
|
|
areaConfigSha256: DIGEST,
|
|
compilerGeometryVersion: 'native-road-package/v1.1',
|
|
});
|
|
document.constraints = CONSTRAINT_KINDS.map((kind) => constraintFor(kind));
|
|
document.operations = [
|
|
{
|
|
id: 'op-1',
|
|
createdAt: '2026-08-26T10:00:00.000Z',
|
|
constraintIds: document.constraints.map((constraint) => constraint.id),
|
|
author: 'dingkang',
|
|
},
|
|
];
|
|
return document;
|
|
}
|
|
|
|
function rejects(document, pattern, what) {
|
|
assert.throws(() => validateEditDocument(document), pattern, what);
|
|
}
|
|
|
|
// --- taxonomy is the one defined in design.md, no drift -----------------------
|
|
assert.equal(EDITS_SCHEMA, 'native-road-edits/v2');
|
|
assert.deepEqual(CONSTRAINT_KINDS, [
|
|
'road-edge-offset',
|
|
'road-sidewalk-width',
|
|
'road-lane-divider',
|
|
'junction-approach-width',
|
|
'junction-cutback',
|
|
'junction-corner-radius',
|
|
'junction-crosswalk-inset',
|
|
'junction-stop-line-offset',
|
|
]);
|
|
|
|
// --- round trip is lossless --------------------------------------------------
|
|
const document = fullDocument();
|
|
assert.deepEqual(validateEditDocument(document), document);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(document)), document);
|
|
assert.equal(emptyEditDocument().documentVersion, 0);
|
|
assert.deepEqual(emptyEditDocument().constraints, []);
|
|
|
|
// --- unknown kind is rejected and the message names the constraint -----------
|
|
const unknownKind = fullDocument();
|
|
unknownKind.constraints[0].kind = 'corridor-profile';
|
|
rejects(unknownKind, /c-road-edge-offset: kind must be one of .*got corridor-profile/, 'unknown kind');
|
|
|
|
// --- a kind may only carry its own anchor type -------------------------------
|
|
const wrongAnchor = fullDocument();
|
|
wrongAnchor.constraints[0].anchor = anchorFor('junction-cutback');
|
|
rejects(wrongAnchor, /anchor\.type must be road-interval for kind road-edge-offset/, 'anchor/kind mismatch');
|
|
|
|
// --- station range and ordering ---------------------------------------------
|
|
for (const [station, label] of [
|
|
[-0.1, 'below zero'],
|
|
[1.5, 'above one'],
|
|
]) {
|
|
const bad = fullDocument();
|
|
bad.constraints[0].anchor.startStation = station;
|
|
rejects(bad, /anchor\.startStation must be a normalized station in 0\.\.1/, `station ${label}`);
|
|
}
|
|
const inverted = fullDocument();
|
|
inverted.constraints[0].anchor.startStation = 0.8;
|
|
inverted.constraints[0].anchor.endStation = 0.2;
|
|
rejects(inverted, /anchor\.startStation must be strictly less than anchor\.endStation/, 'inverted interval');
|
|
|
|
const degenerate = fullDocument();
|
|
degenerate.constraints[0].anchor.startStation = 0.5;
|
|
degenerate.constraints[0].anchor.endStation = 0.5;
|
|
rejects(degenerate, /strictly less than/, 'zero-length interval');
|
|
|
|
// --- boundaryIndex: schema owns "< 1" and "not an integer"; the lane-count
|
|
// bound is the solver's job and surfaces as `stale`, per design.md ------------
|
|
for (const [boundaryIndex, label] of [
|
|
[0, 'zero'],
|
|
[-2, 'negative'],
|
|
[1.5, 'fractional'],
|
|
]) {
|
|
const bad = fullDocument();
|
|
bad.constraints[2].value.boundaryIndex = boundaryIndex;
|
|
rejects(bad, /c-road-lane-divider: value\.boundaryIndex must be an integer >= 1/, `boundaryIndex ${label}`);
|
|
}
|
|
|
|
// --- per-kind value ranges ---------------------------------------------------
|
|
const negativeSidewalk = fullDocument();
|
|
negativeSidewalk.constraints[1].value.widthMeters = -1;
|
|
rejects(negativeSidewalk, /c-road-sidewalk-width: value\.widthMeters must be a non-negative/, 'negative sidewalk');
|
|
|
|
const zeroSidewalk = fullDocument();
|
|
zeroSidewalk.constraints[1].value.widthMeters = 0;
|
|
assert.deepEqual(validateEditDocument(zeroSidewalk), zeroSidewalk, 'width 0 disables the sidewalk, it is legal');
|
|
|
|
const zeroApproach = fullDocument();
|
|
zeroApproach.constraints[3].value.widthMeters = 0;
|
|
rejects(zeroApproach, /c-junction-approach-width: value\.widthMeters must be a positive/, 'zero approach width');
|
|
|
|
const badTransition = fullDocument();
|
|
badTransition.constraints[0].value.transition = 'ease';
|
|
rejects(badTransition, /value\.transition must be smoothstep or linear/, 'unknown transition');
|
|
|
|
const nonFinite = fullDocument();
|
|
nonFinite.constraints[0].value.offsetMeters = Number.NaN;
|
|
rejects(nonFinite, /value\.offsetMeters must be a finite/, 'NaN offset');
|
|
|
|
// --- status enum ------------------------------------------------------------
|
|
const badStatus = fullDocument();
|
|
badStatus.constraints[0].status = 'unknown';
|
|
rejects(badStatus, /status must be exact, recheck, pending, conflicted or stale/, 'unknown status');
|
|
|
|
// --- ids, provenance and referential integrity ------------------------------
|
|
const duplicate = fullDocument();
|
|
duplicate.constraints.push(constraintFor('junction-cutback', 'c-road-edge-offset'));
|
|
rejects(duplicate, /id is duplicated \(c-road-edge-offset\)/, 'duplicate constraint id');
|
|
|
|
const danglingProvenance = fullDocument();
|
|
danglingProvenance.constraints[0].provenance.operationId = 'op-missing';
|
|
rejects(danglingProvenance, /provenance\.operationId references an operation that is not recorded/, 'dangling op');
|
|
|
|
const danglingOperation = fullDocument();
|
|
danglingOperation.operations[0].constraintIds.push('c-missing');
|
|
rejects(
|
|
danglingOperation,
|
|
/operation op-1: constraintIds references an unknown constraint \(c-missing\)/,
|
|
'dangling c',
|
|
);
|
|
|
|
const missingSnapshot = fullDocument();
|
|
delete missingSnapshot.constraints[0].anchorSnapshot;
|
|
rejects(missingSnapshot, /anchorSnapshot is required so a reimport can relocate the anchor/, 'missing snapshot');
|
|
|
|
// --- base digests -----------------------------------------------------------
|
|
const badDigest = fullDocument();
|
|
badDigest.base.osmSha256 = 'not-a-digest';
|
|
rejects(badDigest, /base\.osmSha256 must be a 64-character lowercase sha256 digest or null/, 'bad digest');
|
|
|
|
// --- schema guard -----------------------------------------------------------
|
|
rejects({ ...fullDocument(), schema: 'native-road-edits/v1' }, /Edits must use native-road-edits\/v2/, 'old schema');
|
|
rejects(null, /Edits must use native-road-edits\/v2/, 'null document');
|
|
|
|
// --- persistence: version bumps once per write, writes are atomic -----------
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-edits-'));
|
|
const file = path.join(directory, 'nested', 'native-road-edits.json');
|
|
|
|
assert.equal(loadEditDocument(file).documentVersion, 0, 'a missing file reads as an empty document');
|
|
|
|
const firstWrite = saveEditDocument(file, fullDocument());
|
|
assert.equal(firstWrite.documentVersion, 1);
|
|
assert.equal(loadEditDocument(file).documentVersion, 1);
|
|
|
|
const secondWrite = saveEditDocument(file, firstWrite);
|
|
assert.equal(secondWrite.documentVersion, 2);
|
|
assert.equal(loadEditDocument(file).documentVersion, 2);
|
|
assert.deepEqual(loadEditDocument(file).constraints, fullDocument().constraints, 'constraints survive the round trip');
|
|
assert.equal(fs.existsSync(`${file}.staging`), false, 'the staging file is renamed away, not left behind');
|
|
|
|
// An invalid document must not reach disk at all.
|
|
const invalid = fullDocument();
|
|
invalid.constraints[0].value.offsetMeters = 'wide';
|
|
assert.throws(() => saveEditDocument(file, invalid), /value\.offsetMeters must be a finite/);
|
|
assert.equal(loadEditDocument(file).documentVersion, 2, 'a rejected write leaves the stored version untouched');
|
|
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
|
|
console.log('native road edits document tests passed');
|