mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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.
114 lines
4.3 KiB
JavaScript
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];
|
|
}
|