Files
worldmonitor/tests/panel-config-guardrails.test.mjs
Elie Habib 24f5312191 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
2026-04-12 00:18:04 +04:00

132 lines
5.1 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const panelLayoutSrc = readFileSync(resolve(__dirname, '../src/app/panel-layout.ts'), 'utf-8');
const VARIANT_FILES = ['full', 'tech', 'finance', 'commodity', 'happy'];
function parsePanelKeys(variant) {
const src = readFileSync(resolve(__dirname, '../src/config/panels.ts'), 'utf-8');
const tag = variant.toUpperCase() + '_PANELS';
const start = src.indexOf(`const ${tag}`);
if (start === -1) return [];
const block = src.slice(start, src.indexOf('};', start) + 2);
const keys = [];
for (const m of block.matchAll(/(?:['"]([^'"]+)['"]|(\w[\w-]*))\s*:/g)) {
const key = m[1] || m[2];
if (key && !['name', 'enabled', 'priority', 'string', 'PanelConfig', 'Record'].includes(key)) {
keys.push(key);
}
}
return keys;
}
describe('panel-config guardrails', () => {
it('every variant config includes "map"', () => {
for (const v of VARIANT_FILES) {
const keys = parsePanelKeys(v);
assert.ok(keys.includes('map'), `${v} variant missing "map" panel`);
}
});
it('no unguarded direct this.ctx.panels[...] = assignments in createPanels()', () => {
const lines = panelLayoutSrc.split('\n');
const violations = [];
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
/this\.ctx\.panels\[panelKey\]\s*=/, // FEEDS loop (guarded by DEFAULT_PANELS check)
/this\.ctx\.panels\[spec\.id\]\s*=/, // custom widgets (cw- prefix, always enabled)
];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes('this.ctx.panels[') || !line.includes('=')) continue;
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
if (!line.match(/this\.ctx\.panels\[.+\]\s*=/)) continue;
if (allowedContexts.some(p => p.test(line))) continue;
const preceding20 = lines.slice(Math.max(0, i - 20), i).join('\n');
const isGuarded =
preceding20.includes('shouldCreatePanel') ||
preceding20.includes('createPanel') ||
preceding20.includes('createNewsPanel');
if (isGuarded) continue;
violations.push({ line: i + 1, text: line.trim() });
}
assert.deepStrictEqual(
violations,
[],
`Found unguarded panel assignments that bypass createPanel/shouldCreatePanel guards:\n` +
violations.map(v => ` L${v.line}: ${v.text}`).join('\n') +
`\n\nUse this.createPanel(), this.createNewsPanel(), or wrap with shouldCreatePanel().`
);
});
it('reapplies panel settings after mounting the async deduction panel', () => {
const deductionMount = panelLayoutSrc.match(
/import\('@\/components\/DeductionPanel'\)\.then\(\(\{ DeductionPanel \}\) => \{([\s\S]*?)\n\s*\}\);/
);
assert.ok(deductionMount, 'expected async DeductionPanel mount block in panel-layout.ts');
assert.match(
deductionMount[1],
/this\.applyPanelSettings\(\);/,
'async DeductionPanel mount must replay saved panel settings after insertion',
);
});
it('panel keys are consistent across variant configs (no typos)', () => {
const allKeys = new Map();
for (const v of VARIANT_FILES) {
for (const key of parsePanelKeys(v)) {
if (!allKeys.has(key)) allKeys.set(key, []);
allKeys.get(key).push(v);
}
}
const keys = [...allKeys.keys()];
const allowedPairs = new Set([
'ai-regulation|fin-regulation',
'fin-regulation|ai-regulation',
]);
const typos = [];
for (let i = 0; i < keys.length; i++) {
for (let j = i + 1; j < keys.length; j++) {
const minLen = Math.min(keys[i].length, keys[j].length);
if (minLen < 5) continue;
if (levenshtein(keys[i], keys[j]) <= 2 && keys[i] !== keys[j] && !allowedPairs.has(`${keys[i]}|${keys[j]}`)) {
typos.push(`"${keys[i]}" ↔ "${keys[j]}"`);
}
}
}
assert.deepStrictEqual(typos, [], `Possible panel key typos: ${typos.join(', ')}`);
});
});
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}