t33-jitter-diff.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * 票 33 离线回归:30s 刷新触发全管线重同步的抽搐修复(不联网、不起 dev server)。
  3. *
  4. * 对应 [票 33](../issues/33-33-jitter-fix.md) 验收第 1 条(离线逻辑闸门):
  5. * 1. `syncPipeLayer` 输入内容不变 → **零属性重赋值**(引用身份断言 + unchanged 计数有牙);
  6. * 2. 输入真变化 → 差量正确:**增 / 删 / 改各至少一例**(管段与构件两条路径都覆盖);
  7. * 3. `modeLayerPlan` 引用记忆:输入引用不变 → 计划输出**同一数组引用**(不再 slice 新数组);
  8. * 4. 源码闸门:`syncPointMarkers` 差量化(函数体内不得再 `removeAll`)、
  9. * `MapCanvas` 的 applyMarkers 批间合并、清层时索引作废——这三处在 Vue/引擎模块里,
  10. * Node 下不便整模块加载,按 t25 的先例用源码断言锁定。
  11. *
  12. * 真机回归(验收第 2 条,票 26 探针判据)不在本脚本范围:需要 dev server + real 后端,
  13. * 按项目规则不自动运行。
  14. */
  15. import assert from 'node:assert/strict'
  16. import { register } from 'node:module'
  17. import { readFileSync } from 'node:fs'
  18. import { fileURLToPath } from 'node:url'
  19. import { dirname, join } from 'node:path'
  20. const here = dirname(fileURLToPath(import.meta.url))
  21. register('./t33-cesium-stub-loader.mjs', new URL('file:///' + here.replace(/\\/g, '/') + '/').href)
  22. // register 之后再动态 import:静态 import 会先于钩子注册求值。
  23. const { syncPipeLayer, clearPipeLayer } = await import('../../../src/modules/cockpit/services/cesiumPipeLayer.js')
  24. const { planModeLayers } = await import('../../../src/modules/cockpit/domain/modeLayerPlan.js')
  25. let failed = 0
  26. const checks = []
  27. function check(name, action) {
  28. checks.push({ name, action })
  29. }
  30. // ---- 夹具:与 domain/pipeNetwork.js 输出同形的领域行 ----
  31. const pipe = (code, overrides = {}) => ({
  32. code,
  33. industry: 'GAS',
  34. diameter: 200,
  35. material: 'PE',
  36. name: `管段${code}`,
  37. positions: [[110.39, 28.44], [110.40, 28.45]],
  38. ...overrides,
  39. })
  40. const component = (code, overrides = {}) => ({
  41. code,
  42. kind: 'valve',
  43. position: [110.395, 28.445],
  44. industry: 'GAS',
  45. networkId: 'P-1',
  46. label: '阀门',
  47. ...overrides,
  48. })
  49. function fakeViewer() {
  50. const sources = []
  51. return {
  52. sources,
  53. dataSources: {
  54. getByName: (name) => sources.filter((s) => s.name === name),
  55. add: async (s) => { sources.push(s); return s },
  56. },
  57. }
  58. }
  59. /** 抓当前图层里每个实体的渲染属性**引用**,供「零重赋值」身份比对。 */
  60. function snapshotRefs(source) {
  61. return new Map(source.entities.values.map((e) => [e.id, {
  62. position: e.position, polyline: e.polyline, point: e.point, label: e.label, properties: e.properties,
  63. }]))
  64. }
  65. function assertNoReassign(source, before, exceptIds = []) {
  66. for (const [id, refs] of before) {
  67. const entity = source.entities.values.find((e) => e.id === id)
  68. assert.ok(entity, `${id} 应仍在图上`)
  69. for (const key of Object.keys(refs)) {
  70. if (exceptIds.includes(id)) continue
  71. assert.strictEqual(entity[key], refs[key], `${id}.${key} 被重赋值(应零写入)`)
  72. }
  73. }
  74. }
  75. const viewer = fakeViewer()
  76. const sourceRef = () => viewer.sources[0]
  77. const baseNetwork = () => ({
  78. pipes: [pipe('P-1'), pipe('P-2', { diameter: 400 }), pipe('P-3', { industry: 'DRAINAGE' })],
  79. components: [component('C-1'), component('C-2', { kind: 'elbow', label: '角管道' })],
  80. })
  81. /** 变化类用例各自从已知基线出发:图层先清空再灌满 baseNetwork(用例间不得互相污染)。 */
  82. async function rebaseline() {
  83. await clearPipeLayer(viewer)
  84. await syncPipeLayer(viewer, baseNetwork())
  85. }
  86. // ---- 1. 首次全量 + 内容不变 → 零属性重赋值 ----
  87. check('A1 首次同步全量上图,计数如实', async () => {
  88. const r1 = await syncPipeLayer(viewer, baseNetwork())
  89. assert.equal(r1.pipes, 3)
  90. assert.equal(r1.components, 2)
  91. assert.equal(r1.added, 5)
  92. assert.equal(r1.unchanged, 0)
  93. assert.equal(r1.rendered, true)
  94. })
  95. check('A2 输入内容不变:零属性重赋值,unchanged 计数有牙', async () => {
  96. const before = snapshotRefs(sourceRef())
  97. const r2 = await syncPipeLayer(viewer, baseNetwork())
  98. assert.equal(r2.added, 0, '内容不变不得再新增实体')
  99. assert.equal(r2.updated, 0, '内容不变不得有任何 upsert 写入')
  100. assert.equal(r2.unchanged, 5, 'unchanged 计数必须如实反映跳过数')
  101. assert.equal(r2.removed, 0)
  102. assertNoReassign(sourceRef(), before)
  103. })
  104. // ---- 2. 改:只动被改的那一个 ----
  105. check('B1 改管径:只有该管段被重赋值,其余引用原样', async () => {
  106. await rebaseline()
  107. const before = snapshotRefs(sourceRef())
  108. const network = baseNetwork()
  109. network.pipes[1].diameter = 800
  110. const r = await syncPipeLayer(viewer, network)
  111. assert.equal(r.updated, 1)
  112. assert.equal(r.unchanged, 4)
  113. const p2 = sourceRef().entities.values.find((e) => e.id === 'pipe:P-2')
  114. assert.notStrictEqual(p2.polyline, before.get('pipe:P-2').polyline, '被改的管段 polyline 必须换新')
  115. assertNoReassign(sourceRef(), before, ['pipe:P-2'])
  116. })
  117. check('B2 只改名称/材质这类渲染字段也要被看见(指纹不许漏字段)', async () => {
  118. await rebaseline()
  119. const network = baseNetwork()
  120. network.pipes[1].name = '改名后的管段'
  121. const r = await syncPipeLayer(viewer, network)
  122. assert.equal(r.updated, 1, '名称变了却判「未变」=指纹漏字段')
  123. })
  124. check('B3 改构件:位置变化走构件路径差量', async () => {
  125. await rebaseline()
  126. const before = snapshotRefs(sourceRef())
  127. const network = baseNetwork()
  128. network.components[0].position = [110.41, 28.46]
  129. const r = await syncPipeLayer(viewer, network)
  130. assert.equal(r.updated, 1)
  131. const c1 = sourceRef().entities.values.find((e) => e.id === 'component:C-1')
  132. assert.notStrictEqual(c1.position, before.get('component:C-1').position)
  133. })
  134. // ---- 3. 删 ----
  135. check('C1 删管段:精确撤下那一个', async () => {
  136. await rebaseline()
  137. const network = baseNetwork()
  138. network.pipes = network.pipes.filter((p) => p.code !== 'P-1')
  139. const r = await syncPipeLayer(viewer, network)
  140. assert.equal(r.removed, 1)
  141. assert.ok(!sourceRef().entities.values.some((e) => e.id === 'pipe:P-1'), '被删管段不得留在图上')
  142. assert.equal(sourceRef().entities.values.length, 4)
  143. })
  144. check('C2 删构件:同一条差量路径', async () => {
  145. await rebaseline()
  146. const network = baseNetwork()
  147. network.components = network.components.filter((c) => c.code !== 'C-2')
  148. const r = await syncPipeLayer(viewer, network)
  149. assert.equal(r.removed, 1)
  150. assert.ok(!sourceRef().entities.values.some((e) => e.id === 'component:C-2'))
  151. })
  152. // ---- 4. 增 ----
  153. check('D1 增管段/构件:只 add 新的,存量不动', async () => {
  154. await rebaseline()
  155. const before = snapshotRefs(sourceRef())
  156. const network = baseNetwork()
  157. network.pipes.push(pipe('P-9', { industry: 'WATER_SUPPLY' }))
  158. network.components.push(component('C-9', { kind: 'branch', label: '支管管道' }))
  159. const r = await syncPipeLayer(viewer, network)
  160. assert.equal(r.added, 2)
  161. assert.equal(r.unchanged, sourceRef().entities.values.length - 2)
  162. assertNoReassign(sourceRef(), before)
  163. })
  164. // ---- 5. 计划层引用记忆(modeLayerPlan)----
  165. check('E1 输入引用不变:计划输出同一数组引用(不再 slice 新数组)', () => {
  166. const facilities = [component('C-1')]
  167. const pipelines = [pipe('P-1')]
  168. const args = { mode: 'infrastructure', objects: [], facilities, pipelines }
  169. const p1 = planModeLayers(args)
  170. const p2 = planModeLayers(args)
  171. assert.strictEqual(p2.pipelines, pipelines, 'pipelines 必须原样引用')
  172. assert.strictEqual(p2.facilities, facilities, 'facilities 必须原样引用')
  173. assert.strictEqual(p2.pipelines, p1.pipelines)
  174. })
  175. check('E2 隐藏模式的空数组语义不变(hidden → [],自动撤下)', () => {
  176. const plan = planModeLayers({ mode: 'monitoring', objects: [], facilities: [component('C-1')], pipelines: [pipe('P-1')] })
  177. assert.deepEqual(plan.facilities, [])
  178. })
  179. check('E3 未就绪降级语义不变(null 仍 → [] + degradedNotes)', () => {
  180. const plan = planModeLayers({ mode: 'infrastructure', objects: [], facilities: null, pipelines: null })
  181. assert.deepEqual(plan.pipelines, [])
  182. assert.deepEqual(plan.facilities, [])
  183. const keys = plan.degradedNotes.map((n) => n.key)
  184. assert.ok(keys.includes('pipelines-unready'), '管线未就绪说明必须还在')
  185. assert.ok(keys.includes('facilities-unready'), '设施未就绪说明必须还在')
  186. })
  187. // ---- 6. 源码闸门:Node 下不便整模块加载的三处,按 t25 先例用源码断言 ----
  188. const engineSource = readFileSync(join(here, '../../../src/modules/cockpit/services/cesiumEngine.js'), 'utf8')
  189. const canvasSource = readFileSync(join(here, '../../../src/modules/cockpit/components/MapCanvas.vue'), 'utf8')
  190. function functionBody(source, signature) {
  191. const start = source.indexOf(signature)
  192. assert.ok(start !== -1, `源码里找不到 ${signature}`)
  193. const bodyStart = source.indexOf('{', start)
  194. let depth = 0
  195. for (let i = bodyStart; i < source.length; i += 1) {
  196. if (source[i] === '{') depth += 1
  197. if (source[i] === '}') {
  198. depth -= 1
  199. if (depth === 0) {
  200. // 剥掉注释再交给断言:注释里的词(比如解释「废止 removeAll」)不是代码。
  201. return source.slice(bodyStart, i + 1)
  202. .replace(/\/\*[\s\S]*?\*\//g, '')
  203. .replace(/^\s*\/\/.*$/gm, '')
  204. }
  205. }
  206. }
  207. throw new Error('函数体未闭合')
  208. }
  209. check('F1 syncPointMarkers 函数体内不得再有 removeAll(整层重建废止)', () => {
  210. const body = functionBody(engineSource, 'export function syncPointMarkers(')
  211. assert.doesNotMatch(body, /removeAll/, 'syncPointMarkers 必须差量同步')
  212. assert.match(body, /markerEntityIndex/, '必须带差量索引')
  213. assert.match(body, /markerFingerprint/, '必须按内容指纹判「未变」')
  214. })
  215. check('F2 clearPointMarkers 清层时必须作废差量索引', () => {
  216. const body = functionBody(engineSource, 'export function clearPointMarkers(')
  217. assert.match(body, /markerEntityIndex\.delete/, '留指纹会把下次同步误判成「未变」')
  218. })
  219. check('F3 MapCanvas:watch 走 applyMarkersMerged,且落尾定时器在卸载时清掉', () => {
  220. assert.match(canvasSource, /APPLY_MERGE_MS\s*=\s*120/, '合并窗口必须与 auto-batch 间隔同值')
  221. assert.match(canvasSource, /=> applyMarkersMerged\(\)/, 'marker watch 必须走合并入口')
  222. assert.match(canvasSource, /pendingApplyTimer[\s\S]{0,200}clearTimeout/, '卸载时必须清落尾定时器')
  223. })
  224. check('F4 modeLayerPlan:不再 slice() 出新数组', () => {
  225. const source = readFileSync(join(here, '../../../src/modules/cockpit/domain/modeLayerPlan.js'), 'utf8')
  226. assert.doesNotMatch(source, /facilities\.slice\(\)/)
  227. assert.doesNotMatch(source, /pipelines\.slice\(\)/)
  228. })
  229. for (const { name, action } of checks) {
  230. try {
  231. await action()
  232. console.log(`PASS ${name}`)
  233. } catch (error) {
  234. failed += 1
  235. console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`)
  236. }
  237. }
  238. console.log(`T33 抽搐修复: ${failed ? 'FAILED' : 'passed'}`)
  239. process.exitCode = failed ? 1 : 0