Files
worldmonitor/tests/panel-config-guardrails.test.mjs
Elie Habib 77aee7225c fix(panels): skip LiveNewsPanel on variants with no channels (#1987)
* fix(panels): skip LiveNewsPanel instantiation when variant has no channels

The unified panel registry (PR #1911) registers all panels across all
variants. On the happy variant, DEFAULT_LIVE_CHANNELS is empty, so
LiveNewsPanel would crash at this.channels[0]! in the constructor.

Guard the instantiation with getDefaultLiveChannels().length > 0 so the
panel is never created for variants that have no channels to show, rather
than special-casing inside the component.

* fix(panels): prevent LiveNewsPanel crash when variant has no default channels

The previous guard skipped instantiation entirely on happy variant, which
broke the settings toggle (applyPanelSettings only calls toggle() on
existing instances, never creates new ones) and blocked users who had
already persisted custom channels in STORAGE_KEYS.liveChannels.

Correct fix: always instantiate, but add a final fallback to
FULL_LIVE_CHANNELS in both the constructor and refreshChannelsFromStorage
so this.channels[0]! is never undefined regardless of variant defaults.

* fix(panels): guard LiveNewsPanel instantiation on channels availability

Replace the FULL_LIVE_CHANNELS fallback (which would repopulate the panel
with wrong channels on happy variant and break intentionally empty channel
sets) with a precise instantiation guard: skip creation only when both
getDefaultLiveChannels() and loadChannelsFromStorage() are empty.

This means:
- Happy + no saved channels: panel not instantiated, no crash, no wrong content
- Happy + user-saved channels: panel created, works correctly
- Any variant where user cleared all channels: existing behavior preserved
  (no FULL_LIVE_CHANNELS override)

Adds 6 static regression tests that pin:
- DEFAULT_LIVE_CHANNELS is [] for happy variant
- Constructor has no FULL_LIVE_CHANNELS fallback
- refreshChannelsFromStorage has no FULL_LIVE_CHANNELS fallback
- panel-layout.ts guard checks both defaults and saved channels

* fix(panels): add mid-session lazy instantiation path for LiveNewsPanel

The startup guard correctly skips LiveNewsPanel on happy variant when no
channels exist, but createPanels() only runs once. If the user adds
channels later via the standalone manager (?live-channels=1) or Unified
Settings, ctx.panels['live-news'] stayed undefined and applyPanelSettings
could only toggle existing instances, leaving live-news permanently
unavailable without a full reload.

Fix:
- Add mountLiveNewsIfReady() to PanelLayout: instantiates, makeDraggable,
  and mounts the panel into the grid when called after channels appear
- Add mountLiveNewsIfReady optional callback to EventHandlerCallbacks
- Wire it in App.ts via this.panelLayout.mountLiveNewsIfReady()
- Call it from the liveChannels storage event handler when the panel
  does not yet exist (the existing refreshChannelsFromStorage path is
  taken when it does)

Tests: 3 new regression tests pin the lazy instantiation wiring;
panel-config-guardrails allowlist updated for the new assignment site.
2026-03-21 16:47:42 +04:00

114 lines
4.3 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'\]/, // desktop-only, intentionally ungated
/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('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 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]) {
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];
}