feat: add native road edits document validation
This commit is contained in:
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
- 目标:`native-road-edits/v2` 的读写与结构校验。
|
- 目标:`native-road-edits/v2` 的读写与结构校验。
|
||||||
- 范围:`src/compile/` 下新增文档模块:6 个 kind、4 种锚点、`anchorSnapshot`、`ConstraintStatus`、`documentVersion`、`operations`。仅结构与取值范围校验。
|
- 范围:`src/compile/` 下新增文档模块:6 个 kind、4 种锚点、`anchorSnapshot`、`ConstraintStatus`、`documentVersion`、`operations`。仅结构与取值范围校验。
|
||||||
- 验证:`npm run test` 新增 fixture——文档往返无损;越界 `boundaryIndex`(`<1` 或 `>=laneCount`)被拒;非法 station(超出 `0..1`、`start>=end`)被拒;未知 kind 被拒;每次写入 `documentVersion` 递增 1。
|
- 验证:`npm run test` 新增 fixture——文档往返无损;`boundaryIndex` 非整数或 `<1` 被拒;非法 station(超出 `0..1`、`start>=end`)被拒;未知 kind 被拒;kind 与锚点类型不匹配被拒;provenance 指向未记录的 operation 被拒;每次写入 `documentVersion` 递增 1。
|
||||||
|
- 注意:`boundaryIndex >= laneCount` **不**属于 schema 校验——车道数是模型信息,schema 层无从得知。按 `design.md`,该越界在重放时由求解器判定并转 `stale`,归 `direct-edit-solver-api`。
|
||||||
- 门禁:校验错误信息能指出具体约束 id 与字段,不是笼统失败。
|
- 门禁:校验错误信息能指出具体约束 id 与字段,不是笼统失败。
|
||||||
- 回滚点:纯新增模块,无调用方,revert 无影响。
|
- 回滚点:纯新增模块,无调用方,revert 无影响。
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"assignee": "dingkang",
|
"assignee": "dingkang",
|
||||||
"createdAt": "2026-08-26",
|
"createdAt": "2026-08-26",
|
||||||
"completedAt": null,
|
"completedAt": null,
|
||||||
"branch": null,
|
"branch": "feat/direct-edit-documents",
|
||||||
"base_branch": "main",
|
"base_branch": "main",
|
||||||
"worktree_path": null,
|
"worktree_path": null,
|
||||||
"commit": null,
|
"commit": null,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"road-compiler": "bin/road-compiler.js"
|
"road-compiler": "bin/road-compiler.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node test/index.js && node test/fixtures.js",
|
"test": "node test/index.js && node test/native-road-edits.js && node test/fixtures.js",
|
||||||
"road:workbench": "node bin/road-workbench.js",
|
"road:workbench": "node bin/road-workbench.js",
|
||||||
"road:export": "node bin/road-compiler.js",
|
"road:export": "node bin/road-compiler.js",
|
||||||
"build": "vite build --config workbench/client/vite.config.ts",
|
"build": "vite build --config workbench/client/vite.config.ts",
|
||||||
|
|||||||
240
src/compile/native-road-edits.js
Normal file
240
src/compile/native-road-edits.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const EDITS_SCHEMA = 'native-road-edits/v2';
|
||||||
|
|
||||||
|
// A constraint kind is *what* is constrained; an anchor type is *where* it
|
||||||
|
// attaches. Each kind accepts exactly one anchor type, so the pairing is data
|
||||||
|
// rather than a branch in every validator.
|
||||||
|
const CONSTRAINT_ANCHORS = {
|
||||||
|
'road-edge-offset': 'road-interval',
|
||||||
|
'road-sidewalk-width': 'road-interval',
|
||||||
|
'road-lane-divider': 'road-interval',
|
||||||
|
'junction-approach-width': 'junction-approach',
|
||||||
|
'junction-cutback': 'junction-approach',
|
||||||
|
'junction-corner-radius': 'junction-corner',
|
||||||
|
};
|
||||||
|
const CONSTRAINT_KINDS = Object.freeze(Object.keys(CONSTRAINT_ANCHORS));
|
||||||
|
const ANCHOR_TYPES = new Set(['road-station', 'road-interval', 'junction-approach', 'junction-corner']);
|
||||||
|
const CONSTRAINT_STATUSES = new Set(['exact', 'recheck', 'pending', 'conflicted', 'stale']);
|
||||||
|
const TRANSITIONS = new Set(['smoothstep', 'linear']);
|
||||||
|
const SIDES = new Set(['left', 'right']);
|
||||||
|
const SHA256 = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
function emptyEditDocument(base = {}) {
|
||||||
|
return {
|
||||||
|
schema: EDITS_SCHEMA,
|
||||||
|
documentVersion: 0,
|
||||||
|
base: {
|
||||||
|
osmSha256: base.osmSha256 || null,
|
||||||
|
areaConfigSha256: base.areaConfigSha256 || null,
|
||||||
|
compilerGeometryVersion: base.compilerGeometryVersion || null,
|
||||||
|
},
|
||||||
|
constraints: [],
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every failure names the offending constraint (or operation) and the exact
|
||||||
|
// field, so a caller can point a user at one row instead of "invalid document".
|
||||||
|
function fail(scope, field, detail) {
|
||||||
|
throw new Error(`${scope}: ${field} ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value) {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(scope, field, value) {
|
||||||
|
if (typeof value !== 'string' || !value) fail(scope, field, 'must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStation(scope, field, value) {
|
||||||
|
if (!isFiniteNumber(value) || value < 0 || value > 1) fail(scope, field, 'must be a normalized station in 0..1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAnchor(scope, kind, anchor) {
|
||||||
|
const expected = CONSTRAINT_ANCHORS[kind];
|
||||||
|
if (!anchor || typeof anchor !== 'object') fail(scope, 'anchor', 'is required');
|
||||||
|
if (!ANCHOR_TYPES.has(anchor.type)) fail(scope, 'anchor.type', `is not a known anchor type (got ${anchor.type})`);
|
||||||
|
if (anchor.type !== expected) fail(scope, 'anchor.type', `must be ${expected} for kind ${kind} (got ${anchor.type})`);
|
||||||
|
if (anchor.side !== undefined && !SIDES.has(anchor.side)) fail(scope, 'anchor.side', 'must be left or right');
|
||||||
|
if (expected === 'road-station') {
|
||||||
|
requireString(scope, 'anchor.roadId', anchor.roadId);
|
||||||
|
requireStation(scope, 'anchor.station', anchor.station);
|
||||||
|
} else if (expected === 'road-interval') {
|
||||||
|
requireString(scope, 'anchor.roadId', anchor.roadId);
|
||||||
|
requireStation(scope, 'anchor.startStation', anchor.startStation);
|
||||||
|
requireStation(scope, 'anchor.endStation', anchor.endStation);
|
||||||
|
if (anchor.startStation >= anchor.endStation)
|
||||||
|
fail(scope, 'anchor.startStation', 'must be strictly less than anchor.endStation');
|
||||||
|
} else if (expected === 'junction-approach') {
|
||||||
|
requireString(scope, 'anchor.nodeId', anchor.nodeId);
|
||||||
|
requireString(scope, 'anchor.segmentId', anchor.segmentId);
|
||||||
|
} else if (expected === 'junction-corner') {
|
||||||
|
requireString(scope, 'anchor.nodeId', anchor.nodeId);
|
||||||
|
requireString(scope, 'anchor.incomingRoadId', anchor.incomingRoadId);
|
||||||
|
requireString(scope, 'anchor.outgoingRoadId', anchor.outgoingRoadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTransition(scope, value) {
|
||||||
|
if (value.transition !== undefined && !TRANSITIONS.has(value.transition))
|
||||||
|
fail(scope, 'value.transition', 'must be smoothstep or linear');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range checks only. Geometric feasibility — minimum lane width, a
|
||||||
|
// boundaryIndex beyond the road's lane count, self-intersecting junctions —
|
||||||
|
// belongs to the constraint solver, which reports a diagnostic or flips the
|
||||||
|
// constraint to `stale` rather than rejecting the document outright.
|
||||||
|
function validateValue(scope, kind, value) {
|
||||||
|
if (!value || typeof value !== 'object') fail(scope, 'value', 'is required');
|
||||||
|
if (kind === 'road-edge-offset') {
|
||||||
|
if (!isFiniteNumber(value.offsetMeters)) fail(scope, 'value.offsetMeters', 'must be a finite number of meters');
|
||||||
|
validateTransition(scope, value);
|
||||||
|
} else if (kind === 'road-sidewalk-width') {
|
||||||
|
if (!isFiniteNumber(value.widthMeters) || value.widthMeters < 0)
|
||||||
|
fail(scope, 'value.widthMeters', 'must be a non-negative number of meters (0 disables the sidewalk)');
|
||||||
|
validateTransition(scope, value);
|
||||||
|
} else if (kind === 'road-lane-divider') {
|
||||||
|
if (!Number.isInteger(value.boundaryIndex) || value.boundaryIndex < 1)
|
||||||
|
fail(scope, 'value.boundaryIndex', 'must be an integer >= 1 counted from the left outer edge');
|
||||||
|
if (!isFiniteNumber(value.offsetMeters))
|
||||||
|
fail(scope, 'value.offsetMeters', 'must be a finite signed offset from the centerline');
|
||||||
|
validateTransition(scope, value);
|
||||||
|
} else if (kind === 'junction-approach-width') {
|
||||||
|
if (!isFiniteNumber(value.widthMeters) || value.widthMeters <= 0)
|
||||||
|
fail(scope, 'value.widthMeters', 'must be a positive number of meters');
|
||||||
|
} else if (kind === 'junction-cutback') {
|
||||||
|
if (!isFiniteNumber(value.cutbackMeters) || value.cutbackMeters < 0)
|
||||||
|
fail(scope, 'value.cutbackMeters', 'must be a non-negative number of meters');
|
||||||
|
} else if (kind === 'junction-corner-radius') {
|
||||||
|
if (!isFiniteNumber(value.radiusMeters) || value.radiusMeters < 0)
|
||||||
|
fail(scope, 'value.radiusMeters', 'must be a non-negative number of meters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorded at save time so a later reimport can relocate an anchor whose id no
|
||||||
|
// longer resolves, and can tell "moved slightly" from "ambiguous".
|
||||||
|
function validateAnchorSnapshot(scope, snapshot) {
|
||||||
|
if (!snapshot || typeof snapshot !== 'object')
|
||||||
|
fail(scope, 'anchorSnapshot', 'is required so a reimport can relocate the anchor');
|
||||||
|
const coordinate = snapshot.coordinate;
|
||||||
|
if (!Array.isArray(coordinate) || coordinate.length !== 2 || !coordinate.every(isFiniteNumber))
|
||||||
|
fail(scope, 'anchorSnapshot.coordinate', 'must be [lon, lat] in EPSG:4326');
|
||||||
|
if (!isFiniteNumber(snapshot.tangentAzimuth))
|
||||||
|
fail(scope, 'anchorSnapshot.tangentAzimuth', 'must be a number of degrees from true north');
|
||||||
|
if (!isFiniteNumber(snapshot.roadLengthMeters) || snapshot.roadLengthMeters <= 0)
|
||||||
|
fail(scope, 'anchorSnapshot.roadLengthMeters', 'must be a positive length in meters');
|
||||||
|
if (!Array.isArray(snapshot.osmNodeIds) || !snapshot.osmNodeIds.every((id) => typeof id === 'string' && id))
|
||||||
|
fail(scope, 'anchorSnapshot.osmNodeIds', 'must be an array of non-empty OSM node id strings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConstraint(constraint, index, seen) {
|
||||||
|
const named = constraint && typeof constraint.id === 'string' && constraint.id ? ` ${constraint.id}` : '';
|
||||||
|
const scope = `constraint[${index}]${named}`;
|
||||||
|
if (!constraint || typeof constraint !== 'object') fail(scope, 'constraint', 'must be an object');
|
||||||
|
requireString(scope, 'id', constraint.id);
|
||||||
|
if (seen.has(constraint.id)) fail(scope, 'id', `is duplicated (${constraint.id})`);
|
||||||
|
seen.add(constraint.id);
|
||||||
|
if (!CONSTRAINT_KINDS.includes(constraint.kind))
|
||||||
|
fail(scope, 'kind', `must be one of ${CONSTRAINT_KINDS.join(', ')} (got ${constraint.kind})`);
|
||||||
|
validateAnchor(scope, constraint.kind, constraint.anchor);
|
||||||
|
validateAnchorSnapshot(scope, constraint.anchorSnapshot);
|
||||||
|
validateValue(scope, constraint.kind, constraint.value);
|
||||||
|
if (typeof constraint.enabled !== 'boolean') fail(scope, 'enabled', 'must be a boolean');
|
||||||
|
if (!CONSTRAINT_STATUSES.has(constraint.status))
|
||||||
|
fail(scope, 'status', `must be exact, recheck, pending, conflicted or stale (got ${constraint.status})`);
|
||||||
|
const provenance = constraint.provenance;
|
||||||
|
if (!provenance || typeof provenance !== 'object') fail(scope, 'provenance', 'is required');
|
||||||
|
requireString(scope, 'provenance.operationId', provenance.operationId);
|
||||||
|
requireString(scope, 'provenance.createdAt', provenance.createdAt);
|
||||||
|
if (provenance.author !== undefined && typeof provenance.author !== 'string')
|
||||||
|
fail(scope, 'provenance.author', 'must be a string when present');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOperation(operation, index, seen) {
|
||||||
|
const named = operation && typeof operation.id === 'string' && operation.id ? ` ${operation.id}` : '';
|
||||||
|
const scope = `operation[${index}]${named}`;
|
||||||
|
if (!operation || typeof operation !== 'object') fail(scope, 'operation', 'must be an object');
|
||||||
|
requireString(scope, 'id', operation.id);
|
||||||
|
if (seen.has(operation.id)) fail(scope, 'id', `is duplicated (${operation.id})`);
|
||||||
|
seen.add(operation.id);
|
||||||
|
requireString(scope, 'createdAt', operation.createdAt);
|
||||||
|
if (!Array.isArray(operation.constraintIds) || operation.constraintIds.some((id) => typeof id !== 'string' || !id))
|
||||||
|
fail(scope, 'constraintIds', 'must be an array of non-empty constraint id strings');
|
||||||
|
if (operation.author !== undefined && typeof operation.author !== 'string')
|
||||||
|
fail(scope, 'author', 'must be a string when present');
|
||||||
|
if (operation.inverseOf !== undefined && (typeof operation.inverseOf !== 'string' || !operation.inverseOf))
|
||||||
|
fail(scope, 'inverseOf', 'must be a non-empty operation id when present');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBase(base) {
|
||||||
|
if (!base || typeof base !== 'object') fail('document', 'base', 'is required');
|
||||||
|
for (const field of ['osmSha256', 'areaConfigSha256'])
|
||||||
|
if (base[field] !== null && (typeof base[field] !== 'string' || !SHA256.test(base[field])))
|
||||||
|
fail('document', `base.${field}`, 'must be a 64-character lowercase sha256 digest or null');
|
||||||
|
if (base.compilerGeometryVersion !== null && typeof base.compilerGeometryVersion !== 'string')
|
||||||
|
fail('document', 'base.compilerGeometryVersion', 'must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEditDocument(value) {
|
||||||
|
if (!value || value.schema !== EDITS_SCHEMA) throw new Error(`Edits must use ${EDITS_SCHEMA}.`);
|
||||||
|
if (!Number.isInteger(value.documentVersion) || value.documentVersion < 0)
|
||||||
|
fail('document', 'documentVersion', 'must be an integer >= 0');
|
||||||
|
if (!Array.isArray(value.constraints)) fail('document', 'constraints', 'must be an array');
|
||||||
|
if (!Array.isArray(value.operations)) fail('document', 'operations', 'must be an array');
|
||||||
|
validateBase(value.base);
|
||||||
|
const operationIds = new Set();
|
||||||
|
value.operations.forEach((operation, index) => validateOperation(operation, index, operationIds));
|
||||||
|
const constraintIds = new Set();
|
||||||
|
value.constraints.forEach((constraint, index) => validateConstraint(constraint, index, constraintIds));
|
||||||
|
// Provenance has to resolve to a recorded operation, otherwise "who changed
|
||||||
|
// this, when, and under which gesture" is unanswerable and the audit
|
||||||
|
// requirement stops holding.
|
||||||
|
for (const constraint of value.constraints)
|
||||||
|
if (!operationIds.has(constraint.provenance.operationId))
|
||||||
|
fail(
|
||||||
|
`constraint ${constraint.id}`,
|
||||||
|
'provenance.operationId',
|
||||||
|
`references an operation that is not recorded (${constraint.provenance.operationId})`,
|
||||||
|
);
|
||||||
|
for (const operation of value.operations)
|
||||||
|
for (const id of operation.constraintIds)
|
||||||
|
if (!constraintIds.has(id))
|
||||||
|
fail(`operation ${operation.id}`, 'constraintIds', `references an unknown constraint (${id})`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEditDocument(file) {
|
||||||
|
if (!fs.existsSync(file)) return emptyEditDocument();
|
||||||
|
return validateEditDocument(JSON.parse(fs.readFileSync(file, 'utf8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes go through a sibling staging file and a rename, matching how the
|
||||||
|
// compiler publishes its outputs: a crash mid-write can never leave a
|
||||||
|
// half-written document behind. The version bump is part of the write, so
|
||||||
|
// callers cannot persist twice at the same version.
|
||||||
|
function saveEditDocument(file, document) {
|
||||||
|
const next = validateEditDocument({
|
||||||
|
...document,
|
||||||
|
documentVersion: Number.isInteger(document && document.documentVersion) ? document.documentVersion + 1 : NaN,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const staging = `${file}.staging`;
|
||||||
|
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
|
||||||
|
fs.renameSync(staging, file);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
EDITS_SCHEMA,
|
||||||
|
CONSTRAINT_ANCHORS,
|
||||||
|
CONSTRAINT_KINDS,
|
||||||
|
emptyEditDocument,
|
||||||
|
validateEditDocument,
|
||||||
|
loadEditDocument,
|
||||||
|
saveEditDocument,
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ module.exports = {
|
|||||||
trafficSignals: require('./traffic-signals'),
|
trafficSignals: require('./traffic-signals'),
|
||||||
nativeTrafficSignals: require('./native-traffic-signals'),
|
nativeTrafficSignals: require('./native-traffic-signals'),
|
||||||
nativeRoad: require('./compile/native-road'),
|
nativeRoad: require('./compile/native-road'),
|
||||||
|
nativeRoadEdits: require('./compile/native-road-edits'),
|
||||||
layerManifest: require('./compile/layer-manifest'),
|
layerManifest: require('./compile/layer-manifest'),
|
||||||
nativeRoadPackage: require('./export/native-road-package'),
|
nativeRoadPackage: require('./export/native-road-package'),
|
||||||
compiler: require('./compile/compiler'),
|
compiler: require('./compile/compiler'),
|
||||||
|
|||||||
215
test/native-road-edits.js
Normal file
215
test/native-road-edits.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFor(kind) {
|
||||||
|
if (kind === 'road-edge-offset') return { offsetMeters: 0.8, transition: 'smoothstep' };
|
||||||
|
if (kind === 'road-sidewalk-width') return { widthMeters: 2.5, transition: 'linear' };
|
||||||
|
if (kind === 'road-lane-divider') return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
|
||||||
|
if (kind === 'junction-approach-width') return { widthMeters: 12.5 };
|
||||||
|
if (kind === 'junction-cutback') return { cutbackMeters: 4 };
|
||||||
|
return { radiusMeters: 6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- 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');
|
||||||
Reference in New Issue
Block a user