import { describe, it, expect, vi } from 'vitest' vi.mock('cesium', () => ({ Math: { toDegrees: (r: number) => r * (180 / Math.PI), toRadians: (d: number) => d * (Math.PI / 180), }, JulianDate: { now: () => ({}) }, Cartographic: { fromCartesian: (c: any) => ({ longitude: c._lon * (Math.PI / 180), latitude: c._lat * (Math.PI / 180), height: c._h ?? 0, }), }, Cartesian3: { fromDegrees: (lon: number, lat: number, h?: number) => ({ _lon: lon, _lat: lat, _h: h ?? 0 }), }, Color: { fromCssColorString: () => ({}) }, HorizontalOrigin: { CENTER: 0 }, VerticalOrigin: { BOTTOM: 1 }, HeightReference: { CLAMP_TO_GROUND: 1, NONE: 0 }, LabelStyle: { FILL_AND_OUTLINE: 2 }, NearFarScalar: class { constructor(..._: any[]) {} }, DistanceDisplayCondition: class { constructor(..._: any[]) {} }, ClampToGroundPolyline: class {}, PolylineDashMaterialProperty: class { constructor() {} }, ColorMaterialProperty: class { constructor() {} }, default: {}, })) import { computeFeatureCentroid, centroidOfCoords, batchAddEntities, queryEntities, getEntityProperties } from './entity.js' // ==================== batchAddEntities ==================== describe('batchAddEntities', () => { function makeViewer() { // batchAddEntities wraps the loop with viewer.entities.suspendEvents/resumeEvents // to batch collectionChanged notifications (official Cesium optimization pattern). return { entities: { suspendEvents: () => {}, resumeEvents: () => {}, }, } as any } function makeHelpers() { let counter = 0 const calls: { fn: string; params: any }[] = [] const make = (name: string) => (params: any) => { calls.push({ fn: name, params }) return { id: `entity-${counter++}` } } return { helpers: { addMarker: make('addMarker'), addPolyline: make('addPolyline'), addPolygon: make('addPolygon'), addModel: make('addModel'), addBillboard: make('addBillboard'), addBox: make('addBox'), addCylinder: make('addCylinder'), addEllipse: make('addEllipse'), addRectangle: make('addRectangle'), addWall: make('addWall'), addCorridor: make('addCorridor'), } as any, calls, } } it('should dispatch each entity to the correct helper', () => { const { helpers, calls } = makeHelpers() const result = batchAddEntities(makeViewer(), [ { type: 'marker', longitude: 116.4, latitude: 39.9 }, { type: 'polyline', coordinates: [[0, 0], [1, 1]] }, { type: 'polygon', coordinates: [[0, 0], [1, 0], [1, 1]] }, ] as any, helpers) expect(result.entityIds).toHaveLength(3) expect(result.errors).toHaveLength(0) expect(calls[0]!.fn).toBe('addMarker') expect(calls[1]!.fn).toBe('addPolyline') expect(calls[2]!.fn).toBe('addPolygon') }) it('should collect errors without stopping', () => { const { helpers } = makeHelpers() helpers.addMarker = () => { throw new Error('fail marker') } const result = batchAddEntities(makeViewer(), [ { type: 'marker', longitude: 0, latitude: 0 }, { type: 'polyline', coordinates: [] }, ] as any, helpers) expect(result.entityIds).toHaveLength(1) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('fail marker') }) it('should report error for unknown entity type', () => { const { helpers } = makeHelpers() const result = batchAddEntities(makeViewer(), [ { type: 'unknown_type' as any }, ], helpers) expect(result.entityIds).toHaveLength(0) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('Unknown type') }) it('should handle empty entities array', () => { const { helpers } = makeHelpers() const result = batchAddEntities(makeViewer(), [], helpers) expect(result.entityIds).toHaveLength(0) expect(result.errors).toHaveLength(0) }) it('should suspend/resume entity collection events around the batch', () => { // Cesium official recommendation: batch add should wrap in // suspendEvents/resumeEvents to avoid firing collectionChanged per entity. const suspend = vi.fn() const resume = vi.fn() const viewer = { entities: { suspendEvents: suspend, resumeEvents: resume } } as any const { helpers } = makeHelpers() batchAddEntities(viewer, [ { type: 'marker', longitude: 0, latitude: 0 }, { type: 'marker', longitude: 1, latitude: 1 }, ] as any, helpers) expect(suspend).toHaveBeenCalledTimes(1) expect(resume).toHaveBeenCalledTimes(1) }) it('should resume events even when a helper throws', () => { const suspend = vi.fn() const resume = vi.fn() const viewer = { entities: { suspendEvents: suspend, resumeEvents: resume } } as any const { helpers } = makeHelpers() helpers.addMarker = () => { throw new Error('boom') } batchAddEntities(viewer, [{ type: 'marker' }] as any, helpers) expect(resume).toHaveBeenCalledTimes(1) }) it('should produce distinct entityIds when many are added in the same tick', () => { // Regression guard: layerId used to derive from Date.now() at the bridge // level, causing collisions when batchAddEntities looped synchronously in // one ms. entity.id is Cesium-assigned and unique, so IDs must all differ. const { helpers } = makeHelpers() const defs = Array.from({ length: 20 }, () => ({ type: 'marker' as const })) const result = batchAddEntities(makeViewer(), defs as any, helpers) expect(new Set(result.entityIds).size).toBe(20) }) it('should dispatch all supported entity types', () => { const { helpers, calls } = makeHelpers() const types = ['marker', 'polyline', 'polygon', 'model', 'billboard', 'box', 'cylinder', 'ellipse', 'rectangle', 'wall', 'corridor'] const entities = types.map(type => ({ type })) as any const result = batchAddEntities(makeViewer(), entities, helpers) expect(result.entityIds).toHaveLength(types.length) expect(result.errors).toHaveLength(0) const dispatchedFns = calls.map(c => c.fn) expect(dispatchedFns).toEqual([ 'addMarker', 'addPolyline', 'addPolygon', 'addModel', 'addBillboard', 'addBox', 'addCylinder', 'addEllipse', 'addRectangle', 'addWall', 'addCorridor', ]) }) it('should strip type from params passed to helper', () => { const { helpers, calls } = makeHelpers() batchAddEntities(makeViewer(), [ { type: 'marker', longitude: 100, latitude: 30, label: 'test' }, ] as any, helpers) expect(calls[0]!.params).toEqual({ longitude: 100, latitude: 30, label: 'test' }) expect(calls[0]!.params).not.toHaveProperty('type') }) }) // ==================== queryEntities ==================== describe('queryEntities', () => { function makeEntity(overrides: { id: string name?: string type: string lon?: number lat?: number height?: number }) { const entity: any = { id: overrides.id, name: overrides.name, } // Set the type flag const typeMap: Record = { marker: 'point', billboard: 'billboard', polyline: 'polyline', polygon: 'polygon', model: 'model', box: 'box', cylinder: 'cylinder', ellipse: 'ellipse', rectangle: 'rectangle', wall: 'wall', corridor: 'corridor', label: 'label', } const cesiumProp = typeMap[overrides.type] if (cesiumProp) entity[cesiumProp] = {} // Position if (overrides.lon != null) { entity.position = { getValue: () => ({ _lon: overrides.lon, _lat: overrides.lat, _h: overrides.height ?? 0, }), } } return entity } function makeViewer(entities: any[]) { return { entities: { values: entities } } as any } it('should filter by entity type', () => { const viewer = makeViewer([ makeEntity({ id: '1', type: 'marker' }), makeEntity({ id: '2', type: 'polyline' }), makeEntity({ id: '3', type: 'marker' }), ]) const results = queryEntities(viewer, { type: 'marker' }) expect(results).toHaveLength(2) expect(results.every(r => r.type === 'marker')).toBe(true) }) it('should filter by name (fuzzy, case-insensitive)', () => { const viewer = makeViewer([ makeEntity({ id: '1', type: 'marker', name: 'Beijing Station' }), makeEntity({ id: '2', type: 'marker', name: 'Shanghai Tower' }), makeEntity({ id: '3', type: 'polyline', name: 'beijing road' }), ]) const results = queryEntities(viewer, { name: 'beijing' }) expect(results).toHaveLength(2) expect(results.map(r => r.entityId).sort()).toEqual(['1', '3']) }) it('should return all entities when no filters given', () => { const viewer = makeViewer([ makeEntity({ id: '1', type: 'marker' }), makeEntity({ id: '2', type: 'polygon' }), ]) const results = queryEntities(viewer, {}) expect(results).toHaveLength(2) }) it('should filter by bounding box', () => { const viewer = makeViewer([ makeEntity({ id: 'inside', type: 'marker', lon: 116.4, lat: 39.9 }), makeEntity({ id: 'outside', type: 'marker', lon: 121.0, lat: 31.0 }), ]) // bbox: [west, south, east, north] around Beijing const results = queryEntities(viewer, { bbox: [115, 39, 117, 41] }) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('inside') }) it('should exclude entities without position when bbox filter is set', () => { const viewer = makeViewer([ makeEntity({ id: 'nopos', type: 'marker' }), // no position makeEntity({ id: 'haspos', type: 'marker', lon: 116.4, lat: 39.9 }), ]) const results = queryEntities(viewer, { bbox: [115, 39, 117, 41] }) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('haspos') }) it('should return empty array for empty scene', () => { const results = queryEntities(makeViewer([]), {}) expect(results).toHaveLength(0) }) it('should include position in results when available', () => { const viewer = makeViewer([ makeEntity({ id: '1', type: 'marker', lon: 116.4, lat: 39.9, height: 100 }), ]) const results = queryEntities(viewer, {}) expect(results[0]!.position).toBeDefined() expect(results[0]!.position!.longitude).toBeCloseTo(116.4, 1) expect(results[0]!.position!.latitude).toBeCloseTo(39.9, 1) }) // ---- DataSource 实体查询 ---- function makeViewerWithDataSources(topEntities: any[], dataSources: { entities: any[] }[]) { return { entities: { values: topEntities }, dataSources: { length: dataSources.length, get: (i: number) => ({ entities: { values: dataSources[i]!.entities }, }), }, } as any } it('should find entities inside DataSources', () => { const viewer = makeViewerWithDataSources([], [ { entities: [makeEntity({ id: 'ds1', type: 'polygon', name: 'GeoJSON Feature' })] }, ]) const results = queryEntities(viewer, {}) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('ds1') expect(results[0]!.type).toBe('polygon') }) it('should combine viewer entities and DataSource entities', () => { const viewer = makeViewerWithDataSources( [makeEntity({ id: 'top1', type: 'marker' })], [{ entities: [makeEntity({ id: 'ds1', type: 'polygon' }), makeEntity({ id: 'ds2', type: 'polyline' })] }], ) const results = queryEntities(viewer, {}) expect(results).toHaveLength(3) expect(results.map(r => r.entityId).sort()).toEqual(['ds1', 'ds2', 'top1']) }) it('should filter DataSource entities by type', () => { const viewer = makeViewerWithDataSources([], [ { entities: [ makeEntity({ id: 'a', type: 'polygon' }), makeEntity({ id: 'b', type: 'polyline' }), ] }, ]) const results = queryEntities(viewer, { type: 'polygon' }) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('a') }) it('should filter DataSource entities by name', () => { const viewer = makeViewerWithDataSources([], [ { entities: [ makeEntity({ id: 'a', type: 'polygon', name: 'Beijing District' }), makeEntity({ id: 'b', type: 'polygon', name: 'Shanghai District' }), ] }, ]) const results = queryEntities(viewer, { name: 'beijing' }) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('a') }) it('should filter DataSource entities by bbox', () => { const viewer = makeViewerWithDataSources([], [ { entities: [ makeEntity({ id: 'in', type: 'marker', lon: 116.4, lat: 39.9 }), makeEntity({ id: 'out', type: 'marker', lon: 121.0, lat: 31.0 }), ] }, ]) const results = queryEntities(viewer, { bbox: [115, 39, 117, 41] }) expect(results).toHaveLength(1) expect(results[0]!.entityId).toBe('in') }) it('should handle multiple DataSources', () => { const viewer = makeViewerWithDataSources([], [ { entities: [makeEntity({ id: 'a', type: 'polygon' })] }, { entities: [makeEntity({ id: 'b', type: 'polyline' })] }, ]) const results = queryEntities(viewer, {}) expect(results).toHaveLength(2) }) it('should work when viewer has no dataSources property', () => { const viewer = { entities: { values: [makeEntity({ id: '1', type: 'marker' })] } } as any const results = queryEntities(viewer, {}) expect(results).toHaveLength(1) }) it('should compute centroid for polygon entity without position', () => { const polyEntity: any = { id: 'poly1', name: 'Test Polygon', polygon: { hierarchy: { getValue: () => ({ positions: [ { _lon: 10, _lat: 20, _h: 0 }, { _lon: 20, _lat: 20, _h: 0 }, { _lon: 20, _lat: 30, _h: 0 }, { _lon: 10, _lat: 30, _h: 0 }, ], }), }, }, } const viewer = makeViewer([polyEntity]) const results = queryEntities(viewer, {}) expect(results).toHaveLength(1) expect(results[0]!.position).toBeDefined() expect(results[0]!.position!.longitude).toBeCloseTo(15, 0) expect(results[0]!.position!.latitude).toBeCloseTo(25, 0) }) it('should use bbox intersection for polygon entities', () => { // Polygon spanning lon 10-20, lat 20-30 const polyEntity: any = { id: 'bigpoly', name: 'Large Polygon', polygon: { hierarchy: { getValue: () => ({ positions: [ { _lon: 10, _lat: 20, _h: 0 }, { _lon: 20, _lat: 20, _h: 0 }, { _lon: 20, _lat: 30, _h: 0 }, { _lon: 10, _lat: 30, _h: 0 }, ], }), }, }, } const viewer = makeViewer([polyEntity]) // Query bbox overlaps with polygon's corner (lon 18-22, lat 28-32) const results = queryEntities(viewer, { bbox: [18, 28, 22, 32] }) expect(results).toHaveLength(1) // Should match via bbox intersection expect(results[0]!.entityId).toBe('bigpoly') }) it('should exclude polygon when bbox does not intersect', () => { const polyEntity: any = { id: 'farpoly', name: 'Far Polygon', polygon: { hierarchy: { getValue: () => ({ positions: [ { _lon: 10, _lat: 20, _h: 0 }, { _lon: 20, _lat: 20, _h: 0 }, { _lon: 20, _lat: 30, _h: 0 }, { _lon: 10, _lat: 30, _h: 0 }, ], }), }, }, } const viewer = makeViewer([polyEntity]) // Query bbox far from polygon (lon 100-110, lat 50-60) const results = queryEntities(viewer, { bbox: [100, 50, 110, 60] }) expect(results).toHaveLength(0) }) }) // ==================== Pure helpers ==================== describe('centroidOfCoords', () => { it('should compute centroid of a coordinate array', () => { const result = centroidOfCoords([[0, 0], [10, 0], [10, 10], [0, 10]]) expect(result).toEqual([5, 5]) }) it('should return null for empty array', () => { expect(centroidOfCoords([])).toBeNull() }) it('should return null for null input', () => { expect(centroidOfCoords(null as any)).toBeNull() }) it('should return the point itself for single coordinate', () => { expect(centroidOfCoords([[116.4, 39.9]])).toEqual([116.4, 39.9]) }) }) describe('computeFeatureCentroid', () => { it('should handle Point geometry', () => { const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [116.4, 39.9] }, } expect(computeFeatureCentroid(feature)).toEqual([116.4, 39.9]) }) it('should handle LineString geometry', () => { const feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [10, 10]], }, } expect(computeFeatureCentroid(feature)).toEqual([5, 5]) }) it('should handle Polygon geometry (centroid of outer ring)', () => { const feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], }, } const result = computeFeatureCentroid(feature) expect(result![0]).toBeCloseTo(4, 0) // centroid of closed ring expect(result![1]).toBeCloseTo(4, 0) }) it('should handle MultiPoint geometry', () => { const feature = { type: 'Feature', geometry: { type: 'MultiPoint', coordinates: [[0, 0], [10, 10]], }, } expect(computeFeatureCentroid(feature)).toEqual([5, 5]) }) it('should handle MultiPolygon geometry', () => { const feature = { type: 'Feature', geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]], }, } const result = computeFeatureCentroid(feature) expect(result).toBeTruthy() }) it('should return null for null feature', () => { expect(computeFeatureCentroid(null)).toBeNull() }) it('should return null for feature without geometry', () => { expect(computeFeatureCentroid({ type: 'Feature' })).toBeNull() }) it('should return null for unknown geometry type', () => { const feature = { type: 'Feature', geometry: { type: 'GeometryCollection', geometries: [] }, } expect(computeFeatureCentroid(feature)).toBeNull() }) }) // ==================== getEntityProperties ==================== describe('getEntityProperties', () => { function makeViewer(entities: any[]) { return { entities: { values: entities, getById: (id: string) => entities.find(e => e.id === id) ?? undefined, }, } as any } function makeEntity(overrides: { id: string name?: string type: string lon?: number lat?: number height?: number customProps?: Record graphicOverrides?: Record }) { const entity: any = { id: overrides.id, name: overrides.name, } const typeMap: Record = { marker: 'point', billboard: 'billboard', polyline: 'polyline', polygon: 'polygon', model: 'model', box: 'box', cylinder: 'cylinder', ellipse: 'ellipse', rectangle: 'rectangle', wall: 'wall', corridor: 'corridor', label: 'label', } const cesiumProp = typeMap[overrides.type] if (cesiumProp) { entity[cesiumProp] = overrides.graphicOverrides ?? {} } if (overrides.lon != null) { entity.position = { getValue: () => ({ _lon: overrides.lon, _lat: overrides.lat, _h: overrides.height ?? 0, }), } } if (overrides.customProps) { entity.properties = { propertyNames: Object.keys(overrides.customProps), } for (const [k, v] of Object.entries(overrides.customProps)) { entity.properties[k] = { getValue: () => v } } } return entity } it('should throw for non-existent entity', () => { const viewer = makeViewer([]) expect(() => getEntityProperties(viewer, { entityId: 'nope' })).toThrow('Entity not found: nope') }) it('should return type and position for a marker', () => { const e = makeEntity({ id: 'm1', name: 'Test Marker', type: 'marker', lon: 116.4, lat: 39.9, height: 100 }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'm1' }) expect(result.entityId).toBe('m1') expect(result.name).toBe('Test Marker') expect(result.type).toBe('marker') expect(result.position).toBeDefined() expect(result.position!.longitude).toBeCloseTo(116.4, 1) expect(result.position!.latitude).toBeCloseTo(39.9, 1) expect(result.position!.height).toBeCloseTo(100, 0) }) it('should return custom properties', () => { const e = makeEntity({ id: 'p1', type: 'polygon', customProps: { population: 1000000, city: 'Beijing' } }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'p1' }) expect(result.properties.population).toBe(1000000) expect(result.properties.city).toBe('Beijing') }) it('should return empty properties when entity has none', () => { const e = makeEntity({ id: 'e1', type: 'polyline' }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'e1' }) expect(result.properties).toEqual({}) }) it('should detect polygon type', () => { const e = makeEntity({ id: 'pg1', type: 'polygon' }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'pg1' }) expect(result.type).toBe('polygon') }) it('should detect model type', () => { const e = makeEntity({ id: 'md1', type: 'model' }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'md1' }) expect(result.type).toBe('model') }) it('should handle entity without position', () => { const e = makeEntity({ id: 'np1', type: 'billboard' }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'np1' }) expect(result.position).toBeUndefined() }) it('should find entity inside a DataSource', () => { const e = makeEntity({ id: 'ds-entity', name: 'From GeoJSON', type: 'polygon', lon: 116, lat: 40 }) const viewer = { entities: { values: [], getById: () => undefined, }, dataSources: { length: 1, get: () => ({ entities: { values: [e], getById: (id: string) => id === 'ds-entity' ? e : undefined, }, }), }, } as any const result = getEntityProperties(viewer, { entityId: 'ds-entity' }) expect(result.entityId).toBe('ds-entity') expect(result.name).toBe('From GeoJSON') expect(result.type).toBe('polygon') }) it('should extract description field', () => { const e = makeEntity({ id: 'd1', type: 'marker', lon: 116, lat: 40 }) e.description = { getValue: () => '

