mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 21:08:13 +02:00
v0.9.5: The Voltron Update — modular architecture, stable IDs, parallelized boot
- Parallelized startup (60s → 15s) via ThreadPoolExecutor - Adaptive polling engine with ETag caching (no more bbox interrupts) - useCallback optimization for interpolation functions - Sliding LAYERS/INTEL edge panels replace bulky Record Panel - Modular fetcher architecture (flights, geo, infrastructure, financial, earth_observation) - Stable entity IDs for GDELT & News popups (PR #63, credit @csysp) - Admin auth (X-Admin-Key), rate limiting (slowapi), auto-updater - Docker Swarm secrets support, env_check.py validation - 85+ vitest tests, CI pipeline, geoJSON builder extraction - Server-side viewport bbox filtering reduces payloads 80%+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Former-commit-id: f2883150b5bc78ebc139d89cc966a76f7d7c0408
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR } from '@/types/dashboard';
|
||||
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GPS Jamming ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildJammingGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildCctvGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── KiwiSDR ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildKiwisdrGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{ lat: 52.0, lon: 13.0, name: 'Berlin SDR', url: 'http://test.com', users: 3, users_max: 8, bands: 'HF', antenna: 'Long Wire', location: 'Berlin' },
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIRMS Fires ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 150, brightness: 400, confidence: 'high', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 50, brightness: 350, confidence: 'medium', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
{ lat: 12, lng: 22, frp: 10, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1400' },
|
||||
{ lat: 13, lng: 23, frp: 2, brightness: 250, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1500' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 5, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 5, brightness: 300, confidence: 'low', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
describe('buildInternetOutagesGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: 31.0, lng: -100.0, severity: 45, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: null as any, lng: null as any, severity: 20, level: 'region', datasource: 'bgp' },
|
||||
{ region_code: 'CA', region_name: 'California', country_code: 'US', country_name: 'United States', lat: 37.0, lng: -122.0, severity: 30, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDataCentersGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{ lat: 40.0, lng: -74.0, name: 'NYC-DC1', company: 'Equinix', street: '123 Main', city: 'New York', country: 'US', zip: '10001' },
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GDELT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildGdeltGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [100, 10] }, properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: null as any, properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LiveUAMap ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildLiveuaGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Frontline ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFrontlineGeoJSON', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [{ type: 'Feature' as const, properties: { name: 'zone', zone_id: 1 }, geometry: { type: 'Polygon' as const, coordinates: [[[30, 48], [31, 49], [30, 49], [30, 48]]] as [number, number][][] } }] };
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAircraft, HELI_TYPES, TURBOPROP_TYPES, BIZJET_TYPES } from '@/utils/aircraftClassification';
|
||||
|
||||
describe('classifyAircraft', () => {
|
||||
// ─── Helicopter classification ────────────────────────────────────────────
|
||||
|
||||
it('classifies known helicopter types', () => {
|
||||
const heliModels = ['R22', 'R44', 'B407', 'S76', 'EC35', 'H145', 'UH60', 'AH64', 'CH47'];
|
||||
for (const model of heliModels) {
|
||||
expect(classifyAircraft(model)).toBe('heli');
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies as heli when category hint is "heli"', () => {
|
||||
expect(classifyAircraft('UNKNOWN', 'heli')).toBe('heli');
|
||||
});
|
||||
|
||||
it('category hint "heli" overrides model-based classification', () => {
|
||||
// B738 would normally be airliner, but category says heli
|
||||
expect(classifyAircraft('B738', 'heli')).toBe('heli');
|
||||
});
|
||||
|
||||
// ─── Business jet classification ──────────────────────────────────────────
|
||||
|
||||
it('classifies known bizjet types', () => {
|
||||
const bizjetModels = ['C25A', 'C680', 'CL60', 'GLEX', 'GLF5', 'LJ45', 'FA7X'];
|
||||
for (const model of bizjetModels) {
|
||||
expect(classifyAircraft(model)).toBe('bizjet');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Turboprop classification ─────────────────────────────────────────────
|
||||
|
||||
it('classifies known turboprop types', () => {
|
||||
const turbopropModels = ['AT72', 'C208', 'DHC6', 'DH8D', 'PC12', 'TBM9', 'C130'];
|
||||
for (const model of turbopropModels) {
|
||||
expect(classifyAircraft(model)).toBe('turboprop');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Airliner default ────────────────────────────────────────────────────
|
||||
|
||||
it('defaults to airliner for unknown types', () => {
|
||||
expect(classifyAircraft('B738')).toBe('airliner');
|
||||
expect(classifyAircraft('A320')).toBe('airliner');
|
||||
expect(classifyAircraft('B77W')).toBe('airliner');
|
||||
});
|
||||
|
||||
it('defaults to airliner for empty model string', () => {
|
||||
expect(classifyAircraft('')).toBe('airliner');
|
||||
});
|
||||
|
||||
// ─── Case insensitivity ──────────────────────────────────────────────────
|
||||
|
||||
it('handles lowercase model codes', () => {
|
||||
expect(classifyAircraft('r22')).toBe('heli');
|
||||
expect(classifyAircraft('c25a')).toBe('bizjet');
|
||||
expect(classifyAircraft('at72')).toBe('turboprop');
|
||||
});
|
||||
|
||||
it('handles mixed case model codes', () => {
|
||||
expect(classifyAircraft('Dh8D')).toBe('turboprop');
|
||||
expect(classifyAircraft('Glf5')).toBe('bizjet');
|
||||
});
|
||||
|
||||
// ─── Priority order ──────────────────────────────────────────────────────
|
||||
|
||||
it('prioritizes heli over bizjet (if type appears in both sets)', () => {
|
||||
// heli check comes first in the function
|
||||
for (const model of ['B06', 'S92', 'H225']) {
|
||||
expect(classifyAircraft(model)).toBe('heli');
|
||||
}
|
||||
});
|
||||
|
||||
it('prioritizes bizjet over turboprop', () => {
|
||||
// PC24 appears in both BIZJET_TYPES and TURBOPROP_TYPES
|
||||
// bizjet check comes before turboprop in the function
|
||||
if (BIZJET_TYPES.has('PC24') && TURBOPROP_TYPES.has('PC24')) {
|
||||
expect(classifyAircraft('PC24')).toBe('bizjet');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Set integrity ───────────────────────────────────────────────────────
|
||||
|
||||
it('HELI_TYPES set has expected minimum entries', () => {
|
||||
expect(HELI_TYPES.size).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('TURBOPROP_TYPES set has expected minimum entries', () => {
|
||||
expect(TURBOPROP_TYPES.size).toBeGreaterThan(80);
|
||||
});
|
||||
|
||||
it('BIZJET_TYPES set has expected minimum entries', () => {
|
||||
expect(BIZJET_TYPES.size).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { interpolatePosition } from '@/utils/positioning';
|
||||
|
||||
describe('interpolatePosition', () => {
|
||||
// ─── No-op cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('returns same position when speed is zero', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 0, 10);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when speed is negative', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, -50, 10);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when dt is zero', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, 0);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when dt is negative', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, -5);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
// ─── Cardinal directions ─────────────────────────────────────────────────
|
||||
|
||||
it('moves north when heading is 0°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 0, 100, 10);
|
||||
expect(lat).toBeGreaterThan(40);
|
||||
expect(lng).toBeCloseTo(-74, 4); // longitude should barely change
|
||||
});
|
||||
|
||||
it('moves south when heading is 180°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 180, 100, 10);
|
||||
expect(lat).toBeLessThan(40);
|
||||
expect(lng).toBeCloseTo(-74, 4);
|
||||
});
|
||||
|
||||
it('moves east when heading is 90°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, 10);
|
||||
expect(lat).toBeCloseTo(40, 4);
|
||||
expect(lng).toBeGreaterThan(-74);
|
||||
});
|
||||
|
||||
it('moves west when heading is 270°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 270, 100, 10);
|
||||
expect(lat).toBeCloseTo(40, 4);
|
||||
expect(lng).toBeLessThan(-74);
|
||||
});
|
||||
|
||||
// ─── Distance proportionality ────────────────────────────────────────────
|
||||
|
||||
it('doubles distance when speed doubles', () => {
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 200, 10);
|
||||
const dist1 = lat1; // distance from origin going north
|
||||
const dist2 = lat2;
|
||||
expect(dist2).toBeCloseTo(dist1 * 2, 4);
|
||||
});
|
||||
|
||||
it('doubles distance when time doubles', () => {
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 20);
|
||||
const dist1 = lat1;
|
||||
const dist2 = lat2;
|
||||
expect(dist2).toBeCloseTo(dist1 * 2, 4);
|
||||
});
|
||||
|
||||
// ─── Clamping ────────────────────────────────────────────────────────────
|
||||
|
||||
it('clamps time to maxDt (prevents drift on stale data)', () => {
|
||||
// maxDt=65 by default, so dt=1000 should give same result as dt=65
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 65);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 1000);
|
||||
expect(lat1).toBeCloseTo(lat2, 6);
|
||||
});
|
||||
|
||||
it('clamps distance to maxDist when specified', () => {
|
||||
// At 100 knots for 60 seconds = ~3086m, maxDist=1000 should cap it
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 60, 1000);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 60, 0); // no cap
|
||||
expect(lat1).toBeLessThan(lat2);
|
||||
});
|
||||
|
||||
// ─── Known calculation ───────────────────────────────────────────────────
|
||||
|
||||
it('produces correct magnitude for known speed/time', () => {
|
||||
// 1 knot = 1 NM/hr = 1852 m/hr ≈ 0.5144 m/s
|
||||
// 100 knots for 10 seconds = 514.4 meters
|
||||
// At equator, 1° lat ≈ 111,320m, so 514.4m ≈ 0.00462°
|
||||
const [lat] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const expectedDegrees = (100 * 0.5144 * 10) / 111320;
|
||||
expect(lat).toBeCloseTo(expectedDegrees, 4);
|
||||
});
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('handles positions near the poles', () => {
|
||||
const [lat, lng] = interpolatePosition(89.9, 0, 0, 10, 5);
|
||||
expect(lat).toBeGreaterThan(89.9);
|
||||
expect(Number.isFinite(lat)).toBe(true);
|
||||
expect(Number.isFinite(lng)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles positions near the dateline', () => {
|
||||
const [lat, lng] = interpolatePosition(0, 179.99, 90, 100, 10);
|
||||
expect(Number.isFinite(lat)).toBe(true);
|
||||
expect(Number.isFinite(lng)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeNightPolygon } from '@/utils/solarTerminator';
|
||||
|
||||
/** Extract polygon ring from result (type-narrowing helper) */
|
||||
function getRing(result: GeoJSON.FeatureCollection): number[][] {
|
||||
const geom = result.features[0].geometry;
|
||||
if (geom.type !== 'Polygon') throw new Error('Expected Polygon geometry');
|
||||
return geom.coordinates[0];
|
||||
}
|
||||
|
||||
describe('computeNightPolygon', () => {
|
||||
// ─── Structure validation ────────────────────────────────────────────────
|
||||
|
||||
it('returns a valid GeoJSON FeatureCollection', () => {
|
||||
const result = computeNightPolygon();
|
||||
expect(result.type).toBe('FeatureCollection');
|
||||
expect(result.features).toHaveLength(1);
|
||||
expect(result.features[0].type).toBe('Feature');
|
||||
expect(result.features[0].geometry.type).toBe('Polygon');
|
||||
});
|
||||
|
||||
it('polygon has at least 360 vertices (one per degree of longitude)', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
// 361 terminator points + 2 closing corners + 1 ring-close = ≥364
|
||||
expect(ring.length).toBeGreaterThanOrEqual(364);
|
||||
});
|
||||
|
||||
it('polygon ring is closed (first and last points match)', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
expect(ring[ring.length - 1]).toEqual(ring[0]);
|
||||
});
|
||||
|
||||
// ─── Coordinate bounds ───────────────────────────────────────────────────
|
||||
|
||||
it('all coordinates are within valid lat/lng bounds', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
for (const [lng, lat] of ring) {
|
||||
expect(lng).toBeGreaterThanOrEqual(-180);
|
||||
expect(lng).toBeLessThanOrEqual(180);
|
||||
expect(lat).toBeGreaterThanOrEqual(-85);
|
||||
expect(lat).toBeLessThanOrEqual(85);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Deterministic for same input ────────────────────────────────────────
|
||||
|
||||
it('returns identical result for the same date', () => {
|
||||
const date = new Date('2024-06-21T12:00:00Z');
|
||||
const result1 = computeNightPolygon(date);
|
||||
const result2 = computeNightPolygon(date);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
// ─── Seasonal behavior ──────────────────────────────────────────────────
|
||||
|
||||
it('equinox produces roughly symmetric polygon', () => {
|
||||
const equinox = new Date('2024-03-20T12:00:00Z');
|
||||
const ring = getRing(computeNightPolygon(equinox));
|
||||
const lats = ring.map(([, lat]: number[]) => lat);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLat = Math.min(...lats);
|
||||
expect(maxLat).toBeGreaterThan(50);
|
||||
expect(minLat).toBeLessThan(-50);
|
||||
});
|
||||
|
||||
it('summer solstice shifts night polygon southward', () => {
|
||||
const summer = new Date('2024-06-21T00:00:00Z');
|
||||
const ring = getRing(computeNightPolygon(summer));
|
||||
const terminatorLats = ring
|
||||
.filter(([lng]: number[]) => lng >= -180 && lng <= 180)
|
||||
.slice(0, 361)
|
||||
.map(([, lat]: number[]) => lat);
|
||||
const avgLat = terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
expect(avgLat).toBeLessThan(15);
|
||||
});
|
||||
|
||||
// ─── Different times produce different results ──────────────────────────
|
||||
|
||||
it('produces different polygons for different times of day', () => {
|
||||
const morning = new Date('2024-06-21T06:00:00Z');
|
||||
const evening = new Date('2024-06-21T18:00:00Z');
|
||||
const ringM = getRing(computeNightPolygon(morning));
|
||||
const ringE = getRing(computeNightPolygon(evening));
|
||||
expect(ringM[0]).not.toEqual(ringE[0]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user