| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- /**
- * 票 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
|