Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
parseOverpassElement,
|
||||
parseBbox,
|
||||
tileKeysForBbox,
|
||||
getAlprCameras,
|
||||
markTilesFetched,
|
||||
} from '../../server/utils/alpr.js'
|
||||
import { cameraToFeature, identifyingProperties, inferModelFromTags, isGenericCameraName } from '../../server/utils/alprGeo.js'
|
||||
import { getDb, setDbPathForTest, closeDb } from '../../server/utils/db.js'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
describe('alpr utils', () => {
|
||||
describe('parseOverpassElement', () => {
|
||||
it('parses a valid OSM node', () => {
|
||||
const out = parseOverpassElement({
|
||||
type: 'node',
|
||||
id: 123,
|
||||
lat: 33.75,
|
||||
lon: -84.39,
|
||||
tags: { 'manufacturer': 'Flock Safety', 'camera:direction': '90' },
|
||||
})
|
||||
expect(out?.osmId).toBe(123)
|
||||
expect(out?.manufacturer).toBe('Flock Safety')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cameraToFeature', () => {
|
||||
it('returns GeoJSON Point feature with identifying tags', () => {
|
||||
const feature = cameraToFeature({
|
||||
osmId: 1,
|
||||
lat: 33.5,
|
||||
lng: -84.5,
|
||||
manufacturer: 'Flock Safety',
|
||||
direction: 90,
|
||||
fov: 45,
|
||||
tags: {
|
||||
'model': 'Falcon',
|
||||
'operator': 'City PD',
|
||||
'ref': 'CAM-12',
|
||||
'operator:wikidata': 'Q123',
|
||||
'fixme': 'verify mount',
|
||||
},
|
||||
})
|
||||
expect(feature.properties.manufacturer).toBe('Flock Safety')
|
||||
expect(feature.properties.model).toBe('Falcon')
|
||||
expect(feature.properties.operator).toBe('City PD')
|
||||
expect(feature.properties.ref).toBe('CAM-12')
|
||||
expect(feature.properties.operatorWikidata).toBe('Q123')
|
||||
expect(feature.properties.tags).toEqual({ fixme: 'verify mount' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('identifyingProperties', () => {
|
||||
it('omits brand when same as manufacturer', () => {
|
||||
const props = identifyingProperties({ manufacturer: 'Flock Safety', brand: 'Flock Safety', model: 'Falcon' })
|
||||
expect(props.manufacturer).toBe('Flock Safety')
|
||||
expect(props.model).toBe('Falcon')
|
||||
expect(props.brand).toBeUndefined()
|
||||
})
|
||||
|
||||
it('infers model from lowercase name tag', () => {
|
||||
const props = identifyingProperties({
|
||||
manufacturer: 'Flock Safety',
|
||||
name: 'falcon',
|
||||
})
|
||||
expect(props.model).toBe('Falcon')
|
||||
expect(props.name).toBeUndefined()
|
||||
expect(props.modelUnknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops generic camera names and flags unknown model', () => {
|
||||
const props = identifyingProperties({
|
||||
manufacturer: 'Flock Safety',
|
||||
name: 'Flock ALPR camera',
|
||||
})
|
||||
expect(props.name).toBeUndefined()
|
||||
expect(props.model).toBeUndefined()
|
||||
expect(props.modelUnknown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inferModelFromTags', () => {
|
||||
it('reads model tag and mis-tagged Flock Falcon name', () => {
|
||||
expect(inferModelFromTags({ model: 'Sparrow' })).toBe('Sparrow')
|
||||
expect(inferModelFromTags({ name: 'Flock Falcon' })).toBe('Falcon')
|
||||
expect(isGenericCameraName('Flock ALPR camera')).toBe(true)
|
||||
expect(isGenericCameraName('Peachtree & 5th')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBbox', () => {
|
||||
it('parses valid bbox', () => {
|
||||
expect(parseBbox({ south: 33.0, west: -85.0, north: 33.4, east: -84.6 })).toEqual({
|
||||
south: 33.0,
|
||||
west: -85.0,
|
||||
north: 33.4,
|
||||
east: -84.6,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized bbox', () => {
|
||||
expect(() => parseBbox({ south: 0, west: 0, north: 2, east: 1 })).toThrow(/bbox exceeds/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache and fetch', () => {
|
||||
let dbPath
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = join(tmpdir(), `kestrelos-alpr-${randomUUID()}.db`)
|
||||
setDbPathForTest(dbPath)
|
||||
await getDb()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
setDbPathForTest(null)
|
||||
})
|
||||
|
||||
it('returns GeoJSON FeatureCollection from cache', async () => {
|
||||
const db = await getDb()
|
||||
const bbox = { south: 33.4, west: -85.0, north: 33.6, east: -84.0 }
|
||||
await db.run(
|
||||
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[99, 33.5, -84.5, 'Acme', 180, '{}', new Date().toISOString()],
|
||||
)
|
||||
await markTilesFetched(db, tileKeysForBbox(bbox))
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
const result = await getAlprCameras(db, bbox)
|
||||
expect(result.type).toBe('FeatureCollection')
|
||||
expect(result.features).toHaveLength(1)
|
||||
expect(result.source).toBe('cache')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to cache when Overpass fails', async () => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[42, 33.5, -84.5, null, null, '{}', new Date().toISOString()],
|
||||
)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||
const result = await getAlprCameras(db, { south: 33.4, west: -85.0, north: 33.6, east: -84.0 })
|
||||
expect(result.type).toBe('FeatureCollection')
|
||||
expect(result.features).toHaveLength(1)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatAlprPopup } from '../../app/utils/alprMapLayer.js'
|
||||
|
||||
describe('formatAlprPopup', () => {
|
||||
it('summarizes manufacturer, model, and operator', () => {
|
||||
const html = formatAlprPopup({
|
||||
osmId: 1,
|
||||
manufacturer: 'Flock Safety',
|
||||
model: 'Falcon',
|
||||
operator: 'City PD',
|
||||
direction: 90,
|
||||
fov: 60,
|
||||
})
|
||||
expect(html).toContain('Flock Safety')
|
||||
expect(html).toContain('Falcon')
|
||||
expect(html).toContain('License plate reader')
|
||||
expect(html).toContain('City PD')
|
||||
expect(html).toContain('Facing E (90°)')
|
||||
expect(html).toContain('~60° view')
|
||||
expect(html).not.toContain('Manufacturer')
|
||||
expect(html).not.toContain('fixme')
|
||||
})
|
||||
|
||||
it('uses name as title with make/model below', () => {
|
||||
const html = formatAlprPopup({
|
||||
osmId: 2,
|
||||
name: 'Peachtree & 5th',
|
||||
manufacturer: 'Flock Safety',
|
||||
model: 'Falcon',
|
||||
})
|
||||
expect(html).toContain('<strong>Peachtree & 5th</strong>')
|
||||
expect(html).toContain('Model</span> <strong>Falcon</strong>')
|
||||
expect(html).toContain('Flock Safety Falcon')
|
||||
})
|
||||
|
||||
it('links operator wikidata inline', () => {
|
||||
const html = formatAlprPopup({
|
||||
osmId: 3,
|
||||
operator: 'Atlanta Police',
|
||||
operatorWikidata: 'Q123',
|
||||
})
|
||||
expect(html).toContain('<strong>')
|
||||
expect(html).toContain('href="https://www.wikidata.org/wiki/Q123"')
|
||||
expect(html).toContain('Atlanta Police')
|
||||
expect(html).not.toContain('Wikidata')
|
||||
})
|
||||
|
||||
it('shows model unknown when OSM lacks model tag', () => {
|
||||
const html = formatAlprPopup({
|
||||
osmId: 4,
|
||||
manufacturer: 'Flock Safety',
|
||||
modelUnknown: true,
|
||||
})
|
||||
expect(html).toContain('Flock Safety')
|
||||
expect(html).toContain('Model not recorded in OpenStreetMap')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tilesNearCenter } from '../../app/utils/alprViewport.js'
|
||||
|
||||
describe('tilesNearCenter', () => {
|
||||
it('returns a single tile for a small viewport', () => {
|
||||
const tiles = tilesNearCenter({ south: 37.7, west: -122.5, north: 37.8, east: -122.4 }, 16)
|
||||
expect(tiles).toHaveLength(1)
|
||||
expect(tiles[0]).toEqual({ south: 37.5, west: -122.5, north: 38, east: -122 })
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ describe('authSkipPaths', () => {
|
||||
it('does not skip any protected path', () => {
|
||||
const protectedPaths = [
|
||||
...PROTECTED_PATH_PREFIXES,
|
||||
'/api/alpr',
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/devices/any-id',
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
COT_AUTH_TIMEOUT_MS,
|
||||
LIVE_SESSION_TTL_MS,
|
||||
COT_TTL_MS,
|
||||
COT_ENTITY_TTL_MS,
|
||||
COT_OSINT_TTL_MS,
|
||||
COT_PRUNE_INTERVAL_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
COT_PORT,
|
||||
@@ -18,7 +21,10 @@ describe('constants', () => {
|
||||
it('uses default values when env vars not set', () => {
|
||||
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
|
||||
expect(LIVE_SESSION_TTL_MS).toBe(60000)
|
||||
expect(COT_TTL_MS).toBe(90000)
|
||||
expect(COT_ENTITY_TTL_MS).toBe(90000)
|
||||
expect(COT_OSINT_TTL_MS).toBe(30000)
|
||||
expect(COT_PRUNE_INTERVAL_MS).toBe(15000)
|
||||
expect(POLL_INTERVAL_MS).toBe(1500)
|
||||
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
|
||||
expect(COT_PORT).toBe(8089)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
cotAirIconKind,
|
||||
cotCategory,
|
||||
formatCotPopup,
|
||||
getCotIconHtml,
|
||||
} from '../../app/utils/cotDisplay.js'
|
||||
|
||||
const esc = s => String(s)
|
||||
|
||||
describe('cotDisplay', () => {
|
||||
it('detects helicopter from CoT type', () => {
|
||||
expect(cotAirIconKind({ type: 'a-f-A-C-H' })).toBe('helicopter')
|
||||
expect(cotAirIconKind({ type: 'a-f-A-C-F' })).toBe('fixedWing')
|
||||
})
|
||||
|
||||
it('renders distinct air icon kinds', () => {
|
||||
const plane = getCotIconHtml({ type: 'a-f-A-C-F', heading: 90 })
|
||||
const heli = getCotIconHtml({ type: 'a-f-A-C-H', heading: 180 })
|
||||
expect(plane.className).toBe('cot-entity-fixedWing')
|
||||
expect(heli.className).toBe('cot-entity-helicopter')
|
||||
expect(plane.html).toContain('rotate(90deg)')
|
||||
expect(heli.html).toContain('rotate(180deg)')
|
||||
})
|
||||
|
||||
it('formats rich ADS-B popup', () => {
|
||||
const html = formatCotPopup({
|
||||
source: 'adsb',
|
||||
type: 'a-f-A-C-F',
|
||||
label: 'UAL123',
|
||||
icao: 'abc123',
|
||||
originCountry: 'United States',
|
||||
altitude: 10000,
|
||||
speed: 200,
|
||||
heading: 270,
|
||||
verticalRate: 5,
|
||||
squawk: '1200',
|
||||
}, esc)
|
||||
expect(html).toContain('UAL123')
|
||||
expect(html).toContain('Aircraft')
|
||||
expect(html).toContain('ICAO ABC123')
|
||||
expect(html).toContain('United States')
|
||||
expect(html).toContain('ft')
|
||||
expect(html).toContain('kt')
|
||||
expect(html).toContain('270°')
|
||||
expect(html).toContain('Squawk 1200')
|
||||
})
|
||||
|
||||
it('formats vessel popup with MMSI', () => {
|
||||
const html = formatCotPopup({
|
||||
source: 'ais',
|
||||
type: 'a-f-S-C',
|
||||
label: 'TEST SHIP',
|
||||
id: 'MMSI.366123456',
|
||||
speed: 12,
|
||||
heading: 90,
|
||||
}, esc)
|
||||
expect(html).toContain('Vessel')
|
||||
expect(html).toContain('MMSI 366123456')
|
||||
})
|
||||
|
||||
it('formats team popup', () => {
|
||||
expect(cotCategory('a-f-G-U-C')).toBe('ground')
|
||||
const html = formatCotPopup({ type: 'a-f-G-U-C', label: 'Alpha 1' }, esc)
|
||||
expect(html).toContain('Team')
|
||||
expect(html).toContain('Alpha 1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
inferSourceFromId,
|
||||
cotCategoryFromType,
|
||||
isInBbox,
|
||||
matchesLayerFilter,
|
||||
openSkyStateToCot,
|
||||
aisStreamMessageToCot,
|
||||
unionBboxes,
|
||||
clampBbox,
|
||||
parseBboxParam,
|
||||
parseLayersParam,
|
||||
} from '../../../server/utils/cotEntityUtils.js'
|
||||
|
||||
describe('cotEntityUtils', () => {
|
||||
it('infers source from UID prefix', () => {
|
||||
expect(inferSourceFromId('ICAO.abc123')).toBe('adsb')
|
||||
expect(inferSourceFromId('MMSI.366123456')).toBe('ais')
|
||||
expect(inferSourceFromId('ANDROID-deadbeef')).toBe('tak')
|
||||
})
|
||||
|
||||
it('maps CoT type to category', () => {
|
||||
expect(cotCategoryFromType('a-f-A-C-F')).toBe('air')
|
||||
expect(cotCategoryFromType('a-f-S-C')).toBe('surface')
|
||||
expect(cotCategoryFromType('a-f-G-U-C')).toBe('ground')
|
||||
})
|
||||
|
||||
it('checks bbox membership', () => {
|
||||
const bbox = { west: -123, south: 37, east: -122, north: 38 }
|
||||
expect(isInBbox({ lat: 37.5, lng: -122.5 }, bbox)).toBe(true)
|
||||
expect(isInBbox({ lat: 40, lng: -122.5 }, bbox)).toBe(false)
|
||||
})
|
||||
|
||||
it('filters by layer set', () => {
|
||||
const airOnly = new Set(['air'])
|
||||
expect(matchesLayerFilter(airOnly, { type: 'a-f-A-C-F' })).toBe(true)
|
||||
expect(matchesLayerFilter(airOnly, { type: 'a-f-S-C' })).toBe(false)
|
||||
})
|
||||
|
||||
it('maps OpenSky state vector to CoT', () => {
|
||||
const state = ['abc123', 'UAL123 ', 'United States', 1, 2, -122.4, 37.7, 10000, false, 200, 90, 5, null, null, 1200, false, 0, 0]
|
||||
const cot = openSkyStateToCot(state)
|
||||
expect(cot).toMatchObject({
|
||||
id: 'ICAO.abc123',
|
||||
lat: 37.7,
|
||||
lng: -122.4,
|
||||
label: 'UAL123',
|
||||
source: 'adsb',
|
||||
type: 'a-f-A-C-F',
|
||||
icao: 'abc123',
|
||||
originCountry: 'United States',
|
||||
heading: 90,
|
||||
speed: 200,
|
||||
altitude: 10000,
|
||||
verticalRate: 5,
|
||||
squawk: '1200',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps OpenSky rotorcraft to helicopter CoT type', () => {
|
||||
const state = ['heli01', 'N123HC ', 'United States', 1, 2, -122.4, 37.7, 500, false, 50, 180, 0, null, null, null, false, 0, 8]
|
||||
const cot = openSkyStateToCot(state)
|
||||
expect(cot?.type).toBe('a-f-A-C-H')
|
||||
})
|
||||
|
||||
it('maps AISStream message to CoT', () => {
|
||||
const cot = aisStreamMessageToCot({
|
||||
MetaData: { MMSI: 366123456, ShipName: 'TEST SHIP' },
|
||||
Message: {
|
||||
PositionReport: {
|
||||
UserID: 366123456,
|
||||
Latitude: 37.8,
|
||||
Longitude: -122.3,
|
||||
Sog: 12.5,
|
||||
Cog: 180,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(cot).toMatchObject({
|
||||
id: 'MMSI.366123456',
|
||||
lat: 37.8,
|
||||
lng: -122.3,
|
||||
label: 'TEST SHIP',
|
||||
source: 'ais',
|
||||
type: 'a-f-S-C',
|
||||
})
|
||||
})
|
||||
|
||||
it('unions bboxes', () => {
|
||||
expect(unionBboxes([
|
||||
{ west: -123, south: 37, east: -122, north: 38 },
|
||||
{ west: -124, south: 36, east: -121, north: 39 },
|
||||
])).toEqual({ west: -124, south: 36, east: -121, north: 39 })
|
||||
})
|
||||
|
||||
it('clamps oversized bbox to max span', () => {
|
||||
const huge = { west: -125, south: 32, east: -115, north: 42 }
|
||||
const clamped = clampBbox(huge, 10)
|
||||
expect(clamped.north - clamped.south).toBeCloseTo(10)
|
||||
expect(clamped.east - clamped.west).toBeCloseTo(10)
|
||||
expect((clamped.north + clamped.south) / 2).toBeCloseTo(37)
|
||||
expect((clamped.east + clamped.west) / 2).toBeCloseTo(-120)
|
||||
})
|
||||
|
||||
it('parses bbox and layers query params', () => {
|
||||
expect(parseBboxParam('-123,37,-122,38')).toEqual({ west: -123, south: 37, east: -122, north: 38 })
|
||||
expect(parseBboxParam('bad')).toBeNull()
|
||||
expect(parseLayersParam('air,surface')).toEqual(new Set(['air', 'surface']))
|
||||
expect(parseLayersParam('none').size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
entitiesToFeatures,
|
||||
getCotClusters,
|
||||
loadCotCluster,
|
||||
} from '../../app/utils/cotMapLayer.js'
|
||||
|
||||
function makeEntities(n, centerLat = 37.7, centerLng = -122.4) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `ICAO.${i}`,
|
||||
lat: centerLat + (i % 10) * 0.01,
|
||||
lng: centerLng + Math.floor(i / 10) * 0.01,
|
||||
type: 'a-f-A-C-F',
|
||||
label: `AC${i}`,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('cotMapLayer', () => {
|
||||
it('converts entities to GeoJSON points', () => {
|
||||
const features = entitiesToFeatures([
|
||||
{ id: 'ICAO.1', lat: 37.7, lng: -122.4, type: 'a-f-A-C-F' },
|
||||
{ id: 'bad', lat: 'x', lng: 0 },
|
||||
])
|
||||
expect(features).toHaveLength(1)
|
||||
expect(features[0].geometry.coordinates).toEqual([-122.4, 37.7])
|
||||
expect(features[0].properties.entity.id).toBe('ICAO.1')
|
||||
})
|
||||
|
||||
it('clusters dense tracks at low zoom', () => {
|
||||
loadCotCluster(makeEntities(50))
|
||||
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 6 }
|
||||
const clusters = getCotClusters(view)
|
||||
const clusterCount = clusters.filter(f => f.properties?.cluster).length
|
||||
const pointCount = clusters.filter(f => !f.properties?.cluster).length
|
||||
expect(clusterCount).toBeGreaterThan(0)
|
||||
expect(clusterCount + pointCount).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('shows individual markers at high zoom', () => {
|
||||
loadCotCluster(makeEntities(20))
|
||||
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 15 }
|
||||
const clusters = getCotClusters(view)
|
||||
expect(clusters.every(f => !f.properties?.cluster)).toBe(true)
|
||||
expect(clusters).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
@@ -44,4 +44,17 @@ describe('cotServer (parse-and-store path)', () => {
|
||||
expect(active[0].lng).toBe(4)
|
||||
expect(active[0].label).toBe('Updated')
|
||||
})
|
||||
|
||||
it('infers adsb source from ICAO uid when ingesting CoT position', async () => {
|
||||
await updateFromCot({
|
||||
id: 'ICAO.abc123',
|
||||
lat: 37.7,
|
||||
lng: -122.4,
|
||||
label: 'N12345',
|
||||
eventType: 'a-f-A-C-F',
|
||||
})
|
||||
const active = await getActiveEntities()
|
||||
expect(active[0].source).toBe('adsb')
|
||||
expect(active[0].type).toBe('a-f-A-C-F')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
|
||||
import {
|
||||
updateFromCot,
|
||||
getActiveEntities,
|
||||
getActiveEntitiesInBbox,
|
||||
clearCotStore,
|
||||
onCotChange,
|
||||
pruneStaleEntities,
|
||||
} from '../../../server/utils/cotStore.js'
|
||||
|
||||
describe('cotStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -14,6 +21,28 @@ describe('cotStore', () => {
|
||||
expect(active[0].lat).toBe(37.7)
|
||||
expect(active[0].lng).toBe(-122.4)
|
||||
expect(active[0].label).toBe('Alpha')
|
||||
expect(active[0].source).toBe('tak')
|
||||
})
|
||||
|
||||
it('stores enriched ADS-B fields and infers source', async () => {
|
||||
await updateFromCot({
|
||||
id: 'ICAO.abc123',
|
||||
lat: 37.7,
|
||||
lng: -122.4,
|
||||
label: 'UAL1',
|
||||
type: 'a-f-A-C-F',
|
||||
heading: 90,
|
||||
speed: 200,
|
||||
altitude: 10000,
|
||||
})
|
||||
const active = await getActiveEntities()
|
||||
expect(active[0]).toMatchObject({
|
||||
source: 'adsb',
|
||||
heading: 90,
|
||||
speed: 200,
|
||||
altitude: 10000,
|
||||
type: 'a-f-A-C-F',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates same uid', async () => {
|
||||
@@ -41,10 +70,10 @@ describe('cotStore', () => {
|
||||
|
||||
it('prunes expired entities after getActiveEntities', async () => {
|
||||
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
|
||||
const active1 = await getActiveEntities(100)
|
||||
const active1 = await getActiveEntities({ ttlMs: 100 })
|
||||
expect(active1).toHaveLength(1)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const active2 = await getActiveEntities(100)
|
||||
const active2 = await getActiveEntities({ ttlMs: 100 })
|
||||
expect(active2).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -55,4 +84,56 @@ describe('cotStore', () => {
|
||||
expect(active).toHaveLength(2)
|
||||
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('filters OSINT entities by bbox but keeps team globally', async () => {
|
||||
await updateFromCot({ id: 'ICAO.abc', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
|
||||
await updateFromCot({ id: 'MMSI.123', lat: 40, lng: -100, type: 'a-f-S-C' })
|
||||
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
|
||||
const bbox = { west: -123, south: 37, east: -122, north: 38 }
|
||||
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: false })
|
||||
const ids = active.map(e => e.id).sort()
|
||||
expect(ids).toEqual(['ANDROID-1', 'ICAO.abc'])
|
||||
})
|
||||
|
||||
it('filters team by bbox when COT_TAK_FILTER_BBOX enabled', async () => {
|
||||
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
|
||||
const bbox = { west: -123, south: 37, east: -122, north: 38 }
|
||||
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: true })
|
||||
expect(active).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('caps bbox query results at maxEntities', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await updateFromCot({ id: `ICAO.${i}`, lat: 37.5 + i * 0.01, lng: -122.4, type: 'a-f-A-C-F' })
|
||||
}
|
||||
const bbox = { west: -123, south: 37, east: -122, north: 38 }
|
||||
const active = await getActiveEntitiesInBbox(bbox, { maxEntities: 3 })
|
||||
expect(active).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('skips emit when silent upsert', async () => {
|
||||
const updates = []
|
||||
const off = onCotChange((event) => {
|
||||
if (event === 'update') updates.push(event)
|
||||
})
|
||||
await updateFromCot({ id: 'ICAO.silent', lat: 37, lng: -122, type: 'a-f-A-C-F' }, { silent: true })
|
||||
off()
|
||||
expect(updates).toHaveLength(0)
|
||||
const active = await getActiveEntities()
|
||||
expect(active.some(e => e.id === 'ICAO.silent')).toBe(true)
|
||||
})
|
||||
|
||||
it('pruneStaleEntities uses shorter TTL for OSINT sources', async () => {
|
||||
await updateFromCot({ id: 'ICAO.old', lat: 37, lng: -122, source: 'adsb', type: 'a-f-A-C-F' })
|
||||
await updateFromCot({ id: 'ANDROID-1', lat: 37, lng: -122, source: 'tak' })
|
||||
const removed = []
|
||||
const off = onCotChange((event, payload) => {
|
||||
if (event === 'remove') removed.push(payload.id)
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
await pruneStaleEntities({ ttlMs: 10_000, osintTtlMs: 50 })
|
||||
off()
|
||||
expect(removed).toContain('ICAO.old')
|
||||
expect(removed).not.toContain('ANDROID-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
registerSubscriber,
|
||||
getSubscriberBboxUnion,
|
||||
clearSubscribers,
|
||||
notifySubscribersForEntity,
|
||||
notifySubscribersRemove,
|
||||
broadcastSubscriberSnapshots,
|
||||
} from '../../../server/utils/cotSubscribers.js'
|
||||
import { updateFromCot, clearCotStore } from '../../../server/utils/cotStore.js'
|
||||
|
||||
describe('cotSubscribers', () => {
|
||||
beforeEach(() => {
|
||||
clearSubscribers()
|
||||
})
|
||||
|
||||
it('unions subscriber bboxes', () => {
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push: vi.fn(),
|
||||
})
|
||||
registerSubscriber({
|
||||
bbox: { west: -124, south: 36, east: -121, north: 39 },
|
||||
layers: new Set(['surface']),
|
||||
push: vi.fn(),
|
||||
})
|
||||
expect(getSubscriberBboxUnion()).toEqual({ west: -124, south: 36, east: -121, north: 39 })
|
||||
})
|
||||
|
||||
it('notifies subscribers inside bbox and matching layer', async () => {
|
||||
const push = vi.fn()
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push,
|
||||
})
|
||||
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
|
||||
id: 'ICAO.x',
|
||||
lat: 37.5,
|
||||
lng: -122.5,
|
||||
type: 'a-f-A-C-F',
|
||||
})
|
||||
expect(push).toHaveBeenCalledWith('update', expect.any(String))
|
||||
})
|
||||
|
||||
it('skips subscribers when entity outside bbox', async () => {
|
||||
const push = vi.fn()
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push,
|
||||
})
|
||||
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
|
||||
id: 'ICAO.x',
|
||||
lat: 40,
|
||||
lng: -122.5,
|
||||
type: 'a-f-A-C-F',
|
||||
})
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('notifySubscribersRemove pushes to all subscribers', async () => {
|
||||
const pushA = vi.fn()
|
||||
const pushB = vi.fn()
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push: pushA,
|
||||
})
|
||||
registerSubscriber({
|
||||
bbox: { west: -125, south: 35, east: -120, north: 40 },
|
||||
layers: new Set(['surface']),
|
||||
push: pushB,
|
||||
})
|
||||
await notifySubscribersRemove('ICAO.removed')
|
||||
expect(pushA).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
|
||||
expect(pushB).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
|
||||
})
|
||||
|
||||
it('broadcastSubscriberSnapshots sends per-subscriber filtered snapshot', async () => {
|
||||
clearCotStore()
|
||||
await updateFromCot({ id: 'ICAO.in', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
|
||||
await updateFromCot({ id: 'ICAO.out', lat: 40, lng: -100, type: 'a-f-A-C-F' })
|
||||
const push = vi.fn()
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push,
|
||||
})
|
||||
await broadcastSubscriberSnapshots({ ttlMs: 90_000, osintTtlMs: 30_000, takFilterBbox: false })
|
||||
expect(push).toHaveBeenCalledWith('snapshot', expect.any(String))
|
||||
const payload = JSON.parse(push.mock.calls[0][1])
|
||||
expect(payload.entities.map(e => e.id)).toEqual(['ICAO.in'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createClusterIndex } from '../../app/utils/mapCluster.js'
|
||||
import { clearFeatureMarkers, syncFeatureMarkers } from '../../app/utils/mapMarkerSync.js'
|
||||
import { bboxFetchKey, tilesNearCenter } from '../../app/utils/alprViewport.js'
|
||||
|
||||
describe('mapCluster', () => {
|
||||
it('loads once and queries by viewport', () => {
|
||||
const index = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
|
||||
const features = Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [-122.4 + i * 0.01, 37.7] },
|
||||
properties: { id: i },
|
||||
}))
|
||||
index.load(features)
|
||||
const zoomedOut = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 6 })
|
||||
expect(zoomedOut.some(f => f.properties?.cluster)).toBe(true)
|
||||
const zoomedIn = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 15 })
|
||||
expect(zoomedIn).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapMarkerSync', () => {
|
||||
it('reuses markers by key', () => {
|
||||
const layer = {
|
||||
_layers: [],
|
||||
addLayer(m) { this._layers.push(m) },
|
||||
removeLayer(m) { this._layers = this._layers.filter(x => x !== m) },
|
||||
}
|
||||
const create = vi.fn(f => ({ id: f.properties.id }))
|
||||
const update = vi.fn()
|
||||
const opts = { keyFor: f => f.properties.id, create, update }
|
||||
const pt = (id, lng, lat) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { id } })
|
||||
|
||||
syncFeatureMarkers(layer, [pt(1, -122, 37)], opts)
|
||||
syncFeatureMarkers(layer, [pt(1, -121.9, 37.1)], opts)
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
clearFeatureMarkers(layer)
|
||||
expect(layer._layers).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alprViewport', () => {
|
||||
it('selects nearby tiles without scanning the world', () => {
|
||||
const world = { south: -85, west: -180, north: 85, east: 180 }
|
||||
expect(tilesNearCenter(world, 16)).toHaveLength(16)
|
||||
expect(tilesNearCenter(world, 1)[0]).toEqual({ south: 0, west: 0, north: 0.5, east: 0.5 })
|
||||
})
|
||||
|
||||
it('coarsens fetch keys when zoomed out', () => {
|
||||
const bounds = { south: 37.01, west: -122.51, north: 37.99, east: -122.01, zoom: 6 }
|
||||
expect(bboxFetchKey(bounds)).toBe(bboxFetchKey({ ...bounds, south: bounds.south + 0.05 }))
|
||||
expect(bboxFetchKey({ ...bounds, zoom: 14 })).not.toBe(bboxFetchKey(bounds))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { clearSubscribers, registerSubscriber } from '../../../server/utils/cotSubscribers.js'
|
||||
import {
|
||||
isOpenSkyPollActive,
|
||||
resetTrackingFeedForTests,
|
||||
scheduleOpenSkyPollForTests,
|
||||
} from '../../../server/utils/trackingFeed.js'
|
||||
|
||||
describe('trackingFeed', () => {
|
||||
beforeEach(() => {
|
||||
clearSubscribers()
|
||||
resetTrackingFeedForTests()
|
||||
})
|
||||
|
||||
it('does not start OpenSky poll without SSE subscribers', () => {
|
||||
scheduleOpenSkyPollForTests()
|
||||
expect(isOpenSkyPollActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('starts OpenSky poll when a subscriber is registered', () => {
|
||||
registerSubscriber({
|
||||
bbox: { west: -123, south: 37, east: -122, north: 38 },
|
||||
layers: new Set(['air']),
|
||||
push: vi.fn(),
|
||||
})
|
||||
scheduleOpenSkyPollForTests()
|
||||
expect(isOpenSkyPollActive()).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user