|
|
@@ -0,0 +1,233 @@
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { readFileSync } from 'node:fs'
|
|
|
+import vm from 'node:vm'
|
|
|
+import { transitionOverlapState, OVERLAP_STATE, fanLayout, groupOverlappingDevices } from '../../../src/modules/cockpit/domain/overlapDevices.js'
|
|
|
+import { MAP_LABEL_STYLE, mapLabelText } from '../../../src/modules/cockpit/domain/mapLabelStyle.js'
|
|
|
+
|
|
|
+const root = new URL('../../../src/modules/cockpit/', import.meta.url)
|
|
|
+const engine = readFileSync(new URL('services/cesiumEngine.js', root), 'utf8')
|
|
|
+const service = readFileSync(new URL('services/mapService.js', root), 'utf8')
|
|
|
+const extract = (text, name) => text.match(new RegExp(`(?:export )?function ${name}\\([^]*?\\n\\}`))[0].replace(/^export /, '')
|
|
|
+const prop = (value) => ({ getValue: () => value })
|
|
|
+const entities = [
|
|
|
+ { id: 'aggregate', show: true, properties: { overlapGroup: prop('G'), overlapAggregate: prop(true) }, billboard: { scale: 1, color: { alpha: 1 } }, label: {} },
|
|
|
+ ...['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: {} })),
|
|
|
+]
|
|
|
+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: {} })
|
|
|
+vm.runInContext(extract(engine, 'setOverlapPresentation'), ctx)
|
|
|
+let failed = 0
|
|
|
+const check = (name, run) => { try { run(); console.log(`PASS ${name}`) } catch(e) { failed++; console.error(`FAIL ${name}: ${String(e.message).split('\n')[0]}`) } }
|
|
|
+check('clicking aggregate keeps a dim clickable entry', () => {
|
|
|
+ vm.runInContext("setOverlapPresentation(viewer, {groupKey:'G', state:'expanded', offsets:{A:{x:0,y:-48},B:{x:0,y:48}}})",ctx)
|
|
|
+ assert.equal(entities[0].show,true)
|
|
|
+ assert.ok(entities[0].billboard.color.alpha < 1)
|
|
|
+})
|
|
|
+check('selecting fan device leaves selected device visible at center', () => {
|
|
|
+ vm.runInContext("setOverlapPresentation(viewer, {groupKey:'G', state:'selected', selectedCode:'A'})",ctx)
|
|
|
+ assert.equal(entities[1].show,true)
|
|
|
+ assert.equal(entities[1].billboard.pixelOffset.x,0)
|
|
|
+ assert.equal(entities[2].show,false)
|
|
|
+})
|
|
|
+check('device click transitions to persistent selected state', () => assert.equal(transitionOverlapState('expanded','device-click'), 'selected'))
|
|
|
+check('aggregate symbol carries readable quantity and separate identity', () => {
|
|
|
+ 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:{} })
|
|
|
+ const created = []
|
|
|
+ ctx2.deviceMarkerSource = () => ({entities:{removeAll(){},add(e){created.push(e);return e}}})
|
|
|
+ ctx2.MAP_LABEL_STYLE = MAP_LABEL_STYLE
|
|
|
+ ctx2.mapLabelText = mapLabelText
|
|
|
+ ctx2.Color.fromCssColorString = () => ({ withAlpha: (alpha) => ({ alpha }) })
|
|
|
+ vm.runInContext(extract(engine,'mapTextLabel'),ctx2)
|
|
|
+ vm.runInContext(extract(engine,'syncAssociatedDevices'),ctx2)
|
|
|
+ ctx2.devices = [{code:'A',pointCode:'P',longitude:1,latitude:2,status:'NORMAL'}]
|
|
|
+ ctx2.groups = [{key:'G',pointCode:'P',k:2,rows:ctx2.devices,visual:{status:'NORMAL',shape:'circle'}}]
|
|
|
+ vm.runInContext("syncAssociatedDevices(viewer, devices, ()=>'color', ()=>'image', groups)",ctx2)
|
|
|
+ const aggregateLabel = created.find((e)=>e.id.startsWith('overlap|')).label
|
|
|
+ assert.equal(aggregateLabel.text, '聚合 2 台')
|
|
|
+ assert.equal(aggregateLabel.showBackground, false)
|
|
|
+ assert.equal(created.find((e)=>e.id.startsWith('device|')).label.showBackground, false)
|
|
|
+ assert.equal(aggregateLabel.font, MAP_LABEL_STYLE.count.font)
|
|
|
+})
|
|
|
+check('monitoring point creates a visible name label', () => {
|
|
|
+ const created = []
|
|
|
+ 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:{}})
|
|
|
+ pctx.MAP_LABEL_STYLE = MAP_LABEL_STYLE
|
|
|
+ pctx.mapLabelText = mapLabelText
|
|
|
+ pctx.Color.fromCssColorString = () => ({ withAlpha: (alpha) => ({ alpha }) })
|
|
|
+ vm.runInContext(extract(engine,'mapTextLabel'),pctx)
|
|
|
+ vm.runInContext(extract(engine,'syncPointMarkers'),pctx)
|
|
|
+ vm.runInContext("syncPointMarkers(viewer,[{key:'monitoring-point|P',code:'P',kind:'monitoring-point',name:'城南监测点',longitude:1,latitude:2}],()=>'color',()=>'image')",pctx)
|
|
|
+ assert.equal(created[0].label?.text,'⟦ 城南监测点 ⟧')
|
|
|
+ assert.equal(created[0].label.font, MAP_LABEL_STYLE.name.font)
|
|
|
+ assert.equal(created[0].label.showBackground, false)
|
|
|
+ assert.equal(created[0].label.outlineWidth, 2)
|
|
|
+})
|
|
|
+check('device picking does not prewrite service selection before store callback',()=>{
|
|
|
+ const body=extract(service,'pickDeviceOrPointAt')
|
|
|
+ assert.ok(!body.includes('selectedDeviceCode = resolved.pick.code'))
|
|
|
+})
|
|
|
+// Run the real service selection/hover/click chain against the real engine presentation writer.
|
|
|
+const mutable = (value) => ({ getValue: () => value, setValue(next) { value = next } })
|
|
|
+for (const entity of entities.slice(1)) entity.properties.overlapEntry = mutable(false)
|
|
|
+let hit = null
|
|
|
+let selectedByCallback = ''
|
|
|
+const group = groupOverlappingDevices(['A', 'B'].map((code) => ({ code, pointCode: 'P', longitude: 1, latitude: 2, placement: 'drawn' })))[0]
|
|
|
+group.key = 'G'
|
|
|
+const runtime = vm.createContext({
|
|
|
+ instance: { viewer: {}, engine: {
|
|
|
+ setOverlapPresentation(_viewer, options) { ctx.options = options; vm.runInContext('setOverlapPresentation(viewer, options)', ctx) },
|
|
|
+ pickDeviceMarkerAt() { return hit }, pickPointMarkerAt() { return null },
|
|
|
+ pickDeviceMarkerForHover() { return hit }, nudgeOverlap() { return 1 },
|
|
|
+ } },
|
|
|
+ overlapGroups: [group], overlapExpandedGroup: '', overlapPreviewGroup: '', overlapState: 'collapsed',
|
|
|
+ overlapCountValue: 0, overlapMaxFan: 10, selectedDeviceCode: '', hoveredDeviceCode: '', lastPicked: null,
|
|
|
+ fanLayout, OVERLAP_STATE, transitionOverlapState, syncDeviceReactions() {},
|
|
|
+ isDomHost: () => false, paintDeviceReactions() {}, resolveOverlapPick: ({ aggregateHit, deviceHit }) =>
|
|
|
+ aggregateHit ? { target: 'overlap', pick: aggregateHit } : deviceHit ? { target: 'device', pick: deviceHit } : { target: 'none' },
|
|
|
+ pointPickHandlers: new Set([(picked) => { selectedByCallback = picked.code; runtime.wanted = picked.code; vm.runInContext('setSelectedDevice(wanted)', runtime) }]),
|
|
|
+ deviceHoverHandlers: new Set(),
|
|
|
+})
|
|
|
+for (const name of ['applyOverlapPresentation', 'paintOverlapState', 'collapseOverlap', 'resolveOverlapGroupKey',
|
|
|
+ 'nudgeOverlap', 'expandOverlap', 'setSelectedDevice', 'pickDeviceOrPointAt', 'hoverDeviceAt', 'getOverlapState']) {
|
|
|
+ vm.runInContext(extract(service, name), runtime)
|
|
|
+}
|
|
|
+const dispatch = (name, target) => { hit = target; return vm.runInContext(`${name}(500,300)`, runtime) }
|
|
|
+check('hover previews and click fixes the fan without losing the aggregate', () => {
|
|
|
+ dispatch('hoverDeviceAt', { kind: 'monitoring-device-overlap', overlapGroup: 'G' })
|
|
|
+ assert.equal(entities[0].show, true)
|
|
|
+ assert.equal(entities[1].show, true)
|
|
|
+ assert.equal(entities[0].billboard.scale, 1.2)
|
|
|
+ dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device-overlap', overlapGroup: 'G' })
|
|
|
+ assert.equal(runtime.overlapExpandedGroup, 'G')
|
|
|
+ assert.equal(entities[0].billboard.color.alpha, 0.35)
|
|
|
+})
|
|
|
+check('fan click goes through callback and leaves A as the visible central entry', () => {
|
|
|
+ dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device', code: 'A', pointCode: 'P', overlapGroup: 'G' })
|
|
|
+ assert.equal(selectedByCallback, 'A')
|
|
|
+ assert.equal(runtime.selectedDeviceCode, 'A')
|
|
|
+ assert.equal(entities[1].show, true)
|
|
|
+ assert.equal(entities[1].properties.overlapEntry.getValue(), true)
|
|
|
+ assert.equal(entities[2].show, false)
|
|
|
+ assert.equal(entities[1].billboard.pixelOffset.x, 0)
|
|
|
+ assert.equal(entities[1].billboard.pixelOffset.y, 0)
|
|
|
+})
|
|
|
+check('selected entry still previews and click locks peers for switching', () => {
|
|
|
+ const portal = { kind: 'monitoring-device', code: 'A', pointCode: 'P', overlapGroup: 'G', overlapEntry: true }
|
|
|
+ dispatch('hoverDeviceAt', portal)
|
|
|
+ assert.equal(entities[2].show, true)
|
|
|
+ assert.equal(entities[1].billboard.pixelOffset.y, 0)
|
|
|
+ dispatch('pickDeviceOrPointAt', portal)
|
|
|
+ dispatch('hoverDeviceAt', null)
|
|
|
+ assert.equal(entities[2].show, true)
|
|
|
+ assert.equal(runtime.overlapExpandedGroup, 'G')
|
|
|
+})
|
|
|
+check('choosing B transfers center and preserves device identity', () => {
|
|
|
+ dispatch('pickDeviceOrPointAt', { kind: 'monitoring-device', code: 'B', pointCode: 'P', overlapGroup: 'G' })
|
|
|
+ assert.equal(selectedByCallback, 'B')
|
|
|
+ assert.equal(entities[2].show, true)
|
|
|
+ assert.equal(entities[2].properties.overlapEntry.getValue(), true)
|
|
|
+ assert.equal(entities[1].show, false)
|
|
|
+ assert.equal(entities[1].properties.overlapEntry.getValue(), false)
|
|
|
+ assert.equal(entities[2].billboard.pixelOffset.y, 0)
|
|
|
+})
|
|
|
+check('blank click and preview leave do not hide the selected device', () => {
|
|
|
+ dispatch('hoverDeviceAt', { kind: 'monitoring-device', code: 'B', pointCode: 'P', overlapGroup: 'G', overlapEntry: true })
|
|
|
+ dispatch('hoverDeviceAt', null)
|
|
|
+ dispatch('pickDeviceOrPointAt', null)
|
|
|
+ assert.equal(entities[2].show, true)
|
|
|
+ assert.equal(vm.runInContext('getOverlapState().state', runtime), 'selected')
|
|
|
+})
|
|
|
+check('selecting from one group clears another active preview', () => {
|
|
|
+ runtime.overlapGroups = [group, { ...group, key: 'H', pointCode: 'Q', rows: [{code:'C',pointCode:'Q'},{code:'D',pointCode:'Q'}] }]
|
|
|
+ runtime.overlapPreviewGroup = 'H'
|
|
|
+ const calls = []
|
|
|
+ const render = runtime.instance.engine.setOverlapPresentation
|
|
|
+ runtime.instance.engine.setOverlapPresentation = (viewer, options) => { calls.push(options); render(viewer, options) }
|
|
|
+ vm.runInContext("setSelectedDevice('A')", runtime)
|
|
|
+ assert.ok(calls.some((call) => call.groupKey === 'H' && call.state === 'collapsed'))
|
|
|
+ assert.ok(calls.some((call) => call.groupKey === 'G' && call.state === 'selected'))
|
|
|
+ assert.equal(runtime.overlapPreviewGroup, '')
|
|
|
+ runtime.instance.engine.setOverlapPresentation = render
|
|
|
+})
|
|
|
+check('clear device selection restores aggregate and removes selected portal', () => {
|
|
|
+ vm.runInContext("setSelectedDevice('')", runtime)
|
|
|
+ assert.equal(entities[0].show, true)
|
|
|
+ assert.equal(entities[1].show, false)
|
|
|
+ assert.equal(entities[2].show, false)
|
|
|
+})
|
|
|
+check('K11 selected device outside the first ten stays visible with only nine peers', () => {
|
|
|
+ const rows = Array.from({ length: 11 }, (_, index) => ({code:`D${index}`,pointCode:'P',longitude:1,latitude:2,placement:'drawn'}))
|
|
|
+ const large = groupOverlappingDevices(rows)[0]
|
|
|
+ large.key = 'G'
|
|
|
+ runtime.overlapGroups = [large]
|
|
|
+ entities.splice(0, entities.length,
|
|
|
+ {show:true, properties:{overlapGroup:prop('G'),overlapAggregate:prop(true)},billboard:{},label:{}},
|
|
|
+ ...rows.map((row)=>({show:false,properties:{overlapGroup:prop('G'),overlapAggregate:prop(false),deviceCode:prop(row.code),overlapEntry:mutable(false)},billboard:{},label:{}})))
|
|
|
+ vm.runInContext("setSelectedDevice('D10'); expandOverlap('G', true)", runtime)
|
|
|
+ assert.equal(entities.filter((entity)=>entity.show && entity.properties.deviceCode).length,10)
|
|
|
+ assert.equal(entities[11].show,true)
|
|
|
+ assert.equal(entities[11].billboard.pixelOffset.x,0)
|
|
|
+ assert.equal(entities[11].billboard.pixelOffset.y,0)
|
|
|
+ assert.equal(vm.runInContext('getOverlapState().count',runtime),11)
|
|
|
+})
|
|
|
+check('expanded device panel clears the fan and stays anchored to its device', () => {
|
|
|
+ runtime.DEVICE_LAYER_KIND = 'monitoring-device'
|
|
|
+ runtime.instance.engine.projectDeviceMarker = () => ({x:500,y:300,iconSize:22})
|
|
|
+ vm.runInContext(extract(service,'getMapMarkerAnchor'),runtime)
|
|
|
+ const anchor = vm.runInContext("getMapMarkerAnchor('monitoring-device', 'D10', 'P')",runtime)
|
|
|
+ assert.equal(anchor.panelGap,60)
|
|
|
+ assert.equal(anchor.x,500)
|
|
|
+})
|
|
|
+
|
|
|
+// Actual locate helper must sample device terrain and dispatch its configured tier.
|
|
|
+const locateBody = service.match(/export async function locateMonitoringPoint[^]*?\n\}/)[0].replace(/^export /,'')
|
|
|
+const moves = []
|
|
|
+const locateContext = vm.createContext({
|
|
|
+ pointLocateToken:0, instance:{cameraControl:{},viewer:{},engine:{sampleTerrainHeight:async()=>123,groundHeightAt:()=>123}},
|
|
|
+ isRenderable: (row)=>row.coordinate?.renderable === true,
|
|
|
+ terrainState:'ready',TERRAIN_STATE:{NONE:'none'},isStaleLocate:(a,b)=>a!==b,
|
|
|
+ getCockpitConfig:()=>({map:{}}),planLocateView:(_map,tier)=>({tier}),
|
|
|
+ applyViewPreset:(pivot,hpr)=>{moves.push({pivot,hpr});return true},
|
|
|
+ setCameraFocus:(pivot)=>{moves.push({pivot});return true},
|
|
|
+})
|
|
|
+vm.runInContext(locateBody,locateContext)
|
|
|
+locateContext.device={longitude:1,latitude:2,coordinate:{renderable:true}}
|
|
|
+await vm.runInContext("locateMonitoringPoint(device,{tier:'device'})",locateContext)
|
|
|
+check('first device selection dispatches device view at sampled ground height',()=>{
|
|
|
+ assert.equal(moves[0].hpr.tier,'device')
|
|
|
+ assert.equal(moves[0].pivot.height,123)
|
|
|
+ assert.ok(/void locateMonitoringPoint\(device,/.test(readFileSync(new URL('stores/deviceLayerStore.js',root),'utf8')))
|
|
|
+})
|
|
|
+await vm.runInContext("locateMonitoringPoint(device,{tier:'device',centerOnly:true})",locateContext)
|
|
|
+check('device switching only changes center using sampled terrain',()=>{
|
|
|
+ assert.equal(moves[1].hpr,undefined)
|
|
|
+ assert.equal(moves[1].pivot.height,123)
|
|
|
+})
|
|
|
+const pickedEntity = {
|
|
|
+ show: true,
|
|
|
+ properties: { deviceCode: prop('A'), monitoringPointCode: prop('P'), deviceStatus: prop('NORMAL'),
|
|
|
+ overlapGroup: prop('G'), overlapEntry: prop(true), overlapAggregate: prop(false) },
|
|
|
+}
|
|
|
+const pickContext = vm.createContext({
|
|
|
+ deviceMarkerSource: () => ({ entities: { values: [pickedEntity] } }),
|
|
|
+ projectMarkerEntity: () => ({ x: 500, y: 300 }),
|
|
|
+ Cartesian2: class { constructor(x, y) { this.x = x; this.y = y } },
|
|
|
+ viewer: { scene: { pick: () => ({ id: pickedEntity }) }, canvas: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } },
|
|
|
+})
|
|
|
+for (const name of ['readOverlapTargets', 'overlapTargetAt', 'pickDeviceMarkerAt']) {
|
|
|
+ vm.runInContext(extract(engine, name), pickContext)
|
|
|
+}
|
|
|
+check('projected selected-entry pick retains device and group identity', () => {
|
|
|
+ const picked = vm.runInContext('pickDeviceMarkerAt(viewer, 500, 300)', pickContext)
|
|
|
+ assert.equal(picked.code, 'A')
|
|
|
+ assert.equal(picked.pointCode, 'P')
|
|
|
+ assert.equal(picked.overlapGroup, 'G')
|
|
|
+ assert.equal(picked.overlapEntry, true)
|
|
|
+})
|
|
|
+check('ordinary scene pick retains the same selected-entry metadata', () => {
|
|
|
+ const picked = vm.runInContext('pickDeviceMarkerAt(viewer, 700, 500)', pickContext)
|
|
|
+ assert.equal(picked.code, 'A')
|
|
|
+ assert.equal(picked.overlapGroup, 'G')
|
|
|
+ assert.equal(picked.overlapEntry, true)
|
|
|
+})
|
|
|
+console.log(`T45 selection continuity: ${failed ? 'FAILED' : 'passed'}`)
|
|
|
+process.exitCode=failed ? 1 : 0
|