t23-click-flow.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import vm from 'node:vm'
  4. import { parse as parseJS } from '@babel/parser'
  5. import { parse, compileScript } from '@vue/compiler-sfc'
  6. import * as Vue from 'vue'
  7. import * as Cesium from 'cesium'
  8. import { createPinia, setActivePinia } from 'pinia'
  9. import { attachPointPick } from '../../../src/modules/cockpit/services/pointPick.js'
  10. import { resolveOverlapPick } from '../../../src/modules/cockpit/domain/overlapDevices.js'
  11. import { PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
  12. import { usePipeEnhancementStore } from '../../../src/modules/cockpit/stores/pipeEnhancementStore.js'
  13. import { useEnhancementDevicesStore } from '../../../src/modules/cockpit/stores/enhancementDevicesStore.js'
  14. const src = (name) => readFileSync(new URL(`../../../src/modules/cockpit/${name}`, import.meta.url), 'utf8')
  15. const service = src('services/mapService.js')
  16. const shell = parse(src('CockpitShell.vue')).descriptor
  17. function nodes(source) {
  18. return parseJS(source, { sourceType: 'module' }).program.body.map((n) => n.declaration || n)
  19. }
  20. function functions(source, names) {
  21. return nodes(source).filter((n) => n.type === 'FunctionDeclaration' && names.includes(n.id.name))
  22. .map((n) => source.slice(n.start, n.end)).join('\n')
  23. }
  24. const engineCode = src('services/cesiumEngine.js')
  25. .replace(/^import\s+[^]*?from\s+['"][^'"\n]+['"][ \t]*$/gm, '')
  26. .replace(/^import\s+['"][^'"\n]+['"][ \t]*$/gm, '').replace(/^export /gm, '')
  27. const engineContext = vm.createContext({ ...Cesium, CesiumMath: Cesium.Math })
  28. vm.runInContext(`${engineCode}\nglobalThis.engine = { pickDeviceMarkerAt, pickPointMarkerAt, pickPipeObjectAt };`, engineContext)
  29. const source = new Cesium.CustomDataSource('cockpit-pipelines')
  30. source.entities.add({ id: 'pipe:P-2', properties: { kind: 'pipe', code: 'P-2' } })
  31. const batch = { kind: 'pipe-model-batch', instances: ['pipe:P-1', 'pipe:P-2'] }
  32. let hits = [{ id: batch, instanceId: 1 }]
  33. const devices = new Cesium.CustomDataSource('cockpit-associated-device-markers')
  34. const viewer = {
  35. dataSources: { getByName: (name) => name === source.name ? [source] : name === devices.name ? [devices] : [] },
  36. canvas: { getBoundingClientRect: () => ({ left: 20, top: 30 }) },
  37. scene: { pick: () => hits[0], drillPick: () => hits },
  38. }
  39. setActivePinia(createPinia())
  40. const store = usePipeEnhancementStore()
  41. const pipes = ['P-1', 'P-2'].map((code) => ({ code, positions: [[110.1, 28.4], [110.101, 28.401]] }))
  42. const demoDevice = { code: 'D-real', pointCode: 'MP-real', name: '真实设备', placement: 'nearby', placementText: '邻近设备 · 示意安装' }
  43. // 分页回归素材:12 条邻域管道(超过每页 8 条),断言翻页只切窗口、不挤走设备区。
  44. const manyPipeStates = Array.from({ length: 12 }, (_, i) => ({
  45. sourceKind: 'pipe', code: `NP-${String(i + 1).padStart(2, '0')}`, title: `邻域管段 ${i + 1}`,
  46. stateText: '状态未提供', diameterM: 0.4, diameter: 400, material: '', surface: { label: 'PE塑料' },
  47. geographicLengthM: 10 + i, lengthM: null, clipped: i % 2 === 1,
  48. }))
  49. const plan = Vue.computed(() => ({ ...planPipeEnhancement({ selected: store.selected, visiblePipes: pipes, radiusM: store.radiusM }),
  50. objects: manyPipeStates,
  51. deviceList: [demoDevice], devices: [demoDevice], deviceCoverage: { loading: false, partial: false } }))
  52. const selectContext = vm.createContext({ PIPE_ENHANCEMENT_KIND, pipeEnhancementStore: store })
  53. vm.runInContext(`${functions(shell.scriptSetup.content, ['selectPipeEnhancement'])}\nglobalThis.select = selectPipeEnhancement`, selectContext)
  54. const context = vm.createContext({
  55. instance: { engine: engineContext.engine, viewer }, resolveOverlapPick,
  56. pipelinePickHandlers: new Set(), pointPickHandlers: new Set(),
  57. overlapExpandedGroup: '', overlapPreviewGroup: '', lastPicked: null,
  58. collapseOverlap() {}, paintPicked() {}, expandOverlap() {},
  59. })
  60. vm.runInContext(`${functions(service, ['onPipelinePick', 'pickPipelineAt', 'pickDeviceOrPointAt'])}\nglobalThis.api = { onPipelinePick, pickPipelineAt, pickDeviceOrPointAt };`, context)
  61. // Execute the actual service registration, preserving its device/point-first branch.
  62. const registration = service.match(/record\.detachPickInput = attachPointPick\(record\.host,[\s\S]*?\n \}\)/)?.[0]
  63. assert.ok(registration, 'map must attach its pointer input')
  64. const host = new EventTarget()
  65. Object.assign(context, { record: { host }, attachPointPick })
  66. vm.runInContext(registration, context)
  67. // Execute the actual MapCanvas forwarding expression, followed by the real shell action.
  68. const forwarding = src('components/MapCanvas.vue').match(/onPipelinePick\(\(hit\) => emit\('pipeline-pick', hit\)\)/)?.[0]
  69. assert.ok(forwarding)
  70. Object.assign(context, { onPipelinePick: context.api.onPipelinePick, emit(event, hit) {
  71. assert.equal(event, 'pipeline-pick')
  72. selectContext.select(hit)
  73. } })
  74. vm.runInContext(forwarding, context)
  75. function click(x = 120, y = 230, upX = x) {
  76. for (const [type, clientX] of [['pointerdown', x], ['pointerup', upX]]) {
  77. const event = new Event(type)
  78. Object.assign(event, { button: 0, pointerId: 1, clientX, clientY: y })
  79. host.dispatchEvent(event)
  80. }
  81. }
  82. // Real Vue render/watch/close path; WebGL is replaced only at its renderer boundary.
  83. const enhancement = parse(src('components/PipeEnhancement.vue')).descriptor
  84. let mounts = 0
  85. let releases = 0
  86. let devicePick, objectPick, focusedDevice
  87. let syncs = 0
  88. const script = compileScript(enhancement, { id: 't23-click', inlineTemplate: true }).content
  89. const imports = { vue: Vue, './EquipmentDetailSections.vue': { default: { props: ['deviceCode', 'aggregate'], render() { return Vue.h('div', { 'data-device-detail': this.deviceCode }) } } }, '../services/threePipeEnhancement.js': { mountPipeEnhancement(_host, next, options) {
  90. devicePick = options.onDevicePick
  91. objectPick = options.onObjectPick
  92. assert.equal(next.object.code, 'P-2')
  93. mounts += 1
  94. return { sync() { syncs += 1 }, focusDevice(code) { focusedDevice = code; return true }, destroy() { releases += 1 } }
  95. } } }
  96. let compiled = script
  97. for (const node of parseJS(script, { sourceType: 'module' }).program.body.filter((n) => n.type === 'ImportDeclaration').reverse()) {
  98. const declarations = node.specifiers.map((s) => `const ${s.local.name} = imports[${JSON.stringify(node.source.value)}][${JSON.stringify(s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.name)}];`).join('\n')
  99. compiled = compiled.slice(0, node.start) + declarations + compiled.slice(node.end)
  100. }
  101. const Component = new Function('imports', `${compiled.replace('export default', 'return')}`)(imports)
  102. const element = (tag) => ({ tag, children: [], props: {}, parent: null })
  103. const renderer = Vue.createRenderer({
  104. createElement: element, createText: (text) => ({ text }), createComment: (text) => ({ comment: text }),
  105. setText(n, text) { n.text = text }, setElementText(n, text) { n.text = text },
  106. parentNode: (n) => n.parent, nextSibling: () => null,
  107. insert(n, parent, anchor) { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); n.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, n) },
  108. remove(n) { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); n.parent = null },
  109. patchProp(n, key, _old, next) { n.props[key] = next },
  110. })
  111. const root = element('root')
  112. const deviceStore = useEnhancementDevicesStore()
  113. const app = renderer.createApp({ setup: () => () => Vue.h(Component, {
  114. plan: plan.value, deviceState: deviceStore, onRadiusChange: (radius) => store.setRadius(radius),
  115. onClose: () => { store.clear(); deviceStore.clear() },
  116. onObjectPick: (hit) => selectContext.select(hit),
  117. onDevicePick: (device) => deviceStore.open(device, { fetchDetail: async (code) => ({ state: 'success', data: { deviceCode: code, sections: {} } }) }),
  118. onDeviceClose: () => deviceStore.closeDetail(),
  119. }) })
  120. app.mount(root)
  121. function find(node, key) {
  122. if (Object.hasOwn(node.props || {}, key)) return node
  123. return (node.children || []).map((n) => find(n, key)).find(Boolean)
  124. }
  125. assert.equal(find(root, 'data-pipe-enhancement'), undefined)
  126. click()
  127. await Vue.nextTick(); await Vue.nextTick()
  128. assert.ok(store.selected, `点击未派发:device/point=${JSON.stringify(context.api.pickDeviceOrPointAt(120, 230))}, pipe=${JSON.stringify(engineContext.engine.pickPipeObjectAt(viewer, 120, 230))}, subscribers=${context.pipelinePickHandlers.size}`)
  129. assert.equal(store.selected.code, 'P-2')
  130. assert.equal(mounts, 1)
  131. assert.ok(find(root, 'data-pipe-enhancement'), 'pointer hit must render the enhancement dialog')
  132. assert.ok(find(root, 'data-device-list'))
  133. assert.ok(find(root, 'data-pipe-status-list'))
  134. const deviceButton = find(root, 'data-device-code')
  135. assert.equal(deviceButton.props['data-device-code'], 'D-real')
  136. deviceButton.props.onClick()
  137. assert.equal(focusedDevice, 'D-real')
  138. await Vue.nextTick(); await Vue.nextTick()
  139. assert.equal(deviceStore.aggregate.data.deviceCode, 'D-real')
  140. assert.equal(find(root, 'data-device-detail').props['data-device-detail'], 'D-real')
  141. devicePick(demoDevice)
  142. await Vue.nextTick(); await Vue.nextTick()
  143. assert.equal(find(root, 'data-device-detail').props['data-device-detail'], 'D-real', '场景图标射线点击直接打开设备详情')
  144. assert.equal(find(root, 'data-device-list') !== undefined, true, '详情覆盖层打开时清单仍保留于其下')
  145. find(root, 'data-enhancement-device-detail').children
  146. .filter((node) => node.tag === 'header')[0].children
  147. .find((node) => node.tag === 'button').props.onClick()
  148. await Vue.nextTick()
  149. assert.equal(deviceStore.selected, null)
  150. assert.ok(find(root, 'data-device-list'), '关闭设备详情不关闭设备列表')
  151. assert.equal(mounts, 1, '设备详情关闭不重建三维场景')
  152. // ---- 附近管道状态分页(维护者 2026-10-22)----
  153. // 12 条邻域管道 → 分页控件出现,且首屏只渲染 8 条(不会把设备区挤下去)。
  154. assert.ok(find(root, 'data-pipe-pager'), '管道超过一页时必须有分页控件')
  155. function pipeRowTexts(node, out = []) {
  156. if (node.tag === 'button' && String(node.props?.class || '').includes('pipe-enhancement__pipe')) out.push(JSON.stringify(node.children || []))
  157. for (const child of node.children || []) pipeRowTexts(child, out)
  158. return out
  159. }
  160. const firstPageRows = pipeRowTexts(root)
  161. assert.equal(firstPageRows.length, 8, '第 1 页只渲染 8 条管道')
  162. assert.ok(!firstPageRows.some((t) => t.includes('NP-12')), '第 1 页不应包含第 12 条')
  163. assert.ok(find(root, 'data-device-list'), '分页后设备区仍存在且可达')
  164. function findTag(node, tag) { return node.tag === tag ? node : (node.children || []).map((child) => findTag(child, tag)).find(Boolean) }
  165. findTag(root, 'select').props.onChange({ target: { value: '100' } })
  166. await Vue.nextTick(); await Vue.nextTick()
  167. assert.equal(store.radiusM, 100)
  168. assert.equal(plan.value.radiusM, 100)
  169. assert.equal(mounts, 1, '范围切换复用视口与相机')
  170. assert.ok(syncs > 0)
  171. objectPick({ objectKind: 'pipe', code: 'P-1' })
  172. await Vue.nextTick(); await Vue.nextTick()
  173. assert.equal(store.selected.code, 'P-1')
  174. assert.equal(plan.value.object.code, 'P-1')
  175. assert.equal(find(root, 'data-pipe-enhancement-title').text, '重点管段 P-1')
  176. assert.equal(mounts, 1, '邻管点选通过sync切换完整详情,不重建画布')
  177. find(root, 'data-pipe-enhancement-close').props.onClick()
  178. await Vue.nextTick()
  179. assert.equal(store.selected, null)
  180. assert.equal(releases, 1)
  181. assert.equal(find(root, 'data-pipe-enhancement'), undefined)
  182. click(120, 230, 150)
  183. assert.equal(store.selected, null, 'dragging the map must not select a pipe')
  184. console.log('PASS pointer → 设备/点位优先链 → GPU instanceId=1 → MapCanvas emit → Pinia → Vue 弹窗 → 关闭释放;拖拽不选中')
  185. app.unmount()
  186. context.record.detachPickInput()