Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-7-adsb-degraded
# Conflicts: # package.json
This commit is contained in:
47
test/dashboard-geotagging.test.mjs
Normal file
47
test/dashboard-geotagging.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { geoTagText, stableGeoJitter } from '../dashboard/inject.mjs';
|
||||
|
||||
test('geoTagText matches headlines case-insensitively', () => {
|
||||
assert.deepEqual(geoTagText('ukraine reports new air defense activity'), {
|
||||
lat: 49,
|
||||
lon: 32,
|
||||
region: 'Ukraine',
|
||||
});
|
||||
|
||||
assert.deepEqual(geoTagText('flooding disrupts são paulo transport'), {
|
||||
lat: -23.5,
|
||||
lon: -46.6,
|
||||
region: 'São Paulo',
|
||||
});
|
||||
});
|
||||
|
||||
test('geoTagText prefers longer place names before broad countries', () => {
|
||||
assert.deepEqual(geoTagText('New York markets react before wider US session'), {
|
||||
lat: 40.7,
|
||||
lon: -74,
|
||||
region: 'New York',
|
||||
});
|
||||
});
|
||||
|
||||
test('geoTagText uses word boundaries to reduce false positives', () => {
|
||||
assert.equal(geoTagText('A music festival announces its lineup'), null);
|
||||
assert.equal(geoTagText('Officials discuss a new focus for aid'), null);
|
||||
assert.deepEqual(geoTagText('US officials discuss a new aid package'), {
|
||||
lat: 39,
|
||||
lon: -98,
|
||||
region: 'US',
|
||||
});
|
||||
});
|
||||
|
||||
test('stableGeoJitter is deterministic and bounded', () => {
|
||||
const key = 'BBC|lower-case ukraine headline|Sun, 17 May 2026 12:00:00 GMT|https://example.test/a';
|
||||
const latA = stableGeoJitter(key, 'lat');
|
||||
const latB = stableGeoJitter(key, 'lat');
|
||||
const lon = stableGeoJitter(key, 'lon');
|
||||
|
||||
assert.equal(latA, latB);
|
||||
assert.notEqual(latA, lon);
|
||||
assert.ok(latA >= -1 && latA <= 1);
|
||||
assert.ok(lon >= -1 && lon <= 1);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
||||
import { formatStaleAlert, shouldSendStaleAlert } from '../lib/stale-alerts.mjs';
|
||||
|
||||
test('safeFetch reports HTML as degraded JSON response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -100,6 +101,63 @@ test('safeFetchText returns text and byte count', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('stale alert is skipped for fresh health and resets active key', () => {
|
||||
const state = { lastStaleAlertKey: 'old', lastStaleAlertAt: 100 };
|
||||
const decision = shouldSendStaleAlert({ stale: false }, state, { now: 200 });
|
||||
assert.equal(decision.send, false);
|
||||
assert.equal(decision.reason, 'not_stale');
|
||||
assert.equal(state.lastStaleAlertKey, null);
|
||||
});
|
||||
|
||||
test('stale alert sends once and deduplicates during cooldown', () => {
|
||||
const state = {};
|
||||
const health = {
|
||||
stale: true,
|
||||
lastSuccessfulSweep: '2026-05-17T08:00:00.000Z',
|
||||
lastSweepError: 'network timeout',
|
||||
sourcesFailed: 2,
|
||||
sourcesDegraded: 1,
|
||||
};
|
||||
|
||||
const first = shouldSendStaleAlert(health, state, { now: 1_000, cooldownMs: 60_000 });
|
||||
const second = shouldSendStaleAlert(health, state, { now: 2_000, cooldownMs: 60_000 });
|
||||
|
||||
assert.equal(first.send, true);
|
||||
assert.equal(second.send, false);
|
||||
assert.equal(second.reason, 'cooldown');
|
||||
});
|
||||
|
||||
test('stale alert repeats after cooldown', () => {
|
||||
const state = {};
|
||||
const health = { stale: true, lastSuccessfulSweep: 'a', lastSweepError: 'timeout', sourcesFailed: 1 };
|
||||
|
||||
assert.equal(shouldSendStaleAlert(health, state, { now: 1_000, cooldownMs: 60_000 }).send, true);
|
||||
assert.equal(shouldSendStaleAlert(health, state, { now: 62_000, cooldownMs: 60_000 }).send, true);
|
||||
});
|
||||
|
||||
test('stale alert message includes operator context and affected sources', () => {
|
||||
const message = formatStaleAlert({
|
||||
status: 'stale',
|
||||
stale: true,
|
||||
dataAgeSeconds: 7200,
|
||||
lastSuccessfulSweep: '2026-05-17T08:00:00.000Z',
|
||||
lastSweep: '2026-05-17T10:00:00.000Z',
|
||||
lastSweepError: 'GDELT timeout',
|
||||
sourcesOk: 20,
|
||||
sourcesDegraded: 3,
|
||||
sourcesFailed: 2,
|
||||
sourceHealth: [
|
||||
{ name: 'GDELT', status: 'degraded', error: 'timeout' },
|
||||
{ name: 'Reddit', status: 'no_credentials' },
|
||||
],
|
||||
}, { dashboardUrl: 'https://terminal.example.test', context: 'failed sweep' });
|
||||
|
||||
assert.match(message, /CRUCIX STALE DATA ALERT/);
|
||||
assert.match(message, /Data age: 120 minutes/);
|
||||
assert.match(message, /GDELT: degraded \(timeout\)/);
|
||||
assert.match(message, /Dashboard: https:\/\/terminal\.example\.test/);
|
||||
});
|
||||
|
||||
test('scenario watchlist feature is wired into sweep, briefing, and dashboard', () => {
|
||||
const scenarios = readFileSync(new URL('../lib/scenarios.mjs', import.meta.url), 'utf8');
|
||||
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||
|
||||
Reference in New Issue
Block a user