feat: compile revisions from area config snapshots

This commit is contained in:
2026-08-26 18:08:55 +08:00
parent c0c8a16dd4
commit ba8eaba9d9
11 changed files with 403 additions and 210 deletions

View File

@@ -8,8 +8,10 @@
```js ```js
ensureRevisionStore(workspace) // -> { paths, active, baseline } ensureRevisionStore(workspace) // -> { paths, active, baseline }
setActiveAreaConfig(workspace, options) // -> { paths, areaConfig }
readAreaConfigSnapshot(file) // -> compiler options
createCheckpoint(workspace, label) // -> restored revision createCheckpoint(workspace, label) // -> restored revision
readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides, directEdits, trafficSignals } readRevision(workspace, revisionId) // -> { manifest, osm, areaConfig, nativeRoadOverrides, directEdits, trafficSignals }
``` ```
## 3. Contracts ## 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. - 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/<sha256>.osm`. - OSM bytes are stored once under `osm/<sha256>.osm`.
- `active/native-road-edits.json` starts as a validated v2 document at version `0`; `active/state.json` points to the active revision. - `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. - 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.
- 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. - 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 ## 4. Validation And Error Matrix
| Condition | Error / behavior | | Condition | Error / behavior |
| --- | --- | | --- | --- |
| Required legacy input missing | `Revision source is missing: <path>` | | Required legacy input missing | `Revision source is missing: <path>` |
| Snapshot file is missing | `Area config snapshot is missing: <path>` |
| Snapshot schema or options are invalid | `Invalid area config snapshot: <path>` |
| Content-addressed OSM differs from its filename digest | `Content-addressed OSM is corrupt` | | Content-addressed OSM differs from its filename digest | `Content-addressed OSM is corrupt` |
| Revision manifest schema or ID mismatch | `Invalid revision manifest` | | Revision manifest schema or ID mismatch | `Invalid revision manifest` |
| Revision ID is not `rev-NNNN` | Reject with `Invalid revision id` | | 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. - 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. - 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 ## 6. Tests Required
@@ -45,9 +50,13 @@ readRevision(workspace, revisionId) // -> { manifest, osm, nativeRoadOverrides,
- legacy-byte preservation and baseline creation; - legacy-byte preservation and baseline creation;
- equal OSM bytes yielding one content-addressed copy; - equal OSM bytes yielding one content-addressed copy;
- named checkpoint creation, parent linkage, and complete restore; - 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. - 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 ## 7. Wrong Vs Correct
### Wrong ### 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. 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.

View File

@@ -9,6 +9,7 @@
}, },
"scripts": { "scripts": {
"test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/fixtures.js", "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: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",

View File

@@ -12,13 +12,16 @@ const {
} = require('./native-road'); } = require('./native-road');
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest'); const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
const { loadOrGenerate, runtime } = require('../native-traffic-signals'); const { loadOrGenerate, runtime } = require('../native-traffic-signals');
const { readAreaConfigSnapshot } = require('./road-revisions');
function compileInput(input) { function compileInput(input) {
validateInput(input); validateInput(input);
const options = input.areaConfigSnapshotFile ? readAreaConfigSnapshot(input.areaConfigSnapshotFile) : input.options;
validateOptions(options);
const area = { const area = {
id: input.areaId, id: input.areaId,
input: input.osmFile, input: input.osmFile,
nativeRoad: input.options, nativeRoad: options,
outputs: { outputs: {
nativeRoadOverrides: input.overridesFile, nativeRoadOverrides: input.overridesFile,
nativeTrafficSignals: input.trafficSignalsFile, nativeTrafficSignals: input.trafficSignalsFile,
@@ -161,12 +164,18 @@ function validateInput(input) {
if (typeof input[key] !== 'string' || input[key].trim() === '') if (typeof input[key] !== 'string' || input[key].trim() === '')
throw new Error(`RoadCompilerInput.${key} must be a non-empty string`); throw new Error(`RoadCompilerInput.${key} must be a non-empty string`);
} }
if (!input.options || typeof input.options !== 'object') if (
throw new Error('RoadCompilerInput.options must be an object'); input.areaConfigSnapshotFile !== undefined &&
if (typeof input.options.edgeLines !== 'boolean') (typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile)
throw new Error('RoadCompilerInput.options.edgeLines must be a boolean'); )
if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== 'object') 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'); throw new Error('RoadCompilerInput.options.junctionTemplates must be an object');
} }
module.exports = { compileInput, validateInput }; module.exports = { compileInput, validateInput, validateOptions };

View File

@@ -3,10 +3,11 @@
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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 REVISION_SCHEMA = 'road-workbench-revision/v1';
const ACTIVE_STATE_SCHEMA = 'road-workbench-active/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 PACKAGE_VERSION = require('../../package.json').version;
const GEOMETRY_SCHEMA = 'native-road-package/v1.1'; const GEOMETRY_SCHEMA = 'native-road-package/v1.1';
@@ -39,6 +40,7 @@ function pathsFor(workspace) {
osmDirectory: path.join(workspace, 'osm'), osmDirectory: path.join(workspace, 'osm'),
activeDirectory: path.join(workspace, 'active'), activeDirectory: path.join(workspace, 'active'),
activeEdits: path.join(workspace, 'active', 'native-road-edits.json'), 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'), activeState: path.join(workspace, 'active', 'state.json'),
revisionsDirectory: path.join(workspace, 'revisions'), revisionsDirectory: path.join(workspace, 'revisions'),
}; };
@@ -68,18 +70,71 @@ function revisionId(revisionsDirectory) {
return `rev-${String(Math.max(0, ...numbers) + 1).padStart(4, '0')}`; return `rev-${String(Math.max(0, ...numbers) + 1).padStart(4, '0')}`;
} }
function activeDocument(paths, osmSha256) { function activeDocument(paths, osmSha256, areaConfigSha256) {
if (fs.existsSync(paths.activeEdits)) return validateEditDocument(readJson(paths.activeEdits)); if (fs.existsSync(paths.activeEdits)) {
const document = emptyEditDocument({ osmSha256, compilerGeometryVersion: GEOMETRY_SCHEMA }); 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); writeJsonAtomic(paths.activeEdits, document);
return 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 paths = pathsFor(workspace);
const osm = storeOsm(paths); 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'))) { if (fs.existsSync(path.join(paths.revisionsDirectory, 'rev-0001', 'manifest.json'))) {
backfillAreaConfigSnapshots(paths, snapshot);
const baseline = readRevision(workspace, 'rev-0001'); const baseline = readRevision(workspace, 'rev-0001');
const active = fs.existsSync(paths.activeState) const active = fs.existsSync(paths.activeState)
? readJson(paths.activeState) ? readJson(paths.activeState)
@@ -94,6 +149,7 @@ function ensureRevisionStore(workspace) {
const baseline = createRevision(paths, { const baseline = createRevision(paths, {
id: 'rev-0001', id: 'rev-0001',
directEdits: edits, directEdits: edits,
areaConfig: snapshot,
label: 'Import baseline', label: 'Import baseline',
}); });
writeJsonAtomic(paths.activeState, { writeJsonAtomic(paths.activeState, {
@@ -104,14 +160,44 @@ function ensureRevisionStore(workspace) {
return { paths, active: readJson(paths.activeState), baseline }; 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) { function createCheckpoint(workspace, label) {
if (typeof label !== 'string' || !label.trim()) if (typeof label !== 'string' || !label.trim())
throw new Error('Revision checkpoint label must be a non-empty string.'); throw new Error('Revision checkpoint label must be a non-empty string.');
const initialized = ensureRevisionStore(workspace); const initialized = ensureRevisionStore(workspace);
const directEdits = validateEditDocument(readJson(initialized.paths.activeEdits)); const directEdits = validateEditDocument(readJson(initialized.paths.activeEdits));
const areaConfig = activeAreaConfig(initialized.paths);
const revision = createRevision(initialized.paths, { const revision = createRevision(initialized.paths, {
id: revisionId(initialized.paths.revisionsDirectory), id: revisionId(initialized.paths.revisionsDirectory),
directEdits, directEdits,
areaConfig,
label: label.trim(), label: label.trim(),
parentRevisionId: initialized.active.activeRevisionId, parentRevisionId: initialized.active.activeRevisionId,
}); });
@@ -123,7 +209,7 @@ function createCheckpoint(workspace, label) {
return revision; return revision;
} }
function createRevision(paths, { id, directEdits, label, parentRevisionId = undefined }) { function createRevision(paths, { id, directEdits, areaConfig, label, parentRevisionId = undefined }) {
assertRevisionId(id); assertRevisionId(id);
const osm = storeOsm(paths); const osm = storeOsm(paths);
const overrides = readFile(paths.overrides); const overrides = readFile(paths.overrides);
@@ -142,6 +228,7 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde
writeFileAtomic(path.join(staging, files.nativeRoadOverrides), overrides); writeFileAtomic(path.join(staging, files.nativeRoadOverrides), overrides);
writeJsonAtomic(path.join(staging, files.directEdits), directEdits); writeJsonAtomic(path.join(staging, files.directEdits), directEdits);
writeFileAtomic(path.join(staging, files.trafficSignals), trafficSignals); writeFileAtomic(path.join(staging, files.trafficSignals), trafficSignals);
writeFileAtomic(path.join(staging, 'area-config.snapshot.json'), areaConfig.bytes);
const manifest = { const manifest = {
schema: REVISION_SCHEMA, schema: REVISION_SCHEMA,
id, id,
@@ -151,12 +238,15 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde
source: { source: {
osmFile: path.relative(paths.workspace, osm.file), osmFile: path.relative(paths.workspace, osm.file),
osmSha256: osm.digest, osmSha256: osm.digest,
areaConfigFile: 'area-config.snapshot.json',
areaConfigSha256: areaConfig.digest,
}, },
documents: files, documents: files,
digests: { digests: {
nativeRoadOverrides: sha256(overrides), nativeRoadOverrides: sha256(overrides),
directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`), directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`),
trafficSignals: sha256(trafficSignals), trafficSignals: sha256(trafficSignals),
areaConfigSnapshot: areaConfig.digest,
}, },
compiler: { packageVersion: PACKAGE_VERSION, geometrySchema: GEOMETRY_SCHEMA }, compiler: { packageVersion: PACKAGE_VERSION, geometrySchema: GEOMETRY_SCHEMA },
}; };
@@ -180,6 +270,11 @@ function readRevision(workspace, id) {
const osm = readFile(osmFile); const osm = readFile(osmFile);
if (sha256(osm) !== manifest.source.osmSha256) if (sha256(osm) !== manifest.source.osmSha256)
throw new Error(`Revision ${id} OSM digest does not match its manifest.`); 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( const documents = Object.fromEntries(
Object.entries(manifest.documents).map(([name, file]) => { Object.entries(manifest.documents).map(([name, file]) => {
const document = path.resolve(directory, file); const document = path.resolve(directory, file);
@@ -190,7 +285,7 @@ function readRevision(workspace, id) {
return [name, JSON.parse(bytes.toString('utf8'))]; 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) { function assertRevisionId(id) {
@@ -209,8 +304,11 @@ function readJson(file) {
module.exports = { module.exports = {
REVISION_SCHEMA, REVISION_SCHEMA,
ACTIVE_STATE_SCHEMA, ACTIVE_STATE_SCHEMA,
AREA_CONFIG_SNAPSHOT_SCHEMA,
sha256, sha256,
ensureRevisionStore, ensureRevisionStore,
setActiveAreaConfig,
readAreaConfigSnapshot,
createCheckpoint, createCheckpoint,
readRevision, readRevision,
}; };

View File

@@ -1,105 +1,78 @@
{ {
"contract": "native-road-package/v1", "contract": "native-road-package/v1.1",
"areaId": "fengshu-er-road", "areaId": "fengshu-er-road",
"files": { "files": {
"../native-traffic-signals.json": { "../native-traffic-signals.json": {
"contentHash": "88e7f0ef4fc7bdb9ef202af018b3fb3179035ffc8ed5eeba03276c263e1f0476", "contentHash": "ad4a30a53be21381c7d203093f2ab1e55b444f01c1bf83388321f130cf6702d6",
"bytes": 5886 "bytes": 3819
}, },
"comparison.json": { "comparison.json": {
"contentHash": "651eb4000a44a7521a79c3e1429795b83d20f8899e0c67c4bb25b56cf4a6f75d", "contentHash": "a08cdd67cf9a0a2fde8892dccc35f1a5c6b63e8bdda2aaec1774d36370fc6300",
"bytes": 1295 "bytes": 1045
}, },
"compiled.json": { "compiled.json": {
"contentHash": "2ab82eafcdab70155078fae3559692ef1fde0645a52c6decbaea762a9ffeb24a", "contentHash": "3f1bc0dd16fd3cd566716ba034095200518f2ada5f02619667520fd3024e572a",
"bytes": 159230 "bytes": 126674
}, },
"diagnostics.json": { "diagnostics.json": {
"contentHash": "1d9fdae06ddcbc19b5262b797a4a35c494fc30a98a66dbfa111533b12c5fdfc7", "contentHash": "9ecc0aceaceb65c5c0cdb04a1389169f147c2ff193584838acbb1717abf06912",
"bytes": 6862 "bytes": 6261
}, },
"layers/center_lines.geojson": { "layers/center_lines.geojson": {
"contentHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", "contentHash": "dd2d659415c0d2c0a0872bb658153c74cf3578384cbce03055abbd16c5c22249",
"orderHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", "bytes": 4152
"features": 5,
"bytes": 6909
}, },
"layers/connectors.geojson": { "layers/connectors.geojson": {
"contentHash": "e7979d4642c7c10f33fb23e92721f22533a851ae1a2b4d35f224d50927bf2fe9", "contentHash": "e829361a9223676dc27bda1719910fdcf82e125f4eaec53c3d348a2d328812ce",
"orderHash": "c327b7b154d4d9ca04cd895523eb0d8ad67c37051ef1d2135b2b46c51e12df58", "bytes": 124749
"features": 90,
"bytes": 197885
}, },
"layers/crosswalks.geojson": { "layers/crosswalks.geojson": {
"contentHash": "354cd4d7ed105a11b95904b44848dce567b3a3383f76755936d8c00691fe29af", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "d24376438ac956bde52fcbfa955dd72c2ba31a97995608aaed1c79f6fd3633ab", "bytes": 43
"features": 152,
"bytes": 219286
}, },
"layers/direction_arrows.geojson": { "layers/direction_arrows.geojson": {
"contentHash": "7b8362ad4912c997a7661befde55ddfe58757c3c8d139c15867e1938ea8f6918", "contentHash": "2baaf72199630df794c2551c900ff3c478b294554c1f31e7f14f8131d43df7d3",
"orderHash": "5274e8b433946410a7cdffdaa452c1750e04f53e73a86bf81d034cfb26d4c254", "bytes": 567748
"features": 686,
"bytes": 1053308
}, },
"layers/edge_lines.geojson": { "layers/edge_lines.geojson": {
"contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "bytes": 43
"features": 0,
"bytes": 52
}, },
"layers/intersection_surface.geojson": { "layers/intersection_surface.geojson": {
"contentHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", "contentHash": "8e46c1f1594022010a123fc69632100f7bd7d09e33e9decad5d53e9b5736fd2d",
"orderHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", "bytes": 14887
"features": 5,
"bytes": 13048
}, },
"layers/lane_centerlines.geojson": { "layers/lane_centerlines.geojson": {
"contentHash": "19c550c794810c1071de55dee158295235106a56e3877a2ba10da920ee214593", "contentHash": "a768fc2382a5eb2c35b19a34836a5f74d9957b035a3c33bb21aa1ee4899c7191",
"orderHash": "67f8e7d33ec578644416e4837dd4aafef62a55aaeb4d438d07ab07eddfbb1df1", "bytes": 32242
"features": 84,
"bytes": 73294
}, },
"layers/lane_separators.geojson": { "layers/lane_separators.geojson": {
"contentHash": "02fdf2c7b6e89c2850a60ea2ac72b9f62910259bdb2949681fcaa7e67d2b51f0", "contentHash": "32fd4ff71195e48e5b087fba388b5caf9f51de40ecebbabc556d04858e2c912e",
"orderHash": "81782a059c4ff9f69fdf7de9107d20a8d6cf55d34bb0620ec28a9a8c5106c982", "bytes": 2367951
"features": 3596,
"bytes": 4164106
}, },
"layers/road_surface.geojson": { "layers/road_surface.geojson": {
"contentHash": "5a2936a705e64338d0d79b395a13d761f1893dd72330a9b9ee4696ae75eab295", "contentHash": "880f4c338eceb112364e25738f94572c0c3b4aff68a53da319ef76bbda2cba61",
"orderHash": "d8402ae6e772e0778b862b190abfa291de1b02a15537d455f7dcd8596245dfd8", "bytes": 17464
"features": 30,
"bytes": 49554
}, },
"layers/sidewalk_surface.geojson": { "layers/sidewalk_surface.geojson": {
"contentHash": "b6f8f876e9f18fd2ef6749125a09d654c73d986e916bd262ceb9967bca1a54c6", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "3fd42a67e18e3b8743fa538428e039ef66a1f44150a6881a7aaa18326607b5cb", "bytes": 43
"features": 26,
"bytes": 54997
}, },
"layers/turn_arrows.geojson": { "layers/turn_arrows.geojson": {
"contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "bytes": 43
"features": 0,
"bytes": 52
}, },
"layers/vehicle_stop_lines.geojson": { "layers/vehicle_stop_lines.geojson": {
"contentHash": "cb904550ab8eec011cf33e5edfa81044de6dfca0280dbfe488e77229055ed590", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "8a4b877a17a5af60adc8656465afad6756944736291664139e878d46c5a4f363", "bytes": 43
"features": 4,
"bytes": 4191
}, },
"traffic-signal-assemblies.json": { "traffic-signal-assemblies.json": {
"contentHash": "1788026a0c8c531b2fc7dd49628f7fbadd809c7b2f7f8819cffb3ac7d30689ca", "contentHash": "51ce257c59336d4901b478cba30facb55a284d61695ceedb22fb3a8ea881dde3",
"orderHash": "b409ea6b77b6c8639f63c33a156581f9b184a84fde34d265b5deb05705828251", "bytes": 3729
"features": 7,
"bytes": 5411
}, },
"traffic-signals.json": { "traffic-signals.json": {
"contentHash": "625b79b6eb82b41f2099ba51960717c7f794fdfbd87310eb12e99da966540d89", "contentHash": "8f5a6007bce6effa49bdc613faaecbe6be2bc9f4a1bbdd5f68f8934da53587ca",
"bytes": 15491 "bytes": 10068
} }
}, }
"volatileExcluded": ["absolute paths -> <repo> or <external>", "native-road-* staging directory -> <staging>"]
} }

View File

@@ -1,105 +1,78 @@
{ {
"contract": "native-road-package/v1", "contract": "native-road-package/v1.1",
"areaId": "nantaizi-lake-innovation-valley", "areaId": "nantaizi-lake-innovation-valley",
"files": { "files": {
"../native-traffic-signals.json": { "../native-traffic-signals.json": {
"contentHash": "36cb9f069fb44a6e24f0af0d130ef8cb5b4a93250dc9fb0891191d1ba039a5b2", "contentHash": "35a57a62f211a6f6a3b38e0a378eddd63cb38fa9807c409d1d3c5e52c70c0d6f",
"bytes": 28329 "bytes": 18111
}, },
"comparison.json": { "comparison.json": {
"contentHash": "5aa11da443221a48288641ac6950c6e4d75d834713a93110bbf265859202a265", "contentHash": "d8d4fc7d72527fce08c766f1619777a69813080a2def35652c402edd55a64a2e",
"bytes": 1153 "bytes": 1015
}, },
"compiled.json": { "compiled.json": {
"contentHash": "ea5ae4a04f1259b1ccec9fc1cca7ef8ba81426a77584a447480b6047b37f5e01", "contentHash": "ba7c97c7117b882e798185e3f023b8358ff0330e9ce8fe38ee6c8d88dbbda0b6",
"bytes": 184985 "bytes": 135855
}, },
"diagnostics.json": { "diagnostics.json": {
"contentHash": "7e9327f0424dd612f389c560c114a4cafee1349bbfa36f49b2b4eab846e5ca91", "contentHash": "05b09f50df213c162978941b5f771c970e9928d73d4f9041c4209db8ffd5d579",
"bytes": 9755 "bytes": 7037
}, },
"layers/center_lines.geojson": { "layers/center_lines.geojson": {
"contentHash": "2b2710fce06e75b0a3e4941c2da6119cea7b8bb7834345607bbee384de20ee6b", "contentHash": "04a2de8ac58ae27f10be9e2ee0c817b27f41e990caa65e626391cb1f3e1a18c8",
"orderHash": "ea71a8841370689d6fc2b75d0b30ec7104638fb2904da6d707dc82ff3131e065", "bytes": 413551
"features": 511,
"bytes": 694102
}, },
"layers/connectors.geojson": { "layers/connectors.geojson": {
"contentHash": "901a86bc6c536d38bb73ae4654ec1ce7d1c0790c2d6eba92543c48f584cd15ac", "contentHash": "3647dfb768370d66b9b316a7a69356f6217233627804bea7e2566c27023eec05",
"orderHash": "4df882ad59b65a97d892df64de0156b35beaf071b04eed671ef7b535e9e2a7b9", "bytes": 99978
"features": 76,
"bytes": 160866
}, },
"layers/crosswalks.geojson": { "layers/crosswalks.geojson": {
"contentHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", "contentHash": "72079521c1f6891b77b226b87ee3656c113f3a72c4fb51981dfdaa9dafc4eeb2",
"orderHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", "bytes": 28012
"features": 48,
"bytes": 50536
}, },
"layers/direction_arrows.geojson": { "layers/direction_arrows.geojson": {
"contentHash": "38e80bbfe02d4f12ce40e7d0c8342e3bef6b602c64f81dd320b366b6a6b3d205", "contentHash": "5e60d48520fa09aa75a8aa9bb89c4701c7f5dabc4e5ce57c9dc87bdefbbf3d1c",
"orderHash": "579aa6c37585bf4edf9d6b6b1e120f976b93ec2d0c303d82a154b75c114a45f5", "bytes": 270831
"features": 324,
"bytes": 493431
}, },
"layers/edge_lines.geojson": { "layers/edge_lines.geojson": {
"contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "contentHash": "6d18edfe75b3149104038dcbca4d0096bbcb923f0f9fa4b49f4815344e75bb90",
"orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", "bytes": 43
"features": 0,
"bytes": 52
}, },
"layers/intersection_surface.geojson": { "layers/intersection_surface.geojson": {
"contentHash": "7f79e57215e3fd07c5c0f703c71e516873db770cb869efdf18925a3184059ca3", "contentHash": "d0620e5c1cda3fd46ca2a3c6484d1e51aa97e717b7326d118f3f2659e5d0dd95",
"orderHash": "d69df8d92d0fce8f7ee172959b978d941fd011e094857e9ab7304b3d1e98bf92", "bytes": 18960
"features": 12,
"bytes": 38528
}, },
"layers/lane_centerlines.geojson": { "layers/lane_centerlines.geojson": {
"contentHash": "3fea4ebfe62e0d720c6977e2289f4e6517ed7b3c5ab361270d03378971aec780", "contentHash": "6589ee8a94f106b05b426b320e78b86b86d390ab6c38fbc7b5bf8e28332509ed",
"orderHash": "69a8ebaa42a74da4f7d5940c5a81bd82ceb104efd3f858723dccfadbae12121e", "bytes": 26292
"features": 50,
"bytes": 43582
}, },
"layers/lane_separators.geojson": { "layers/lane_separators.geojson": {
"contentHash": "dc0b50b53da191e65f3dba01162acb7561966d1796a9c0789527cebfd6ed06a4", "contentHash": "545a1ed21674d7e6ead04c70eeb511ae59760f49b0a734b79d3ad01ef4f39382",
"orderHash": "0bfd873e5698869fb4855783fe30bb6db29456958e4d154b7264374a5fdb4a10", "bytes": 471152
"features": 731,
"bytes": 835933
}, },
"layers/road_surface.geojson": { "layers/road_surface.geojson": {
"contentHash": "81af19c71cf6fc8a3884488c00cfb590e1a7fe9ed9e4351fce65825ceadd50fa", "contentHash": "176e2dd1e7bc73687283eece17827d7aeaa0e3b0840a31583dd88b821a93f987",
"orderHash": "f2ddec5258411fbdbc050b7ecefbb157b59e00b8226afcc30d2b772e1859e357", "bytes": 20244
"features": 25,
"bytes": 38275
}, },
"layers/sidewalk_surface.geojson": { "layers/sidewalk_surface.geojson": {
"contentHash": "bb8f4516e27b16238d753b112d18b8acfa5cfad2cea522a65b7972a609be9563", "contentHash": "7ce873294a163973cfd4a4a439e3c55fb003e22985943165210268288fe8f690",
"orderHash": "4fa773a6f39c5b6d23551e1338c6d99ef8a5ecd9ebfcafa16154c9765fef8c6e", "bytes": 32236
"features": 73,
"bytes": 122432
}, },
"layers/turn_arrows.geojson": { "layers/turn_arrows.geojson": {
"contentHash": "a65bb8fe079f27e00bf2e6208148fc6b73d2d07e76d48b1cd1bf8a610d7e2d66", "contentHash": "599820effb9db9e497e8415f3191870baf1f1273aeb81cb4f0034f76c5ca6b40",
"orderHash": "66966ac2c65464f9c6715ca9cda5a3620520bf1d066cba2dee36114dc46af936", "bytes": 75592
"features": 108,
"bytes": 132536
}, },
"layers/vehicle_stop_lines.geojson": { "layers/vehicle_stop_lines.geojson": {
"contentHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", "contentHash": "c4cb8c3235fae7e2fd64306fe1b853d604ee1811286162f79f9ca8286b6a922f",
"orderHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", "bytes": 4711
"features": 8,
"bytes": 8475
}, },
"traffic-signal-assemblies.json": { "traffic-signal-assemblies.json": {
"contentHash": "9f8d032c00784bb72bb1322238cd7b0a80e621d435b43c728da86f1e77ca352a", "contentHash": "14a66bd60cae387891dc6e29d8bdcbe4a021dc0c7f75dc3629cae6f25195959e",
"orderHash": "0b9cae13094dba1994faca4bf1d4d80e8bc5497daa46df5899bf5e6fc7f26d61", "bytes": 18021
"features": 35,
"bytes": 26398
}, },
"traffic-signals.json": { "traffic-signals.json": {
"contentHash": "c4b68cc1cefeb0d393a18eef6c7eb79933fba28630826745165ed433c25779d0", "contentHash": "f405a47d61c69ebd76f19339b54d468dfab39d7c7115417c6f790c26fc661c79",
"bytes": 74151 "bytes": 47678
} }
}, }
"volatileExcluded": ["absolute paths -> <repo> or <external>", "native-road-* staging directory -> <staging>"]
} }

71
test/fixture-baseline.js Normal file
View File

@@ -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('<repo>') : 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 };

View File

@@ -8,25 +8,14 @@ const path = require('path');
const { unzipSync, strFromU8 } = require('fflate'); const { unzipSync, strFromU8 } = require('fflate');
const { compiler } = require('../src'); const { compiler } = require('../src');
const { exportNativeRoadPackage } = require('../src/export/native-road-package'); const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { root, fixtureInputs, snapshot, baselineFile } = require('./fixture-baseline');
const root = path.resolve(__dirname, '..'); for (const input of fixtureInputs()) {
for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) { assert.ok(fs.existsSync(baselineFile(input.areaId)));
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`)));
const { result } = compiler.compileInput(input); 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.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 first = exportNativeRoadPackage(input.outDir);
const second = exportNativeRoadPackage(input.outDir); const second = exportNativeRoadPackage(input.outDir);
assert.equal( assert.equal(
@@ -35,7 +24,7 @@ for (const areaId of ['fengshu-er-road', 'nantaizi-lake-innovation-valley']) {
); );
const entries = unzipSync(first.bytes); const entries = unzipSync(first.bytes);
const manifest = JSON.parse(strFromU8(entries['manifest.json'])); 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.equal(manifest.contract, 'native-road-package/v1.1');
assert.deepEqual(manifest.generator, { name: 'road-compiler', version: '0.3.0' }); assert.deepEqual(manifest.generator, { name: 'road-compiler', version: '0.3.0' });
assert.equal('source' in JSON.parse(strFromU8(entries['compiled.json'])), false); 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, false,
); );
assert.equal(Object.keys(entries).length, 18); assert.equal(Object.keys(entries).length, 18);
if (isFengshu) { if (input.areaId === 'fengshu-er-road') {
const inputFile = path.join(outputRoot, 'road-compiler-input.json'); const inputFile = path.join(path.dirname(input.outDir), 'road-compiler-input.json');
const archive = path.join(outputRoot, 'export.zip'); const archive = path.join(path.dirname(input.outDir), 'export.zip');
fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`); fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`);
execFileSync(process.execPath, [ execFileSync(process.execPath, [
path.join(root, 'bin', 'road-compiler.js'), path.join(root, 'bin', 'road-compiler.js'),

View File

@@ -6,6 +6,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { nativeRoadEdits, roadRevisions } = require('../src'); const { nativeRoadEdits, roadRevisions } = require('../src');
const { compileInput } = require('../src/compile/compiler');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-'));
const source = '<osm version="0.6"><node id="1" lon="114.1" lat="22.5"/></osm>\n'; const source = '<osm version="0.6"><node id="1" lon="114.1" lat="22.5"/></osm>\n';
@@ -15,6 +16,7 @@ const signals = {
provenance: 'empty', provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] }, 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, 'source.osm'), source);
fs.writeFileSync(path.join(directory, 'native-road-overrides.json'), `${JSON.stringify(overrides, null, 2)}\n`); 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.id, 'rev-0001');
assert.equal(first.baseline.manifest.schema, roadRevisions.REVISION_SCHEMA); assert.equal(first.baseline.manifest.schema, roadRevisions.REVISION_SCHEMA);
assert.equal(first.active.activeRevisionId, 'rev-0001'); assert.equal(first.active.activeRevisionId, 'rev-0001');
assert.equal(first.baseline.directEdits.schema, nativeRoadEdits.EDITS_SCHEMA); assert.equal(first.baseline.directEdits.schema, nativeRoadEdits.EDITS_SCHEMA);
assert.equal(first.baseline.directEdits.documentVersion, 0); 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) for (const [file, bytes] of legacyBytes)
assert.deepEqual(fs.readFileSync(path.join(directory, file)), bytes, `${file} remains byte-for-byte unchanged`); assert.deepEqual(fs.readFileSync(path.join(directory, file)), bytes, `${file} remains byte-for-byte unchanged`);
const digest = crypto.createHash('sha256').update(source).digest('hex'); const digest = crypto.createHash('sha256').update(source).digest('hex');
assert.equal(first.baseline.manifest.source.osmSha256, digest); assert.equal(first.baseline.manifest.source.osmSha256, digest);
assert.equal(fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length, 1); assert.equal(fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length, 1);
roadRevisions.ensureRevisionStore(directory); roadRevisions.ensureRevisionStore(directory, options);
assert.equal( assert.equal(
fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length, fs.readdirSync(path.join(directory, 'osm')).filter((file) => file.endsWith('.osm')).length,
1, 1,
@@ -47,6 +51,7 @@ assert.equal(
const activeEdits = nativeRoadEdits.emptyEditDocument({ const activeEdits = nativeRoadEdits.emptyEditDocument({
osmSha256: digest, osmSha256: digest,
areaConfigSha256: first.baseline.manifest.source.areaConfigSha256,
compilerGeometryVersion: 'native-road-package/v1.1', compilerGeometryVersion: 'native-road-package/v1.1',
}); });
activeEdits.operations = [{ id: 'op-1', createdAt: '2026-08-26T12:00:00.000Z', constraintIds: [] }]; 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.nativeRoadOverrides, overrides);
assert.deepEqual(checkpoint.trafficSignals, signals); assert.deepEqual(checkpoint.trafficSignals, signals);
assert.deepEqual(checkpoint.directEdits, activeEdits); assert.deepEqual(checkpoint.directEdits, activeEdits);
assert.deepEqual(checkpoint.areaConfig, options);
assert.equal( assert.equal(
checkpoint.manifest.digests.nativeRoadOverrides, checkpoint.manifest.digests.nativeRoadOverrides,
crypto crypto
@@ -72,6 +78,54 @@ assert.deepEqual(
); );
assert.throws(() => roadRevisions.createCheckpoint(directory, ''), /label must be a non-empty string/); assert.throws(() => roadRevisions.createCheckpoint(directory, ''), /label must be a non-empty string/);
assert.throws(() => roadRevisions.readRevision(directory, '../rev-0002'), /Invalid revision id/); 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 }); fs.rmSync(directory, { recursive: true, force: true });
console.log('road revision storage tests passed'); console.log('road revision storage tests passed');

View File

@@ -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');

View File

@@ -9,7 +9,7 @@ const { generate, validateDocument, runtime } = require('../src/native-traffic-s
const { convertGeoJson } = require('../src/reference/gaode'); const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require('../src/export/native-road-package'); const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { compileInput } = require('../src/compile/compiler'); const { compileInput } = require('../src/compile/compiler');
const { ensureRevisionStore } = require('../src/compile/road-revisions'); const { ensureRevisionStore, setActiveAreaConfig } = require('../src/compile/road-revisions');
function startWorkbench({ function startWorkbench({
area = null, area = null,
@@ -39,7 +39,7 @@ function startWorkbench({
}; };
if (input) { if (input) {
session.context.compileFresh = () => { session.context.compileFresh = () => {
const compiled = compileInput(input); const compiled = compileWorkbenchInput(input);
session.area = compiled.area; session.area = compiled.area;
return compiled; return compiled;
}; };
@@ -143,16 +143,15 @@ function handle(request, response, session) {
.then((body) => { .then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。'); if (!area) throw new Error('请先导入 OSM 文件。');
if (!debug) throw new Error('该接口仅在 --debug 模式下可用。'); if (!debug) throw new Error('该接口仅在 --debug 模式下可用。');
const added = addJunctionCluster( const added = addJunctionCluster(body, readCompiled(area), area.nativeRoad);
context.configPath, setActiveAreaConfig(path.dirname(area.input), added.options);
body,
readCompiled(area),
context.readAreaConfig,
context.repoRoot,
);
context.compileFresh(); context.compileFresh();
const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot }); sendJson(response, 200, {
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) }); 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 })); .catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/compile') if (request.method === 'POST' && url.pathname === '/api/compile')
@@ -170,7 +169,7 @@ function handle(request, response, session) {
function state(area, junctionReference = null, debug = false) { function state(area, junctionReference = null, debug = false) {
// This is the read entry point for both a fresh import and a reopened legacy // 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. // 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 nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson') ? 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 // 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 // own payload with a stable index so the map can label them "#1, #2, ..." and
// the inspector can offer a ready-to-paste cluster配置. // 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 // 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 // write a cluster that no longer exists. The external config remains untouched;
// real loader before it replaces the file: an invalid write would break every // the response carries a copyable snippet while future compiles use the snapshot.
// later command, and the file is git-tracked so a bad accept stays revertible. function addJunctionCluster(body, compiled, options) {
function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) {
const index = Number(body?.index); const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。'); if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。');
const candidate = junctionCandidates(compiled).find((item) => item.index === index); const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`); if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath); const templates = options?.junctionTemplates;
const templates = raw.nativeRoad?.junctionTemplates;
if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。'); if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。');
const clusters = Array.isArray(templates.clusters) ? templates.clusters : []; const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String))); const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
@@ -240,24 +237,11 @@ function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot
outerRadiusExtraMeters: 18, outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String), nodeIds: candidate.nodeIds.map(String),
}; };
const next = { const nextOptions = {
...raw, ...options,
nativeRoad: { junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
...raw.nativeRoad,
junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
},
}; };
const staging = `${configPath}.candidate-${process.pid}.json`; return { cluster, options: nextOptions };
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;
} }
function uniqueClusterId(base, taken) { function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base; if (!taken.has(base)) return base;
@@ -328,8 +312,7 @@ function readUpload(request, session) {
), ),
); );
try { try {
const compiled = compileInput(input); const compiled = compileWorkbenchInput(input);
ensureRevisionStore(root);
session.area = compiled.area; session.area = compiled.area;
return compiled; return compiled;
} catch (error) { } 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) { function readMultipart(request, limit) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const type = request.headers['content-type'] || ''; const type = request.headers['content-type'] || '';