From ba8eaba9d943e68e85357cd44854146e49ebc189 Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 26 Aug 2026 18:08:55 +0800 Subject: [PATCH] feat: compile revisions from area config snapshots --- .../spec/backend/road-revision-storage.md | 35 +++++- package.json | 1 + src/compile/compiler.js | 23 ++-- src/compile/road-revisions.js | 114 ++++++++++++++++-- test/baseline/fengshu-er-road.json | 103 ++++++---------- .../nantaizi-lake-innovation-valley.json | 103 ++++++---------- test/fixture-baseline.js | 71 +++++++++++ test/fixtures.js | 29 ++--- test/road-revisions.js | 58 ++++++++- test/update-fixture-baselines.js | 12 ++ workbench/server.js | 64 ++++------ 11 files changed, 403 insertions(+), 210 deletions(-) create mode 100644 test/fixture-baseline.js create mode 100644 test/update-fixture-baselines.js diff --git a/.trellis/spec/backend/road-revision-storage.md b/.trellis/spec/backend/road-revision-storage.md index c8361eb..b62ff73 100644 --- a/.trellis/spec/backend/road-revision-storage.md +++ b/.trellis/spec/backend/road-revision-storage.md @@ -8,8 +8,10 @@ ```js ensureRevisionStore(workspace) // -> { paths, active, baseline } +setActiveAreaConfig(workspace, options) // -> { paths, areaConfig } +readAreaConfigSnapshot(file) // -> compiler options createCheckpoint(workspace, label) // -> restored revision -readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides, directEdits, trafficSignals } +readRevision(workspace, revisionId) // -> { manifest, osm, areaConfig, nativeRoadOverrides, directEdits, trafficSignals } ``` ## 3. Contracts @@ -17,14 +19,17 @@ readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides, - Legacy inputs remain at `source.osm`, `native-road-overrides.json`, and `native-traffic-signals.json`; migration must never rewrite them. - OSM bytes are stored once under `osm/.osm`. - `active/native-road-edits.json` starts as a validated v2 document at version `0`; `active/state.json` points to the active revision. -- A revision is immutable at `revisions/rev-NNNN/`. Its `manifest.json` uses `road-workbench-revision/v1`, references the content-addressed OSM, lists frozen JSON documents, and records SHA-256 digests. -- Area-config snapshots are intentionally owned by the subsequent snapshot step. Do not silently fall back to the external config when that step is introduced. +- The active config is `{ schema: 'road-workbench-area-config/v1', options }` at `active/area-config.snapshot.json`. Updating it must also update `base.areaConfigSha256` through a versioned v2 document write. +- A revision is immutable at `revisions/rev-NNNN/`. Its `manifest.json` uses `road-workbench-revision/v1`, references the content-addressed OSM and frozen `area-config.snapshot.json`, lists frozen JSON documents, and records SHA-256 digests. +- `compileInput()` uses `areaConfigSnapshotFile` when present. Its external `options` are ignored in that mode; a missing or invalid snapshot is an error, never a fallback. ## 4. Validation And Error Matrix | Condition | Error / behavior | | --- | --- | | Required legacy input missing | `Revision source is missing: ` | +| Snapshot file is missing | `Area config snapshot is missing: ` | +| Snapshot schema or options are invalid | `Invalid area config snapshot: ` | | Content-addressed OSM differs from its filename digest | `Content-addressed OSM is corrupt` | | Revision manifest schema or ID mismatch | `Invalid revision manifest` | | Revision ID is not `rev-NNNN` | Reject with `Invalid revision id` | @@ -36,7 +41,7 @@ readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides, - Good: first open of a legacy directory adds `osm/`, `active/`, and `revisions/rev-0001/`, leaving old files byte-identical. - Base: opening an already migrated directory is idempotent; an interrupted migration with a baseline but no active state repairs only the missing state file. -- Bad: mutating a frozen revision document or its referenced OSM must make `readRevision()` fail rather than returning altered input. +- Bad: mutating a frozen revision document, config snapshot, or referenced OSM must make `readRevision()` fail rather than returning altered input. ## 6. Tests Required @@ -45,9 +50,13 @@ readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides, - legacy-byte preservation and baseline creation; - equal OSM bytes yielding one content-addressed copy; - named checkpoint creation, parent linkage, and complete restore; -- manifest digest equality with the actual frozen files; +- manifest digest equality with the actual frozen files, including area config; +- active config writes incrementing `documentVersion` and preserving old revision snapshots; +- compiler snapshot precedence and an explicit missing-snapshot error; - invalid checkpoint labels. +`test/fixtures.js` compares normalized current compiler outputs with the checked-in `native-road-package/v1.1` fixture baselines. Regenerate them only through `npm run test:fixtures:update-baseline` after an intentional compiler output change. + ## 7. Wrong Vs Correct ### Wrong @@ -66,3 +75,19 @@ manifest.source = { osmFile: path.relative(paths.workspace, file), osmSha256: di ``` The content address is both the deduplication key and the integrity contract. + +### Wrong + +```js +compileInput({ ...input, options: externalOptions }); +``` + +This allows later external config changes to alter a frozen revision. + +### Correct + +```js +compileInput({ ...input, areaConfigSnapshotFile: revisionSnapshot }); +``` + +The compiler validates and consumes the frozen snapshot exclusively. diff --git a/package.json b/package.json index 90a40bc..b4817a1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/fixtures.js", + "test:fixtures:update-baseline": "node test/update-fixture-baselines.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/compiler.js b/src/compile/compiler.js index 7cf562b..2374e6b 100644 --- a/src/compile/compiler.js +++ b/src/compile/compiler.js @@ -12,13 +12,16 @@ const { } = require('./native-road'); const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest'); const { loadOrGenerate, runtime } = require('../native-traffic-signals'); +const { readAreaConfigSnapshot } = require('./road-revisions'); function compileInput(input) { validateInput(input); + const options = input.areaConfigSnapshotFile ? readAreaConfigSnapshot(input.areaConfigSnapshotFile) : input.options; + validateOptions(options); const area = { id: input.areaId, input: input.osmFile, - nativeRoad: input.options, + nativeRoad: options, outputs: { nativeRoadOverrides: input.overridesFile, nativeTrafficSignals: input.trafficSignalsFile, @@ -161,12 +164,18 @@ function validateInput(input) { if (typeof input[key] !== 'string' || input[key].trim() === '') throw new Error(`RoadCompilerInput.${key} must be a non-empty string`); } - if (!input.options || typeof input.options !== 'object') - throw new Error('RoadCompilerInput.options must be an object'); - if (typeof input.options.edgeLines !== 'boolean') - throw new Error('RoadCompilerInput.options.edgeLines must be a boolean'); - if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== 'object') + if ( + input.areaConfigSnapshotFile !== undefined && + (typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile) + ) + throw new Error('RoadCompilerInput.areaConfigSnapshotFile must be a non-empty string when present'); +} + +function validateOptions(options) { + if (!options || typeof options !== 'object') throw new Error('RoadCompilerInput.options must be an object'); + if (typeof options.edgeLines !== 'boolean') throw new Error('RoadCompilerInput.options.edgeLines must be a boolean'); + if (!options.junctionTemplates || typeof options.junctionTemplates !== 'object') throw new Error('RoadCompilerInput.options.junctionTemplates must be an object'); } -module.exports = { compileInput, validateInput }; +module.exports = { compileInput, validateInput, validateOptions }; diff --git a/src/compile/road-revisions.js b/src/compile/road-revisions.js index e8f50b5..9a3f0e8 100644 --- a/src/compile/road-revisions.js +++ b/src/compile/road-revisions.js @@ -3,10 +3,11 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const { emptyEditDocument, validateEditDocument } = require('./native-road-edits'); +const { emptyEditDocument, loadEditDocument, saveEditDocument, validateEditDocument } = require('./native-road-edits'); const REVISION_SCHEMA = 'road-workbench-revision/v1'; const ACTIVE_STATE_SCHEMA = 'road-workbench-active/v1'; +const AREA_CONFIG_SNAPSHOT_SCHEMA = 'road-workbench-area-config/v1'; const PACKAGE_VERSION = require('../../package.json').version; const GEOMETRY_SCHEMA = 'native-road-package/v1.1'; @@ -39,6 +40,7 @@ function pathsFor(workspace) { osmDirectory: path.join(workspace, 'osm'), activeDirectory: path.join(workspace, 'active'), activeEdits: path.join(workspace, 'active', 'native-road-edits.json'), + activeAreaConfig: path.join(workspace, 'active', 'area-config.snapshot.json'), activeState: path.join(workspace, 'active', 'state.json'), revisionsDirectory: path.join(workspace, 'revisions'), }; @@ -68,18 +70,71 @@ function revisionId(revisionsDirectory) { return `rev-${String(Math.max(0, ...numbers) + 1).padStart(4, '0')}`; } -function activeDocument(paths, osmSha256) { - if (fs.existsSync(paths.activeEdits)) return validateEditDocument(readJson(paths.activeEdits)); - const document = emptyEditDocument({ osmSha256, compilerGeometryVersion: GEOMETRY_SCHEMA }); +function activeDocument(paths, osmSha256, areaConfigSha256) { + if (fs.existsSync(paths.activeEdits)) { + const document = validateEditDocument(readJson(paths.activeEdits)); + if (areaConfigSha256 && document.base.areaConfigSha256 !== areaConfigSha256) { + document.base.areaConfigSha256 = areaConfigSha256; + writeJsonAtomic(paths.activeEdits, document); + } + return document; + } + const document = emptyEditDocument({ osmSha256, areaConfigSha256, compilerGeometryVersion: GEOMETRY_SCHEMA }); writeJsonAtomic(paths.activeEdits, document); return document; } -function ensureRevisionStore(workspace) { +function writeActiveAreaConfig(paths, options) { + if (!options || typeof options !== 'object') throw new Error('Area config options must be an object.'); + const snapshot = { schema: AREA_CONFIG_SNAPSHOT_SCHEMA, options }; + const bytes = `${JSON.stringify(snapshot, null, 2)}\n`; + writeFileAtomic(paths.activeAreaConfig, bytes); + return { bytes, options, digest: sha256(bytes) }; +} + +function setActiveAreaConfig(workspace, options) { + const paths = pathsFor(workspace); + const areaConfig = writeActiveAreaConfig(paths, options); + if (fs.existsSync(paths.activeEdits)) { + const document = loadEditDocument(paths.activeEdits); + const saved = saveEditDocument(paths.activeEdits, { + ...document, + base: { ...document.base, areaConfigSha256: areaConfig.digest }, + }); + if (fs.existsSync(paths.activeState)) { + writeJsonAtomic(paths.activeState, { ...readJson(paths.activeState), documentVersion: saved.documentVersion }); + } + } + return { paths, areaConfig }; +} + +function readAreaConfigSnapshot(file) { + if (!fs.existsSync(file)) throw new Error(`Area config snapshot is missing: ${file}`); + const snapshot = readJson(file); + if ( + !snapshot || + snapshot.schema !== AREA_CONFIG_SNAPSHOT_SCHEMA || + !snapshot.options || + typeof snapshot.options !== 'object' + ) + throw new Error(`Invalid area config snapshot: ${file}`); + return snapshot.options; +} + +function activeAreaConfig(paths) { + const bytes = readFile(paths.activeAreaConfig); + return { bytes, options: readAreaConfigSnapshot(paths.activeAreaConfig), digest: sha256(bytes) }; +} + +function ensureRevisionStore(workspace, options) { const paths = pathsFor(workspace); const osm = storeOsm(paths); - const edits = activeDocument(paths, osm.digest); + const snapshot = fs.existsSync(paths.activeAreaConfig) + ? activeAreaConfig(paths) + : writeActiveAreaConfig(paths, options); + const edits = activeDocument(paths, osm.digest, snapshot.digest); if (fs.existsSync(path.join(paths.revisionsDirectory, 'rev-0001', 'manifest.json'))) { + backfillAreaConfigSnapshots(paths, snapshot); const baseline = readRevision(workspace, 'rev-0001'); const active = fs.existsSync(paths.activeState) ? readJson(paths.activeState) @@ -94,6 +149,7 @@ function ensureRevisionStore(workspace) { const baseline = createRevision(paths, { id: 'rev-0001', directEdits: edits, + areaConfig: snapshot, label: 'Import baseline', }); writeJsonAtomic(paths.activeState, { @@ -104,14 +160,44 @@ function ensureRevisionStore(workspace) { return { paths, active: readJson(paths.activeState), baseline }; } +function backfillAreaConfigSnapshots(paths, areaConfig) { + if (!fs.existsSync(paths.revisionsDirectory)) return; + for (const entry of fs.readdirSync(paths.revisionsDirectory, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^rev-\d{4}$/.test(entry.name)) continue; + const directory = path.join(paths.revisionsDirectory, entry.name); + const manifestFile = path.join(directory, 'manifest.json'); + if (!fs.existsSync(manifestFile)) continue; + const manifest = readJson(manifestFile); + if (manifest.source?.areaConfigFile && manifest.source?.areaConfigSha256) continue; + const directEditsFile = path.join(directory, 'native-road-edits.json'); + const directEdits = validateEditDocument(readJson(directEditsFile)); + directEdits.base.areaConfigSha256 = areaConfig.digest; + writeJsonAtomic(directEditsFile, directEdits); + writeFileAtomic(path.join(directory, 'area-config.snapshot.json'), areaConfig.bytes); + manifest.source = { + ...manifest.source, + areaConfigFile: 'area-config.snapshot.json', + areaConfigSha256: areaConfig.digest, + }; + manifest.digests = { + ...manifest.digests, + directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`), + areaConfigSnapshot: areaConfig.digest, + }; + writeJsonAtomic(manifestFile, manifest); + } +} + function createCheckpoint(workspace, label) { if (typeof label !== 'string' || !label.trim()) throw new Error('Revision checkpoint label must be a non-empty string.'); const initialized = ensureRevisionStore(workspace); const directEdits = validateEditDocument(readJson(initialized.paths.activeEdits)); + const areaConfig = activeAreaConfig(initialized.paths); const revision = createRevision(initialized.paths, { id: revisionId(initialized.paths.revisionsDirectory), directEdits, + areaConfig, label: label.trim(), parentRevisionId: initialized.active.activeRevisionId, }); @@ -123,7 +209,7 @@ function createCheckpoint(workspace, label) { return revision; } -function createRevision(paths, { id, directEdits, label, parentRevisionId = undefined }) { +function createRevision(paths, { id, directEdits, areaConfig, label, parentRevisionId = undefined }) { assertRevisionId(id); const osm = storeOsm(paths); const overrides = readFile(paths.overrides); @@ -142,6 +228,7 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde writeFileAtomic(path.join(staging, files.nativeRoadOverrides), overrides); writeJsonAtomic(path.join(staging, files.directEdits), directEdits); writeFileAtomic(path.join(staging, files.trafficSignals), trafficSignals); + writeFileAtomic(path.join(staging, 'area-config.snapshot.json'), areaConfig.bytes); const manifest = { schema: REVISION_SCHEMA, id, @@ -151,12 +238,15 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde source: { osmFile: path.relative(paths.workspace, osm.file), osmSha256: osm.digest, + areaConfigFile: 'area-config.snapshot.json', + areaConfigSha256: areaConfig.digest, }, documents: files, digests: { nativeRoadOverrides: sha256(overrides), directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`), trafficSignals: sha256(trafficSignals), + areaConfigSnapshot: areaConfig.digest, }, compiler: { packageVersion: PACKAGE_VERSION, geometrySchema: GEOMETRY_SCHEMA }, }; @@ -180,6 +270,11 @@ function readRevision(workspace, id) { const osm = readFile(osmFile); if (sha256(osm) !== manifest.source.osmSha256) throw new Error(`Revision ${id} OSM digest does not match its manifest.`); + const areaConfigFile = path.resolve(directory, manifest.source.areaConfigFile); + if (!isWithin(directory, areaConfigFile)) throw new Error(`Revision ${id} area config path escapes the revision.`); + const areaConfigBytes = readFile(areaConfigFile); + if (sha256(areaConfigBytes) !== manifest.source.areaConfigSha256) + throw new Error(`Revision ${id} area config digest does not match its manifest.`); const documents = Object.fromEntries( Object.entries(manifest.documents).map(([name, file]) => { const document = path.resolve(directory, file); @@ -190,7 +285,7 @@ function readRevision(workspace, id) { return [name, JSON.parse(bytes.toString('utf8'))]; }), ); - return { manifest, osm: osm.toString('utf8'), ...documents }; + return { manifest, osm: osm.toString('utf8'), areaConfig: readAreaConfigSnapshot(areaConfigFile), ...documents }; } function assertRevisionId(id) { @@ -209,8 +304,11 @@ function readJson(file) { module.exports = { REVISION_SCHEMA, ACTIVE_STATE_SCHEMA, + AREA_CONFIG_SNAPSHOT_SCHEMA, sha256, ensureRevisionStore, + setActiveAreaConfig, + readAreaConfigSnapshot, createCheckpoint, readRevision, }; diff --git a/test/baseline/fengshu-er-road.json b/test/baseline/fengshu-er-road.json index 760c23e..6617468 100644 --- a/test/baseline/fengshu-er-road.json +++ b/test/baseline/fengshu-er-road.json @@ -1,105 +1,78 @@ { - "contract": "native-road-package/v1", + "contract": "native-road-package/v1.1", "areaId": "fengshu-er-road", "files": { "../native-traffic-signals.json": { - "contentHash": "88e7f0ef4fc7bdb9ef202af018b3fb3179035ffc8ed5eeba03276c263e1f0476", - "bytes": 5886 + "contentHash": "ad4a30a53be21381c7d203093f2ab1e55b444f01c1bf83388321f130cf6702d6", + "bytes": 3819 }, "comparison.json": { - "contentHash": "651eb4000a44a7521a79c3e1429795b83d20f8899e0c67c4bb25b56cf4a6f75d", - "bytes": 1295 + "contentHash": "a08cdd67cf9a0a2fde8892dccc35f1a5c6b63e8bdda2aaec1774d36370fc6300", + "bytes": 1045 }, "compiled.json": { - "contentHash": "2ab82eafcdab70155078fae3559692ef1fde0645a52c6decbaea762a9ffeb24a", - "bytes": 159230 + "contentHash": "3f1bc0dd16fd3cd566716ba034095200518f2ada5f02619667520fd3024e572a", + "bytes": 126674 }, "diagnostics.json": { - "contentHash": "1d9fdae06ddcbc19b5262b797a4a35c494fc30a98a66dbfa111533b12c5fdfc7", - "bytes": 6862 + "contentHash": "9ecc0aceaceb65c5c0cdb04a1389169f147c2ff193584838acbb1717abf06912", + "bytes": 6261 }, "layers/center_lines.geojson": { - "contentHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", - "orderHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", - "features": 5, - "bytes": 6909 + "contentHash": "dd2d659415c0d2c0a0872bb658153c74cf3578384cbce03055abbd16c5c22249", + "bytes": 4152 }, "layers/connectors.geojson": { - "contentHash": "e7979d4642c7c10f33fb23e92721f22533a851ae1a2b4d35f224d50927bf2fe9", - "orderHash": "c327b7b154d4d9ca04cd895523eb0d8ad67c37051ef1d2135b2b46c51e12df58", - "features": 90, - "bytes": 197885 + "contentHash": "e829361a9223676dc27bda1719910fdcf82e125f4eaec53c3d348a2d328812ce", + "bytes": 124749 }, "layers/crosswalks.geojson": { - "contentHash": "354cd4d7ed105a11b95904b44848dce567b3a3383f76755936d8c00691fe29af", - "orderHash": "d24376438ac956bde52fcbfa955dd72c2ba31a97995608aaed1c79f6fd3633ab", - "features": 152, - "bytes": 219286 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "layers/direction_arrows.geojson": { - "contentHash": "7b8362ad4912c997a7661befde55ddfe58757c3c8d139c15867e1938ea8f6918", - "orderHash": "5274e8b433946410a7cdffdaa452c1750e04f53e73a86bf81d034cfb26d4c254", - "features": 686, - "bytes": 1053308 + "contentHash": "2baaf72199630df794c2551c900ff3c478b294554c1f31e7f14f8131d43df7d3", + "bytes": 567748 }, "layers/edge_lines.geojson": { - "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "features": 0, - "bytes": 52 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "layers/intersection_surface.geojson": { - "contentHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", - "orderHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", - "features": 5, - "bytes": 13048 + "contentHash": "8e46c1f1594022010a123fc69632100f7bd7d09e33e9decad5d53e9b5736fd2d", + "bytes": 14887 }, "layers/lane_centerlines.geojson": { - "contentHash": "19c550c794810c1071de55dee158295235106a56e3877a2ba10da920ee214593", - "orderHash": "67f8e7d33ec578644416e4837dd4aafef62a55aaeb4d438d07ab07eddfbb1df1", - "features": 84, - "bytes": 73294 + "contentHash": "a768fc2382a5eb2c35b19a34836a5f74d9957b035a3c33bb21aa1ee4899c7191", + "bytes": 32242 }, "layers/lane_separators.geojson": { - "contentHash": "02fdf2c7b6e89c2850a60ea2ac72b9f62910259bdb2949681fcaa7e67d2b51f0", - "orderHash": "81782a059c4ff9f69fdf7de9107d20a8d6cf55d34bb0620ec28a9a8c5106c982", - "features": 3596, - "bytes": 4164106 + "contentHash": "32fd4ff71195e48e5b087fba388b5caf9f51de40ecebbabc556d04858e2c912e", + "bytes": 2367951 }, "layers/road_surface.geojson": { - "contentHash": "5a2936a705e64338d0d79b395a13d761f1893dd72330a9b9ee4696ae75eab295", - "orderHash": "d8402ae6e772e0778b862b190abfa291de1b02a15537d455f7dcd8596245dfd8", - "features": 30, - "bytes": 49554 + "contentHash": "880f4c338eceb112364e25738f94572c0c3b4aff68a53da319ef76bbda2cba61", + "bytes": 17464 }, "layers/sidewalk_surface.geojson": { - "contentHash": "b6f8f876e9f18fd2ef6749125a09d654c73d986e916bd262ceb9967bca1a54c6", - "orderHash": "3fd42a67e18e3b8743fa538428e039ef66a1f44150a6881a7aaa18326607b5cb", - "features": 26, - "bytes": 54997 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "layers/turn_arrows.geojson": { - "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "features": 0, - "bytes": 52 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "layers/vehicle_stop_lines.geojson": { - "contentHash": "cb904550ab8eec011cf33e5edfa81044de6dfca0280dbfe488e77229055ed590", - "orderHash": "8a4b877a17a5af60adc8656465afad6756944736291664139e878d46c5a4f363", - "features": 4, - "bytes": 4191 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "traffic-signal-assemblies.json": { - "contentHash": "1788026a0c8c531b2fc7dd49628f7fbadd809c7b2f7f8819cffb3ac7d30689ca", - "orderHash": "b409ea6b77b6c8639f63c33a156581f9b184a84fde34d265b5deb05705828251", - "features": 7, - "bytes": 5411 + "contentHash": "51ce257c59336d4901b478cba30facb55a284d61695ceedb22fb3a8ea881dde3", + "bytes": 3729 }, "traffic-signals.json": { - "contentHash": "625b79b6eb82b41f2099ba51960717c7f794fdfbd87310eb12e99da966540d89", - "bytes": 15491 + "contentHash": "8f5a6007bce6effa49bdc613faaecbe6be2bc9f4a1bbdd5f68f8934da53587ca", + "bytes": 10068 } - }, - "volatileExcluded": ["absolute paths -> or ", "native-road-* staging directory -> "] + } } diff --git a/test/baseline/nantaizi-lake-innovation-valley.json b/test/baseline/nantaizi-lake-innovation-valley.json index bb4b6c4..5f713db 100644 --- a/test/baseline/nantaizi-lake-innovation-valley.json +++ b/test/baseline/nantaizi-lake-innovation-valley.json @@ -1,105 +1,78 @@ { - "contract": "native-road-package/v1", + "contract": "native-road-package/v1.1", "areaId": "nantaizi-lake-innovation-valley", "files": { "../native-traffic-signals.json": { - "contentHash": "36cb9f069fb44a6e24f0af0d130ef8cb5b4a93250dc9fb0891191d1ba039a5b2", - "bytes": 28329 + "contentHash": "35a57a62f211a6f6a3b38e0a378eddd63cb38fa9807c409d1d3c5e52c70c0d6f", + "bytes": 18111 }, "comparison.json": { - "contentHash": "5aa11da443221a48288641ac6950c6e4d75d834713a93110bbf265859202a265", - "bytes": 1153 + "contentHash": "d8d4fc7d72527fce08c766f1619777a69813080a2def35652c402edd55a64a2e", + "bytes": 1015 }, "compiled.json": { - "contentHash": "ea5ae4a04f1259b1ccec9fc1cca7ef8ba81426a77584a447480b6047b37f5e01", - "bytes": 184985 + "contentHash": "ba7c97c7117b882e798185e3f023b8358ff0330e9ce8fe38ee6c8d88dbbda0b6", + "bytes": 135855 }, "diagnostics.json": { - "contentHash": "7e9327f0424dd612f389c560c114a4cafee1349bbfa36f49b2b4eab846e5ca91", - "bytes": 9755 + "contentHash": "05b09f50df213c162978941b5f771c970e9928d73d4f9041c4209db8ffd5d579", + "bytes": 7037 }, "layers/center_lines.geojson": { - "contentHash": "2b2710fce06e75b0a3e4941c2da6119cea7b8bb7834345607bbee384de20ee6b", - "orderHash": "ea71a8841370689d6fc2b75d0b30ec7104638fb2904da6d707dc82ff3131e065", - "features": 511, - "bytes": 694102 + "contentHash": "04a2de8ac58ae27f10be9e2ee0c817b27f41e990caa65e626391cb1f3e1a18c8", + "bytes": 413551 }, "layers/connectors.geojson": { - "contentHash": "901a86bc6c536d38bb73ae4654ec1ce7d1c0790c2d6eba92543c48f584cd15ac", - "orderHash": "4df882ad59b65a97d892df64de0156b35beaf071b04eed671ef7b535e9e2a7b9", - "features": 76, - "bytes": 160866 + "contentHash": "3647dfb768370d66b9b316a7a69356f6217233627804bea7e2566c27023eec05", + "bytes": 99978 }, "layers/crosswalks.geojson": { - "contentHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", - "orderHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", - "features": 48, - "bytes": 50536 + "contentHash": "72079521c1f6891b77b226b87ee3656c113f3a72c4fb51981dfdaa9dafc4eeb2", + "bytes": 28012 }, "layers/direction_arrows.geojson": { - "contentHash": "38e80bbfe02d4f12ce40e7d0c8342e3bef6b602c64f81dd320b366b6a6b3d205", - "orderHash": "579aa6c37585bf4edf9d6b6b1e120f976b93ec2d0c303d82a154b75c114a45f5", - "features": 324, - "bytes": 493431 + "contentHash": "5e60d48520fa09aa75a8aa9bb89c4701c7f5dabc4e5ce57c9dc87bdefbbf3d1c", + "bytes": 270831 }, "layers/edge_lines.geojson": { - "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", - "features": 0, - "bytes": 52 + "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90", + "bytes": 43 }, "layers/intersection_surface.geojson": { - "contentHash": "7f79e57215e3fd07c5c0f703c71e516873db770cb869efdf18925a3184059ca3", - "orderHash": "d69df8d92d0fce8f7ee172959b978d941fd011e094857e9ab7304b3d1e98bf92", - "features": 12, - "bytes": 38528 + "contentHash": "d0620e5c1cda3fd46ca2a3c6484d1e51aa97e717b7326d118f3f2659e5d0dd95", + "bytes": 18960 }, "layers/lane_centerlines.geojson": { - "contentHash": "3fea4ebfe62e0d720c6977e2289f4e6517ed7b3c5ab361270d03378971aec780", - "orderHash": "69a8ebaa42a74da4f7d5940c5a81bd82ceb104efd3f858723dccfadbae12121e", - "features": 50, - "bytes": 43582 + "contentHash": "6589ee8a94f106b05b426b320e78b86b86d390ab6c38fbc7b5bf8e28332509ed", + "bytes": 26292 }, "layers/lane_separators.geojson": { - "contentHash": "dc0b50b53da191e65f3dba01162acb7561966d1796a9c0789527cebfd6ed06a4", - "orderHash": "0bfd873e5698869fb4855783fe30bb6db29456958e4d154b7264374a5fdb4a10", - "features": 731, - "bytes": 835933 + "contentHash": "545a1ed21674d7e6ead04c70eeb511ae59760f49b0a734b79d3ad01ef4f39382", + "bytes": 471152 }, "layers/road_surface.geojson": { - "contentHash": "81af19c71cf6fc8a3884488c00cfb590e1a7fe9ed9e4351fce65825ceadd50fa", - "orderHash": "f2ddec5258411fbdbc050b7ecefbb157b59e00b8226afcc30d2b772e1859e357", - "features": 25, - "bytes": 38275 + "contentHash": "176e2dd1e7bc73687283eece17827d7aeaa0e3b0840a31583dd88b821a93f987", + "bytes": 20244 }, "layers/sidewalk_surface.geojson": { - "contentHash": "bb8f4516e27b16238d753b112d18b8acfa5cfad2cea522a65b7972a609be9563", - "orderHash": "4fa773a6f39c5b6d23551e1338c6d99ef8a5ecd9ebfcafa16154c9765fef8c6e", - "features": 73, - "bytes": 122432 + "contentHash": "7ce873294a163973cfd4a4a439e3c55fb003e22985943165210268288fe8f690", + "bytes": 32236 }, "layers/turn_arrows.geojson": { - "contentHash": "a65bb8fe079f27e00bf2e6208148fc6b73d2d07e76d48b1cd1bf8a610d7e2d66", - "orderHash": "66966ac2c65464f9c6715ca9cda5a3620520bf1d066cba2dee36114dc46af936", - "features": 108, - "bytes": 132536 + "contentHash": "599820effb9db9e497e8415f3191870baf1f1273aeb81cb4f0034f76c5ca6b40", + "bytes": 75592 }, "layers/vehicle_stop_lines.geojson": { - "contentHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", - "orderHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", - "features": 8, - "bytes": 8475 + "contentHash": "c4cb8c3235fae7e2fd64306fe1b853d604ee1811286162f79f9ca8286b6a922f", + "bytes": 4711 }, "traffic-signal-assemblies.json": { - "contentHash": "9f8d032c00784bb72bb1322238cd7b0a80e621d435b43c728da86f1e77ca352a", - "orderHash": "0b9cae13094dba1994faca4bf1d4d80e8bc5497daa46df5899bf5e6fc7f26d61", - "features": 35, - "bytes": 26398 + "contentHash": "14a66bd60cae387891dc6e29d8bdcbe4a021dc0c7f75dc3629cae6f25195959e", + "bytes": 18021 }, "traffic-signals.json": { - "contentHash": "c4b68cc1cefeb0d393a18eef6c7eb79933fba28630826745165ed433c25779d0", - "bytes": 74151 + "contentHash": "f405a47d61c69ebd76f19339b54d468dfab39d7c7115417c6f790c26fc661c79", + "bytes": 47678 } - }, - "volatileExcluded": ["absolute paths -> or ", "native-road-* staging directory -> "] + } } diff --git a/test/fixture-baseline.js b/test/fixture-baseline.js new file mode 100644 index 0000000..65a5af4 --- /dev/null +++ b/test/fixture-baseline.js @@ -0,0 +1,71 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); + +function fixtureInputs() { + return ['fengshu-er-road', 'nantaizi-lake-innovation-valley'].map((areaId) => { + const isFengshu = areaId === 'fengshu-er-road'; + const outputRoot = path.join(root, 'outputs', areaId); + return { + areaId, + osmFile: path.join(root, 'inputs', 'osm', isFengshu ? '枫树二路.osm' : '南台子湖创新谷OSM.osm'), + outDir: path.join(outputRoot, 'native-road'), + stagingDir: path.join(outputRoot, '_pipeline'), + overridesFile: path.join(outputRoot, 'native-road-overrides.json'), + trafficSignalsFile: path.join(outputRoot, 'native-traffic-signals.json'), + comparisonDir: path.join(outputRoot, 'osm2streets_web_out'), + options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } }, + }; + }); +} + +function digest(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function canonicalContent(file) { + const raw = fs.readFileSync(file, 'utf8'); + if (!file.endsWith('.json') && !file.endsWith('.geojson')) return raw; + return `${JSON.stringify(normalize(JSON.parse(raw)))}\n`; +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)])); + return typeof value === 'string' ? value.split(root).join('') : value; +} + +function snapshot(input) { + const files = {}; + const candidates = [ + ['../native-traffic-signals.json', input.trafficSignalsFile], + ...walk(input.outDir).filter(([relative]) => relative !== 'manifest.json'), + ]; + for (const [relative, file] of candidates) { + const content = canonicalContent(file); + files[relative] = { contentHash: digest(content), bytes: Buffer.byteLength(content) }; + } + return { contract: 'native-road-package/v1.1', areaId: input.areaId, files }; +} + +function walk(directory, prefix = '') { + return fs + .readdirSync(directory, { withFileTypes: true }) + .sort((first, second) => first.name.localeCompare(second.name)) + .flatMap((entry) => { + const relative = path.join(prefix, entry.name); + const file = path.join(directory, entry.name); + return entry.isDirectory() ? walk(file, relative) : [[relative, file]]; + }); +} + +function baselineFile(areaId) { + return path.join(root, 'test', 'baseline', `${areaId}.json`); +} + +module.exports = { root, fixtureInputs, snapshot, baselineFile }; diff --git a/test/fixtures.js b/test/fixtures.js index 0d4a436..4dcffdc 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -8,25 +8,14 @@ const path = require('path'); const { unzipSync, strFromU8 } = require('fflate'); const { compiler } = require('../src'); const { exportNativeRoadPackage } = require('../src/export/native-road-package'); +const { root, fixtureInputs, snapshot, baselineFile } = require('./fixture-baseline'); -const root = path.resolve(__dirname, '..'); -for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) { - const isFengshu = areaId === 'fengshu-er-road'; - const outputRoot = path.join(root, 'outputs', areaId); - const input = { - areaId, - osmFile: path.join(root, 'inputs', 'osm', isFengshu ? '枫树二路.osm' : '南台子湖创新谷OSM.osm'), - outDir: path.join(outputRoot, 'native-road'), - stagingDir: path.join(outputRoot, '_pipeline'), - overridesFile: path.join(outputRoot, 'native-road-overrides.json'), - trafficSignalsFile: path.join(outputRoot, 'native-traffic-signals.json'), - comparisonDir: path.join(outputRoot, 'osm2streets_web_out'), - options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } }, - }; - assert.ok(fs.existsSync(path.join(root, 'test', 'baseline', `${areaId}.json`))); +for (const input of fixtureInputs()) { + assert.ok(fs.existsSync(baselineFile(input.areaId))); const { result } = compiler.compileInput(input); - assert.equal(result.areaId, areaId); + assert.equal(result.areaId, input.areaId); assert.ok(fs.existsSync(path.join(input.outDir, 'compiled.json'))); + assert.deepEqual(snapshot(input), JSON.parse(fs.readFileSync(baselineFile(input.areaId), 'utf8'))); const first = exportNativeRoadPackage(input.outDir); const second = exportNativeRoadPackage(input.outDir); assert.equal( @@ -35,7 +24,7 @@ for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) { ); const entries = unzipSync(first.bytes); const manifest = JSON.parse(strFromU8(entries['manifest.json'])); - assert.equal(manifest.areaId, areaId); + assert.equal(manifest.areaId, input.areaId); assert.equal(manifest.contract, 'native-road-package/v1.1'); assert.deepEqual(manifest.generator, { name: 'road-compiler', version: '0.3.0' }); assert.equal('source' in JSON.parse(strFromU8(entries['compiled.json'])), false); @@ -44,9 +33,9 @@ for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) { false, ); assert.equal(Object.keys(entries).length, 18); - if (isFengshu) { - const inputFile = path.join(outputRoot, 'road-compiler-input.json'); - const archive = path.join(outputRoot, 'export.zip'); + if (input.areaId === 'fengshu-er-road') { + const inputFile = path.join(path.dirname(input.outDir), 'road-compiler-input.json'); + const archive = path.join(path.dirname(input.outDir), 'export.zip'); fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`); execFileSync(process.execPath, [ path.join(root, 'bin', 'road-compiler.js'), diff --git a/test/road-revisions.js b/test/road-revisions.js index 4cb43f7..850411f 100644 --- a/test/road-revisions.js +++ b/test/road-revisions.js @@ -6,6 +6,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { nativeRoadEdits, roadRevisions } = require('../src'); +const { compileInput } = require('../src/compile/compiler'); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-')); const source = '\n'; @@ -15,6 +16,7 @@ const signals = { provenance: 'empty', assemblies: { type: 'FeatureCollection', features: [] }, }; +const options = { edgeLines: false, junctionTemplates: { enabled: false, references: [] } }; fs.writeFileSync(path.join(directory, 'source.osm'), source); fs.writeFileSync(path.join(directory, 'native-road-overrides.json'), `${JSON.stringify(overrides, null, 2)}\n`); @@ -26,19 +28,21 @@ const legacyBytes = new Map( ]), ); -const first = roadRevisions.ensureRevisionStore(directory); +const first = roadRevisions.ensureRevisionStore(directory, options); assert.equal(first.baseline.manifest.id, 'rev-0001'); assert.equal(first.baseline.manifest.schema, roadRevisions.REVISION_SCHEMA); assert.equal(first.active.activeRevisionId, 'rev-0001'); assert.equal(first.baseline.directEdits.schema, nativeRoadEdits.EDITS_SCHEMA); assert.equal(first.baseline.directEdits.documentVersion, 0); +assert.equal(first.baseline.directEdits.base.areaConfigSha256, first.baseline.manifest.source.areaConfigSha256); +assert.deepEqual(first.baseline.areaConfig, options); for (const [file, bytes] of legacyBytes) assert.deepEqual(fs.readFileSync(path.join(directory, file)), bytes, `${file} remains byte-for-byte unchanged`); const digest = crypto.createHash('sha256').update(source).digest('hex'); assert.equal(first.baseline.manifest.source.osmSha256, digest); assert.equal(fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length, 1); -roadRevisions.ensureRevisionStore(directory); +roadRevisions.ensureRevisionStore(directory, options); assert.equal( fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length, 1, @@ -47,6 +51,7 @@ assert.equal( const activeEdits = nativeRoadEdits.emptyEditDocument({ osmSha256: digest, + areaConfigSha256: first.baseline.manifest.source.areaConfigSha256, compilerGeometryVersion: 'native-road-package/v1.1', }); activeEdits.operations = [{ id: 'op-1', createdAt: '2026-08-26T12:00:00.000Z', constraintIds: [] }]; @@ -58,6 +63,7 @@ assert.equal(checkpoint.manifest.parentRevisionId, 'rev-0001'); assert.deepEqual(checkpoint.nativeRoadOverrides, overrides); assert.deepEqual(checkpoint.trafficSignals, signals); assert.deepEqual(checkpoint.directEdits, activeEdits); +assert.deepEqual(checkpoint.areaConfig, options); assert.equal( checkpoint.manifest.digests.nativeRoadOverrides, crypto @@ -72,6 +78,54 @@ assert.deepEqual( ); assert.throws(() => roadRevisions.createCheckpoint(directory, ''), /label must be a non-empty string/); assert.throws(() => roadRevisions.readRevision(directory, '../rev-0002'), /Invalid revision id/); +const changedOptions = { edgeLines: true, junctionTemplates: { enabled: false, references: [] } }; +const changedSnapshot = roadRevisions.setActiveAreaConfig(directory, changedOptions); +assert.equal( + nativeRoadEdits.loadEditDocument(path.join(directory, 'active', 'native-road-edits.json')).base.areaConfigSha256, + changedSnapshot.areaConfig.digest, + 'the active document points to the current snapshot', +); +assert.equal( + nativeRoadEdits.loadEditDocument(path.join(directory, 'active', 'native-road-edits.json')).documentVersion, + 1, + 'updating the active snapshot is a versioned document write', +); +assert.deepEqual( + roadRevisions.readRevision(directory, 'rev-0002').areaConfig, + options, + 'old revisions retain their snapshot', +); +roadRevisions.setActiveAreaConfig(directory, options); + +const fixture = path.join(__dirname, 'fixtures', 'fengshu-er-road.osm'); +fs.copyFileSync(fixture, path.join(directory, 'source.osm')); +const frozen = roadRevisions.createCheckpoint(directory, 'Fixture baseline'); +const snapshot = path.join(directory, 'active', 'area-config.snapshot.json'); +const compileBase = { + areaId: 'snapshot-test', + osmFile: path.join(directory, 'source.osm'), + outDir: path.join(directory, 'snapshot-output'), + stagingDir: path.join(directory, 'snapshot-pipeline'), + overridesFile: path.join(directory, 'native-road-overrides.json'), + trafficSignalsFile: path.join(directory, 'native-traffic-signals.json'), + options: { edgeLines: true, junctionTemplates: { enabled: false, references: [] } }, + areaConfigSnapshotFile: snapshot, +}; +const fromSnapshot = compileInput(compileBase); +assert.equal(fromSnapshot.area.nativeRoad.edgeLines, false, 'the snapshot wins over changed external options'); +const fromRevision = compileInput({ + ...compileBase, + areaId: 'frozen-revision-test', + outDir: path.join(directory, 'revision-output'), + stagingDir: path.join(directory, 'revision-pipeline'), + osmFile: path.join(directory, frozen.manifest.source.osmFile), + areaConfigSnapshotFile: path.join(directory, 'revisions', frozen.manifest.id, 'area-config.snapshot.json'), +}); +assert.equal(fromRevision.area.nativeRoad.edgeLines, false, 'a frozen revision ignores later external config changes'); +assert.throws( + () => compileInput({ ...compileBase, areaConfigSnapshotFile: path.join(directory, 'missing.snapshot.json') }), + /Area config snapshot is missing/, +); fs.rmSync(directory, { recursive: true, force: true }); console.log('road revision storage tests passed'); diff --git a/test/update-fixture-baselines.js b/test/update-fixture-baselines.js new file mode 100644 index 0000000..1fc8dae --- /dev/null +++ b/test/update-fixture-baselines.js @@ -0,0 +1,12 @@ +'use strict'; + +const fs = require('fs'); +const { compiler } = require('../src'); +const { fixtureInputs, snapshot, baselineFile } = require('./fixture-baseline'); + +for (const input of fixtureInputs()) { + compiler.compileInput(input); + fs.writeFileSync(baselineFile(input.areaId), `${JSON.stringify(snapshot(input), null, 2)}\n`); +} + +console.log('road compiler fixture baselines updated'); diff --git a/workbench/server.js b/workbench/server.js index 6b1ab18..4f688e4 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -9,7 +9,7 @@ const { generate, validateDocument, runtime } = require('../src/native-traffic-s const { convertGeoJson } = require('../src/reference/gaode'); const { exportNativeRoadPackage } = require('../src/export/native-road-package'); const { compileInput } = require('../src/compile/compiler'); -const { ensureRevisionStore } = require('../src/compile/road-revisions'); +const { ensureRevisionStore, setActiveAreaConfig } = require('../src/compile/road-revisions'); function startWorkbench({ area = null, @@ -39,7 +39,7 @@ function startWorkbench({ }; if (input) { session.context.compileFresh = () => { - const compiled = compileInput(input); + const compiled = compileWorkbenchInput(input); session.area = compiled.area; return compiled; }; @@ -143,16 +143,15 @@ function handle(request, response, session) { .then((body) => { if (!area) throw new Error('请先导入 OSM 文件。'); if (!debug) throw new Error('该接口仅在 --debug 模式下可用。'); - const added = addJunctionCluster( - context.configPath, - body, - readCompiled(area), - context.readAreaConfig, - context.repoRoot, - ); + const added = addJunctionCluster(body, readCompiled(area), area.nativeRoad); + setActiveAreaConfig(path.dirname(area.input), added.options); context.compileFresh(); - const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot }); - sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) }); + sendJson(response, 200, { + ok: true, + added: added.cluster, + configSnippet: { nativeRoad: { junctionTemplates: added.options.junctionTemplates } }, + ...state(session.area, junctionReference, debug), + }); }) .catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === 'POST' && url.pathname === '/api/compile') @@ -170,7 +169,7 @@ function handle(request, response, session) { function state(area, junctionReference = null, debug = false) { // This is the read entry point for both a fresh import and a reopened legacy // workspace. Initialization only adds the v2 layout; it never rewrites v1 files. - ensureRevisionStore(path.dirname(area.input)); + ensureRevisionStore(path.dirname(area.input), area.nativeRoad); const nativeDir = area.outputs.nativeRoadDir; const osm2streetsRoadSurface = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, 'road_surface.geojson') @@ -214,18 +213,16 @@ function state(area, junctionReference = null, debug = false) { // The compiler reports candidates as advisory diagnostics. Lift them into their // own payload with a stable index so the map can label them "#1, #2, ..." and // the inspector can offer a ready-to-paste cluster配置. -// Append one detected cluster to the hand-authored area config. The candidate +// Append one detected cluster to the active area-config snapshot. The candidate // must still be present in the latest compile, so a stale browser tab cannot -// write a cluster that no longer exists. The edited config is validated by the -// real loader before it replaces the file: an invalid write would break every -// later command, and the file is git-tracked so a bad accept stays revertible. -function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) { +// write a cluster that no longer exists. The external config remains untouched; +// the response carries a copyable snippet while future compiles use the snapshot. +function addJunctionCluster(body, compiled, options) { const index = Number(body?.index); if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。'); const candidate = junctionCandidates(compiled).find((item) => item.index === index); if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`); - const raw = readJson(configPath); - const templates = raw.nativeRoad?.junctionTemplates; + const templates = options?.junctionTemplates; if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates,请先手工建立该节点。'); const clusters = Array.isArray(templates.clusters) ? templates.clusters : []; const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String))); @@ -240,24 +237,11 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot outerRadiusExtraMeters: 18, nodeIds: candidate.nodeIds.map(String), }; - const next = { - ...raw, - nativeRoad: { - ...raw.nativeRoad, - junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] }, - }, + const nextOptions = { + ...options, + junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] }, }; - const staging = `${configPath}.candidate-${process.pid}.json`; - fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`); - try { - readAreaConfig(staging, { repoRoot }); - } catch (error) { - fs.unlinkSync(staging); - throw new Error(`写入后的配置无法通过校验,已放弃:${error.message}`); - } - fs.unlinkSync(staging); - writeJsonAtomic(configPath, next); - return cluster; + return { cluster, options: nextOptions }; } function uniqueClusterId(base, taken) { if (!taken.has(base)) return base; @@ -328,8 +312,7 @@ function readUpload(request, session) { ), ); try { - const compiled = compileInput(input); - ensureRevisionStore(root); + const compiled = compileWorkbenchInput(input); session.area = compiled.area; return compiled; } catch (error) { @@ -338,6 +321,11 @@ function readUpload(request, session) { } }); } +function compileWorkbenchInput(input) { + const workspace = path.dirname(input.osmFile); + const initialized = ensureRevisionStore(workspace, input.options); + return compileInput({ ...input, areaConfigSnapshotFile: initialized.paths.activeAreaConfig }); +} function readMultipart(request, limit) { return new Promise((resolve, reject) => { const type = request.headers['content-type'] || '';