Files
worldmonitor/tests/panel-config-guardrails.test.mjs
Elie Habib c07bd5939c fix(panels): deduplicate AI Regulation Dashboard panel name (#2796)
* fix(panels): rename duplicate "AI Regulation Dashboard" panel

The `regulation` panel key (finance variant RSS feed) and `ai-regulation`
(custom RegulationPanel dashboard) both resolved to "AI Regulation
Dashboard" in the panel picker, causing a duplicate entry.

Renamed `regulation` to "Financial Regulation" since it serves financial
regulation news feeds, distinct from the AI regulation dashboard.

* fix(panels): split finance regulation into own key to fix duplicate name

The `regulation` panel key was shared between the AI regulation
dashboard (RegulationPanel, which reads t('panels.regulation')) and
the finance variant's financial regulation RSS feeds. Both resolved
to "AI Regulation Dashboard" in the picker.

Created `fin-regulation` as a distinct key for the finance RSS panel:
- panels.ts: renamed panel config entries in tech + finance variants
- feeds.ts: renamed feed key from `regulation` to `fin-regulation`
- panel-layout.ts: added createNewsPanel('fin-regulation') call
- commands.ts: updated CMD+K entry
- en.json: restored panels.regulation for AI dashboard, added
  panels.fin-regulation for financial regulation

The `panels.regulation` locale key now exclusively serves
RegulationPanel's title across all locales.

* fix(panels): add migration, remove from tech variant, fix locale key

- Add ['regulation', 'fin-regulation'] to panel key rename migration
  table so existing users' saved settings are preserved on upgrade
- Remove fin-regulation from TECH_PANELS (finance-only panel, tech
  variant uses policy for AI regulation feeds)
- Fix locale key: panels.finRegulation (camelCase) not panels.fin-
  regulation, matching the dashed-to-camel lookup in
  getLocalizedPanelName()

* fix(panels): bump migration key, remove explicit createNewsPanel

P1: Bumped migration key to v2.6.8 so the regulation->fin-regulation
rename runs for users who already have the v2.6 flag set.

P2+P3: Removed explicit createNewsPanel('fin-regulation') call. The
generic FEEDS loop auto-creates it in finance builds (where the feed
key exists in FINANCE_FEEDS). This prevents an empty panel in non-
finance variants and avoids the t('panels.fin-regulation') lookup
mismatch (the settings UI uses camelCased getLocalizedPanelName
which correctly resolves panels.finRegulation).

* fix(panels): variant-aware migration + all 21 locale translations

P1: Migration is now variant-aware. In finance, regulation is renamed
to fin-regulation (preserving settings). In all other variants, the
stale regulation key is pruned (it was dead config with no feeds).

P2: Added finRegulation locale key to all 21 locale files so non-
English users see the translated "Financial Regulation" label instead
of the English fallback.

* fix(panels): migrate saved order keys + prune stale keys in settings window

Medium: The rename migration now also rewrites regulation->fin-regulation
in saved panel order, bottom-set, and bottom storage keys so finance
users preserve their custom panel positioning after upgrade.

Low: The standalone settings window now prunes unknown panel keys from
loaded storage before rendering, preventing stale regulation entries
from appearing as ghost toggles alongside fin-regulation.
2026-04-07 23:15:50 +04:00

131 lines
5.0 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\['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];
}