mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Adds tests/handlers.test.mts with 72 test cases across 14 suites covering exported pure functions from three server handler modules: - cyber/_shared.ts: clampInt, dedupeThreats, toProtoCyberThreat, enum maps - news/_shared.ts + dedup.mjs + hash.ts: deduplicateHeadlines, hashString, buildArticlePrompts - infrastructure/get-cable-health.ts: isCableRelated, parseCoordinates, matchCableByName, findNearestCable, parseIssueDate, processNgaSignals, computeHealthMap Closes #181 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Elie Habib <elie.habib@gmail.com>
659 lines
22 KiB
TypeScript
659 lines
22 KiB
TypeScript
/**
|
|
* Unit tests for server handler business logic.
|
|
*
|
|
* Covers exported pure functions from:
|
|
* - server/worldmonitor/cyber/v1/_shared.ts
|
|
* - server/worldmonitor/news/v1/_shared.ts (+ dedup.mjs + hash.ts)
|
|
* - server/worldmonitor/infrastructure/v1/get-cable-health.ts
|
|
*
|
|
* NOTE: server/worldmonitor/military/v1/get-usni-fleet-report.ts has many useful
|
|
* pure helpers (hullToVesselType, detectDeploymentStatus, extractHomePort,
|
|
* stripHtml, getRegionCoords, parseUSNIArticle) but they are NOT exported.
|
|
* A follow-up PR should export those functions to enable testing.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cyber domain helpers
|
|
// ---------------------------------------------------------------------------
|
|
import {
|
|
clampInt,
|
|
dedupeThreats,
|
|
toProtoCyberThreat,
|
|
THREAT_TYPE_MAP,
|
|
SOURCE_MAP,
|
|
SEVERITY_MAP,
|
|
SEVERITY_RANK,
|
|
type RawThreat,
|
|
} from '../server/worldmonitor/cyber/v1/_shared.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// News domain helpers
|
|
// ---------------------------------------------------------------------------
|
|
import { deduplicateHeadlines } from '../server/worldmonitor/news/v1/dedup.mjs';
|
|
import { buildArticlePrompts, hashString } from '../server/worldmonitor/news/v1/_shared.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Infrastructure / cable health helpers
|
|
// ---------------------------------------------------------------------------
|
|
import {
|
|
isCableRelated,
|
|
parseCoordinates,
|
|
matchCableByName,
|
|
findNearestCable,
|
|
parseIssueDate,
|
|
processNgaSignals,
|
|
computeHealthMap,
|
|
} from '../server/worldmonitor/infrastructure/v1/get-cable-health.ts';
|
|
|
|
|
|
// ========================================================================
|
|
// 1. Cyber: clampInt
|
|
// ========================================================================
|
|
|
|
describe('clampInt', () => {
|
|
it('returns fallback for undefined', () => {
|
|
assert.equal(clampInt(undefined, 50, 1, 100), 50);
|
|
});
|
|
|
|
it('returns fallback for NaN', () => {
|
|
assert.equal(clampInt(NaN, 50, 1, 100), 50);
|
|
});
|
|
|
|
it('returns fallback for Infinity', () => {
|
|
assert.equal(clampInt(Infinity, 50, 1, 100), 50);
|
|
});
|
|
|
|
it('clamps below min', () => {
|
|
assert.equal(clampInt(-5, 50, 1, 100), 1);
|
|
});
|
|
|
|
it('clamps above max', () => {
|
|
assert.equal(clampInt(200, 50, 1, 100), 100);
|
|
});
|
|
|
|
it('floors float values', () => {
|
|
assert.equal(clampInt(7.9, 50, 1, 100), 7);
|
|
});
|
|
|
|
it('passes through valid value', () => {
|
|
assert.equal(clampInt(42, 50, 1, 100), 42);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 2. Cyber: dedupeThreats
|
|
// ========================================================================
|
|
|
|
describe('dedupeThreats', () => {
|
|
const baseThreat: RawThreat = {
|
|
id: 'feodo:1.2.3.4',
|
|
type: 'c2_server',
|
|
source: 'feodo',
|
|
indicator: '1.2.3.4',
|
|
indicatorType: 'ip',
|
|
lat: null,
|
|
lon: null,
|
|
country: 'US',
|
|
severity: 'high',
|
|
malwareFamily: 'emotet',
|
|
tags: ['c2'],
|
|
firstSeen: 1000,
|
|
lastSeen: 2000,
|
|
};
|
|
|
|
it('returns empty array for empty input', () => {
|
|
assert.deepEqual(dedupeThreats([]), []);
|
|
});
|
|
|
|
it('keeps a single threat unchanged', () => {
|
|
const result = dedupeThreats([baseThreat]);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0]!.id, baseThreat.id);
|
|
});
|
|
|
|
it('deduplicates threats with the same source:indicatorType:indicator key', () => {
|
|
const older = { ...baseThreat, lastSeen: 1000 };
|
|
const newer = { ...baseThreat, id: 'feodo:1.2.3.4:v2', lastSeen: 3000, severity: 'critical' as const };
|
|
const result = dedupeThreats([older, newer]);
|
|
assert.equal(result.length, 1);
|
|
// Newer entry wins
|
|
assert.equal(result[0]!.severity, 'critical');
|
|
});
|
|
|
|
it('keeps threats with different indicators separate', () => {
|
|
const other = { ...baseThreat, indicator: '5.6.7.8', id: 'feodo:5.6.7.8' };
|
|
const result = dedupeThreats([baseThreat, other]);
|
|
assert.equal(result.length, 2);
|
|
});
|
|
|
|
it('keeps threats with different sources separate even if same indicator', () => {
|
|
const otherSource = { ...baseThreat, source: 'urlhaus', id: 'urlhaus:ip:1.2.3.4' };
|
|
const result = dedupeThreats([baseThreat, otherSource]);
|
|
assert.equal(result.length, 2);
|
|
});
|
|
|
|
it('merges tags from both entries during dedup', () => {
|
|
const first = { ...baseThreat, tags: ['c2'], lastSeen: 1000 };
|
|
const second = { ...baseThreat, tags: ['botnet'], lastSeen: 2000 };
|
|
const result = dedupeThreats([first, second]);
|
|
assert.equal(result.length, 1);
|
|
assert.ok(result[0]!.tags.includes('c2'));
|
|
assert.ok(result[0]!.tags.includes('botnet'));
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 3. Cyber: toProtoCyberThreat
|
|
// ========================================================================
|
|
|
|
describe('toProtoCyberThreat', () => {
|
|
const raw: RawThreat = {
|
|
id: 'feodo:1.2.3.4',
|
|
type: 'c2_server',
|
|
source: 'feodo',
|
|
indicator: '1.2.3.4',
|
|
indicatorType: 'ip',
|
|
lat: 40.0,
|
|
lon: -74.0,
|
|
country: 'US',
|
|
severity: 'high',
|
|
malwareFamily: 'emotet',
|
|
tags: ['c2', 'botnet'],
|
|
firstSeen: 1000,
|
|
lastSeen: 2000,
|
|
};
|
|
|
|
it('maps type to proto enum', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.equal(proto.type, 'CYBER_THREAT_TYPE_C2_SERVER');
|
|
});
|
|
|
|
it('maps source to proto enum', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.equal(proto.source, 'CYBER_THREAT_SOURCE_FEODO');
|
|
});
|
|
|
|
it('maps severity to proto enum', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.equal(proto.severity, 'CRITICALITY_LEVEL_HIGH');
|
|
});
|
|
|
|
it('maps indicatorType to proto enum', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.equal(proto.indicatorType, 'CYBER_THREAT_INDICATOR_TYPE_IP');
|
|
});
|
|
|
|
it('includes location when lat/lon are valid', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.ok(proto.location);
|
|
assert.equal(proto.location!.latitude, 40.0);
|
|
assert.equal(proto.location!.longitude, -74.0);
|
|
});
|
|
|
|
it('excludes location when lat/lon are null', () => {
|
|
const noGeo = { ...raw, lat: null, lon: null };
|
|
const proto = toProtoCyberThreat(noGeo);
|
|
assert.equal(proto.location, undefined);
|
|
});
|
|
|
|
it('falls back to UNSPECIFIED for unknown type string', () => {
|
|
const unknown = { ...raw, type: 'unknown_thing' };
|
|
const proto = toProtoCyberThreat(unknown);
|
|
assert.equal(proto.type, 'CYBER_THREAT_TYPE_UNSPECIFIED');
|
|
});
|
|
|
|
it('preserves tags and family', () => {
|
|
const proto = toProtoCyberThreat(raw);
|
|
assert.deepEqual(proto.tags, ['c2', 'botnet']);
|
|
assert.equal(proto.malwareFamily, 'emotet');
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 4. Cyber: enum maps sanity checks
|
|
// ========================================================================
|
|
|
|
describe('Cyber enum maps', () => {
|
|
it('THREAT_TYPE_MAP covers all 4 legacy types', () => {
|
|
assert.equal(Object.keys(THREAT_TYPE_MAP).length, 4);
|
|
assert.equal(THREAT_TYPE_MAP['c2_server'], 'CYBER_THREAT_TYPE_C2_SERVER');
|
|
assert.equal(THREAT_TYPE_MAP['phishing'], 'CYBER_THREAT_TYPE_PHISHING');
|
|
});
|
|
|
|
it('SOURCE_MAP covers all 5 sources', () => {
|
|
assert.equal(Object.keys(SOURCE_MAP).length, 5);
|
|
assert.equal(SOURCE_MAP['feodo'], 'CYBER_THREAT_SOURCE_FEODO');
|
|
assert.equal(SOURCE_MAP['abuseipdb'], 'CYBER_THREAT_SOURCE_ABUSEIPDB');
|
|
});
|
|
|
|
it('SEVERITY_MAP has 4 levels', () => {
|
|
assert.equal(Object.keys(SEVERITY_MAP).length, 4);
|
|
});
|
|
|
|
it('SEVERITY_RANK orders critical > high > medium > low > unspecified', () => {
|
|
assert.ok(SEVERITY_RANK['CRITICALITY_LEVEL_CRITICAL']! > SEVERITY_RANK['CRITICALITY_LEVEL_HIGH']!);
|
|
assert.ok(SEVERITY_RANK['CRITICALITY_LEVEL_HIGH']! > SEVERITY_RANK['CRITICALITY_LEVEL_MEDIUM']!);
|
|
assert.ok(SEVERITY_RANK['CRITICALITY_LEVEL_MEDIUM']! > SEVERITY_RANK['CRITICALITY_LEVEL_LOW']!);
|
|
assert.ok(SEVERITY_RANK['CRITICALITY_LEVEL_LOW']! > SEVERITY_RANK['CRITICALITY_LEVEL_UNSPECIFIED']!);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 5. News: deduplicateHeadlines
|
|
// ========================================================================
|
|
|
|
describe('deduplicateHeadlines', () => {
|
|
it('returns empty for empty input', () => {
|
|
assert.deepEqual(deduplicateHeadlines([]), []);
|
|
});
|
|
|
|
it('returns single headline unchanged', () => {
|
|
assert.deepEqual(deduplicateHeadlines(['Breaking: earthquake hits Japan']), ['Breaking: earthquake hits Japan']);
|
|
});
|
|
|
|
it('removes near-duplicate headlines (>60% word overlap)', () => {
|
|
const headlines = [
|
|
'Trump announces new tariffs on Chinese imports',
|
|
'Trump announces new tariffs on Chinese goods',
|
|
];
|
|
const result = deduplicateHeadlines(headlines);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
it('keeps dissimilar headlines', () => {
|
|
const headlines = [
|
|
'Earthquake shakes Tokyo, no casualties reported',
|
|
'SpaceX launches Starship prototype successfully',
|
|
'Bitcoin reaches new all-time high above $100,000',
|
|
];
|
|
const result = deduplicateHeadlines(headlines);
|
|
assert.equal(result.length, 3);
|
|
});
|
|
|
|
it('filters short words (< 4 chars) from similarity comparison', () => {
|
|
// These share only short words like "the", "is", "of"
|
|
const headlines = [
|
|
'The art of the deal is dead',
|
|
'The end of an era is here',
|
|
];
|
|
const result = deduplicateHeadlines(headlines);
|
|
// "deal", "dead" vs "era", "here" - very different 4+ letter words
|
|
assert.equal(result.length, 2);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 6. News: hashString (FNV-1a 52-bit)
|
|
// ========================================================================
|
|
|
|
describe('hashString', () => {
|
|
it('returns a non-empty string', () => {
|
|
const result = hashString('hello');
|
|
assert.ok(result.length > 0);
|
|
});
|
|
|
|
it('produces consistent output for the same input', () => {
|
|
assert.equal(hashString('test'), hashString('test'));
|
|
});
|
|
|
|
it('produces different output for different inputs', () => {
|
|
assert.notEqual(hashString('hello'), hashString('world'));
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
const result = hashString('');
|
|
assert.ok(typeof result === 'string');
|
|
assert.ok(result.length > 0);
|
|
});
|
|
|
|
it('output is base-36 encoded', () => {
|
|
const result = hashString('some arbitrary text');
|
|
// base-36 uses [0-9a-z]
|
|
assert.match(result, /^[0-9a-z]+$/);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 7. News: buildArticlePrompts
|
|
// ========================================================================
|
|
|
|
describe('buildArticlePrompts', () => {
|
|
const headlines = ['Earthquake hits Tokyo', 'SpaceX launch delayed'];
|
|
const unique = headlines;
|
|
const baseOpts = { mode: 'brief', geoContext: '', variant: 'full', lang: 'en' };
|
|
|
|
it('returns systemPrompt and userPrompt strings', () => {
|
|
const result = buildArticlePrompts(headlines, unique, baseOpts);
|
|
assert.ok(typeof result.systemPrompt === 'string');
|
|
assert.ok(typeof result.userPrompt === 'string');
|
|
});
|
|
|
|
it('brief mode includes numbered headlines in userPrompt', () => {
|
|
const result = buildArticlePrompts(headlines, unique, baseOpts);
|
|
assert.ok(result.userPrompt.includes('1. Earthquake hits Tokyo'));
|
|
assert.ok(result.userPrompt.includes('2. SpaceX launch delayed'));
|
|
});
|
|
|
|
it('brief tech variant focuses on technology', () => {
|
|
const techOpts = { ...baseOpts, variant: 'tech' };
|
|
const result = buildArticlePrompts(headlines, unique, techOpts);
|
|
assert.ok(result.systemPrompt.includes('tech'));
|
|
});
|
|
|
|
it('analysis mode produces analysis-focused prompt', () => {
|
|
const analysisOpts = { ...baseOpts, mode: 'analysis' };
|
|
const result = buildArticlePrompts(headlines, unique, analysisOpts);
|
|
assert.ok(result.systemPrompt.includes('Analyze'));
|
|
});
|
|
|
|
it('translate mode produces translation-focused prompt', () => {
|
|
const translateOpts = { mode: 'translate', geoContext: '', variant: 'Spanish', lang: 'es' };
|
|
const result = buildArticlePrompts(headlines, unique, translateOpts);
|
|
assert.ok(result.systemPrompt.includes('translator'));
|
|
assert.ok(result.userPrompt.includes('Translate to Spanish'));
|
|
});
|
|
|
|
it('includes geo context when provided', () => {
|
|
const geoOpts = { ...baseOpts, geoContext: 'Intel: 7.1 magnitude quake, Pacific Ring of Fire' };
|
|
const result = buildArticlePrompts(headlines, unique, geoOpts);
|
|
assert.ok(result.userPrompt.includes('Pacific Ring of Fire'));
|
|
});
|
|
|
|
it('includes language instruction for non-English', () => {
|
|
const frOpts = { ...baseOpts, lang: 'fr' };
|
|
const result = buildArticlePrompts(headlines, unique, frOpts);
|
|
assert.ok(result.systemPrompt.includes('FR'));
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 8. Cable health: isCableRelated
|
|
// ========================================================================
|
|
|
|
describe('isCableRelated', () => {
|
|
it('returns true for text mentioning CABLE', () => {
|
|
assert.ok(isCableRelated('WARNING: SUBMARINE CABLE OPERATIONS IN AREA'));
|
|
});
|
|
|
|
it('returns true for CABLESHIP', () => {
|
|
assert.ok(isCableRelated('CABLESHIP ILE DE BATZ ON STATION'));
|
|
});
|
|
|
|
it('returns true for FIBER OPTIC', () => {
|
|
assert.ok(isCableRelated('FIBER OPTIC REPAIR IN PROGRESS'));
|
|
});
|
|
|
|
it('returns false for unrelated text', () => {
|
|
assert.ok(!isCableRelated('MILITARY EXERCISE IN PROGRESS'));
|
|
});
|
|
|
|
it('is case insensitive', () => {
|
|
assert.ok(isCableRelated('submarine cable laying operations'));
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 9. Cable health: parseCoordinates
|
|
// ========================================================================
|
|
|
|
describe('parseCoordinates', () => {
|
|
it('parses DMS coordinates (N/E)', () => {
|
|
const coords = parseCoordinates('36-30.5N 075-58.2W');
|
|
assert.equal(coords.length, 1);
|
|
const [lat, lon] = coords[0]!;
|
|
assert.ok(Math.abs(lat - 36.508333) < 0.01);
|
|
assert.ok(lon < 0); // W is negative
|
|
});
|
|
|
|
it('parses multiple coordinate pairs', () => {
|
|
const text = '36-30.0N 075-58.0W THRU 37-00.0N 076-00.0W';
|
|
const coords = parseCoordinates(text);
|
|
assert.equal(coords.length, 2);
|
|
});
|
|
|
|
it('returns empty for text with no coordinates', () => {
|
|
assert.deepEqual(parseCoordinates('No coordinates here'), []);
|
|
});
|
|
|
|
it('handles S latitude correctly', () => {
|
|
const coords = parseCoordinates('33-52.0S 151-13.0E');
|
|
assert.equal(coords.length, 1);
|
|
assert.ok(coords[0]![0] < 0); // S is negative
|
|
assert.ok(coords[0]![1] > 0); // E is positive
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 10. Cable health: matchCableByName
|
|
// ========================================================================
|
|
|
|
describe('matchCableByName', () => {
|
|
it('matches known cable MAREA', () => {
|
|
assert.equal(matchCableByName('DAMAGE TO MAREA CABLE SYSTEM'), 'marea');
|
|
});
|
|
|
|
it('matches 2AFRICA', () => {
|
|
assert.equal(matchCableByName('2AFRICA cable repair operations'), '2africa');
|
|
});
|
|
|
|
it('matches GRACE HOPPER', () => {
|
|
assert.equal(matchCableByName('GRACE HOPPER cable laying'), 'grace_hopper');
|
|
});
|
|
|
|
it('returns null for unknown cables', () => {
|
|
assert.equal(matchCableByName('Generic warning about shipping'), null);
|
|
});
|
|
|
|
it('is case insensitive', () => {
|
|
assert.equal(matchCableByName('marea cable advisory'), 'marea');
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 11. Cable health: findNearestCable
|
|
// ========================================================================
|
|
|
|
describe('findNearestCable', () => {
|
|
it('finds marea near Virginia Beach (36.85, -75.98)', () => {
|
|
const result = findNearestCable(36.85, -75.98);
|
|
assert.ok(result);
|
|
assert.equal(result.cableId, 'marea');
|
|
assert.ok(result.distanceKm < 10);
|
|
});
|
|
|
|
it('returns null for coordinates far from any cable landing', () => {
|
|
// Middle of Sahara desert
|
|
const result = findNearestCable(23.0, 12.0);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it('finds a cable near Singapore (1.35, 103.82)', () => {
|
|
const result = findNearestCable(1.35, 103.82);
|
|
assert.ok(result);
|
|
// Multiple cables land in Singapore
|
|
assert.ok(result.distanceKm < 10);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 12. Cable health: parseIssueDate
|
|
// ========================================================================
|
|
|
|
describe('parseIssueDate', () => {
|
|
it('parses standard NGA date format', () => {
|
|
// "DD HHMM Z MON YYYY" -> e.g., "151430Z MAR 2025"
|
|
const ts = parseIssueDate('151430Z MAR 2025');
|
|
assert.ok(ts > 0);
|
|
const d = new Date(ts);
|
|
assert.equal(d.getUTCFullYear(), 2025);
|
|
assert.equal(d.getUTCMonth(), 2); // March = 2
|
|
assert.equal(d.getUTCDate(), 15);
|
|
assert.equal(d.getUTCHours(), 14);
|
|
assert.equal(d.getUTCMinutes(), 30);
|
|
});
|
|
|
|
it('returns 0 for undefined', () => {
|
|
assert.equal(parseIssueDate(undefined), 0);
|
|
});
|
|
|
|
it('returns 0 for unparseable string', () => {
|
|
assert.equal(parseIssueDate('not a date'), 0);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 13. Cable health: processNgaSignals
|
|
// ========================================================================
|
|
|
|
describe('processNgaSignals', () => {
|
|
it('returns empty signals for non-cable warnings', () => {
|
|
const warnings = [{ text: 'MILITARY EXERCISE IN AREA', issueDate: '011200Z JAN 2025' }];
|
|
assert.deepEqual(processNgaSignals(warnings), []);
|
|
});
|
|
|
|
it('produces a signal for a cable-related warning with known cable name', () => {
|
|
const warnings = [{
|
|
text: 'CABLE OPERATIONS NEAR MAREA CABLE SYSTEM. VESSELS ADVISED TO KEEP CLEAR.',
|
|
issueDate: '151430Z MAR 2025',
|
|
}];
|
|
const signals = processNgaSignals(warnings);
|
|
assert.ok(signals.length >= 1);
|
|
assert.equal(signals[0]!.cableId, 'marea');
|
|
});
|
|
|
|
it('produces fault signal when FAULT keyword is present', () => {
|
|
const warnings = [{
|
|
text: 'FAULT REPORTED ON SUBMARINE CABLE MAREA. REPAIR VESSEL EN ROUTE.',
|
|
issueDate: '151430Z MAR 2025',
|
|
}];
|
|
const signals = processNgaSignals(warnings);
|
|
const faultSignals = signals.filter((s) => s.kind === 'operator_fault');
|
|
assert.ok(faultSignals.length >= 1);
|
|
assert.equal(faultSignals[0]!.severity, 1.0);
|
|
});
|
|
|
|
it('produces repair_activity signal when ship name pattern matches', () => {
|
|
const warnings = [{
|
|
text: 'CABLESHIP ILE DE BATZ CABLE OPERATIONS ON STATION NEAR MAREA CABLE SYSTEM.',
|
|
issueDate: '151430Z MAR 2025',
|
|
}];
|
|
const signals = processNgaSignals(warnings);
|
|
const repairSignals = signals.filter((s) => s.kind === 'repair_activity');
|
|
assert.ok(repairSignals.length >= 1);
|
|
});
|
|
|
|
it('skips warnings that cannot be matched to a cable', () => {
|
|
const warnings = [{
|
|
text: 'SUBMARINE CABLE OPERATIONS IN UNSPECIFIED AREA',
|
|
issueDate: '151430Z MAR 2025',
|
|
}];
|
|
const signals = processNgaSignals(warnings);
|
|
assert.equal(signals.length, 0);
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================================
|
|
// 14. Cable health: computeHealthMap
|
|
// ========================================================================
|
|
|
|
describe('computeHealthMap', () => {
|
|
it('returns empty map for empty signals', () => {
|
|
assert.deepEqual(computeHealthMap([]), {});
|
|
});
|
|
|
|
it('computes FAULT status for high-severity operator fault signal', () => {
|
|
const now = Date.now();
|
|
const signals = [{
|
|
cableId: 'marea',
|
|
ts: now - 1000, // 1 second ago
|
|
severity: 1.0,
|
|
confidence: 0.9,
|
|
ttlSeconds: 5 * 86400,
|
|
kind: 'operator_fault',
|
|
evidence: [{ source: 'NGA', summary: 'Fault reported', ts: now - 1000 }],
|
|
}];
|
|
const result = computeHealthMap(signals);
|
|
assert.ok(result['marea']);
|
|
assert.equal(result['marea']!.status, 'CABLE_HEALTH_STATUS_FAULT');
|
|
});
|
|
|
|
it('computes DEGRADED status for medium-score signals', () => {
|
|
const now = Date.now();
|
|
const signals = [{
|
|
cableId: 'faster',
|
|
ts: now - 1000,
|
|
severity: 0.6,
|
|
confidence: 0.8,
|
|
ttlSeconds: 3 * 86400,
|
|
kind: 'cable_advisory',
|
|
evidence: [{ source: 'NGA', summary: 'Cable advisory', ts: now - 1000 }],
|
|
}];
|
|
const result = computeHealthMap(signals);
|
|
assert.ok(result['faster']);
|
|
// 0.6 * 0.8 * ~1.0 recency = ~0.48, which should be less than 0.50 -> OK
|
|
// Let's verify the actual status
|
|
assert.ok(
|
|
result['faster']!.status === 'CABLE_HEALTH_STATUS_OK' ||
|
|
result['faster']!.status === 'CABLE_HEALTH_STATUS_DEGRADED'
|
|
);
|
|
});
|
|
|
|
it('drops signals that have expired (beyond TTL)', () => {
|
|
const now = Date.now();
|
|
const signals = [{
|
|
cableId: 'marea',
|
|
ts: now - (6 * 86400 * 1000), // 6 days ago
|
|
severity: 1.0,
|
|
confidence: 0.9,
|
|
ttlSeconds: 5 * 86400, // 5 day TTL
|
|
kind: 'operator_fault',
|
|
evidence: [{ source: 'NGA', summary: 'Old fault', ts: now - (6 * 86400 * 1000) }],
|
|
}];
|
|
const result = computeHealthMap(signals);
|
|
// Should be empty because the signal is beyond its TTL
|
|
assert.deepEqual(result, {});
|
|
});
|
|
|
|
it('groups signals by cableId', () => {
|
|
const now = Date.now();
|
|
const signals = [
|
|
{
|
|
cableId: 'marea',
|
|
ts: now - 1000,
|
|
severity: 0.6,
|
|
confidence: 0.8,
|
|
ttlSeconds: 3 * 86400,
|
|
kind: 'cable_advisory',
|
|
evidence: [{ source: 'NGA', summary: 'Advisory', ts: now - 1000 }],
|
|
},
|
|
{
|
|
cableId: 'faster',
|
|
ts: now - 1000,
|
|
severity: 0.6,
|
|
confidence: 0.7,
|
|
ttlSeconds: 3 * 86400,
|
|
kind: 'cable_advisory',
|
|
evidence: [{ source: 'NGA', summary: 'Advisory', ts: now - 1000 }],
|
|
},
|
|
];
|
|
const result = computeHealthMap(signals);
|
|
assert.ok(result['marea']);
|
|
assert.ok(result['faster']);
|
|
});
|
|
});
|