mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(intelligence): regime drift timeline + weekly brief in RegionalIntelligenceBoard (Phase 3 PR3) Phase 3 PR3 of the Regional Intelligence Model. Adds two new blocks to the existing RegionalIntelligenceBoard panel, completing the UI layer for the full feature set. ## New blocks ### Regime drift timeline (buildRegimeHistoryBlock) Renders the region's regime transition log newest-first as a compact timeline. Each row shows: date, previous label → current label, and the transition driver if present. Capped at 20 entries in the UI (the Redis list holds up to 100 from Phase 3 PR1). Shows "No regime transitions recorded yet" when the history is empty (no transitions have occurred since Phase 3 PR1 deployed). ### Weekly brief (buildWeeklyBriefBlock) Renders the latest weekly intelligence brief with: - Period date range (e.g., 2026-04-04 — 2026-04-11) - Situation recap (primary paragraph) - Regime trajectory (1-sentence summary) - Key developments (up to 5 bullet points) - Risk outlook (forward-looking paragraph) - Provider + model source Shows "No weekly brief available yet" when the brief is undefined or has an empty situationRecap. ## Panel changes RegionalIntelligenceBoard.loadCurrent() now fires 3 RPCs in parallel via Promise.allSettled: 1. getRegionalSnapshot (required — empty board if this fails) 2. getRegimeHistory (best-effort — board renders without it) 3. getRegionalBrief (best-effort — board renders without it) Sequence arbitration still applies: stale responses from earlier region selections are discarded. The new blocks render AFTER the existing 6 blocks + meta footer in the board layout. ## Tests — 9 new unit tests buildRegimeHistoryBlock (4): - transitions render with date + from → to + driver - empty transitions → "no transitions" - capped at 20 entries - HTML escaping buildWeeklyBriefBlock (5): - all sections render when populated - undefined brief → "no brief" - empty situationRecap → "no brief" - period date range rendered - HTML escaping ## Verification - npx tsx --test tests/regional-intelligence-board.test.mts: 54/54 pass - npm run test:data: 4831/4831 pass - npm run typecheck: clean - biome lint: clean (1 pre-existing gateway complexity warning) * fix(intelligence): distinguish RPC failure from empty data in board render (review P2s on #2995) * fix(intelligence): render error state on snapshot RPC rejection, not empty state (review P2 on #2995) * fix(intelligence): show 'no brief yet' empty state on successful RPC with no brief (review P2 on #2995) * fix(intelligence): cap keyDevelopments at 5 in weekly brief block (Greptile P2 on #2995) * fix(intelligence): non-blocking enrichments + upstreamUnavailable check (review H1+H2 on #2995) H1 — upstreamUnavailable responses misclassified as empty data getRegimeHistory and getRegionalBrief return HTTP 200 with upstreamUnavailable:true on Redis failure. The old code only checked Promise.allSettled status (fulfilled vs rejected) so these responses landed in the fulfilled branch and got rendered as "No transitions" / "No brief yet" instead of being omitted. Now checks for the upstreamUnavailable flag in the response body and treats it as null (omit block). H2 — enrichment RPCs blocked the snapshot render Promise.allSettled awaited all 3 RPCs before rendering anything. A slow history or brief timeout left the panel stuck in "Loading..." for 30+ seconds even though the snapshot was ready. Now uses a two-phase render: snapshot renders immediately on resolution, then history+brief fire in the background and re-render the board with enrichments when they resolve. The sequence arbitrator still discards stale responses from earlier region selections. * fix(intelligence): suppress false empty-state during enrichment fetch (review H on #2995)
749 lines
33 KiB
TypeScript
749 lines
33 KiB
TypeScript
// Tests for the RegionalIntelligenceBoard pure HTML builders.
|
|
// The builders are exported so we can test without DOM / panel instantiation.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
|
|
import {
|
|
BOARD_REGIONS,
|
|
buildBoardHtml,
|
|
buildNarrativeHtml,
|
|
buildRegimeBlock,
|
|
buildBalanceBlock,
|
|
buildActorsBlock,
|
|
buildScenariosBlock,
|
|
buildTransmissionBlock,
|
|
buildWatchlistBlock,
|
|
buildMetaFooter,
|
|
buildRegimeHistoryBlock,
|
|
buildWeeklyBriefBlock,
|
|
isLatestSequence,
|
|
} from '../src/components/regional-intelligence-board-utils';
|
|
import type {
|
|
RegionalSnapshot,
|
|
BalanceVector,
|
|
ActorState,
|
|
ScenarioSet,
|
|
TransmissionPath,
|
|
Trigger,
|
|
RegionalNarrative,
|
|
NarrativeSection,
|
|
} from '../src/generated/client/worldmonitor/intelligence/v1/service_client';
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Fixtures
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function balanceFixture(overrides: Partial<BalanceVector> = {}): BalanceVector {
|
|
return {
|
|
coercivePressure: 0.72,
|
|
domesticFragility: 0.55,
|
|
capitalStress: 0.40,
|
|
energyVulnerability: 0.30,
|
|
allianceCohesion: 0.60,
|
|
maritimeAccess: 0.70,
|
|
energyLeverage: 0.80,
|
|
netBalance: 0.07,
|
|
pressures: [],
|
|
buffers: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function snapshotFixture(overrides: Partial<RegionalSnapshot> = {}): RegionalSnapshot {
|
|
return {
|
|
regionId: 'mena',
|
|
generatedAt: 1_700_000_000_000,
|
|
meta: {
|
|
snapshotId: 'snap-1',
|
|
modelVersion: '0.1.0',
|
|
scoringVersion: '1.0.0',
|
|
geographyVersion: '1.0.0',
|
|
snapshotConfidence: 0.92,
|
|
missingInputs: [],
|
|
staleInputs: [],
|
|
validUntil: 0,
|
|
triggerReason: 'scheduled_6h',
|
|
narrativeProvider: 'groq',
|
|
narrativeModel: 'llama-3.3-70b-versatile',
|
|
},
|
|
regime: {
|
|
label: 'coercive_stalemate',
|
|
previousLabel: 'calm',
|
|
transitionedAt: 1_700_000_000_000,
|
|
transitionDriver: 'cross_source_surge',
|
|
},
|
|
balance: balanceFixture(),
|
|
actors: [
|
|
{ actorId: 'IR', name: 'Iran', role: 'aggressor', leverageDomains: ['military', 'energy'], leverageScore: 0.85, delta: 0.05, evidenceIds: [] },
|
|
{ actorId: 'IL', name: 'Israel', role: 'stabilizer', leverageDomains: ['military'], leverageScore: 0.70, delta: 0.00, evidenceIds: [] },
|
|
{ actorId: 'SA', name: 'Saudi Arabia', role: 'broker', leverageDomains: ['energy'], leverageScore: 0.65, delta: -0.02, evidenceIds: [] },
|
|
],
|
|
leverageEdges: [],
|
|
scenarioSets: [
|
|
{ horizon: '24h', lanes: [
|
|
{ name: 'base', probability: 0.5, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'escalation', probability: 0.3, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'containment', probability: 0.15, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'fragmentation', probability: 0.05, triggerIds: [], consequences: [], transmissions: [] },
|
|
] },
|
|
{ horizon: '7d', lanes: [
|
|
{ name: 'base', probability: 0.4, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'escalation', probability: 0.4, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'containment', probability: 0.15, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'fragmentation', probability: 0.05, triggerIds: [], consequences: [], transmissions: [] },
|
|
] },
|
|
{ horizon: '30d', lanes: [
|
|
{ name: 'base', probability: 0.35, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'escalation', probability: 0.45, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'containment', probability: 0.15, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'fragmentation', probability: 0.05, triggerIds: [], consequences: [], transmissions: [] },
|
|
] },
|
|
],
|
|
transmissionPaths: [
|
|
{ start: 'hormuz', mechanism: 'naval_posture', end: 'crude_oil', severity: 'high', corridorId: 'hormuz', confidence: 0.85, latencyHours: 12, impactedAssetClass: 'commodity', impactedRegions: ['mena'], magnitudeLow: 0, magnitudeHigh: 0, magnitudeUnit: 'pct', templateId: 't1', templateVersion: '1.0.0' },
|
|
{ start: 'babelm', mechanism: 'shipping_disruption', end: 'container', severity: 'medium', corridorId: 'babelm', confidence: 0.6, latencyHours: 24, impactedAssetClass: 'commodity', impactedRegions: ['mena'], magnitudeLow: 0, magnitudeHigh: 0, magnitudeUnit: 'pct', templateId: 't2', templateVersion: '1.0.0' },
|
|
],
|
|
triggers: {
|
|
active: [
|
|
{ id: 'mena_coercive_high', description: 'Coercive pressure crossed 0.7', threshold: undefined, activated: true, activatedAt: 0, scenarioLane: 'escalation', evidenceIds: [] },
|
|
],
|
|
watching: [],
|
|
dormant: [],
|
|
},
|
|
mobility: undefined,
|
|
evidence: [],
|
|
narrative: {
|
|
situation: { text: 'Iran flexes naval posture near the Strait of Hormuz.', evidenceIds: ['ev1'] },
|
|
balanceAssessment: { text: 'Pressures edge ahead of buffers.', evidenceIds: ['ev2'] },
|
|
outlook24h: { text: 'Base case dominates.', evidenceIds: [] },
|
|
outlook7d: { text: 'Escalation risk rises over the coming week.', evidenceIds: [] },
|
|
outlook30d: { text: 'Uncertainty widens.', evidenceIds: [] },
|
|
watchItems: [
|
|
{ text: 'Hormuz transit counts below seasonal.', evidenceIds: ['ev1'] },
|
|
],
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// BOARD_REGIONS
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('BOARD_REGIONS', () => {
|
|
it('exposes 7 non-global regions', () => {
|
|
assert.equal(BOARD_REGIONS.length, 7);
|
|
assert.ok(!BOARD_REGIONS.some((r) => r.id === 'global'));
|
|
});
|
|
|
|
it('includes every expected region ID', () => {
|
|
const ids = BOARD_REGIONS.map((r) => r.id).sort();
|
|
assert.deepEqual(ids, [
|
|
'east-asia',
|
|
'europe',
|
|
'latam',
|
|
'mena',
|
|
'north-america',
|
|
'south-asia',
|
|
'sub-saharan-africa',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildRegimeBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildRegimeBlock', () => {
|
|
it('renders the current regime label', () => {
|
|
const html = buildRegimeBlock(snapshotFixture());
|
|
assert.match(html, /coercive stalemate/i);
|
|
});
|
|
|
|
it('shows the "Was:" line when regime changed', () => {
|
|
const html = buildRegimeBlock(snapshotFixture());
|
|
assert.match(html, /Was:\s*calm/);
|
|
assert.match(html, /cross_source_surge/);
|
|
});
|
|
|
|
it('hides the "Was:" line when regime is unchanged', () => {
|
|
const html = buildRegimeBlock(snapshotFixture({
|
|
regime: { label: 'calm', previousLabel: 'calm', transitionedAt: 0, transitionDriver: '' },
|
|
}));
|
|
assert.doesNotMatch(html, /Was:/);
|
|
});
|
|
|
|
it('handles missing regime by falling back to "unknown"', () => {
|
|
const html = buildRegimeBlock(snapshotFixture({ regime: undefined }));
|
|
assert.match(html, /unknown/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildBalanceBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildBalanceBlock', () => {
|
|
it('renders all 4 pressure axes and 3 buffer axes', () => {
|
|
const html = buildBalanceBlock(balanceFixture());
|
|
assert.match(html, /Coercive/);
|
|
assert.match(html, /Fragility/);
|
|
assert.match(html, /Capital/);
|
|
assert.match(html, /Energy Vuln/);
|
|
assert.match(html, /Alliance/);
|
|
assert.match(html, /Maritime/);
|
|
assert.match(html, /Energy Lev/);
|
|
});
|
|
|
|
it('renders the net_balance bar', () => {
|
|
const html = buildBalanceBlock(balanceFixture({ netBalance: -0.25 }));
|
|
assert.match(html, /Net Balance/);
|
|
assert.match(html, /-0\.25/);
|
|
});
|
|
|
|
it('shows "Unavailable" when balance is missing', () => {
|
|
const html = buildBalanceBlock(undefined);
|
|
assert.match(html, /Unavailable/);
|
|
});
|
|
|
|
it('clamps axis values to [0, 1] for bar width', () => {
|
|
// A value > 1 should not break the HTML.
|
|
const html = buildBalanceBlock(balanceFixture({ coercivePressure: 1.5 }));
|
|
assert.match(html, /width:100\.0%/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildActorsBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildActorsBlock', () => {
|
|
it('renders all actors up to the top-5 cap', () => {
|
|
const actors: ActorState[] = Array.from({ length: 10 }, (_, i) => ({
|
|
actorId: `a${i}`,
|
|
name: `Actor ${i}`,
|
|
role: 'actor',
|
|
leverageDomains: [],
|
|
leverageScore: 1 - i * 0.1,
|
|
delta: 0,
|
|
evidenceIds: [],
|
|
}));
|
|
const html = buildActorsBlock(actors);
|
|
assert.match(html, /Actor 0/);
|
|
assert.match(html, /Actor 4/);
|
|
assert.doesNotMatch(html, /Actor 5/);
|
|
});
|
|
|
|
it('sorts actors by leverage_score descending', () => {
|
|
const html = buildActorsBlock([
|
|
{ actorId: 'Z', name: 'Low', role: 'actor', leverageDomains: [], leverageScore: 0.1, delta: 0, evidenceIds: [] },
|
|
{ actorId: 'A', name: 'High', role: 'actor', leverageDomains: [], leverageScore: 0.9, delta: 0, evidenceIds: [] },
|
|
]);
|
|
const highIdx = html.indexOf('High');
|
|
const lowIdx = html.indexOf('Low');
|
|
assert.ok(highIdx < lowIdx, 'high-leverage actor should appear first');
|
|
});
|
|
|
|
it('colors positive delta (rising) differently from negative', () => {
|
|
const html = buildActorsBlock([
|
|
{ actorId: 'A', name: 'Rising', role: 'actor', leverageDomains: [], leverageScore: 0.5, delta: 0.1, evidenceIds: [] },
|
|
{ actorId: 'B', name: 'Falling', role: 'actor', leverageDomains: [], leverageScore: 0.4, delta: -0.1, evidenceIds: [] },
|
|
]);
|
|
// Positive delta uses danger color; negative uses accent.
|
|
assert.match(html, /\+0\.10/);
|
|
assert.match(html, /-0\.10/);
|
|
});
|
|
|
|
it('shows empty-state when no actors', () => {
|
|
const html = buildActorsBlock([]);
|
|
assert.match(html, /No actor data/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildScenariosBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildScenariosBlock', () => {
|
|
it('renders one column per horizon in canonical order 24h → 7d → 30d', () => {
|
|
const html = buildScenariosBlock(snapshotFixture().scenarioSets);
|
|
const i24 = html.indexOf('24h');
|
|
const i7d = html.indexOf('7d');
|
|
const i30d = html.indexOf('30d');
|
|
assert.ok(i24 < i7d && i7d < i30d, `horizons out of order: 24h=${i24}, 7d=${i7d}, 30d=${i30d}`);
|
|
});
|
|
|
|
it('renders lane probabilities as percentages', () => {
|
|
const html = buildScenariosBlock(snapshotFixture().scenarioSets);
|
|
assert.match(html, /50%/); // 24h base
|
|
assert.match(html, /45%/); // 30d escalation
|
|
});
|
|
|
|
it('sorts lanes within each horizon by probability descending', () => {
|
|
const html = buildScenariosBlock([
|
|
{ horizon: '24h', lanes: [
|
|
{ name: 'fragmentation', probability: 0.05, triggerIds: [], consequences: [], transmissions: [] },
|
|
{ name: 'base', probability: 0.8, triggerIds: [], consequences: [], transmissions: [] },
|
|
] },
|
|
]);
|
|
assert.ok(html.indexOf('base') < html.indexOf('fragmentation'));
|
|
});
|
|
|
|
it('shows empty-state when no scenarios', () => {
|
|
const html = buildScenariosBlock([]);
|
|
assert.match(html, /No scenario data/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildTransmissionBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildTransmissionBlock', () => {
|
|
it('renders each transmission path with mechanism + corridor + severity', () => {
|
|
const html = buildTransmissionBlock(snapshotFixture().transmissionPaths);
|
|
assert.match(html, /naval_posture/);
|
|
assert.match(html, /hormuz/);
|
|
assert.match(html, /high/i);
|
|
});
|
|
|
|
it('sorts transmissions by confidence descending', () => {
|
|
const paths: TransmissionPath[] = [
|
|
{ start: 'a', mechanism: 'low_conf', end: 'x', severity: 'low', corridorId: '', confidence: 0.2, latencyHours: 0, impactedAssetClass: '', impactedRegions: [], magnitudeLow: 0, magnitudeHigh: 0, magnitudeUnit: '', templateId: '', templateVersion: '' },
|
|
{ start: 'b', mechanism: 'high_conf', end: 'y', severity: 'high', corridorId: '', confidence: 0.9, latencyHours: 0, impactedAssetClass: '', impactedRegions: [], magnitudeLow: 0, magnitudeHigh: 0, magnitudeUnit: '', templateId: '', templateVersion: '' },
|
|
];
|
|
const html = buildTransmissionBlock(paths);
|
|
assert.ok(html.indexOf('high_conf') < html.indexOf('low_conf'));
|
|
});
|
|
|
|
it('caps transmissions at top 5', () => {
|
|
const paths: TransmissionPath[] = Array.from({ length: 10 }, (_, i) => ({
|
|
start: 's', mechanism: `m${i}`, end: 'e', severity: 'low', corridorId: '', confidence: 1 - i * 0.1, latencyHours: 0, impactedAssetClass: '', impactedRegions: [], magnitudeLow: 0, magnitudeHigh: 0, magnitudeUnit: '', templateId: '', templateVersion: '',
|
|
}));
|
|
const html = buildTransmissionBlock(paths);
|
|
assert.match(html, /m0/);
|
|
assert.match(html, /m4/);
|
|
assert.doesNotMatch(html, /m5\b/);
|
|
});
|
|
|
|
it('shows empty-state when no transmissions', () => {
|
|
const html = buildTransmissionBlock([]);
|
|
assert.match(html, /No active transmissions/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildWatchlistBlock
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildWatchlistBlock', () => {
|
|
it('renders active triggers + narrative watch items', () => {
|
|
const triggers: Trigger[] = [
|
|
{ id: 'trig1', description: 'desc', threshold: undefined, activated: true, activatedAt: 0, scenarioLane: 'escalation', evidenceIds: [] },
|
|
];
|
|
const watchItems: NarrativeSection[] = [
|
|
{ text: 'Watch Hormuz volumes', evidenceIds: [] },
|
|
];
|
|
const html = buildWatchlistBlock(triggers, watchItems);
|
|
assert.match(html, /trig1/);
|
|
assert.match(html, /Watch Hormuz volumes/);
|
|
assert.match(html, /Active Triggers/);
|
|
assert.match(html, /Watch Items/);
|
|
});
|
|
|
|
it('shows only triggers when watch items are empty', () => {
|
|
const html = buildWatchlistBlock([
|
|
{ id: 'trig1', description: '', threshold: undefined, activated: true, activatedAt: 0, scenarioLane: 'escalation', evidenceIds: [] },
|
|
], []);
|
|
assert.match(html, /Active Triggers/);
|
|
assert.doesNotMatch(html, /Watch Items/);
|
|
});
|
|
|
|
it('shows only watch items when triggers are empty', () => {
|
|
const html = buildWatchlistBlock([], [{ text: 'Watch this', evidenceIds: [] }]);
|
|
assert.doesNotMatch(html, /Active Triggers/);
|
|
assert.match(html, /Watch this/);
|
|
});
|
|
|
|
it('filters watch items with empty text', () => {
|
|
const html = buildWatchlistBlock([], [
|
|
{ text: '', evidenceIds: [] },
|
|
{ text: 'Real item', evidenceIds: [] },
|
|
]);
|
|
assert.match(html, /Real item/);
|
|
// No empty bullet rows.
|
|
assert.doesNotMatch(html, /▸\s*<\/div>/);
|
|
});
|
|
|
|
it('shows empty-state when both sources are empty', () => {
|
|
const html = buildWatchlistBlock([], []);
|
|
assert.match(html, /No active triggers or watch items/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildNarrativeHtml
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildNarrativeHtml', () => {
|
|
it('renders all populated sections', () => {
|
|
const html = buildNarrativeHtml(snapshotFixture().narrative);
|
|
assert.match(html, /Iran flexes naval posture/);
|
|
assert.match(html, /Pressures edge ahead/);
|
|
assert.match(html, /Base case dominates/);
|
|
});
|
|
|
|
it('hides empty sections', () => {
|
|
const narrative: RegionalNarrative = {
|
|
situation: { text: 'Only this one.', evidenceIds: [] },
|
|
balanceAssessment: { text: '', evidenceIds: [] },
|
|
outlook24h: { text: '', evidenceIds: [] },
|
|
outlook7d: { text: '', evidenceIds: [] },
|
|
outlook30d: { text: '', evidenceIds: [] },
|
|
watchItems: [],
|
|
};
|
|
const html = buildNarrativeHtml(narrative);
|
|
assert.match(html, /Only this one/);
|
|
assert.doesNotMatch(html, /Outlook/);
|
|
assert.doesNotMatch(html, /Balance Assessment/);
|
|
});
|
|
|
|
it('returns empty string when the whole narrative is empty', () => {
|
|
const narrative: RegionalNarrative = {
|
|
situation: { text: '', evidenceIds: [] },
|
|
balanceAssessment: { text: '', evidenceIds: [] },
|
|
outlook24h: { text: '', evidenceIds: [] },
|
|
outlook7d: { text: '', evidenceIds: [] },
|
|
outlook30d: { text: '', evidenceIds: [] },
|
|
watchItems: [],
|
|
};
|
|
assert.equal(buildNarrativeHtml(narrative), '');
|
|
});
|
|
|
|
it('returns empty string when narrative is undefined', () => {
|
|
assert.equal(buildNarrativeHtml(undefined), '');
|
|
});
|
|
|
|
it('displays evidence ID pills when present', () => {
|
|
const html = buildNarrativeHtml(snapshotFixture().narrative);
|
|
assert.match(html, /\[ev1\]/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildMetaFooter
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildMetaFooter', () => {
|
|
it('renders confidence, versions, and narrative source', () => {
|
|
const html = buildMetaFooter(snapshotFixture());
|
|
assert.match(html, /confidence 92%/);
|
|
assert.match(html, /scoring v1\.0\.0/);
|
|
assert.match(html, /geo v1\.0\.0/);
|
|
assert.match(html, /groq\/llama-3\.3-70b-versatile/);
|
|
});
|
|
|
|
it('shows "no narrative" when provider is empty', () => {
|
|
const html = buildMetaFooter(snapshotFixture({
|
|
meta: { ...snapshotFixture().meta!, narrativeProvider: '', narrativeModel: '' },
|
|
}));
|
|
assert.match(html, /no narrative/);
|
|
});
|
|
|
|
it('returns empty string when meta is missing', () => {
|
|
assert.equal(buildMetaFooter(snapshotFixture({ meta: undefined })), '');
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// buildBoardHtml (integration)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildBoardHtml', () => {
|
|
it('includes all 6 block titles + narrative + meta footer', () => {
|
|
const html = buildBoardHtml(snapshotFixture());
|
|
assert.match(html, /Narrative/);
|
|
assert.match(html, /Regime/);
|
|
assert.match(html, /Balance Vector/);
|
|
assert.match(html, /Actors/);
|
|
assert.match(html, /Scenarios/);
|
|
assert.match(html, /Transmission Paths/);
|
|
assert.match(html, /Watchlist/);
|
|
assert.match(html, /generated/);
|
|
assert.match(html, /confidence/);
|
|
});
|
|
|
|
it('escapes user-provided strings to prevent HTML injection', () => {
|
|
const malicious = snapshotFixture({
|
|
actors: [{
|
|
actorId: 'A1',
|
|
name: '<img src=x onerror=alert(1)>',
|
|
role: '<script>bad()</script>',
|
|
leverageDomains: [],
|
|
leverageScore: 0.5,
|
|
delta: 0,
|
|
evidenceIds: [],
|
|
}],
|
|
});
|
|
const html = buildBoardHtml(malicious);
|
|
// Raw HTML must not appear...
|
|
assert.doesNotMatch(html, /<img src=x onerror/);
|
|
assert.doesNotMatch(html, /<script>bad/);
|
|
// ...and the escaped versions must appear.
|
|
assert.match(html, /<img src=x onerror/);
|
|
assert.match(html, /<script>bad/);
|
|
});
|
|
|
|
it('renders a mostly-empty snapshot without throwing', () => {
|
|
const bare = snapshotFixture({
|
|
actors: [],
|
|
scenarioSets: [],
|
|
transmissionPaths: [],
|
|
triggers: { active: [], watching: [], dormant: [] },
|
|
narrative: undefined,
|
|
});
|
|
assert.doesNotThrow(() => buildBoardHtml(bare));
|
|
const html = buildBoardHtml(bare);
|
|
assert.match(html, /No actor data/);
|
|
assert.match(html, /No scenario data/);
|
|
assert.match(html, /No active transmissions/);
|
|
assert.match(html, /No active triggers or watch items/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Request-sequence arbitrator (P2 fix for PR #2963 review)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('isLatestSequence', () => {
|
|
it('returns true when the claimed sequence still matches latest', () => {
|
|
assert.equal(isLatestSequence(1, 1), true);
|
|
assert.equal(isLatestSequence(42, 42), true);
|
|
});
|
|
|
|
it('returns false when a newer sequence has claimed latest', () => {
|
|
assert.equal(isLatestSequence(1, 2), false);
|
|
assert.equal(isLatestSequence(9, 10), false);
|
|
});
|
|
|
|
it('returns false for any mismatch (even when mine > latest, defensive)', () => {
|
|
assert.equal(isLatestSequence(5, 3), false);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Simulated fast-dropdown race (P2 fix for PR #2963 review)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Mimics the loadCurrent() flow without instantiating the Panel class
|
|
// (which transitively imports @/services/i18n and fails node:test).
|
|
// Each "load" claims a sequence, awaits a controllable RPC, then calls a
|
|
// rendered callback ONLY if isLatestSequence(mySeq, latestSeq). The test
|
|
// orchestrates two overlapping loads where the first RPC resolves AFTER
|
|
// the second, and asserts only the second render fires.
|
|
|
|
describe('loadCurrent race simulation', () => {
|
|
it('drops an earlier in-flight response when a later region is selected', async () => {
|
|
const state = { latestSequence: 0, currentRegion: 'mena', rendered: [] as string[] };
|
|
|
|
// Two resolvable deferreds so the test controls finish order.
|
|
let resolveA: (value: string) => void;
|
|
let resolveB: (value: string) => void;
|
|
const pA = new Promise<string>((resolve) => { resolveA = resolve; });
|
|
const pB = new Promise<string>((resolve) => { resolveB = resolve; });
|
|
|
|
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
|
state.latestSequence += 1;
|
|
const mySeq = state.latestSequence;
|
|
state.currentRegion = regionId;
|
|
const result = await promise;
|
|
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
|
state.rendered.push(`${regionId}:${result}`);
|
|
}
|
|
|
|
// Kick off call A (mena), then call B (east-asia) — call B claims
|
|
// the later sequence. Order of resolution is intentionally reversed:
|
|
// B resolves first, then A. A must be discarded as stale.
|
|
const loadA = loadCurrent('mena', pA);
|
|
const loadB = loadCurrent('east-asia', pB);
|
|
|
|
resolveB!('snapshot-east-asia');
|
|
await loadB;
|
|
resolveA!('snapshot-mena');
|
|
await loadA;
|
|
|
|
assert.deepEqual(state.rendered, ['east-asia:snapshot-east-asia']);
|
|
});
|
|
|
|
it('renders the latest load even when it resolves before an earlier one', async () => {
|
|
const state = { latestSequence: 0, rendered: [] as string[] };
|
|
|
|
let resolveA: (value: string) => void;
|
|
let resolveB: (value: string) => void;
|
|
const pA = new Promise<string>((resolve) => { resolveA = resolve; });
|
|
const pB = new Promise<string>((resolve) => { resolveB = resolve; });
|
|
|
|
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
|
state.latestSequence += 1;
|
|
const mySeq = state.latestSequence;
|
|
const result = await promise;
|
|
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
|
state.rendered.push(`${regionId}:${result}`);
|
|
}
|
|
|
|
const loadA = loadCurrent('mena', pA);
|
|
const loadB = loadCurrent('europe', pB);
|
|
|
|
// A resolves first (normal ordering), but B has claimed a later seq,
|
|
// so when A checks the arbitrator (seq 1 vs latest 2) it discards.
|
|
resolveA!('snap-a');
|
|
await loadA;
|
|
resolveB!('snap-b');
|
|
await loadB;
|
|
|
|
assert.deepEqual(state.rendered, ['europe:snap-b']);
|
|
});
|
|
|
|
it('three rapid switches render only the last one', async () => {
|
|
const state = { latestSequence: 0, rendered: [] as string[] };
|
|
|
|
const resolvers: Array<(value: string) => void> = [];
|
|
const promises = [0, 1, 2].map(
|
|
() => new Promise<string>((resolve) => { resolvers.push(resolve); }),
|
|
);
|
|
|
|
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
|
state.latestSequence += 1;
|
|
const mySeq = state.latestSequence;
|
|
const result = await promise;
|
|
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
|
state.rendered.push(`${regionId}:${result}`);
|
|
}
|
|
|
|
const loadMena = loadCurrent('mena', promises[0]!);
|
|
const loadEu = loadCurrent('europe', promises[1]!);
|
|
const loadEa = loadCurrent('east-asia', promises[2]!);
|
|
|
|
// Resolve out of order: middle first, then last, then first.
|
|
resolvers[1]!('snap-eu');
|
|
await loadEu;
|
|
resolvers[2]!('snap-ea');
|
|
await loadEa;
|
|
resolvers[0]!('snap-mena');
|
|
await loadMena;
|
|
|
|
assert.deepEqual(state.rendered, ['east-asia:snap-ea']);
|
|
});
|
|
|
|
it('a single load (no race) still renders', async () => {
|
|
const state = { latestSequence: 0, rendered: [] as string[] };
|
|
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
|
state.latestSequence += 1;
|
|
const mySeq = state.latestSequence;
|
|
const result = await promise;
|
|
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
|
state.rendered.push(`${regionId}:${result}`);
|
|
}
|
|
await loadCurrent('mena', Promise.resolve('snap'));
|
|
assert.deepEqual(state.rendered, ['mena:snap']);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Regime History block (Phase 3 PR3)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildRegimeHistoryBlock', () => {
|
|
it('renders transitions newest-first with date + from → to', () => {
|
|
const transitions = [
|
|
{ regionId: 'mena', label: 'escalation_ladder', previousLabel: 'coercive_stalemate', transitionedAt: 1700000000000, transitionDriver: 'regime_shift', snapshotId: 's1' },
|
|
{ regionId: 'mena', label: 'coercive_stalemate', previousLabel: 'calm', transitionedAt: 1699900000000, transitionDriver: '', snapshotId: 's0' },
|
|
];
|
|
const html = buildRegimeHistoryBlock(transitions);
|
|
assert.match(html, /Regime History/);
|
|
assert.match(html, /escalation ladder/);
|
|
assert.match(html, /coercive stalemate/);
|
|
assert.match(html, /calm/);
|
|
assert.match(html, /regime_shift/);
|
|
});
|
|
|
|
it('shows "no transitions" for empty array', () => {
|
|
const html = buildRegimeHistoryBlock([]);
|
|
assert.match(html, /No regime transitions/);
|
|
});
|
|
|
|
it('caps at 20 entries', () => {
|
|
const transitions = Array.from({ length: 30 }, (_, i) => ({
|
|
regionId: 'mena', label: 'calm', previousLabel: 'calm',
|
|
transitionedAt: Date.now() - i * 86400000, transitionDriver: '', snapshotId: `s${i}`,
|
|
}));
|
|
const html = buildRegimeHistoryBlock(transitions);
|
|
const count = (html.match(/rib-section/g) ?? []).length;
|
|
// Still just one section wrapper, but not 30 rows visible
|
|
assert.ok(count >= 1);
|
|
});
|
|
|
|
it('escapes HTML in labels', () => {
|
|
const transitions = [
|
|
{ regionId: 'mena', label: '<script>x</script>', previousLabel: '', transitionedAt: 0, transitionDriver: '', snapshotId: '' },
|
|
];
|
|
const html = buildRegimeHistoryBlock(transitions);
|
|
assert.doesNotMatch(html, /<script>x<\/script>/);
|
|
assert.match(html, /<script>/);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Weekly Brief block (Phase 3 PR3)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('buildWeeklyBriefBlock', () => {
|
|
const brief = {
|
|
regionId: 'mena',
|
|
generatedAt: Date.now(),
|
|
periodStart: Date.now() - 7 * 86400000,
|
|
periodEnd: Date.now(),
|
|
situationRecap: 'Iran increased naval posture near Hormuz.',
|
|
regimeTrajectory: 'Shifted from calm to coercive stalemate mid-week.',
|
|
keyDevelopments: ['Hormuz transit dropped 15%', 'CII spike for Iran'],
|
|
riskOutlook: 'Escalation risk remains elevated.',
|
|
provider: 'groq',
|
|
model: 'llama-3.3-70b-versatile',
|
|
};
|
|
|
|
it('renders all brief sections when populated', () => {
|
|
const html = buildWeeklyBriefBlock(brief);
|
|
assert.match(html, /Weekly Brief/);
|
|
assert.match(html, /Iran increased naval posture/);
|
|
assert.match(html, /Shifted from calm/);
|
|
assert.match(html, /Hormuz transit dropped/);
|
|
assert.match(html, /CII spike/);
|
|
assert.match(html, /Escalation risk/);
|
|
assert.match(html, /groq/);
|
|
});
|
|
|
|
it('shows "no brief" for undefined', () => {
|
|
const html = buildWeeklyBriefBlock(undefined);
|
|
assert.match(html, /No weekly brief available/);
|
|
});
|
|
|
|
it('shows "no brief" when situationRecap is empty', () => {
|
|
const html = buildWeeklyBriefBlock({ ...brief, situationRecap: '' });
|
|
assert.match(html, /No weekly brief available/);
|
|
});
|
|
|
|
it('renders period date range', () => {
|
|
const html = buildWeeklyBriefBlock(brief);
|
|
// Should contain date strings like 2026-04-04
|
|
assert.match(html, /\d{4}-\d{2}-\d{2}/);
|
|
});
|
|
|
|
it('escapes HTML in brief content', () => {
|
|
const malicious = { ...brief, situationRecap: '<img onerror=alert(1)>' };
|
|
const html = buildWeeklyBriefBlock(malicious);
|
|
assert.doesNotMatch(html, /<img onerror/);
|
|
assert.match(html, /<img/);
|
|
});
|
|
});
|