/** * 票 33 离线回归:30s 刷新触发全管线重同步的抽搐修复(不联网、不起 dev server)。 * * 对应 [票 33](../issues/33-33-jitter-fix.md) 验收第 1 条(离线逻辑闸门): * 1. `syncPipeLayer` 输入内容不变 → **零属性重赋值**(引用身份断言 + unchanged 计数有牙); * 2. 输入真变化 → 差量正确:**增 / 删 / 改各至少一例**(管段与构件两条路径都覆盖); * 3. `modeLayerPlan` 引用记忆:输入引用不变 → 计划输出**同一数组引用**(不再 slice 新数组); * 4. 源码闸门:`syncPointMarkers` 差量化(函数体内不得再 `removeAll`)、 * `MapCanvas` 的 applyMarkers 批间合并、清层时索引作废——这三处在 Vue/引擎模块里, * Node 下不便整模块加载,按 t25 的先例用源码断言锁定。 * * 真机回归(验收第 2 条,票 26 探针判据)不在本脚本范围:需要 dev server + real 后端, * 按项目规则不自动运行。 */ import assert from 'node:assert/strict' import { register } from 'node:module' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const here = dirname(fileURLToPath(import.meta.url)) register('./t33-cesium-stub-loader.mjs', new URL('file:///' + here.replace(/\\/g, '/') + '/').href) // register 之后再动态 import:静态 import 会先于钩子注册求值。 const { syncPipeLayer, clearPipeLayer } = await import('../../../src/modules/cockpit/services/cesiumPipeLayer.js') const { planModeLayers } = await import('../../../src/modules/cockpit/domain/modeLayerPlan.js') let failed = 0 const checks = [] function check(name, action) { checks.push({ name, action }) } // ---- 夹具:与 domain/pipeNetwork.js 输出同形的领域行 ---- const pipe = (code, overrides = {}) => ({ code, industry: 'GAS', diameter: 200, material: 'PE', name: `管段${code}`, positions: [[110.39, 28.44], [110.40, 28.45]], ...overrides, }) const component = (code, overrides = {}) => ({ code, kind: 'valve', position: [110.395, 28.445], industry: 'GAS', networkId: 'P-1', label: '阀门', ...overrides, }) function fakeViewer() { const sources = [] return { sources, dataSources: { getByName: (name) => sources.filter((s) => s.name === name), add: async (s) => { sources.push(s); return s }, }, } } /** 抓当前图层里每个实体的渲染属性**引用**,供「零重赋值」身份比对。 */ function snapshotRefs(source) { return new Map(source.entities.values.map((e) => [e.id, { position: e.position, polyline: e.polyline, point: e.point, label: e.label, properties: e.properties, }])) } function assertNoReassign(source, before, exceptIds = []) { for (const [id, refs] of before) { const entity = source.entities.values.find((e) => e.id === id) assert.ok(entity, `${id} 应仍在图上`) for (const key of Object.keys(refs)) { if (exceptIds.includes(id)) continue assert.strictEqual(entity[key], refs[key], `${id}.${key} 被重赋值(应零写入)`) } } } const viewer = fakeViewer() const sourceRef = () => viewer.sources[0] const baseNetwork = () => ({ pipes: [pipe('P-1'), pipe('P-2', { diameter: 400 }), pipe('P-3', { industry: 'DRAINAGE' })], components: [component('C-1'), component('C-2', { kind: 'elbow', label: '角管道' })], }) /** 变化类用例各自从已知基线出发:图层先清空再灌满 baseNetwork(用例间不得互相污染)。 */ async function rebaseline() { await clearPipeLayer(viewer) await syncPipeLayer(viewer, baseNetwork()) } // ---- 1. 首次全量 + 内容不变 → 零属性重赋值 ---- check('A1 首次同步全量上图,计数如实', async () => { const r1 = await syncPipeLayer(viewer, baseNetwork()) assert.equal(r1.pipes, 3) assert.equal(r1.components, 2) assert.equal(r1.added, 5) assert.equal(r1.unchanged, 0) assert.equal(r1.rendered, true) }) check('A2 输入内容不变:零属性重赋值,unchanged 计数有牙', async () => { const before = snapshotRefs(sourceRef()) const r2 = await syncPipeLayer(viewer, baseNetwork()) assert.equal(r2.added, 0, '内容不变不得再新增实体') assert.equal(r2.updated, 0, '内容不变不得有任何 upsert 写入') assert.equal(r2.unchanged, 5, 'unchanged 计数必须如实反映跳过数') assert.equal(r2.removed, 0) assertNoReassign(sourceRef(), before) }) // ---- 2. 改:只动被改的那一个 ---- check('B1 改管径:只有该管段被重赋值,其余引用原样', async () => { await rebaseline() const before = snapshotRefs(sourceRef()) const network = baseNetwork() network.pipes[1].diameter = 800 const r = await syncPipeLayer(viewer, network) assert.equal(r.updated, 1) assert.equal(r.unchanged, 4) const p2 = sourceRef().entities.values.find((e) => e.id === 'pipe:P-2') assert.notStrictEqual(p2.polyline, before.get('pipe:P-2').polyline, '被改的管段 polyline 必须换新') assertNoReassign(sourceRef(), before, ['pipe:P-2']) }) check('B2 只改名称/材质这类渲染字段也要被看见(指纹不许漏字段)', async () => { await rebaseline() const network = baseNetwork() network.pipes[1].name = '改名后的管段' const r = await syncPipeLayer(viewer, network) assert.equal(r.updated, 1, '名称变了却判「未变」=指纹漏字段') }) check('B3 改构件:位置变化走构件路径差量', async () => { await rebaseline() const before = snapshotRefs(sourceRef()) const network = baseNetwork() network.components[0].position = [110.41, 28.46] const r = await syncPipeLayer(viewer, network) assert.equal(r.updated, 1) const c1 = sourceRef().entities.values.find((e) => e.id === 'component:C-1') assert.notStrictEqual(c1.position, before.get('component:C-1').position) }) // ---- 3. 删 ---- check('C1 删管段:精确撤下那一个', async () => { await rebaseline() const network = baseNetwork() network.pipes = network.pipes.filter((p) => p.code !== 'P-1') const r = await syncPipeLayer(viewer, network) assert.equal(r.removed, 1) assert.ok(!sourceRef().entities.values.some((e) => e.id === 'pipe:P-1'), '被删管段不得留在图上') assert.equal(sourceRef().entities.values.length, 4) }) check('C2 删构件:同一条差量路径', async () => { await rebaseline() const network = baseNetwork() network.components = network.components.filter((c) => c.code !== 'C-2') const r = await syncPipeLayer(viewer, network) assert.equal(r.removed, 1) assert.ok(!sourceRef().entities.values.some((e) => e.id === 'component:C-2')) }) // ---- 4. 增 ---- check('D1 增管段/构件:只 add 新的,存量不动', async () => { await rebaseline() const before = snapshotRefs(sourceRef()) const network = baseNetwork() network.pipes.push(pipe('P-9', { industry: 'WATER_SUPPLY' })) network.components.push(component('C-9', { kind: 'branch', label: '支管管道' })) const r = await syncPipeLayer(viewer, network) assert.equal(r.added, 2) assert.equal(r.unchanged, sourceRef().entities.values.length - 2) assertNoReassign(sourceRef(), before) }) // ---- 5. 计划层引用记忆(modeLayerPlan)---- check('E1 输入引用不变:计划输出同一数组引用(不再 slice 新数组)', () => { const facilities = [component('C-1')] const pipelines = [pipe('P-1')] const args = { mode: 'infrastructure', objects: [], facilities, pipelines } const p1 = planModeLayers(args) const p2 = planModeLayers(args) assert.strictEqual(p2.pipelines, pipelines, 'pipelines 必须原样引用') assert.strictEqual(p2.facilities, facilities, 'facilities 必须原样引用') assert.strictEqual(p2.pipelines, p1.pipelines) }) check('E2 隐藏模式的空数组语义不变(hidden → [],自动撤下)', () => { const plan = planModeLayers({ mode: 'monitoring', objects: [], facilities: [component('C-1')], pipelines: [pipe('P-1')] }) assert.deepEqual(plan.facilities, []) }) check('E3 未就绪降级语义不变(null 仍 → [] + degradedNotes)', () => { const plan = planModeLayers({ mode: 'infrastructure', objects: [], facilities: null, pipelines: null }) assert.deepEqual(plan.pipelines, []) assert.deepEqual(plan.facilities, []) const keys = plan.degradedNotes.map((n) => n.key) assert.ok(keys.includes('pipelines-unready'), '管线未就绪说明必须还在') assert.ok(keys.includes('facilities-unready'), '设施未就绪说明必须还在') }) // ---- 6. 源码闸门:Node 下不便整模块加载的三处,按 t25 先例用源码断言 ---- const engineSource = readFileSync(join(here, '../../../src/modules/cockpit/services/cesiumEngine.js'), 'utf8') const canvasSource = readFileSync(join(here, '../../../src/modules/cockpit/components/MapCanvas.vue'), 'utf8') function functionBody(source, signature) { const start = source.indexOf(signature) assert.ok(start !== -1, `源码里找不到 ${signature}`) const bodyStart = source.indexOf('{', start) let depth = 0 for (let i = bodyStart; i < source.length; i += 1) { if (source[i] === '{') depth += 1 if (source[i] === '}') { depth -= 1 if (depth === 0) { // 剥掉注释再交给断言:注释里的词(比如解释「废止 removeAll」)不是代码。 return source.slice(bodyStart, i + 1) .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, '') } } } throw new Error('函数体未闭合') } check('F1 syncPointMarkers 函数体内不得再有 removeAll(整层重建废止)', () => { const body = functionBody(engineSource, 'export function syncPointMarkers(') assert.doesNotMatch(body, /removeAll/, 'syncPointMarkers 必须差量同步') assert.match(body, /markerEntityIndex/, '必须带差量索引') assert.match(body, /markerFingerprint/, '必须按内容指纹判「未变」') }) check('F2 clearPointMarkers 清层时必须作废差量索引', () => { const body = functionBody(engineSource, 'export function clearPointMarkers(') assert.match(body, /markerEntityIndex\.delete/, '留指纹会把下次同步误判成「未变」') }) check('F3 MapCanvas:watch 走 applyMarkersMerged,且落尾定时器在卸载时清掉', () => { assert.match(canvasSource, /APPLY_MERGE_MS\s*=\s*120/, '合并窗口必须与 auto-batch 间隔同值') assert.match(canvasSource, /=> applyMarkersMerged\(\)/, 'marker watch 必须走合并入口') assert.match(canvasSource, /pendingApplyTimer[\s\S]{0,200}clearTimeout/, '卸载时必须清落尾定时器') }) check('F4 modeLayerPlan:不再 slice() 出新数组', () => { const source = readFileSync(join(here, '../../../src/modules/cockpit/domain/modeLayerPlan.js'), 'utf8') assert.doesNotMatch(source, /facilities\.slice\(\)/) assert.doesNotMatch(source, /pipelines\.slice\(\)/) }) for (const { name, action } of checks) { try { await action() console.log(`PASS ${name}`) } catch (error) { failed += 1 console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`) } } console.log(`T33 抽搐修复: ${failed ? 'FAILED' : 'passed'}`) process.exitCode = failed ? 1 : 0