Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
PR / lint (pull_request) Failing after 31s
PR / test (pull_request) Successful in 45s
PR / docker-build (pull_request) Successful in 1m3s
PR / e2e (pull_request) Successful in 1m33s

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:
Madison Grubb
2026-06-24 16:24:41 -04:00
parent a6b87305a1
commit aa8a0bd83f
64 changed files with 5761 additions and 2119 deletions
+154
View File
@@ -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()
})
})
})
+57
View File
@@ -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 &amp; 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')
})
})
+10
View File
@@ -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 })
})
})
+1
View File
@@ -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',
+6
View File
@@ -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)
+68
View File
@@ -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')
})
})
+111
View File
@@ -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)
})
})
+46
View File
@@ -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)
})
})
+13
View File
@@ -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')
})
})
+84 -3
View File
@@ -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')
})
})
+96
View File
@@ -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'])
})
})
+55
View File
@@ -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))
})
})
+29
View File
@@ -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)
})
})