t45-selection-continuity.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import vm from 'node:vm'
  4. import { transitionOverlapState, OVERLAP_STATE, fanLayout, groupOverlappingDevices } from '../../../src/modules/cockpit/domain/overlapDevices.js'
  5. import { MAP_LABEL_STYLE, mapLabelText } from '../../../src/modules/cockpit/domain/mapLabelStyle.js'
  6. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  7. const engine = readFileSync(new URL('services/cesiumEngine.js', root), 'utf8')
  8. const service = readFileSync(new URL('services/mapService.js', root), 'utf8')
  9. const extract = (text, name) => text.match(new RegExp(`(?:export )?function ${name}\\([^]*?\\n\\}`))[0].replace(/^export /, '')
  10. const prop = (value) => ({ getValue: () => value })
  11. const entities = [
  12. { id: 'aggregate', show: true, properties: { overlapGroup: prop('G'), overlapAggregate: prop(true) }, billboard: { scale: 1, color: { alpha: 1 } }, label: {} },
  13. ...['A', 'B'].map((code) => ({ id: code, show: false, properties: { overlapGroup: prop('G'), deviceCode: prop(code), overlapAggregate: prop(false) }, billboard: { pixelOffset: { x: 0, y: 0 } }, label: {} })),
  14. ]
  15. const ctx = vm.createContext({ deviceMarkerSource: () => ({ entities: { values: entities } }), Cartesian2: class { constructor(x,y){this.x=x;this.y=y} }, Color: { WHITE: { withAlpha: (alpha) => ({ alpha }) } }, viewer: {} })
  16. vm.runInContext(extract(engine, 'setOverlapPresentation'), ctx)
  17. let failed = 0
  18. const check = (name, run) => { try { run(); console.log(`PASS ${name}`) } catch(e) { failed++; console.error(`FAIL ${name}: ${String(e.message).split('\n')[0]}`) } }
  19. check('clicking aggregate keeps a dim clickable entry', () => {
  20. vm.runInContext("setOverlapPresentation(viewer, {groupKey:'G', state:'expanded', offsets:{A:{x:0,y:-48},B:{x:0,y:48}}})",ctx)
  21. assert.equal(entities[0].show,true)
  22. assert.ok(entities[0].billboard.color.alpha < 1)
  23. })
  24. check('selecting fan device leaves selected device visible at center', () => {
  25. vm.runInContext("setOverlapPresentation(viewer, {groupKey:'G', state:'selected', selectedCode:'A'})",ctx)
  26. assert.equal(entities[1].show,true)
  27. assert.equal(entities[1].billboard.pixelOffset.x,0)
  28. assert.equal(entities[2].show,false)
  29. })
  30. check('device click transitions to persistent selected state', () => assert.equal(transitionOverlapState('expanded','device-click'), 'selected'))
  31. check('aggregate symbol carries readable quantity and separate identity', () => {
  32. const ctx2 = vm.createContext({ deviceMarkerSource: () => ({ entities: { removeAll(){}, add(e){ return e } } }), deviceBaseStyle: { clear(){} }, stopDeviceEffects(){}, Cartesian2: class {}, Cartesian3: { fromDegrees(){} }, HeightReference:{}, VerticalOrigin:{}, Color:{WHITE:{},BLACK:{withAlpha:(alpha)=>({alpha})}}, LabelStyle:{}, viewer:{} })
  33. const created = []
  34. ctx2.deviceMarkerSource = () => ({entities:{removeAll(){},add(e){created.push(e);return e}}})
  35. ctx2.MAP_LABEL_STYLE = MAP_LABEL_STYLE
  36. ctx2.mapLabelText = mapLabelText
  37. ctx2.Color.fromCssColorString = () => ({ withAlpha: (alpha) => ({ alpha }) })
  38. vm.runInContext(extract(engine,'mapTextLabel'),ctx2)
  39. vm.runInContext(extract(engine,'syncAssociatedDevices'),ctx2)
  40. ctx2.devices = [{code:'A',pointCode:'P',longitude:1,latitude:2,status:'NORMAL'}]
  41. ctx2.groups = [{key:'G',pointCode:'P',k:2,rows:ctx2.devices,visual:{status:'NORMAL',shape:'circle'}}]
  42. vm.runInContext("syncAssociatedDevices(viewer, devices, ()=>'color', ()=>'image', groups)",ctx2)
  43. const aggregateLabel = created.find((e)=>e.id.startsWith('overlap|')).label
  44. assert.equal(aggregateLabel.text, '聚合 2 台')
  45. assert.equal(aggregateLabel.showBackground, false)
  46. assert.equal(created.find((e)=>e.id.startsWith('device|')).label.showBackground, false)
  47. assert.equal(aggregateLabel.font, MAP_LABEL_STYLE.count.font)
  48. })
  49. check('monitoring point creates a visible name label', () => {
  50. const created = []
  51. const pctx=vm.createContext({pointMarkerSource:()=>({entities:{removeAll(){},add(e){created.push(e)}}}),Cartesian3:{fromDegrees(){}},Cartesian2:class{},HeightReference:{},VerticalOrigin:{},Color:{WHITE:{},BLACK:{withAlpha:(alpha)=>({alpha})}},LabelStyle:{},viewer:{}})
  52. pctx.MAP_LABEL_STYLE = MAP_LABEL_STYLE
  53. pctx.mapLabelText = mapLabelText
  54. pctx.Color.fromCssColorString = () => ({ withAlpha: (alpha) => ({ alpha }) })
  55. vm.runInContext(extract(engine,'mapTextLabel'),pctx)
  56. vm.runInContext(extract(engine,'syncPointMarkers'),pctx)
  57. vm.runInContext("syncPointMarkers(viewer,[{key:'monitoring-point|P',code:'P',kind:'monitoring-point',name:'城南监测点',longitude:1,latitude:2}],()=>'color',()=>'image')",pctx)
  58. assert.equal(created[0].label?.text,'⟦ 城南监测点 ⟧')
  59. assert.equal(created[0].label.font, MAP_LABEL_STYLE.name.font)
  60. assert.equal(created[0].label.showBackground, false)
  61. assert.equal(created[0].label.outlineWidth, 2)
  62. })
  63. check('device picking does not prewrite service selection before store callback',()=>{
  64. const body=extract(service,'pickDeviceOrPointAt')
  65. assert.ok(!body.includes('selectedDeviceCode = resolved.pick.code'))
  66. })
  67. // Run the real service selection/hover/click chain against the real engine presentation writer.
  68. const mutable = (value) => ({ getValue: () => value, setValue(next) { value = next } })
  69. for (const entity of entities.slice(1)) entity.properties.overlapEntry = mutable(false)
  70. let hit = null
  71. let selectedByCallback = ''
  72. const group = groupOverlappingDevices(['A', 'B'].map((code) => ({ code, pointCode: 'P', longitude: 1, latitude: 2, placement: 'drawn' })))[0]
  73. group.key = 'G'
  74. const runtime = vm.createContext({
  75. instance: { viewer: {}, engine: {
  76. setOverlapPresentation(_viewer, options) { ctx.options = options; vm.runInContext('setOverlapPresentation(viewer, options)', ctx) },
  77. pickDeviceMarkerAt() { return hit }, pickPointMarkerAt() { return null },
  78. pickDeviceMarkerForHover() { return hit }, nudgeOverlap() { return 1 },
  79. } },
  80. overlapGroups: [group], overlapExpandedGroup: '', overlapPreviewGroup: '', overlapState: 'collapsed',
  81. overlapCountValue: 0, overlapMaxFan: 10, selectedDeviceCode: '', hoveredDeviceCode: '', lastPicked: null,
  82. fanLayout, OVERLAP_STATE, transitionOverlapState, syncDeviceReactions() {},
  83. isDomHost: () => false, paintDeviceReactions() {}, resolveOverlapPick: ({ aggregateHit, deviceHit }) =>
  84. aggregateHit ? { target: 'overlap', pick: aggregateHit } : deviceHit ? { target: 'device', pick: deviceHit } : { target: 'none' },
  85. pointPickHandlers: new Set([(picked) => { selectedByCallback = picked.code; runtime.wanted = picked.code; vm.runInContext('setSelectedDevice(wanted)', runtime) }]),
  86. deviceHoverHandlers: new Set(),
  87. })
  88. for (const name of ['applyOverlapPresentation', 'paintOverlapState', 'collapseOverlap', 'resolveOverlapGroupKey',
  89. 'nudgeOverlap', 'expandOverlap', 'setSelectedDevice', 'pickDeviceOrPointAt', 'hoverDeviceAt', 'getOverlapState']) {
  90. vm.runInContext(extract(service, name), runtime)
  91. }
  92. const dispatch = (name, target) => { hit = target; return vm.runInContext(`${name}(500,300)`, runtime) }
  93. check('hover previews and click fixes the fan without losing the aggregate', () => {
  94. dispatch('hoverDeviceAt', { kind: 'monitoring-device-overlap', overlapGroup: 'G' })
  95. assert.equal(entities[0].show, true)
  96. assert.equal(entities[1].show, true)
  97. assert.equal(entities[0].billboard.scale, 1.2)
  98. dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device-overlap', overlapGroup: 'G' })
  99. assert.equal(runtime.overlapExpandedGroup, 'G')
  100. assert.equal(entities[0].billboard.color.alpha, 0.35)
  101. })
  102. check('fan click goes through callback and leaves A as the visible central entry', () => {
  103. dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device', code: 'A', pointCode: 'P', overlapGroup: 'G' })
  104. assert.equal(selectedByCallback, 'A')
  105. assert.equal(runtime.selectedDeviceCode, 'A')
  106. assert.equal(entities[1].show, true)
  107. assert.equal(entities[1].properties.overlapEntry.getValue(), true)
  108. assert.equal(entities[2].show, false)
  109. assert.equal(entities[1].billboard.pixelOffset.x, 0)
  110. assert.equal(entities[1].billboard.pixelOffset.y, 0)
  111. })
  112. check('selected entry still previews and click locks peers for switching', () => {
  113. const portal = { kind: 'monitoring-device', code: 'A', pointCode: 'P', overlapGroup: 'G', overlapEntry: true }
  114. dispatch('hoverDeviceAt', portal)
  115. assert.equal(entities[2].show, true)
  116. assert.equal(entities[1].billboard.pixelOffset.y, 0)
  117. dispatch('pickDeviceOrPointAt', portal)
  118. dispatch('hoverDeviceAt', null)
  119. assert.equal(entities[2].show, true)
  120. assert.equal(runtime.overlapExpandedGroup, 'G')
  121. })
  122. check('choosing B transfers center and preserves device identity', () => {
  123. dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device', code: 'B', pointCode: 'P', overlapGroup: 'G' })
  124. assert.equal(selectedByCallback, 'B')
  125. assert.equal(entities[2].show, true)
  126. assert.equal(entities[2].properties.overlapEntry.getValue(), true)
  127. assert.equal(entities[1].show, false)
  128. assert.equal(entities[1].properties.overlapEntry.getValue(), false)
  129. assert.equal(entities[2].billboard.pixelOffset.y, 0)
  130. })
  131. check('blank click and preview leave do not hide the selected device', () => {
  132. dispatch('hoverDeviceAt', { kind: 'monitoring-device', code: 'B', pointCode: 'P', overlapGroup: 'G', overlapEntry: true })
  133. dispatch('hoverDeviceAt', null)
  134. dispatch('pickDeviceOrPointAt', null)
  135. assert.equal(entities[2].show, true)
  136. assert.equal(vm.runInContext('getOverlapState().state', runtime), 'selected')
  137. })
  138. check('selecting from one group clears another active preview', () => {
  139. runtime.overlapGroups = [group, { ...group, key: 'H', pointCode: 'Q', rows: [{code:'C',pointCode:'Q'},{code:'D',pointCode:'Q'}] }]
  140. runtime.overlapPreviewGroup = 'H'
  141. const calls = []
  142. const render = runtime.instance.engine.setOverlapPresentation
  143. runtime.instance.engine.setOverlapPresentation = (viewer, options) => { calls.push(options); render(viewer, options) }
  144. vm.runInContext("setSelectedDevice('A')", runtime)
  145. assert.ok(calls.some((call) => call.groupKey === 'H' && call.state === 'collapsed'))
  146. assert.ok(calls.some((call) => call.groupKey === 'G' && call.state === 'selected'))
  147. assert.equal(runtime.overlapPreviewGroup, '')
  148. runtime.instance.engine.setOverlapPresentation = render
  149. })
  150. check('clear device selection restores aggregate and removes selected portal', () => {
  151. vm.runInContext("setSelectedDevice('')", runtime)
  152. assert.equal(entities[0].show, true)
  153. assert.equal(entities[1].show, false)
  154. assert.equal(entities[2].show, false)
  155. })
  156. check('K11 selected device outside the first ten stays visible with only nine peers', () => {
  157. const rows = Array.from({ length: 11 }, (_, index) => ({code:`D${index}`,pointCode:'P',longitude:1,latitude:2,placement:'drawn'}))
  158. const large = groupOverlappingDevices(rows)[0]
  159. large.key = 'G'
  160. runtime.overlapGroups = [large]
  161. entities.splice(0, entities.length,
  162. {show:true, properties:{overlapGroup:prop('G'),overlapAggregate:prop(true)},billboard:{},label:{}},
  163. ...rows.map((row)=>({show:false,properties:{overlapGroup:prop('G'),overlapAggregate:prop(false),deviceCode:prop(row.code),overlapEntry:mutable(false)},billboard:{},label:{}})))
  164. vm.runInContext("setSelectedDevice('D10'); expandOverlap('G', true)", runtime)
  165. assert.equal(entities.filter((entity)=>entity.show && entity.properties.deviceCode).length,10)
  166. assert.equal(entities[11].show,true)
  167. assert.equal(entities[11].billboard.pixelOffset.x,0)
  168. assert.equal(entities[11].billboard.pixelOffset.y,0)
  169. assert.equal(vm.runInContext('getOverlapState().count',runtime),11)
  170. })
  171. check('expanded device panel clears the fan and stays anchored to its device', () => {
  172. runtime.DEVICE_LAYER_KIND = 'monitoring-device'
  173. runtime.instance.engine.projectDeviceMarker = () => ({x:500,y:300,iconSize:22})
  174. vm.runInContext(extract(service,'getMapMarkerAnchor'),runtime)
  175. const anchor = vm.runInContext("getMapMarkerAnchor('monitoring-device', 'D10', 'P')",runtime)
  176. assert.equal(anchor.panelGap,60)
  177. assert.equal(anchor.x,500)
  178. })
  179. // Actual locate helper must sample device terrain and dispatch its configured tier.
  180. const locateBody = service.match(/export async function locateMonitoringPoint[^]*?\n\}/)[0].replace(/^export /,'')
  181. const moves = []
  182. const locateContext = vm.createContext({
  183. pointLocateToken:0, instance:{cameraControl:{},viewer:{},engine:{sampleTerrainHeight:async()=>123,groundHeightAt:()=>123}},
  184. isRenderable: (row)=>row.coordinate?.renderable === true,
  185. terrainState:'ready',TERRAIN_STATE:{NONE:'none'},isStaleLocate:(a,b)=>a!==b,
  186. getCockpitConfig:()=>({map:{}}),planLocateView:(_map,tier)=>({tier}),
  187. applyViewPreset:(pivot,hpr)=>{moves.push({pivot,hpr});return true},
  188. setCameraFocus:(pivot)=>{moves.push({pivot});return true},
  189. })
  190. vm.runInContext(locateBody,locateContext)
  191. locateContext.device={longitude:1,latitude:2,coordinate:{renderable:true}}
  192. await vm.runInContext("locateMonitoringPoint(device,{tier:'device'})",locateContext)
  193. check('first device selection dispatches device view at sampled ground height',()=>{
  194. assert.equal(moves[0].hpr.tier,'device')
  195. assert.equal(moves[0].pivot.height,123)
  196. assert.ok(/void locateMonitoringPoint\(device,/.test(readFileSync(new URL('stores/deviceLayerStore.js',root),'utf8')))
  197. })
  198. await vm.runInContext("locateMonitoringPoint(device,{tier:'device',centerOnly:true})",locateContext)
  199. check('device switching only changes center using sampled terrain',()=>{
  200. assert.equal(moves[1].hpr,undefined)
  201. assert.equal(moves[1].pivot.height,123)
  202. })
  203. const pickedEntity = {
  204. show: true,
  205. properties: { deviceCode: prop('A'), monitoringPointCode: prop('P'), deviceStatus: prop('NORMAL'),
  206. overlapGroup: prop('G'), overlapEntry: prop(true), overlapAggregate: prop(false) },
  207. }
  208. const pickContext = vm.createContext({
  209. deviceMarkerSource: () => ({ entities: { values: [pickedEntity] } }),
  210. projectMarkerEntity: () => ({ x: 500, y: 300 }),
  211. Cartesian2: class { constructor(x, y) { this.x = x; this.y = y } },
  212. viewer: { scene: { pick: () => ({ id: pickedEntity }) }, canvas: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } },
  213. })
  214. for (const name of ['readOverlapTargets', 'overlapTargetAt', 'pickDeviceMarkerAt']) {
  215. vm.runInContext(extract(engine, name), pickContext)
  216. }
  217. check('projected selected-entry pick retains device and group identity', () => {
  218. const picked = vm.runInContext('pickDeviceMarkerAt(viewer, 500, 300)', pickContext)
  219. assert.equal(picked.code, 'A')
  220. assert.equal(picked.pointCode, 'P')
  221. assert.equal(picked.overlapGroup, 'G')
  222. assert.equal(picked.overlapEntry, true)
  223. })
  224. check('ordinary scene pick retains the same selected-entry metadata', () => {
  225. const picked = vm.runInContext('pickDeviceMarkerAt(viewer, 700, 500)', pickContext)
  226. assert.equal(picked.code, 'A')
  227. assert.equal(picked.overlapGroup, 'G')
  228. assert.equal(picked.overlapEntry, true)
  229. })
  230. console.log(`T45 selection continuity: ${failed ? 'FAILED' : 'passed'}`)
  231. process.exitCode=failed ? 1 : 0