diff --git a/.trellis/tasks/08-26-direct-edit-documents/implement.md b/.trellis/tasks/08-26-direct-edit-documents/implement.md index 449bffc..a58f3b5 100644 --- a/.trellis/tasks/08-26-direct-edit-documents/implement.md +++ b/.trellis/tasks/08-26-direct-edit-documents/implement.md @@ -14,7 +14,8 @@ - 目标:`native-road-edits/v2` 的读写与结构校验。 - 范围:`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 与字段,不是笼统失败。 - 回滚点:纯新增模块,无调用方,revert 无影响。 diff --git a/.trellis/tasks/08-26-direct-edit-documents/task.json b/.trellis/tasks/08-26-direct-edit-documents/task.json index cacf376..3c6a800 100644 --- a/.trellis/tasks/08-26-direct-edit-documents/task.json +++ b/.trellis/tasks/08-26-direct-edit-documents/task.json @@ -12,7 +12,7 @@ "assignee": "dingkang", "createdAt": "2026-08-26", "completedAt": null, - "branch": null, + "branch": "feat/direct-edit-documents", "base_branch": "main", "worktree_path": null, "commit": null, diff --git a/package.json b/package.json index c920cc6..b008b54 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "road-compiler": "bin/road-compiler.js" }, "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:export": "node bin/road-compiler.js", "build": "vite build --config workbench/client/vite.config.ts", diff --git a/src/compile/native-road-edits.js b/src/compile/native-road-edits.js new file mode 100644 index 0000000..4b904f4 --- /dev/null +++ b/src/compile/native-road-edits.js @@ -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, +}; diff --git a/src/index.js b/src/index.js index 70df7f2..f5a0646 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ module.exports = { trafficSignals: require('./traffic-signals'), nativeTrafficSignals: require('./native-traffic-signals'), nativeRoad: require('./compile/native-road'), + nativeRoadEdits: require('./compile/native-road-edits'), layerManifest: require('./compile/layer-manifest'), nativeRoadPackage: require('./export/native-road-package'), compiler: require('./compile/compiler'), diff --git a/test/native-road-edits.js b/test/native-road-edits.js new file mode 100644 index 0000000..719a282 --- /dev/null +++ b/test/native-road-edits.js @@ -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');