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:
@@ -23,7 +23,7 @@ const ensureDevCerts = () => {
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to generate dev certificates: ${error.message}`)
|
||||
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ensureDevCerts() {
|
||||
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to generate dev certificates: ${error.message}`)
|
||||
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import { connect } from 'node:tls'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -21,11 +22,21 @@ const devCertsDir = join(projectRoot, '.dev-certs')
|
||||
const devKey = join(devCertsDir, 'key.pem')
|
||||
const devCert = join(devCertsDir, 'cert.pem')
|
||||
|
||||
const API_PORT = 3000
|
||||
const COT_PORT = 8089
|
||||
const COT_AUTH_USER = 'test'
|
||||
const COT_AUTH_PASS = 'test'
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function ensureDevCerts() {
|
||||
if (existsSync(devKey) && existsSync(devCert)) return
|
||||
mkdirSync(devCertsDir, { recursive: true })
|
||||
@@ -37,12 +48,12 @@ function ensureDevCerts() {
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5000
|
||||
|
||||
async function waitForHealth(timeoutMs = 90000) {
|
||||
async function waitForHealth(apiPort, timeoutMs = 90000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const protocol of ['https', 'http']) {
|
||||
try {
|
||||
const baseURL = `${protocol}://localhost:${API_PORT}`
|
||||
const baseURL = `${protocol}://localhost:${apiPort}`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
||||
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
|
||||
@@ -55,16 +66,20 @@ async function waitForHealth(timeoutMs = 90000) {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
|
||||
throw new Error(`Health not OK on ${apiPort} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('Server and CoT integration', () => {
|
||||
const testState = {
|
||||
serverProcess: null,
|
||||
apiPort: 0,
|
||||
cotPort: 0,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ensureDevCerts()
|
||||
testState.apiPort = await getFreePort()
|
||||
testState.cotPort = await getFreePort()
|
||||
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
|
||||
if (!existsSync(serverPath)) {
|
||||
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
|
||||
@@ -72,6 +87,8 @@ describe('Server and CoT integration', () => {
|
||||
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(testState.apiPort),
|
||||
COT_PORT: String(testState.cotPort),
|
||||
DB_PATH: dbPath,
|
||||
BOOTSTRAP_EMAIL: COT_AUTH_USER,
|
||||
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
|
||||
@@ -83,7 +100,7 @@ describe('Server and CoT integration', () => {
|
||||
})
|
||||
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
|
||||
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
|
||||
await waitForHealth(90000)
|
||||
await waitForHealth(testState.apiPort, 90000)
|
||||
}, 120000)
|
||||
|
||||
afterAll(() => {
|
||||
@@ -92,11 +109,11 @@ describe('Server and CoT integration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serves health on port 3000', async () => {
|
||||
it('serves health on the configured API port', async () => {
|
||||
const tryProtocols = async (protocols) => {
|
||||
if (protocols.length === 0) throw new Error('No protocol succeeded')
|
||||
try {
|
||||
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
|
||||
const res = await fetch(`${protocols[0]}://localhost:${testState.apiPort}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
|
||||
if (res?.ok) return res
|
||||
return tryProtocols(protocols.slice(1))
|
||||
}
|
||||
@@ -112,10 +129,10 @@ describe('Server and CoT integration', () => {
|
||||
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
|
||||
})
|
||||
|
||||
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
|
||||
it('CoT: TAK client auth with username/password succeeds (socket stays open)', async () => {
|
||||
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
|
||||
const s = connect(testState.cotPort, '127.0.0.1', { rejectUnauthorized: false }, () => {
|
||||
s.write(payload, () => resolve(s))
|
||||
})
|
||||
s.on('error', reject)
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { SHUTDOWN_TIMEOUT_MS } from '../../server/utils/constants.js'
|
||||
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
|
||||
|
||||
describe('shutdown integration', () => {
|
||||
const testState = {
|
||||
originalExit: null,
|
||||
exitCalls: [],
|
||||
originalOn: null,
|
||||
}
|
||||
/** @type {import('vitest').MockInstance} */
|
||||
let exitSpy
|
||||
/** @type {typeof process.on} */
|
||||
let originalOn
|
||||
|
||||
beforeEach(() => {
|
||||
clearCleanup()
|
||||
testState.exitCalls = []
|
||||
testState.originalExit = process.exit
|
||||
process.exit = vi.fn((code) => {
|
||||
testState.exitCalls.push(code)
|
||||
})
|
||||
testState.originalOn = process.on
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
||||
originalOn = process.on
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = testState.originalExit
|
||||
process.on = testState.originalOn
|
||||
exitSpy.mockRestore()
|
||||
process.on = originalOn
|
||||
clearCleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('initializes signal handlers', () => {
|
||||
@@ -32,7 +29,6 @@ describe('shutdown integration', () => {
|
||||
initShutdownHandlers()
|
||||
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
|
||||
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
|
||||
process.on = testState.originalOn
|
||||
})
|
||||
|
||||
it('signal handler calls graceful', async () => {
|
||||
@@ -43,9 +39,10 @@ describe('shutdown integration', () => {
|
||||
initShutdownHandlers()
|
||||
const sigtermHandler = handlers.SIGTERM
|
||||
expect(sigtermHandler).toBeDefined()
|
||||
await sigtermHandler()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = testState.originalOn
|
||||
sigtermHandler()
|
||||
await vi.waitFor(() => {
|
||||
expect(exitSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('signal handler handles graceful error', async () => {
|
||||
@@ -59,18 +56,20 @@ describe('shutdown integration', () => {
|
||||
registerCleanup(async () => {
|
||||
throw new Error('Force error')
|
||||
})
|
||||
await sigintHandler()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = testState.originalOn
|
||||
sigintHandler()
|
||||
await vi.waitFor(() => {
|
||||
expect(exitSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('covers timeout path in graceful', async () => {
|
||||
vi.useFakeTimers()
|
||||
registerCleanup(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 40000))
|
||||
await new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS + 5_000))
|
||||
})
|
||||
graceful()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
await vi.advanceTimersByTimeAsync(SHUTDOWN_TIMEOUT_MS + 1)
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('covers graceful catch block', async () => {
|
||||
@@ -78,6 +77,6 @@ describe('shutdown integration', () => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
await graceful()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
expect(exitSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,4 +75,12 @@ describe('KestrelMap', () => {
|
||||
expect(wrapper.props('pois')).toHaveLength(1)
|
||||
expect(wrapper.props('canEditPois')).toBe(false)
|
||||
})
|
||||
|
||||
it('includes CoT layer toggles and supercluster layer', async () => {
|
||||
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
|
||||
const source = readFileSync(componentPath, 'utf-8')
|
||||
expect(source).toContain('cot-layer-toggles')
|
||||
expect(source).toContain('cotMapLayer')
|
||||
expect(source).toContain('syncCotLayer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,26 +15,12 @@ describe('useCameras', () => {
|
||||
setupEndpoints(() => ({
|
||||
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
|
||||
liveSessions: [],
|
||||
cotEntities: [],
|
||||
}))
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await wait()
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes cotEntities from API', async () => {
|
||||
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
|
||||
setupEndpoints(() => ({
|
||||
devices: [],
|
||||
liveSessions: [],
|
||||
cotEntities,
|
||||
}))
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await wait()
|
||||
const map = wrapper.findComponent({ name: 'KestrelMap' })
|
||||
expect(map.props('cotEntities')).toEqual(cotEntities)
|
||||
})
|
||||
|
||||
it('handles API error and falls back to empty devices and liveSessions', async () => {
|
||||
setupEndpoints(() => {
|
||||
throw new Error('network')
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { useCotLayers } from '../../app/composables/useCotLayers.js'
|
||||
|
||||
describe('useCotLayers', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults all layers on', () => {
|
||||
const { layers, layerQuery } = useCotLayers()
|
||||
expect(layers.value).toEqual({ air: true, surface: true, ground: true })
|
||||
expect(layerQuery.value).toBe('air,surface,ground')
|
||||
})
|
||||
|
||||
it('toggles layers and persists to localStorage', () => {
|
||||
const { layers, toggleLayer, layerQuery } = useCotLayers()
|
||||
toggleLayer('air')
|
||||
expect(layers.value.air).toBe(false)
|
||||
expect(layerQuery.value).toBe('surface,ground')
|
||||
const stored = JSON.parse(localStorage.getItem('kestrel-cot-layers'))
|
||||
expect(stored.air).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCotStream helpers', () => {
|
||||
it('layer query none when all off', () => {
|
||||
localStorage.clear()
|
||||
const { toggleLayer, layerQuery } = useCotLayers()
|
||||
toggleLayer('air')
|
||||
toggleLayer('surface')
|
||||
toggleLayer('ground')
|
||||
expect(layerQuery.value).toBe('none')
|
||||
})
|
||||
})
|
||||
@@ -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