| import assert from 'node:assert/strict'; |
| import { describe, it } from 'node:test'; |
|
|
| import { |
| COMPONENT_WEIGHTS, |
| HISTORY_WINDOW_MS, |
| MAX_HISTORY_POINTS, |
| SIGNAL_COOLDOWN_MS, |
| blendScores, |
| calculateDynamicRaw, |
| computeComponents, |
| computeEscalationChange, |
| computeEscalationScore, |
| countMilitaryNearHotspot, |
| detectTrend, |
| evaluateEscalationSignal, |
| findHotspotById, |
| getStaticBaseline, |
| haversineKm, |
| normalizeCII, |
| normalizeGeo, |
| normalizeMilitary, |
| normalizeNewsActivity, |
| pruneHistory, |
| rawToScore, |
| } from '../shared/analysis-hotspot-escalation.ts'; |
| import { CONFLICT_ZONES, INTEL_HOTSPOTS, STRATEGIC_WATERWAYS } from '../shared/geo-data.ts'; |
| import { getHotspotCountryScore } from '../shared/hotspot-country-map.ts'; |
| import * as clientGeo from '../src/config/geo.ts'; |
|
|
| const HOUR_MS = 60 * 60 * 1000; |
|
|
| |
| const NOW = Date.UTC(2026, 6, 28, 12, 0, 0); |
|
|
| const FIXTURE_HOTSPOT = { |
| id: 'fixture-theater', |
| name: 'Fixture Theater', |
| lat: 25, |
| lon: 55, |
| keywords: ['fixture'], |
| escalationScore: 4, |
| }; |
|
|
| const QUIET_INPUTS = { |
| newsMatches: 0, |
| hasBreaking: false, |
| newsVelocity: 0, |
| ciiScore: null, |
| geoAlertScore: 0, |
| geoAlertTypes: 0, |
| flightsNearby: 0, |
| vesselsNearby: 0, |
| }; |
|
|
| describe('hotspot country score mapping', () => { |
| it('uses the maximum available score for regional hotspots', () => { |
| const scores = new Map([['ML', 44], ['NE', null], ['BF', 71]]); |
| assert.equal(getHotspotCountryScore('sahel', (code) => scores.get(code) ?? null), 71); |
| assert.equal(getHotspotCountryScore('pak_afghan', (code) => ({ PK: 66, AF: 82 })[code] ?? null), 82); |
| }); |
|
|
| it('returns null when no mapped country has a score', () => { |
| assert.equal(getHotspotCountryScore('sahel', () => null), null); |
| assert.equal(getHotspotCountryScore('not-a-hotspot', () => 99), null); |
| }); |
| }); |
|
|
| describe('hotspot escalation component normalization', () => { |
| it('weights the four components at 0.35 / 0.25 / 0.25 / 0.15', () => { |
| assert.deepEqual(COMPONENT_WEIGHTS, { news: 0.35, cii: 0.25, geo: 0.25, military: 0.15 }); |
| const total = COMPONENT_WEIGHTS.news + COMPONENT_WEIGHTS.cii + COMPONENT_WEIGHTS.geo + COMPONENT_WEIGHTS.military; |
| assert.equal(total, 1); |
| }); |
|
|
| it('scores news activity from match count, breaking flag and velocity', () => { |
| assert.equal(normalizeNewsActivity(0, false, 0), 0); |
| assert.equal(normalizeNewsActivity(2, false, 0), 30); |
| assert.equal(normalizeNewsActivity(2, true, 0), 60); |
| assert.equal(normalizeNewsActivity(2, true, 1), 65); |
| }); |
|
|
| it('substitutes a neutral 30 when CII is unavailable', () => { |
| assert.equal(normalizeCII(null), 30); |
| assert.equal(normalizeCII(0), 0); |
| assert.equal(normalizeCII(72), 72); |
| }); |
|
|
| it('returns zero geo convergence when no alert scored, and adds 10 per alert type otherwise', () => { |
| assert.equal(normalizeGeo(0, 0), 0); |
| assert.equal(normalizeGeo(0, 5), 0, 'alert types alone must not manufacture convergence'); |
| assert.equal(normalizeGeo(40, 2), 60); |
| }); |
|
|
| it('weights vessels heavier than flights for military activity', () => { |
| assert.equal(normalizeMilitary(0, 0), 0); |
| assert.equal(normalizeMilitary(3, 1), 45); |
| }); |
|
|
| it('clamps every normalized component at 100', () => { |
| assert.equal(normalizeNewsActivity(100, true, 100), 100); |
| assert.equal(normalizeGeo(95, 5), 100); |
| assert.equal(normalizeMilitary(20, 20), 100); |
| }); |
|
|
| it('applies the weights to produce the raw 0-100 composite', () => { |
| const raw = calculateDynamicRaw({ |
| newsActivity: 65, |
| ciiContribution: 80, |
| geoConvergence: 60, |
| militaryActivity: 45, |
| }); |
| assert.equal(raw, 64.5); |
| }); |
|
|
| it('maps the raw 0-100 composite onto the published 1-5 scale', () => { |
| assert.equal(rawToScore(0), 1); |
| assert.equal(rawToScore(100), 5); |
| assert.equal(rawToScore(50), 3); |
| }); |
|
|
| it('blends the static baseline and dynamic score 30/70', () => { |
| assert.equal(blendScores(5, 5), 5); |
| assert.equal(blendScores(1, 1), 1); |
| assert.equal(Math.round(blendScores(4, 3.58) * 1000) / 1000, 3.706); |
| }); |
|
|
| it('falls back to a baseline of 3 for hotspots with no curated escalationScore', () => { |
| assert.equal(getStaticBaseline({ escalationScore: 5 }), 5); |
| assert.equal(getStaticBaseline({}), 3); |
| }); |
| }); |
|
|
| describe('hotspot escalation scoring', () => { |
| it('computes a full score from injected inputs with no ambient clock', () => { |
| const result = computeEscalationScore( |
| FIXTURE_HOTSPOT, |
| { |
| newsMatches: 2, |
| hasBreaking: true, |
| newsVelocity: 1, |
| ciiScore: 80, |
| geoAlertScore: 40, |
| geoAlertTypes: 2, |
| flightsNearby: 3, |
| vesselsNearby: 1, |
| }, |
| { now: NOW }, |
| ); |
|
|
| assert.equal(result.hotspotId, 'fixture-theater'); |
| assert.equal(result.staticBaseline, 4); |
| assert.deepEqual(result.components, { |
| newsActivity: 65, |
| ciiContribution: 80, |
| geoConvergence: 60, |
| militaryActivity: 45, |
| }); |
| assert.equal(result.dynamicScore, 3.6); |
| assert.equal(result.combinedScore, 3.7); |
| assert.equal(result.trend, 'stable', 'a first observation has too little history for a trend'); |
| assert.equal(result.history.length, 1); |
| assert.equal(result.history[0].timestamp, NOW); |
| assert.equal( |
| Math.round(result.history[0].score * 1000) / 1000, |
| 3.706, |
| 'history keeps the unrounded blended score', |
| ); |
| assert.equal(result.lastUpdated.getTime(), NOW); |
| }); |
|
|
| it('is deterministic — identical inputs and clock produce an identical score', () => { |
| const options = { now: NOW }; |
| const first = computeEscalationScore(FIXTURE_HOTSPOT, QUIET_INPUTS, options); |
| const second = computeEscalationScore(FIXTURE_HOTSPOT, QUIET_INPUTS, options); |
| assert.deepEqual(second, first); |
| }); |
|
|
| it('treats a missing CII reading as the neutral 30 rather than dropping the component', () => { |
| const withoutCii = computeEscalationScore(FIXTURE_HOTSPOT, QUIET_INPUTS, { now: NOW }); |
| const withNeutralCii = computeEscalationScore( |
| FIXTURE_HOTSPOT, |
| { ...QUIET_INPUTS, ciiScore: 30 }, |
| { now: NOW }, |
| ); |
| assert.equal(withoutCii.components.ciiContribution, 30); |
| assert.equal(withoutCii.combinedScore, withNeutralCii.combinedScore); |
| }); |
|
|
| it('clamps the combined score inside the 1-5 band at both extremes', () => { |
| const floor = computeEscalationScore( |
| { id: 'floor', lat: 0, lon: 0, escalationScore: 1 }, |
| { ...QUIET_INPUTS, ciiScore: 0 }, |
| { now: NOW }, |
| ); |
| assert.equal(floor.combinedScore, 1); |
|
|
| const ceiling = computeEscalationScore( |
| { id: 'ceiling', lat: 0, lon: 0, escalationScore: 5 }, |
| { |
| newsMatches: 50, |
| hasBreaking: true, |
| newsVelocity: 50, |
| ciiScore: 100, |
| geoAlertScore: 100, |
| geoAlertTypes: 9, |
| flightsNearby: 50, |
| vesselsNearby: 50, |
| }, |
| { now: NOW }, |
| ); |
| assert.equal(ceiling.combinedScore, 5); |
| assert.deepEqual(ceiling.components, { |
| newsActivity: 100, |
| ciiContribution: 100, |
| geoConvergence: 100, |
| militaryActivity: 100, |
| }); |
| }); |
|
|
| it('appends to prior history and drops observations older than the 24h window', () => { |
| const previousHistory = [ |
| { timestamp: NOW - HISTORY_WINDOW_MS - 1, score: 1 }, |
| { timestamp: NOW - 2 * HOUR_MS, score: 2 }, |
| ]; |
| const result = computeEscalationScore(FIXTURE_HOTSPOT, QUIET_INPUTS, { now: NOW, previousHistory }); |
| assert.equal(result.history.length, 2); |
| assert.equal(result.history[0].timestamp, NOW - 2 * HOUR_MS); |
| assert.equal(result.history[1].timestamp, NOW); |
| assert.equal(previousHistory.length, 2, 'the caller-supplied history must not be mutated'); |
| }); |
|
|
| it('derives an escalating trend from a rising history', () => { |
| let history; |
| let result; |
| for (let i = 0; i < 4; i++) { |
| result = computeEscalationScore( |
| FIXTURE_HOTSPOT, |
| { ...QUIET_INPUTS, newsMatches: i * 2 }, |
| { now: NOW + i * HOUR_MS, previousHistory: history }, |
| ); |
| history = result.history; |
| } |
| assert.equal(result.trend, 'escalating'); |
| }); |
| }); |
|
|
| describe('hotspot escalation history and trend', () => { |
| it('prunes to the 24h window against an injected clock', () => { |
| const history = [ |
| { timestamp: NOW - HISTORY_WINDOW_MS - 1, score: 1 }, |
| { timestamp: NOW - HISTORY_WINDOW_MS, score: 2 }, |
| { timestamp: NOW, score: 3 }, |
| ]; |
| assert.deepEqual( |
| pruneHistory(history, NOW).map((point) => point.score), |
| [2, 3], |
| ); |
| }); |
|
|
| it('caps retained history at MAX_HISTORY_POINTS, keeping the newest', () => { |
| const history = Array.from({ length: MAX_HISTORY_POINTS + 10 }, (_, i) => ({ |
| timestamp: NOW - (MAX_HISTORY_POINTS + 10 - i) * 60_000, |
| score: i, |
| })); |
| const pruned = pruneHistory(history, NOW); |
| assert.equal(pruned.length, MAX_HISTORY_POINTS); |
| assert.equal(pruned[pruned.length - 1].score, MAX_HISTORY_POINTS + 9); |
| }); |
|
|
| it('reports stable below three history points', () => { |
| assert.equal(detectTrend([]), 'stable'); |
| assert.equal(detectTrend([{ timestamp: 1, score: 1 }]), 'stable'); |
| assert.equal(detectTrend([{ timestamp: 1, score: 1 }, { timestamp: 2, score: 5 }]), 'stable'); |
| }); |
|
|
| it('classifies the regression slope into the three published trend tokens', () => { |
| const at = (scores) => scores.map((score, i) => ({ timestamp: NOW + i * HOUR_MS, score })); |
| assert.equal(detectTrend(at([1, 2, 3])), 'escalating'); |
| assert.equal(detectTrend(at([3, 2, 1])), 'de-escalating'); |
| assert.equal(detectTrend(at([2, 2, 2])), 'stable'); |
| assert.equal(detectTrend(at([2, 2.05, 2.1])), 'stable', 'a slope inside +/-0.1 stays stable'); |
| }); |
|
|
| it('reports the 24h change between the oldest in-window and newest observation', () => { |
| const history = [ |
| { timestamp: NOW - 3 * HOUR_MS, score: 2.14 }, |
| { timestamp: NOW - HOUR_MS, score: 2.9 }, |
| { timestamp: NOW, score: 3.46 }, |
| ]; |
| assert.deepEqual(computeEscalationChange(history, NOW), { change: 1.3, start: 2.1, end: 3.5 }); |
| }); |
|
|
| it('returns null when there is not enough history to compute a change', () => { |
| assert.equal(computeEscalationChange([], NOW), null); |
| assert.equal(computeEscalationChange([{ timestamp: NOW, score: 3 }], NOW), null); |
| }); |
| }); |
|
|
| describe('hotspot escalation signal gating', () => { |
| const base = { lastSignalAt: 0, now: NOW }; |
|
|
| it('never signals without a prior score to compare against', () => { |
| assert.equal(evaluateEscalationSignal({ ...base, oldScore: null, newScore: 5 }), null); |
| }); |
|
|
| it('suppresses signals inside the two-hour cooldown', () => { |
| assert.equal( |
| evaluateEscalationSignal({ oldScore: 1, newScore: 5, lastSignalAt: NOW - SIGNAL_COOLDOWN_MS + 1, now: NOW }), |
| null, |
| ); |
| assert.ok( |
| evaluateEscalationSignal({ oldScore: 1, newScore: 5, lastSignalAt: NOW - SIGNAL_COOLDOWN_MS, now: NOW }), |
| 'a signal is allowed once the cooldown has fully elapsed', |
| ); |
| }); |
|
|
| it('reports a threshold crossing when the integer band increases', () => { |
| assert.deepEqual(evaluateEscalationSignal({ ...base, oldScore: 2.9, newScore: 3.1 }), { |
| type: 'threshold_crossed', |
| oldScore: 2.9, |
| newScore: 3.1, |
| threshold: 3, |
| }); |
| }); |
|
|
| it('reports a rapid increase inside a single band', () => { |
| assert.deepEqual(evaluateEscalationSignal({ ...base, oldScore: 1.1, newScore: 1.7 }), { |
| type: 'rapid_increase', |
| oldScore: 1.1, |
| newScore: 1.7, |
| }); |
| }); |
|
|
| it('reports the critical threshold on a small move across 4.5', () => { |
| assert.deepEqual(evaluateEscalationSignal({ ...base, oldScore: 4.4, newScore: 4.6 }), { |
| type: 'critical_reached', |
| oldScore: 4.4, |
| newScore: 4.6, |
| }); |
| }); |
|
|
| it('stays silent on a flat or falling score', () => { |
| assert.equal(evaluateEscalationSignal({ ...base, oldScore: 3.2, newScore: 3.2 }), null); |
| assert.equal(evaluateEscalationSignal({ ...base, oldScore: 3.2, newScore: 2.4 }), null); |
| }); |
| }); |
|
|
| describe('military proximity counting', () => { |
| it('measures great-circle distance in kilometres', () => { |
| assert.equal(Math.round(haversineKm(0, 0, 0, 1)), 111); |
| assert.equal(haversineKm(25, 55, 25, 55), 0); |
| }); |
|
|
| it('counts only flights and vessels inside the radius', () => { |
| const counts = countMilitaryNearHotspot( |
| FIXTURE_HOTSPOT, |
| [ |
| { lat: 25.1, lon: 55.1 }, |
| { lat: 45, lon: 55 }, |
| ], |
| [{ lat: 24.9, lon: 54.9 }, { lat: -30, lon: 10 }], |
| 200, |
| ); |
| assert.deepEqual(counts, { flights: 1, vessels: 1 }); |
| }); |
|
|
| it('defaults to a 200km radius', () => { |
| const near = [{ lat: 26.5, lon: 55 }]; |
| assert.deepEqual(countMilitaryNearHotspot(FIXTURE_HOTSPOT, near, []), { flights: 1, vessels: 0 }); |
| assert.deepEqual(countMilitaryNearHotspot(FIXTURE_HOTSPOT, near, [], 100), { flights: 0, vessels: 0 }); |
| }); |
| }); |
|
|
| describe('shared curated geo datasets', () => { |
| it('publishes the 29 curated intel hotspots with unique ids and valid coordinates', () => { |
| assert.equal(INTEL_HOTSPOTS.length, 29); |
| const ids = INTEL_HOTSPOTS.map((h) => h.id); |
| assert.equal(new Set(ids).size, ids.length, 'hotspot ids must be unique'); |
| for (const hotspot of INTEL_HOTSPOTS) { |
| assert.ok(hotspot.id && hotspot.name, `hotspot ${hotspot.id} needs an id and a name`); |
| assert.ok(Array.isArray(hotspot.keywords) && hotspot.keywords.length > 0, `${hotspot.id} needs keywords`); |
| assert.ok(hotspot.lat >= -90 && hotspot.lat <= 90, `${hotspot.id} lat in range`); |
| assert.ok(hotspot.lon >= -180 && hotspot.lon <= 180, `${hotspot.id} lon in range`); |
| if (hotspot.escalationScore !== undefined) { |
| assert.ok( |
| Number.isInteger(hotspot.escalationScore) && hotspot.escalationScore >= 1 && hotspot.escalationScore <= 5, |
| `${hotspot.id} escalationScore must be 1-5`, |
| ); |
| } |
| } |
| }); |
|
|
| it('every curated hotspot is resolvable by id and scoreable', () => { |
| for (const hotspot of INTEL_HOTSPOTS) { |
| assert.equal(findHotspotById(hotspot.id), hotspot); |
| const score = computeEscalationScore(hotspot, QUIET_INPUTS, { now: NOW }); |
| assert.ok(score.combinedScore >= 1 && score.combinedScore <= 5, `${hotspot.id} score inside 1-5`); |
| } |
| assert.equal(findHotspotById('no-such-hotspot'), undefined); |
| }); |
|
|
| it('publishes 13 strategic waterways keyed 1:1 with the chokepoint registry', () => { |
| assert.equal(STRATEGIC_WATERWAYS.length, 13); |
| const ids = STRATEGIC_WATERWAYS.map((w) => w.id); |
| assert.equal(new Set(ids).size, ids.length, 'waterway ids must be unique'); |
| for (const waterway of STRATEGIC_WATERWAYS) { |
| assert.equal(waterway.chokepointId, waterway.id, `${waterway.id} chokepointId must mirror id`); |
| assert.ok(waterway.name, `${waterway.id} needs a name`); |
| assert.ok(waterway.lat >= -90 && waterway.lat <= 90, `${waterway.id} lat in range`); |
| assert.ok(waterway.lon >= -180 && waterway.lon <= 180, `${waterway.id} lon in range`); |
| } |
| }); |
|
|
| it('publishes conflict zones with a valid [lon, lat] ring and centre', () => { |
| assert.equal(CONFLICT_ZONES.length, 10); |
| const ids = CONFLICT_ZONES.map((z) => z.id); |
| assert.equal(new Set(ids).size, ids.length, 'conflict zone ids must be unique'); |
| for (const zone of CONFLICT_ZONES) { |
| assert.ok(zone.name, `${zone.id} needs a name`); |
| |
| |
| assert.ok(zone.coords.length >= 3, `${zone.id} needs at least 3 ring points`); |
| for (const [lon, lat] of zone.coords) { |
| assert.ok(lat >= -90 && lat <= 90, `${zone.id} ring lat in range`); |
| assert.ok(lon >= -180 && lon <= 180, `${zone.id} ring lon in range`); |
| } |
| const [centerLon, centerLat] = zone.center; |
| assert.ok(centerLat >= -90 && centerLat <= 90, `${zone.id} centre lat in range`); |
| assert.ok(centerLon >= -180 && centerLon <= 180, `${zone.id} centre lon in range`); |
| } |
| }); |
|
|
| it('src/config/geo.ts re-exports the shared datasets by identity', () => { |
| assert.equal(clientGeo.INTEL_HOTSPOTS, INTEL_HOTSPOTS); |
| assert.equal(clientGeo.CONFLICT_ZONES, CONFLICT_ZONES); |
| assert.equal(clientGeo.STRATEGIC_WATERWAYS, STRATEGIC_WATERWAYS); |
| assert.deepEqual(Object.keys(clientGeo).sort(), ['CONFLICT_ZONES', 'INTEL_HOTSPOTS', 'STRATEGIC_WATERWAYS']); |
| }); |
| }); |
|
|