Some description HTML

' } const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'd1' }) expect(result.description).toBe('

Some description HTML

') }) it('should tolerate property getValue throwing', () => { const e = makeEntity({ id: 'err1', type: 'marker', lon: 116, lat: 40 }) e.properties = { propertyNames: ['badProp', 'goodProp'], badProp: { getValue: () => { throw new Error('no sample') } }, goodProp: { getValue: () => 42 }, } const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'err1' }) expect(result.properties.goodProp).toBe(42) expect(result.properties.badProp).toBeUndefined() }) it('should compute centroid for polygon entity without position', () => { const e: any = { id: 'poly-no-pos', name: 'No Position Polygon', polygon: { hierarchy: { getValue: () => ({ positions: [ { _lon: 0, _lat: 0, _h: 0 }, { _lon: 10, _lat: 0, _h: 0 }, { _lon: 10, _lat: 10, _h: 0 }, { _lon: 0, _lat: 10, _h: 0 }, ], }), }, }, } const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'poly-no-pos' }) expect(result.position).toBeDefined() expect(result.position!.longitude).toBeCloseTo(5, 0) expect(result.position!.latitude).toBeCloseTo(5, 0) }) it('should extract material color for polygon', () => { const e = makeEntity({ id: 'pgc1', type: 'polygon', graphicOverrides: { material: { getValue: () => ({ color: { toCssColorString: () => 'rgba(255,0,0,0.6)' } }) }, hierarchy: { getValue: () => null }, extrudedHeight: null, fill: null, outline: null, }, }) const viewer = makeViewer([e]) const result = getEntityProperties(viewer, { entityId: 'pgc1' }) expect(result.graphicProperties.color).toBe('rgba(255,0,0,0.6)') }) })