mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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) => {
|
||||
|
||||
133
src/components/RegionalIntelligenceBoard.ts
Normal file
133
src/components/RegionalIntelligenceBoard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
375
src/components/regional-intelligence-board-utils.ts
Normal file
375
src/components/regional-intelligence-board-utils.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
649
tests/regional-intelligence-board.test.mts
Normal file
649
tests/regional-intelligence-board.test.mts
Normal 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, /<img src=x onerror/);
|
||||
assert.match(html, /<script>bad/);
|
||||
});
|
||||
|
||||
it('renders a mostly-empty snapshot without throwing', () => {
|
||||
const bare = snapshotFixture({
|
||||
actors: [],
|
||||
scenarioSets: [],
|
||||
transmissionPaths: [],
|
||||
triggers: { active: [], watching: [], dormant: [] },
|
||||
narrative: undefined,
|
||||
});
|
||||
assert.doesNotThrow(() => buildBoardHtml(bare));
|
||||
const html = buildBoardHtml(bare);
|
||||
assert.match(html, /No actor data/);
|
||||
assert.match(html, /No scenario data/);
|
||||
assert.match(html, /No active transmissions/);
|
||||
assert.match(html, /No active triggers or watch items/);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Request-sequence arbitrator (P2 fix for PR #2963 review)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isLatestSequence', () => {
|
||||
it('returns true when the claimed sequence still matches latest', () => {
|
||||
assert.equal(isLatestSequence(1, 1), true);
|
||||
assert.equal(isLatestSequence(42, 42), true);
|
||||
});
|
||||
|
||||
it('returns false when a newer sequence has claimed latest', () => {
|
||||
assert.equal(isLatestSequence(1, 2), false);
|
||||
assert.equal(isLatestSequence(9, 10), false);
|
||||
});
|
||||
|
||||
it('returns false for any mismatch (even when mine > latest, defensive)', () => {
|
||||
assert.equal(isLatestSequence(5, 3), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Simulated fast-dropdown race (P2 fix for PR #2963 review)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mimics the loadCurrent() flow without instantiating the Panel class
|
||||
// (which transitively imports @/services/i18n and fails node:test).
|
||||
// Each "load" claims a sequence, awaits a controllable RPC, then calls a
|
||||
// rendered callback ONLY if isLatestSequence(mySeq, latestSeq). The test
|
||||
// orchestrates two overlapping loads where the first RPC resolves AFTER
|
||||
// the second, and asserts only the second render fires.
|
||||
|
||||
describe('loadCurrent race simulation', () => {
|
||||
it('drops an earlier in-flight response when a later region is selected', async () => {
|
||||
const state = { latestSequence: 0, currentRegion: 'mena', rendered: [] as string[] };
|
||||
|
||||
// Two resolvable deferreds so the test controls finish order.
|
||||
let resolveA: (value: string) => void;
|
||||
let resolveB: (value: string) => void;
|
||||
const pA = new Promise<string>((resolve) => { resolveA = resolve; });
|
||||
const pB = new Promise<string>((resolve) => { resolveB = resolve; });
|
||||
|
||||
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
||||
state.latestSequence += 1;
|
||||
const mySeq = state.latestSequence;
|
||||
state.currentRegion = regionId;
|
||||
const result = await promise;
|
||||
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
||||
state.rendered.push(`${regionId}:${result}`);
|
||||
}
|
||||
|
||||
// Kick off call A (mena), then call B (east-asia) — call B claims
|
||||
// the later sequence. Order of resolution is intentionally reversed:
|
||||
// B resolves first, then A. A must be discarded as stale.
|
||||
const loadA = loadCurrent('mena', pA);
|
||||
const loadB = loadCurrent('east-asia', pB);
|
||||
|
||||
resolveB!('snapshot-east-asia');
|
||||
await loadB;
|
||||
resolveA!('snapshot-mena');
|
||||
await loadA;
|
||||
|
||||
assert.deepEqual(state.rendered, ['east-asia:snapshot-east-asia']);
|
||||
});
|
||||
|
||||
it('renders the latest load even when it resolves before an earlier one', async () => {
|
||||
const state = { latestSequence: 0, rendered: [] as string[] };
|
||||
|
||||
let resolveA: (value: string) => void;
|
||||
let resolveB: (value: string) => void;
|
||||
const pA = new Promise<string>((resolve) => { resolveA = resolve; });
|
||||
const pB = new Promise<string>((resolve) => { resolveB = resolve; });
|
||||
|
||||
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
||||
state.latestSequence += 1;
|
||||
const mySeq = state.latestSequence;
|
||||
const result = await promise;
|
||||
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
||||
state.rendered.push(`${regionId}:${result}`);
|
||||
}
|
||||
|
||||
const loadA = loadCurrent('mena', pA);
|
||||
const loadB = loadCurrent('europe', pB);
|
||||
|
||||
// A resolves first (normal ordering), but B has claimed a later seq,
|
||||
// so when A checks the arbitrator (seq 1 vs latest 2) it discards.
|
||||
resolveA!('snap-a');
|
||||
await loadA;
|
||||
resolveB!('snap-b');
|
||||
await loadB;
|
||||
|
||||
assert.deepEqual(state.rendered, ['europe:snap-b']);
|
||||
});
|
||||
|
||||
it('three rapid switches render only the last one', async () => {
|
||||
const state = { latestSequence: 0, rendered: [] as string[] };
|
||||
|
||||
const resolvers: Array<(value: string) => void> = [];
|
||||
const promises = [0, 1, 2].map(
|
||||
() => new Promise<string>((resolve) => { resolvers.push(resolve); }),
|
||||
);
|
||||
|
||||
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
||||
state.latestSequence += 1;
|
||||
const mySeq = state.latestSequence;
|
||||
const result = await promise;
|
||||
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
||||
state.rendered.push(`${regionId}:${result}`);
|
||||
}
|
||||
|
||||
const loadMena = loadCurrent('mena', promises[0]!);
|
||||
const loadEu = loadCurrent('europe', promises[1]!);
|
||||
const loadEa = loadCurrent('east-asia', promises[2]!);
|
||||
|
||||
// Resolve out of order: middle first, then last, then first.
|
||||
resolvers[1]!('snap-eu');
|
||||
await loadEu;
|
||||
resolvers[2]!('snap-ea');
|
||||
await loadEa;
|
||||
resolvers[0]!('snap-mena');
|
||||
await loadMena;
|
||||
|
||||
assert.deepEqual(state.rendered, ['east-asia:snap-ea']);
|
||||
});
|
||||
|
||||
it('a single load (no race) still renders', async () => {
|
||||
const state = { latestSequence: 0, rendered: [] as string[] };
|
||||
async function loadCurrent(regionId: string, promise: Promise<string>) {
|
||||
state.latestSequence += 1;
|
||||
const mySeq = state.latestSequence;
|
||||
const result = await promise;
|
||||
if (!isLatestSequence(mySeq, state.latestSequence)) return;
|
||||
state.rendered.push(`${regionId}:${result}`);
|
||||
}
|
||||
await loadCurrent('mena', Promise.resolve('snap'));
|
||||
assert.deepEqual(state.rendered, ['mena:snap']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user