bb01e9a06c
## Summary - **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles. - **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones. - **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn. ## Docs - `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys - `docs/map-and-cameras.md` — ALPR layer and map behavior updates --------- Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #36
155 lines
5.2 KiB
JavaScript
155 lines
5.2 KiB
JavaScript
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()
|
|
})
|
|
})
|
|
})
|