diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 2e545f314..6b1e5fc67 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -836,6 +836,24 @@ export class PanelLayoutManager implements AppModule { this.updatePanelGating(getAuthState()); }); + import('@/components/RegionalIntelligenceBoard').then(({ RegionalIntelligenceBoard }) => { + const regionalBoard = new RegionalIntelligenceBoard(); + this.ctx.panels['regional-intelligence'] = regionalBoard; + const el = regionalBoard.getElement(); + this.makeDraggable(el, 'regional-intelligence'); + const grid = document.getElementById('panelsGrid'); + if (grid) { + const deductionEl = this.ctx.panels['deduction']?.getElement(); + if (deductionEl?.parentNode === grid && deductionEl.nextSibling) { + grid.insertBefore(el, deductionEl.nextSibling); + } else { + grid.appendChild(el); + } + } + this.applyPanelSettings(); + this.updatePanelGating(getAuthState()); + }); + if (this.shouldCreatePanel('cii')) { const ciiPanel = new CIIPanel(); ciiPanel.setShareStoryHandler((code, name) => { diff --git a/src/components/RegionalIntelligenceBoard.ts b/src/components/RegionalIntelligenceBoard.ts new file mode 100644 index 000000000..b089ee953 --- /dev/null +++ b/src/components/RegionalIntelligenceBoard.ts @@ -0,0 +1,133 @@ +import { Panel } from './Panel'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { IntelligenceServiceClient } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; +import type { RegionalSnapshot } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; +import { h, replaceChildren } from '@/utils/dom-utils'; +import { escapeHtml } from '@/utils/sanitize'; +import { BOARD_REGIONS, DEFAULT_REGION_ID, buildBoardHtml, isLatestSequence } from './regional-intelligence-board-utils'; + +const client = new IntelligenceServiceClient(getRpcBaseUrl(), { + fetch: (...args) => globalThis.fetch(...args), +}); + +/** + * RegionalIntelligenceBoard — premium panel rendering a canonical + * RegionalSnapshot as 6 structured blocks plus narrative sections. + * + * Blocks: + * 1. Regime — current label, previous label, transition driver + * 2. Balance — 7 axes + net_balance bar chart + * 3. Actors — top 5 actors by leverage score with deltas + * 4. Scenarios — 3 horizons × 4 lanes (probability bars) + * 5. Transmission — top 5 transmission paths + * 6. Watchlist — active triggers + narrative watch_items + * + * Narrative sections (situation, balance_assessment, outlook 24h/7d/30d) + * render inline above the blocks when populated by the seed's LLM layer. + * Empty narrative fields are hidden rather than showing empty placeholders. + * + * Data source: /api/intelligence/v1/get-regional-snapshot (premium-gated). + * One call per region change; no polling. Results are cached by the gateway. + * + * All HTML builders live in regional-intelligence-board-utils.ts so they can + * be imported by node:test runners without pulling in Vite-only services. + */ +export class RegionalIntelligenceBoard extends Panel { + private selector: HTMLSelectElement; + private body: HTMLElement; + private currentRegion: string = DEFAULT_REGION_ID; + /** + * Monotonically-increasing request sequence. Each `loadCurrent()` call + * claims a new sequence before it awaits the RPC; when the response comes + * back, it renders ONLY if its sequence still matches `latestSequence`. + * Earlier in-flight fetches whose user has already moved on are discarded. + * Replaces a naive `loading` boolean that used to drop rapid region + * switches — see PR #2963 review. + */ + private latestSequence = 0; + + constructor() { + super({ + id: 'regional-intelligence', + title: 'Regional Intelligence', + infoTooltip: + 'Canonical regional intelligence brief: regime label, 7-axis balance vector, top actors, scenario lanes, transmission paths, and watchlist. One snapshot per region, refreshed every 6 hours.', + premium: 'locked', + }); + + this.selector = h('select', { + className: 'rib-region-selector', + 'aria-label': 'Region', + }) as HTMLSelectElement; + for (const r of BOARD_REGIONS) { + const opt = document.createElement('option'); + opt.value = r.id; + opt.textContent = r.label; + if (r.id === DEFAULT_REGION_ID) opt.selected = true; + this.selector.appendChild(opt); + } + this.selector.addEventListener('change', () => { + this.currentRegion = this.selector.value; + void this.loadCurrent(); + }); + + const controls = h('div', { className: 'rib-controls' }, this.selector); + this.body = h('div', { className: 'rib-body' }); + + replaceChildren(this.content, h('div', { className: 'rib-shell' }, controls, this.body)); + + this.renderLoading(); + void this.loadCurrent(); + } + + /** Public API for tests and agent tools: force-load a region directly. */ + public async loadRegion(regionId: string): Promise { + this.currentRegion = regionId; + this.selector.value = regionId; + await this.loadCurrent(); + } + + private async loadCurrent(): Promise { + // Claim a sequence number BEFORE we await anything. The latest claim + // wins — any response from an earlier sequence is dropped so fast + // dropdown switches can't leave the panel rendering a stale region. + this.latestSequence += 1; + const mySequence = this.latestSequence; + const myRegion = this.currentRegion; + this.renderLoading(); + + try { + const resp = await client.getRegionalSnapshot({ regionId: myRegion }); + if (!isLatestSequence(mySequence, this.latestSequence)) return; + const snapshot = resp.snapshot; + if (!snapshot?.regionId) { + this.renderEmpty(); + return; + } + this.renderBoard(snapshot); + } catch (err) { + if (!isLatestSequence(mySequence, this.latestSequence)) return; + console.error('[RegionalIntelligenceBoard] load failed', err); + this.renderError(err instanceof Error ? err.message : String(err)); + } + } + + private renderLoading(): void { + this.body.innerHTML = + '
Loading regional snapshot…
'; + } + + private renderEmpty(): void { + this.body.innerHTML = + '
No snapshot available yet for this region. The next cron cycle will populate it within 6 hours.
'; + } + + private renderError(message: string): void { + this.body.innerHTML = `
Failed to load snapshot: ${escapeHtml(message)}
`; + } + + /** Render the full board HTML from a hydrated snapshot. Public for tests. */ + public renderBoard(snapshot: RegionalSnapshot): void { + this.body.innerHTML = buildBoardHtml(snapshot); + } +} diff --git a/src/components/regional-intelligence-board-utils.ts b/src/components/regional-intelligence-board-utils.ts new file mode 100644 index 000000000..6fd7ee4c4 --- /dev/null +++ b/src/components/regional-intelligence-board-utils.ts @@ -0,0 +1,375 @@ +// Pure HTML builders for RegionalIntelligenceBoard. Kept dependency-free +// (only escapeHtml from sanitize) so it can be imported by node:test runners +// without pulling in Vite-only services like @/services/i18n. +// +// The Panel class in RegionalIntelligenceBoard.ts is a thin wrapper that +// calls these builders and inserts the result via Panel.setContent(). + +import { escapeHtml } from '@/utils/sanitize'; +import type { + RegionalSnapshot, + BalanceVector, + ActorState, + ScenarioSet, + TransmissionPath, + Trigger, + NarrativeSection, + RegionalNarrative, +} from '@/generated/client/worldmonitor/intelligence/v1/service_client'; + +/** Non-global regions available in the dropdown. Matches shared/geography.js REGIONS. */ +export const BOARD_REGIONS: ReadonlyArray<{ id: string; label: string }> = [ + { id: 'mena', label: 'Middle East & North Africa' }, + { id: 'east-asia', label: 'East Asia & Pacific' }, + { id: 'europe', label: 'Europe & Central Asia' }, + { id: 'north-america', label: 'North America' }, + { id: 'south-asia', label: 'South Asia' }, + { id: 'latam', label: 'Latin America & Caribbean' }, + { id: 'sub-saharan-africa', label: 'Sub-Saharan Africa' }, +]; + +export const DEFAULT_REGION_ID = 'mena'; + +// ──────────────────────────────────────────────────────────────────────────── +// Request-sequence arbitrator (race condition fix for PR #2963 review) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Request-sequence arbitrator. The panel's loadCurrent() claims a monotonic + * sequence before awaiting its RPC; when the response comes back it passes + * (mySequence, latestSequence) to this helper and only renders when it wins. + * + * A rapid dropdown switch therefore goes: seq=1 claims → seq=2 claims → + * seq=1 returns, stale (1 !== 2), discarded → seq=2 returns, fresh, renders. + * Without this check the earlier in-flight response could overwrite the + * newer region's render. + * + * Pure — exported only so it can be unit tested in isolation from the + * Panel class (which can't be imported by node:test due to import.meta.glob + * in @/services/i18n). + */ +export function isLatestSequence(mySequence: number, latestSequence: number): boolean { + return mySequence === latestSequence; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Top-level +// ──────────────────────────────────────────────────────────────────────────── + +/** Build the complete board HTML from a hydrated snapshot. Pure. */ +export function buildBoardHtml(snapshot: RegionalSnapshot): string { + return [ + buildNarrativeHtml(snapshot.narrative), + buildRegimeBlock(snapshot), + buildBalanceBlock(snapshot.balance), + buildActorsBlock(snapshot.actors), + buildScenariosBlock(snapshot.scenarioSets), + buildTransmissionBlock(snapshot.transmissionPaths), + buildWatchlistBlock(snapshot.triggers?.active ?? [], snapshot.narrative?.watchItems ?? []), + buildMetaFooter(snapshot), + ].join(''); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Section wrappers +// ──────────────────────────────────────────────────────────────────────────── + +function section(title: string, bodyHtml: string, extraStyle = ''): string { + return ` +
+
${escapeHtml(title)}
+ ${bodyHtml} +
+ `; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Narrative +// ──────────────────────────────────────────────────────────────────────────── + +function narrativeSectionHtml(label: string, sec: NarrativeSection | undefined): string { + const text = (sec?.text ?? '').trim(); + if (!text) return ''; + const evidence = (sec?.evidenceIds ?? []).filter((id) => id.length > 0); + const evidencePill = evidence.length > 0 + ? `[${escapeHtml(evidence.slice(0, 4).join(', '))}]` + : ''; + return ` +
+
${escapeHtml(label)}${evidencePill}
+
${escapeHtml(text)}
+
+ `; +} + +export function buildNarrativeHtml(narrative: RegionalNarrative | undefined): string { + if (!narrative) return ''; + const rows = [ + narrativeSectionHtml('Situation', narrative.situation), + narrativeSectionHtml('Balance Assessment', narrative.balanceAssessment), + narrativeSectionHtml('Outlook — 24h', narrative.outlook24h), + narrativeSectionHtml('Outlook — 7d', narrative.outlook7d), + narrativeSectionHtml('Outlook — 30d', narrative.outlook30d), + ].join(''); + if (!rows) return ''; + return section('Narrative', rows); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Regime +// ──────────────────────────────────────────────────────────────────────────── + +export function buildRegimeBlock(snapshot: RegionalSnapshot): string { + const regime = snapshot.regime; + const label = regime?.label ?? 'unknown'; + const previous = regime?.previousLabel ?? ''; + const driver = regime?.transitionDriver ?? ''; + const changed = previous && previous !== label; + const previousLine = changed + ? `
Was: ${escapeHtml(previous)}${driver ? ` · ${escapeHtml(driver)}` : ''}
` + : ''; + const body = ` +
${escapeHtml(label.replace(/_/g, ' '))}
+ ${previousLine} + `; + return section('Regime', body); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Balance +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Render a single axis row with label, value text, and horizontal bar. + * Values are already in [0, 1] for axes; net_balance is in [-1, 1] and + * is rendered separately with a centered zero point. + */ +function axisRow(label: string, value: number, colorClass: string): string { + const pct = Math.max(0, Math.min(1, value)) * 100; + return ` +
+
${escapeHtml(label)}
+
${value.toFixed(2)}
+
+
+
+
+ `; +} + +export function buildBalanceBlock(balance: BalanceVector | undefined): string { + if (!balance) { + return section('Balance Vector', '
Unavailable
'); + } + const pressures = [ + axisRow('Coercive', balance.coercivePressure, '--danger'), + axisRow('Fragility', balance.domesticFragility, '--danger'), + axisRow('Capital', balance.capitalStress, '--danger'), + axisRow('Energy Vuln', balance.energyVulnerability, '--danger'), + ].join(''); + const buffers = [ + axisRow('Alliance', balance.allianceCohesion, '--accent'), + axisRow('Maritime', balance.maritimeAccess, '--accent'), + axisRow('Energy Lev', balance.energyLeverage, '--accent'), + ].join(''); + + const net = balance.netBalance; + const netPct = Math.max(-1, Math.min(1, net)); + const netFill = Math.abs(netPct) * 50; + const netSide = netPct >= 0 ? 'right' : 'left'; + const netColor = netPct >= 0 ? 'var(--accent)' : 'var(--danger)'; + const netBar = ` +
+
Net Balance
+
${net.toFixed(2)}
+
+
+
+
+
+ `; + + const body = ` +
+
+
Pressures
+ ${pressures} +
+
+
Buffers
+ ${buffers} +
+
+ ${netBar} + `; + return section('Balance Vector', body); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Actors +// ──────────────────────────────────────────────────────────────────────────── + +export function buildActorsBlock(actors: ActorState[]): string { + if (!actors || actors.length === 0) { + return section('Actors', '
No actor data
'); + } + const sorted = [...actors].sort((a, b) => (b.leverageScore ?? 0) - (a.leverageScore ?? 0)).slice(0, 5); + const rows = sorted.map((a) => { + const deltaText = a.delta > 0 ? `+${a.delta.toFixed(2)}` : a.delta.toFixed(2); + const deltaColor = a.delta > 0 ? 'var(--danger)' : a.delta < 0 ? 'var(--accent)' : 'var(--text-dim)'; + const domains = (a.leverageDomains ?? []).slice(0, 3).join(', '); + return ` +
+
+
${escapeHtml(a.name || a.actorId)}
+
${escapeHtml(a.role || 'actor')}${domains ? ` · ${escapeHtml(domains)}` : ''}
+
+
${(a.leverageScore ?? 0).toFixed(2)}
+
${escapeHtml(deltaText)}
+
+ `; + }).join(''); + return section('Actors', rows); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Scenarios +// ──────────────────────────────────────────────────────────────────────────── + +export function buildScenariosBlock(scenarioSets: ScenarioSet[]): string { + if (!scenarioSets || scenarioSets.length === 0) { + return section('Scenarios', '
No scenario data
'); + } + // Sort by canonical horizon order. + const order: Record = { '24h': 0, '7d': 1, '30d': 2 }; + const sorted = [...scenarioSets].sort((a, b) => (order[a.horizon] ?? 99) - (order[b.horizon] ?? 99)); + const laneColor: Record = { + base: 'var(--text-dim)', + escalation: 'var(--danger)', + containment: 'var(--accent)', + fragmentation: 'var(--warning, #e0a020)', + }; + const cols = sorted.map((set) => { + const lanes = [...(set.lanes ?? [])].sort((a, b) => b.probability - a.probability); + const lanesHtml = lanes.map((l) => { + const pct = Math.round((l.probability ?? 0) * 100); + const color = laneColor[l.name] ?? 'var(--text-dim)'; + return ` +
+
+ ${escapeHtml(l.name)} + ${pct}% +
+
+
+
+
+ `; + }).join(''); + return ` +
+
${escapeHtml(set.horizon)}
+ ${lanesHtml} +
+ `; + }).join(''); + const body = `
${cols}
`; + return section('Scenarios', body); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Transmission paths +// ──────────────────────────────────────────────────────────────────────────── + +function severityColor(severity: string): string { + switch ((severity ?? '').toLowerCase()) { + case 'critical': return 'var(--danger)'; + case 'high': return 'var(--danger)'; + case 'medium': return 'var(--warning, #e0a020)'; + case 'low': return 'var(--text-dim)'; + default: return 'var(--text-dim)'; + } +} + +export function buildTransmissionBlock(paths: TransmissionPath[]): string { + if (!paths || paths.length === 0) { + return section('Transmission Paths', '
No active transmissions
'); + } + const sorted = [...paths] + .sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)) + .slice(0, 5); + const rows = sorted.map((p) => { + const color = severityColor(p.severity); + const corridor = p.corridorId ? ` via ${escapeHtml(p.corridorId)}` : ''; + const conf = Math.round((p.confidence ?? 0) * 100); + const latency = p.latencyHours > 0 ? ` · ${p.latencyHours}h` : ''; + return ` +
+
+
${escapeHtml(p.mechanism || 'mechanism')}${corridor}
+
${escapeHtml(p.start || '')} → ${escapeHtml(p.end || '')}${latency}
+
+
${escapeHtml(p.severity || 'unspec')} · ${conf}%
+
+ `; + }).join(''); + return section('Transmission Paths', rows); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Watchlist +// ──────────────────────────────────────────────────────────────────────────── + +export function buildWatchlistBlock(activeTriggers: Trigger[], watchItems: NarrativeSection[]): string { + const triggerRows = (activeTriggers ?? []).map((t) => ` +
+ + ${escapeHtml(t.id)}${t.description ? ` — ${escapeHtml(t.description)}` : ''} +
+ `).join(''); + + const watchRows = (watchItems ?? []).filter((w) => (w.text ?? '').trim().length > 0).map((w) => ` +
+ + ${escapeHtml(w.text)} +
+ `).join(''); + + if (!triggerRows && !watchRows) { + return section('Watchlist', '
No active triggers or watch items
'); + } + + const parts: string[] = []; + if (triggerRows) { + parts.push(`
Active Triggers
${triggerRows}
`); + } + if (watchRows) { + parts.push(`
Watch Items
${watchRows}
`); + } + return section('Watchlist', parts.join('')); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Meta footer +// ──────────────────────────────────────────────────────────────────────────── + +export function buildMetaFooter(snapshot: RegionalSnapshot): string { + const meta = snapshot.meta; + if (!meta) return ''; + const confidence = Math.round((meta.snapshotConfidence ?? 0) * 100); + const generated = snapshot.generatedAt + ? `${new Date(snapshot.generatedAt).toISOString().replace('T', ' ').slice(0, 16)}Z` + : '—'; + const narrativeSrc = meta.narrativeProvider + ? `${escapeHtml(meta.narrativeProvider)}/${escapeHtml(meta.narrativeModel || 'unknown')}` + : 'no narrative'; + return ` +
+ generated ${escapeHtml(generated)} + confidence ${confidence}% + scoring v${escapeHtml(meta.scoringVersion || '')} + geo v${escapeHtml(meta.geographyVersion || '')} + narrative: ${narrativeSrc} +
+ `; +} diff --git a/src/config/commands.ts b/src/config/commands.ts index bdb4cb328..052ccf7f0 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -139,6 +139,7 @@ export const COMMANDS: Command[] = [ { id: 'panel:strategic-posture', keywords: ['strategic posture', 'ai posture', 'posture assessment'], label: 'Panel: AI Strategic Posture', icon: '\u{1F3AF}', category: 'panels' }, { id: 'panel:forecast', keywords: ['forecast', 'ai forecast', 'predictions ai'], label: 'Panel: AI Forecasts', icon: '\u{1F52E}', category: 'panels' }, { id: 'panel:deduction', keywords: ['deduct', 'deduction', 'ai deduction', 'situation analysis', 'scenario analysis'], label: 'Panel: AI Deduction', icon: '\u{1F9E0}', category: 'panels' }, + { id: 'panel:regional-intelligence', keywords: ['regional intelligence', 'regional brief', 'region snapshot', 'balance vector', 'regime', 'mena brief', 'east asia brief', 'europe brief', 'scenarios', 'transmission paths'], label: 'Panel: Regional Intelligence', icon: '\u{1F30D}', category: 'panels' }, { id: 'panel:military-correlation', keywords: ['force posture', 'military correlation', 'military posture'], label: 'Panel: Force Posture', icon: '\u{1F396}\uFE0F', category: 'panels' }, { id: 'panel:escalation-correlation', keywords: ['escalation', 'escalation monitor', 'escalation risk'], label: 'Panel: Escalation Monitor', icon: '\u{1F4C8}', category: 'panels' }, { id: 'panel:economic-correlation', keywords: ['economic warfare', 'economic correlation', 'sanctions impact'], label: 'Economic Warfare', icon: '\u{1F4B1}', category: 'panels' }, diff --git a/src/config/panels.ts b/src/config/panels.ts index 55c085e59..2089bda4f 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -101,6 +101,7 @@ const FULL_PANELS: Record = { 'national-debt': { name: 'Global Debt Clock', enabled: true, priority: 2 }, 'cross-source-signals': { name: 'Cross-Source Signals', enabled: true, priority: 2 }, 'market-implications': { name: 'AI Market Implications', enabled: true, priority: 1, premium: 'locked' as const }, + 'regional-intelligence': { name: 'Regional Intelligence', enabled: false, priority: 1, premium: 'locked' as const }, 'deduction': { name: 'Deduct Situation', enabled: false, priority: 1, premium: 'locked' as const }, 'geo-hubs': { name: 'Geopolitical Hubs', enabled: false, priority: 2 }, 'tech-hubs': { name: 'Hot Tech Hubs', enabled: false, priority: 2 }, @@ -952,7 +953,7 @@ export function isPanelEntitled(key: string, config: PanelConfig, isPro = false) if (!config.premium) return true; // Dodo entitlements unlock all premium panels if (isEntitled()) return true; - const apiKeyPanels = ['stock-analysis', 'stock-backtest', 'daily-market-brief', 'market-implications', 'deduction', 'chat-analyst', 'wsb-ticker-scanner']; + const apiKeyPanels = ['stock-analysis', 'stock-backtest', 'daily-market-brief', 'market-implications', 'regional-intelligence', 'deduction', 'chat-analyst', 'wsb-ticker-scanner']; if (apiKeyPanels.includes(key)) { return getSecretState('WORLDMONITOR_API_KEY').present || isPro; } diff --git a/tests/panel-config-guardrails.test.mjs b/tests/panel-config-guardrails.test.mjs index 5b182e169..af7dcec77 100644 --- a/tests/panel-config-guardrails.test.mjs +++ b/tests/panel-config-guardrails.test.mjs @@ -40,6 +40,7 @@ describe('panel-config guardrails', () => { const allowedContexts = [ /this\.ctx\.panels\[key\]\s*=/, // createPanel helper /this\.ctx\.panels\['deduction'\]/, // async-mounted PRO panel — gated via WEB_PREMIUM_PANELS + /this\.ctx\.panels\['regional-intelligence'\]/, // async-mounted PRO panel — gated via WEB_PREMIUM_PANELS /this\.ctx\.panels\['runtime-config'\]/, // desktop-only, intentionally ungated /this\.ctx\.panels\['live-news'\]/, // mountLiveNewsIfReady — has its own channel guard /panel as unknown as/, // lazyPanel generic cast diff --git a/tests/regional-intelligence-board.test.mts b/tests/regional-intelligence-board.test.mts new file mode 100644 index 000000000..6d34e9574 --- /dev/null +++ b/tests/regional-intelligence-board.test.mts @@ -0,0 +1,649 @@ +// 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, + 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 { + 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 { + 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: '', + role: '', + leverageDomains: [], + leverageScore: 0.5, + delta: 0, + evidenceIds: [], + }], + }); + const html = buildBoardHtml(malicious); + // Raw HTML must not appear... + assert.doesNotMatch(html, /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((resolve) => { resolveA = resolve; }); + const pB = new Promise((resolve) => { resolveB = resolve; }); + + async function loadCurrent(regionId: string, promise: Promise) { + 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((resolve) => { resolveA = resolve; }); + const pB = new Promise((resolve) => { resolveB = resolve; }); + + async function loadCurrent(regionId: string, promise: Promise) { + 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((resolve) => { resolvers.push(resolve); }), + ); + + async function loadCurrent(regionId: string, promise: Promise) { + 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) { + 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']); + }); +});