Phase 1 PR3: RegionalIntelligenceBoard panel UI (#2963)

* feat(intelligence): RegionalIntelligenceBoard panel UI (Phase 1 PR3)

Phase 1 PR3 of the Regional Intelligence Model. New premium panel that
renders a canonical RegionalSnapshot with 6 structured blocks plus the
LLM narrative sections from Phase 1 PR2.

## What landed

### New panel: RegionalIntelligenceBoard

src/components/RegionalIntelligenceBoard.ts (Panel wrapper)
src/components/regional-intelligence-board-utils.ts (pure HTML builders)

Region dropdown (7 non-global regions) → on change calls
IntelligenceServiceClient.getRegionalSnapshot → renders buildBoardHtml().

Layout (top to bottom):
  - Narrative sections (situation, balance_assessment, outlook 24h/7d/30d)
    — each section hidden when its text is empty, evidence IDs shown as pills
  - Regime block — current label, previous label, transition driver
  - Balance Vector — 4 pressure axes + 3 buffer axes with horizontal bars,
    plus a centered net_balance bar
  - Actors — top 5 by leverage_score with role, domains, and colored delta
  - Scenarios — 3 horizon columns (24h/7d/30d) × 4 lanes (base/escalation/
    containment/fragmentation), sorted by probability within each horizon
  - Transmission Paths — top 5 by confidence with mechanism, corridor,
    severity, and latency
  - Watchlist — active triggers + narrative watch_items
  - Meta footer — generated timestamp, confidence, scoring/geo versions,
    narrative provider/model

### Pure builders split for test isolation

All HTML builders live in regional-intelligence-board-utils.ts. The Panel
class in RegionalIntelligenceBoard.ts is a thin wrapper that calls them
and inserts the result via setContent. This split matches the existing
resilience-widget-utils pattern and lets node:test runners import the
builders directly without pulling in Vite-only services like
@/services/i18n (which fails with `import.meta.glob is not a function`).

### PRO gating + registration

- src/config/panels.ts: added 'regional-intelligence' to FULL_PANELS with
  premium: 'locked', enabled: false, plus isPanelEntitled API-key list
- src/app/panel-layout.ts: async dynamic import mounts the panel after
  the DeductionPanel block, reusing the same async-mount + gating pattern
- src/config/commands.ts: CMD+K entry with 🌍 icon and keywords
- tests/panel-config-guardrails.test.mjs: regional-intelligence added to
  the allowedContexts allowlist for the ungated direct-assignment check
  (the panel is intentionally premium-gated via WEB_PREMIUM_PANELS and
  async-mounted, matching DeductionPanel)

## Tests — 38 new unit tests

tests/regional-intelligence-board.test.mts exercises the pure builders:

- BOARD_REGIONS (2): 7 non-global regions, correct IDs
- buildRegimeBlock (4): current label rendered, "Was:" shown on change,
  hidden when unchanged, unknown fallback
- buildBalanceBlock (4): all 7 axes rendered, net_balance shown,
  unavailable fallback, value clamping for >1 inputs
- buildActorsBlock (4): top-5 cap, sort by leverage_score, delta colors,
  empty state
- buildScenariosBlock (4): horizon order (24h/7d/30d), percentage
  rendering, in-horizon probability sort, empty state
- buildTransmissionBlock (4): content rendering, confidence sort,
  top-5 cap, empty state
- buildWatchlistBlock (5): triggers + watch items, triggers-only,
  watch-items-only, empty-text filtering, all-empty state
- buildNarrativeHtml (5): all sections, empty-section hiding, all-empty
  returns '', undefined returns '', evidence ID pills
- buildMetaFooter (3): content, "no narrative" when provider empty,
  missing-meta returns ''
- buildBoardHtml (3): all 6 block titles + footer, HTML escaping,
  mostly-empty snapshot renders without throwing

## Verification

- npm run test:data: 4390/4390 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- biome lint on touched files: clean (pre-existing panel-layout.ts
  complexity warning unchanged)

## Dependency on PR2

This PR renders whatever narrative the snapshot carries. Phase 1 PR2
(#2960) populates the narrative; without PR2 merged the narrative
section just stays hidden (empty sections are filtered out in
buildNarrativeHtml). The UI ships safely in either order.

* fix(intelligence): request-sequence cancellation in RegionalIntelligenceBoard (PR #2963 review)

P2 review finding on PR #2963. loadCurrent() used a naive `loading`
boolean that DROPPED any region change while a fetch was in flight, so
a fast dropdown switch would leave the panel rendering the previous
region indefinitely until the user changed it a third time.

Fix: replaced the boolean with a monotonic `latestSequence` counter.
Each load claims a sequence before awaiting the RPC and only renders
its response when mySequence still matches latestSequence on return.
Earlier in-flight responses are silently discarded. Latest selection
always wins.

## Pure arbitrator helper

Added isLatestSequence(mySequence, latestSequence) to
regional-intelligence-board-utils.ts. The helper is trivially pure,
but exporting it makes the arbitration semantics testable without
instantiating the Panel class (which can't be imported by node:test
due to import.meta.glob in @/services/i18n — see the
feedback_panel_utils_split_for_node_test memory).

## Tests — 7 new regression tests

isLatestSequence (3):
  - matching sequences return true
  - newer sequences return false
  - defensive: mine > latest also returns false

loadCurrent race simulation (4): each test mimics the real
loadCurrent() sequence-claim-and-arbitrate flow with controllable
deferred promises so the resolution order can be exercised directly:
  - earlier load resolves AFTER the later one → discarded
  - earlier load resolves BEFORE the later one → still discarded
  - three rapid switches, scrambled resolution order → only last renders
  - single load (no race) still renders normally

## Verification

- npx tsx --test tests/regional-intelligence-board.test.mts: 45/45 pass
- npm run test:data: 4393/4393 pass
- npm run typecheck: clean
- biome lint on touched files: clean
This commit is contained in:
Elie Habib
2026-04-12 00:18:04 +04:00
committed by GitHub
parent dca2e1ca3c
commit 24f5312191
7 changed files with 1179 additions and 1 deletions

View File

@@ -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) => {

View File

@@ -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<void> {
this.currentRegion = regionId;
this.selector.value = regionId;
await this.loadCurrent();
}
private async loadCurrent(): Promise<void> {
// 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 =
'<div class="rib-status" style="padding:16px;color:var(--text-dim);font-size:12px">Loading regional snapshot…</div>';
}
private renderEmpty(): void {
this.body.innerHTML =
'<div class="rib-status" style="padding:16px;color:var(--text-dim);font-size:12px">No snapshot available yet for this region. The next cron cycle will populate it within 6 hours.</div>';
}
private renderError(message: string): void {
this.body.innerHTML = `<div class="rib-status rib-status-error" style="padding:16px;color:var(--danger);font-size:12px">Failed to load snapshot: ${escapeHtml(message)}</div>`;
}
/** Render the full board HTML from a hydrated snapshot. Public for tests. */
public renderBoard(snapshot: RegionalSnapshot): void {
this.body.innerHTML = buildBoardHtml(snapshot);
}
}

View File

@@ -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 `
<div class="rib-section" style="margin-bottom:12px;padding:10px 12px;border:1px solid var(--border);border-radius:4px;background:rgba(255,255,255,0.02);${extraStyle}">
<div class="rib-section-title" style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:8px">${escapeHtml(title)}</div>
${bodyHtml}
</div>
`;
}
// ────────────────────────────────────────────────────────────────────────────
// 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
? `<span style="font-size:10px;color:var(--text-dim);margin-left:6px">[${escapeHtml(evidence.slice(0, 4).join(', '))}]</span>`
: '';
return `
<div class="rib-narrative-row" style="margin-bottom:8px">
<div style="font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:2px">${escapeHtml(label)}${evidencePill}</div>
<div style="font-size:12px;line-height:1.5">${escapeHtml(text)}</div>
</div>
`;
}
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
? `<div style="font-size:11px;color:var(--text-dim);margin-top:2px">Was: ${escapeHtml(previous)}${driver ? ` · ${escapeHtml(driver)}` : ''}</div>`
: '';
const body = `
<div class="rib-regime-label" style="font-size:15px;font-weight:600;text-transform:capitalize">${escapeHtml(label.replace(/_/g, ' '))}</div>
${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 `
<div style="display:grid;grid-template-columns:110px 40px 1fr;gap:8px;align-items:center;margin-bottom:4px">
<div style="font-size:11px;color:var(--text-dim)">${escapeHtml(label)}</div>
<div style="font-size:11px;font-variant-numeric:tabular-nums">${value.toFixed(2)}</div>
<div style="height:6px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden">
<div style="height:100%;width:${pct.toFixed(1)}%;background:var(${colorClass})"></div>
</div>
</div>
`;
}
export function buildBalanceBlock(balance: BalanceVector | undefined): string {
if (!balance) {
return section('Balance Vector', '<div style="font-size:11px;color:var(--text-dim)">Unavailable</div>');
}
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 = `
<div style="display:grid;grid-template-columns:110px 40px 1fr;gap:8px;align-items:center;margin-top:6px;padding-top:6px;border-top:1px dashed rgba(255,255,255,0.1)">
<div style="font-size:11px;color:var(--text-dim);font-weight:600">Net Balance</div>
<div style="font-size:11px;font-variant-numeric:tabular-nums;font-weight:600">${net.toFixed(2)}</div>
<div style="position:relative;height:6px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden">
<div style="position:absolute;left:50%;top:0;bottom:0;width:1px;background:rgba(255,255,255,0.3)"></div>
<div style="position:absolute;${netSide}:50%;top:0;bottom:0;width:${netFill.toFixed(1)}%;background:${netColor}"></div>
</div>
</div>
`;
const body = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:4px">Pressures</div>
${pressures}
</div>
<div>
<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:4px">Buffers</div>
${buffers}
</div>
</div>
${netBar}
`;
return section('Balance Vector', body);
}
// ────────────────────────────────────────────────────────────────────────────
// Actors
// ────────────────────────────────────────────────────────────────────────────
export function buildActorsBlock(actors: ActorState[]): string {
if (!actors || actors.length === 0) {
return section('Actors', '<div style="font-size:11px;color:var(--text-dim)">No actor data</div>');
}
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 `
<div style="display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;padding:4px 0;border-bottom:1px dashed rgba(255,255,255,0.06)">
<div>
<div style="font-size:12px;font-weight:500">${escapeHtml(a.name || a.actorId)}</div>
<div style="font-size:10px;color:var(--text-dim);text-transform:capitalize">${escapeHtml(a.role || 'actor')}${domains ? ` · ${escapeHtml(domains)}` : ''}</div>
</div>
<div style="font-size:11px;font-variant-numeric:tabular-nums">${(a.leverageScore ?? 0).toFixed(2)}</div>
<div style="font-size:10px;color:${deltaColor};font-variant-numeric:tabular-nums;min-width:38px;text-align:right">${escapeHtml(deltaText)}</div>
</div>
`;
}).join('');
return section('Actors', rows);
}
// ────────────────────────────────────────────────────────────────────────────
// Scenarios
// ────────────────────────────────────────────────────────────────────────────
export function buildScenariosBlock(scenarioSets: ScenarioSet[]): string {
if (!scenarioSets || scenarioSets.length === 0) {
return section('Scenarios', '<div style="font-size:11px;color:var(--text-dim)">No scenario data</div>');
}
// Sort by canonical horizon order.
const order: Record<string, number> = { '24h': 0, '7d': 1, '30d': 2 };
const sorted = [...scenarioSets].sort((a, b) => (order[a.horizon] ?? 99) - (order[b.horizon] ?? 99));
const laneColor: Record<string, string> = {
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 `
<div style="margin-bottom:3px">
<div style="display:flex;justify-content:space-between;font-size:11px;text-transform:capitalize">
<span>${escapeHtml(l.name)}</span>
<span style="font-variant-numeric:tabular-nums">${pct}%</span>
</div>
<div style="height:4px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${color}"></div>
</div>
</div>
`;
}).join('');
return `
<div>
<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:6px">${escapeHtml(set.horizon)}</div>
${lanesHtml}
</div>
`;
}).join('');
const body = `<div style="display:grid;grid-template-columns:repeat(${sorted.length},1fr);gap:12px">${cols}</div>`;
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', '<div style="font-size:11px;color:var(--text-dim)">No active transmissions</div>');
}
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 `
<div style="padding:4px 0;border-bottom:1px dashed rgba(255,255,255,0.06);display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center">
<div>
<div style="font-size:11px;font-weight:500">${escapeHtml(p.mechanism || 'mechanism')}${corridor}</div>
<div style="font-size:10px;color:var(--text-dim)">${escapeHtml(p.start || '')}${escapeHtml(p.end || '')}${latency}</div>
</div>
<div style="font-size:10px;font-variant-numeric:tabular-nums;color:${color};text-transform:uppercase">${escapeHtml(p.severity || 'unspec')} · ${conf}%</div>
</div>
`;
}).join('');
return section('Transmission Paths', rows);
}
// ────────────────────────────────────────────────────────────────────────────
// Watchlist
// ────────────────────────────────────────────────────────────────────────────
export function buildWatchlistBlock(activeTriggers: Trigger[], watchItems: NarrativeSection[]): string {
const triggerRows = (activeTriggers ?? []).map((t) => `
<div style="padding:3px 0;font-size:11px">
<span style="color:var(--danger);font-weight:600">●</span>
${escapeHtml(t.id)}${t.description ? ` — <span style="color:var(--text-dim)">${escapeHtml(t.description)}</span>` : ''}
</div>
`).join('');
const watchRows = (watchItems ?? []).filter((w) => (w.text ?? '').trim().length > 0).map((w) => `
<div style="padding:3px 0;font-size:11px">
<span style="color:var(--text-dim)">▸</span>
${escapeHtml(w.text)}
</div>
`).join('');
if (!triggerRows && !watchRows) {
return section('Watchlist', '<div style="font-size:11px;color:var(--text-dim)">No active triggers or watch items</div>');
}
const parts: string[] = [];
if (triggerRows) {
parts.push(`<div style="margin-bottom:6px"><div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:2px">Active Triggers</div>${triggerRows}</div>`);
}
if (watchRows) {
parts.push(`<div><div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:2px">Watch Items</div>${watchRows}</div>`);
}
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 `
<div style="display:flex;flex-wrap:wrap;gap:12px;padding:6px 2px 0;font-size:10px;color:var(--text-dim)">
<span>generated ${escapeHtml(generated)}</span>
<span>confidence ${confidence}%</span>
<span>scoring v${escapeHtml(meta.scoringVersion || '')}</span>
<span>geo v${escapeHtml(meta.geographyVersion || '')}</span>
<span>narrative: ${narrativeSrc}</span>
</div>
`;
}

View File

@@ -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' },

View File

@@ -101,6 +101,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'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;
}

View File

@@ -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

View File

@@ -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> = {}): 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, /&lt;img src=x onerror/);
assert.match(html, /&lt;script&gt;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']);
});
});