mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(energy-atlas): EnergyRiskOverviewPanel — executive overview tile (parity PR 2, plan U5-U6) (#3398)
* feat(energy-atlas): EnergyRiskOverviewPanel — executive overview tile (PR 2, plan U5-U6)
Lands the consolidated "first fold" surface that peer reference energy-intel
sites use as their executive overview. Six tiles in a single panel:
1. Strait of Hormuz status (closed/disrupted/restricted/open)
2. EU gas storage fill % (red <30, amber 30-49, green ≥50)
3. Brent crude price + 1-day change (importer-leaning: up=red, down=green)
4. Active disruption count (filtered to endAt === null)
5. Data freshness ("X min ago" from youngest fetchedAt)
6. Hormuz crisis day counter (default 2026-02-23 start, env-overridable)
Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 2.
U5 — Component (src/components/EnergyRiskOverviewPanel.ts):
- Composes 5 existing services via Promise.allSettled — never .all. One slow
or failing source CANNOT freeze the panel; failed tiles render '—' and
carry data-degraded="true" for QA inspection. Single most important
behavior — guards against the recurrence of the #3386 panel-stuck bug.
- Uses the actual Hormuz status enum 'closed'|'disrupted'|'restricted'|'open'
(NOT 'normal'/'reduced'/'critical' — that triplet was a misread in earlier
drafts). Test suite explicitly rejects the wrong triplet via the gray
sentinel fallback.
- Brent color inverted from a default market panel: oil price UP = red
(bad for energy importers, the dominant Atlas reader); DOWN = green.
- Crisis-day counter sourced from VITE_HORMUZ_CRISIS_START_DATE env
(default 2026-02-23). NaN/future-dated values handled with explicit
'—' / 'pending' sentinels so the tile never renders 'Day NaN'.
- 60s setInterval re-renders the freshness tile only — no new RPCs fire
on tick. setInterval cleared in destroy() so panel teardown is clean.
- Tests: 24 in tests/energy-risk-overview-panel.test.mts cover Hormuz
color enum (including the wrong-triplet rejection), EU gas thresholds,
Brent inversion, active-disruption color bands, freshness label
formatting, crisis-day counter (today/5-days/NaN/future), and the
degraded-mode contract (all-fail still renders 6 tiles with 4 marked
data-degraded).
U6 — Wiring (5 sites per skill panel-stuck-loading-means-missing-primetask):
- src/components/index.ts: barrel export
- src/app/panel-layout.ts: import + createPanel('energy-risk-overview', ...)
- src/config/panels.ts: priority-1 entry in ENERGY_PANELS (top-of-grid),
priority-2 entry in FULL_PANELS (CMD+K-discoverable, default disabled),
panelKey added to PANEL_CATEGORY_MAP marketsFinance category
- src/App.ts: import type + primeTask kickoff (between energy-disruptions
and climate-news in the existing ordering convention)
- src/config/commands.ts: panel:energy-risk-overview command with keywords
for 'risk overview', 'executive overview', 'hormuz status', 'crisis day'
No new RPCs (preserves agent-native parity — every metric the panel shows
is already exposed via existing Connect-RPC handlers and bootstrap-cache
keys; agents can answer the same questions through the same surface).
Tests: typecheck clean; 24 unit tests pass on the panel's pure helpers.
Manual visual QA pending PR merge + deploy.
Plan section §M effort estimate: ~1.5d. Codex-approved through 8 review
rounds against origin/main @ 050073354.
* fix(energy-atlas): extract Risk Overview state-builder + real component test (PR2 review)
P2 — tests duplicated helper logic instead of testing the real panel
(energy-risk-overview-panel.test.mts:10):
- The original tests pinned color/threshold helpers but didn't import
the panel's actual state-building logic, so the panel could ship
with a broken Promise.allSettled wiring while the tests stayed green.
Refactor:
- Extract the state-building logic into a NEW Vite-free module:
src/components/_energy-risk-overview-state.ts. Exports
buildOverviewState(hormuz, euGas, brent, disruptions, now) and a
countDegradedTiles() helper for tests.
- The panel now imports and calls buildOverviewState() directly inside
fetchData(); no logic duplication. The Hormuz tile renderer narrows
status with an explicit cast at use site.
- Why a new module: the panel transitively imports `import.meta.glob`
via the i18n service, which doesn't resolve under node:test even
with tsx loader. Extracting the testable logic into a
Vite-dependency-free module is the cleanest way to exercise the
production code from tests, per skill panel-stuck-loading-means-
missing-primetask's emphasis on "test the actual production logic,
not a copy-paste of it".
Tests added (11 real-component cases via the new module):
- All four sources fulfilled → 0 degraded.
- All four sources rejected → 4 degraded, no throw, no cascade.
- Mixed (1 fulfilled, 3 rejected) → only one tile populated.
- euGas with `unavailable: true` sentinel → degraded.
- euGas with fillPct=0 → degraded (treats as no-data, not "0% red").
- brent empty data array → degraded.
- brent first-quote price=null → degraded.
- disruptions upstreamUnavailable=true → degraded.
- disruptions ongoing filter: counts only endAt-falsy events.
- Malformed hormuz response (missing status field) → degraded sentinel.
- One rejected source MUST NOT cascade to fulfilled siblings (the
core degraded-mode contract — pinned explicitly).
Total: 35 tests in this file (was 24; +11 real-component cases).
typecheck clean.
* fix(energy-atlas): server-side disruptions filter + once-only style + panel name parity (PR2 review)
Three Greptile P2 findings on PR #3398:
- listEnergyDisruptions called with ongoingOnly:true so the server filters
the historical 52-event payload server-side. The state builder still
re-filters as defense-in-depth.
- RISK_OVERVIEW_CSS injected once into <head> via injectRiskOverviewStylesOnce
instead of being emitted into setContent on every render. The 60s freshness
setInterval was tearing out and re-inserting the style tag every minute.
- FULL_PANELS entry renamed from "Energy Risk Overview" to
"Global Energy Risk Overview" to match ENERGY_PANELS and the CMD+K command.
This commit is contained in:
@@ -44,6 +44,7 @@ import type { PipelineStatusPanel } from '@/components/PipelineStatusPanel';
|
||||
import type { StorageFacilityMapPanel } from '@/components/StorageFacilityMapPanel';
|
||||
import type { FuelShortagePanel } from '@/components/FuelShortagePanel';
|
||||
import type { EnergyDisruptionsPanel } from '@/components/EnergyDisruptionsPanel';
|
||||
import type { EnergyRiskOverviewPanel } from '@/components/EnergyRiskOverviewPanel';
|
||||
import type { ClimateNewsPanel } from '@/components/ClimateNewsPanel';
|
||||
import type { ConsumerPricesPanel } from '@/components/ConsumerPricesPanel';
|
||||
import type { DefensePatentsPanel } from '@/components/DefensePatentsPanel';
|
||||
@@ -351,6 +352,10 @@ export class App {
|
||||
const panel = this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel | undefined;
|
||||
if (panel) primeTask('energy-disruptions', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('energy-risk-overview')) {
|
||||
const panel = this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel | undefined;
|
||||
if (panel) primeTask('energy-risk-overview', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('climate-news')) {
|
||||
const panel = this.state.panels['climate-news'] as ClimateNewsPanel | undefined;
|
||||
if (panel) primeTask('climate-news', () => panel.fetchData());
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
StorageFacilityMapPanel,
|
||||
FuelShortagePanel,
|
||||
EnergyDisruptionsPanel,
|
||||
EnergyRiskOverviewPanel,
|
||||
MacroTilesPanel,
|
||||
FSIPanel,
|
||||
YieldCurvePanel,
|
||||
@@ -891,6 +892,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
this.createPanel('storage-facility-map', () => new StorageFacilityMapPanel());
|
||||
this.createPanel('fuel-shortages', () => new FuelShortagePanel());
|
||||
this.createPanel('energy-disruptions', () => new EnergyDisruptionsPanel());
|
||||
this.createPanel('energy-risk-overview', () => new EnergyRiskOverviewPanel());
|
||||
this.createPanel('polymarket', () => new PredictionPanel());
|
||||
|
||||
this.createNewsPanel('gov', 'panels.gov');
|
||||
|
||||
284
src/components/EnergyRiskOverviewPanel.ts
Normal file
284
src/components/EnergyRiskOverviewPanel.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// Energy Risk Overview Panel
|
||||
//
|
||||
// One consolidated executive surface composing five existing data sources:
|
||||
// 1. Hormuz status (vessels/day + status from src/services/hormuz-tracker.ts)
|
||||
// 2. EU Gas storage fill % (bootstrap-cached `euGasStorage` + RPC fallback)
|
||||
// 3. Brent crude price + 1-day delta (BZ=F via fetchCommodityQuotes)
|
||||
// 4. Active disruptions count (listEnergyDisruptions filtered to endAt === null)
|
||||
// 5. Data freshness (now - youngest fetchedAt across the four upstream signals)
|
||||
//
|
||||
// Plus a "Day N of crisis" counter computed at render time from a configurable
|
||||
// pinned start date. NOT an editorial issue counter — we don't ship weekly
|
||||
// briefings yet — but the same surface area at the top of the energy variant
|
||||
// grid that peer reference sites use as their first-fold consolidator.
|
||||
//
|
||||
// Degraded-mode contract: every tile renders independently. If one of the five
|
||||
// fetches rejects, that tile shows "—" and a `data-degraded="true"` attribute
|
||||
// for QA inspection; the others render normally. Promise.allSettled — never
|
||||
// Promise.all. This is the single most important behavior of the panel: a
|
||||
// stuck Hormuz tracker must not freeze the whole executive overview.
|
||||
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { getRpcBaseUrl } from '@/services/rpc-client';
|
||||
import { fetchHormuzTracker, type HormuzTrackerData } from '@/services/hormuz-tracker';
|
||||
import { getEuGasStorageData } from '@/services/economic';
|
||||
import { fetchCommodityQuotes } from '@/services/market';
|
||||
import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client';
|
||||
import { buildOverviewState, type OverviewState } from './_energy-risk-overview-state';
|
||||
|
||||
const supplyChain = new SupplyChainServiceClient(getRpcBaseUrl(), {
|
||||
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
|
||||
});
|
||||
|
||||
const BRENT_SYMBOL = 'BZ=F';
|
||||
const BRENT_META = [{ symbol: BRENT_SYMBOL, name: 'Brent Crude', display: 'BRENT' }];
|
||||
|
||||
// Default pinned crisis-start date for the running Hormuz situation. Overridable
|
||||
// via VITE_HORMUZ_CRISIS_START_DATE so the date can be re-pinned without a
|
||||
// redeploy when the editorial framing shifts.
|
||||
const DEFAULT_CRISIS_START_DATE = '2026-02-23';
|
||||
const CRISIS_START_DATE: string =
|
||||
(import.meta.env?.VITE_HORMUZ_CRISIS_START_DATE as string | undefined) ||
|
||||
DEFAULT_CRISIS_START_DATE;
|
||||
const CRISIS_START_MS = Date.parse(`${CRISIS_START_DATE}T00:00:00Z`);
|
||||
|
||||
// Map Hormuz status enum → severity color. Values come from
|
||||
// src/services/hormuz-tracker.ts:20: 'closed' | 'disrupted' | 'restricted' | 'open'.
|
||||
// NOT 'normal'/'reduced'/'critical' — that triplet was a misread in earlier
|
||||
// drafts and would silently render as undefined.
|
||||
const HORMUZ_STATUS_COLOR: Record<HormuzTrackerData['status'], string> = {
|
||||
closed: '#e74c3c', // red — passage closed
|
||||
disrupted: '#e74c3c', // red — significant disruption
|
||||
restricted: '#f39c12', // amber — partial constraints
|
||||
open: '#27ae60', // green — flowing normally
|
||||
};
|
||||
const HORMUZ_STATUS_LABEL: Record<HormuzTrackerData['status'], string> = {
|
||||
closed: 'Closed',
|
||||
disrupted: 'Disrupted',
|
||||
restricted: 'Restricted',
|
||||
open: 'Open',
|
||||
};
|
||||
|
||||
// State shape lives in _energy-risk-overview-state.ts so it can be tested
|
||||
// under node:test without pulling in Vite-only modules. The panel's
|
||||
// `state` field is typed loosely (just OverviewState) — the per-tile
|
||||
// renderers cast `value` based on the tile they're rendering. The only
|
||||
// downside is the Hormuz tile loses its enum literal type from
|
||||
// HormuzTrackerData['status']; renderers narrow it again at use site.
|
||||
|
||||
const EMPTY_STATE: OverviewState = {
|
||||
hormuz: { status: 'pending' },
|
||||
euGas: { status: 'pending' },
|
||||
brent: { status: 'pending' },
|
||||
activeDisruptions: { status: 'pending' },
|
||||
};
|
||||
|
||||
export class EnergyRiskOverviewPanel extends Panel {
|
||||
private state: OverviewState = EMPTY_STATE;
|
||||
private freshnessTickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'energy-risk-overview',
|
||||
title: 'Global Energy Risk Overview',
|
||||
defaultRowSpan: 1,
|
||||
infoTooltip:
|
||||
'Consolidated executive view: Strait of Hormuz vessel status, EU gas ' +
|
||||
'storage fill, Brent crude price + 1-day change, active disruption ' +
|
||||
'count, data freshness, and a configurable crisis-day counter. Each ' +
|
||||
'tile renders independently; one source failing does not block the ' +
|
||||
'others.',
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.freshnessTickHandle !== null) {
|
||||
clearInterval(this.freshnessTickHandle);
|
||||
this.freshnessTickHandle = null;
|
||||
}
|
||||
super.destroy?.();
|
||||
}
|
||||
|
||||
public async fetchData(): Promise<void> {
|
||||
const [hormuz, euGas, brent, disruptions] = await Promise.allSettled([
|
||||
fetchHormuzTracker(),
|
||||
getEuGasStorageData(),
|
||||
fetchCommodityQuotes(BRENT_META),
|
||||
// ongoingOnly=true: the panel only ever shows the count of active
|
||||
// disruptions, so let the server filter rather than ship the full
|
||||
// historical 52-event payload to be filtered client-side. This was
|
||||
// a Greptile P2 finding (over-fetch); buildOverviewState's count
|
||||
// calculation handles either response (the redundant client-side
|
||||
// filter remains as defense-in-depth in the state builder).
|
||||
supplyChain.listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: true }),
|
||||
]);
|
||||
this.state = buildOverviewState(hormuz, euGas, brent, disruptions, Date.now());
|
||||
|
||||
if (!this.element?.isConnected) return;
|
||||
this.render();
|
||||
|
||||
// Once we have data, kick a 60s freshness re-render so the "X minutes ago"
|
||||
// string ticks live. No new RPCs — this only updates the freshness label.
|
||||
if (this.freshnessTickHandle === null) {
|
||||
this.freshnessTickHandle = setInterval(() => {
|
||||
if (this.element?.isConnected) this.render();
|
||||
}, 60_000);
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
injectRiskOverviewStylesOnce();
|
||||
const html = `
|
||||
<div class="ero-grid">
|
||||
${this.renderHormuzTile()}
|
||||
${this.renderEuGasTile()}
|
||||
${this.renderBrentTile()}
|
||||
${this.renderActiveDisruptionsTile()}
|
||||
${this.renderFreshnessTile()}
|
||||
${this.renderCrisisDayTile()}
|
||||
</div>
|
||||
`;
|
||||
this.setContent(html);
|
||||
}
|
||||
|
||||
private renderHormuzTile(): string {
|
||||
const t = this.state.hormuz;
|
||||
if (t.status !== 'fulfilled' || !t.value) {
|
||||
return tileHtml('Hormuz', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
// After extracting state-builder into a Vite-free module, the Hormuz
|
||||
// tile's value.status is typed as plain string (not the enum literal
|
||||
// union). Cast at use site so the lookup tables index correctly.
|
||||
const status = t.value.status as HormuzTrackerData['status'];
|
||||
const color = HORMUZ_STATUS_COLOR[status] ?? '#7f8c8d';
|
||||
const label = HORMUZ_STATUS_LABEL[status] ?? t.value.status;
|
||||
return tileHtml('Hormuz', label, color);
|
||||
}
|
||||
|
||||
private renderEuGasTile(): string {
|
||||
const t = this.state.euGas;
|
||||
if (t.status !== 'fulfilled' || !t.value) {
|
||||
return tileHtml('EU Gas', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
const fill = t.value.fillPct.toFixed(0);
|
||||
// Below 30% during refill season is critical; below 50% is amber.
|
||||
const color = t.value.fillPct < 30 ? '#e74c3c' : t.value.fillPct < 50 ? '#f39c12' : '#27ae60';
|
||||
return tileHtml('EU Gas', `${fill}%`, color);
|
||||
}
|
||||
|
||||
private renderBrentTile(): string {
|
||||
const t = this.state.brent;
|
||||
if (t.status !== 'fulfilled' || !t.value) {
|
||||
return tileHtml('Brent', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
const price = `$${t.value.price.toFixed(2)}`;
|
||||
const change = t.value.change;
|
||||
const sign = change >= 0 ? '+' : '';
|
||||
const deltaText = `${sign}${change.toFixed(2)}%`;
|
||||
// Oil price up = bad for energy importers (the dominant Atlas reader).
|
||||
// Up = red. Down = green. Inverted from a usual market panel.
|
||||
const color = change >= 0 ? '#e74c3c' : '#27ae60';
|
||||
return tileHtml('Brent', price, color, '', deltaText);
|
||||
}
|
||||
|
||||
private renderActiveDisruptionsTile(): string {
|
||||
const t = this.state.activeDisruptions;
|
||||
if (t.status !== 'fulfilled' || !t.value) {
|
||||
return tileHtml('Active disruptions', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
const n = t.value.count;
|
||||
const color = n === 0 ? '#27ae60' : n < 5 ? '#f39c12' : '#e74c3c';
|
||||
return tileHtml('Active disruptions', String(n), color);
|
||||
}
|
||||
|
||||
private renderFreshnessTile(): string {
|
||||
// Youngest fetchedAt across all 4 upstream signals.
|
||||
const tiles = [this.state.hormuz, this.state.euGas, this.state.brent, this.state.activeDisruptions];
|
||||
const fetchedAts = tiles
|
||||
.map(t => t.fetchedAt)
|
||||
.filter((v): v is number => typeof v === 'number');
|
||||
if (fetchedAts.length === 0) {
|
||||
return tileHtml('Updated', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
const youngest = Math.max(...fetchedAts);
|
||||
const ageMin = Math.floor((Date.now() - youngest) / 60_000);
|
||||
const label = ageMin <= 0 ? 'just now' : ageMin === 1 ? '1 min ago' : `${ageMin} min ago`;
|
||||
return tileHtml('Updated', label, '#7f8c8d');
|
||||
}
|
||||
|
||||
private renderCrisisDayTile(): string {
|
||||
if (!Number.isFinite(CRISIS_START_MS)) {
|
||||
// Mis-configured env (Date.parse returned NaN). Fail loudly via "—"
|
||||
// rather than rendering "Day NaN" or "Day -50".
|
||||
return tileHtml('Hormuz crisis', '—', '#7f8c8d', 'data-degraded="true"');
|
||||
}
|
||||
const days = Math.floor((Date.now() - CRISIS_START_MS) / 86_400_000);
|
||||
if (days < 0) {
|
||||
// Future-dated start: still render but with a sentinel value.
|
||||
return tileHtml('Hormuz crisis', 'pending', '#7f8c8d');
|
||||
}
|
||||
return tileHtml('Hormuz crisis', `Day ${days}`, '#7f8c8d');
|
||||
}
|
||||
}
|
||||
|
||||
function tileHtml(label: string, value: string, color: string, attrs = '', sub = ''): string {
|
||||
const subHtml = sub ? `<div class="ero-tile__sub" style="color:${color}">${escapeHtml(sub)}</div>` : '';
|
||||
return `
|
||||
<div class="ero-tile" ${attrs}>
|
||||
<div class="ero-tile__label">${escapeHtml(label)}</div>
|
||||
<div class="ero-tile__value" style="color:${color}">${escapeHtml(value)}</div>
|
||||
${subHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// CSS is injected once into <head> rather than emitted into the panel body.
|
||||
// Pre-fix, the freshness setInterval re-rendered every 60s and called
|
||||
// setContent(html + <style>...) — the style tag was torn out and re-inserted
|
||||
// on every tick. Now the panel HTML is style-free; the rules live in head.
|
||||
let _riskOverviewStylesInjected = false;
|
||||
function injectRiskOverviewStylesOnce(): void {
|
||||
if (_riskOverviewStylesInjected) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('data-ero-styles', '');
|
||||
style.textContent = RISK_OVERVIEW_CSS;
|
||||
document.head.appendChild(style);
|
||||
_riskOverviewStylesInjected = true;
|
||||
}
|
||||
|
||||
const RISK_OVERVIEW_CSS = `
|
||||
.ero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.ero-tile {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.ero-tile__label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ero-tile__value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.ero-tile__sub {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
`;
|
||||
84
src/components/_energy-risk-overview-state.ts
Normal file
84
src/components/_energy-risk-overview-state.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Pure state-building logic for EnergyRiskOverviewPanel. Extracted from the
|
||||
// panel class so it can be imported under node:test without pulling in the
|
||||
// Vite-only modules the panel transitively depends on (i18n's import.meta.glob,
|
||||
// etc). Keep this file dep-free apart from generated types.
|
||||
|
||||
export interface TileState<T> {
|
||||
status: 'fulfilled' | 'rejected' | 'pending';
|
||||
value?: T;
|
||||
fetchedAt?: number;
|
||||
}
|
||||
|
||||
export interface OverviewState {
|
||||
hormuz: TileState<{ status: string }>;
|
||||
euGas: TileState<{ fillPct: number; fillPctChange1d: number }>;
|
||||
brent: TileState<{ price: number; change: number }>;
|
||||
activeDisruptions: TileState<{ count: number }>;
|
||||
}
|
||||
|
||||
// Minimal shapes — only the fields the state builder reads. Loose enough that
|
||||
// tests can pass plain objects without importing the full generated types.
|
||||
interface HormuzMin { status?: string }
|
||||
interface EuGasMin { unavailable?: boolean; fillPct?: number; fillPctChange1d?: number }
|
||||
interface BrentResultMin { data?: Array<{ price: number | null; change?: number | null }> }
|
||||
interface DisruptionsMin {
|
||||
upstreamUnavailable?: boolean;
|
||||
events?: Array<{ endAt?: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OverviewState from the four allSettled results. Pure: no I/O,
|
||||
* no Date.now() unless the caller passes a clock. Each tile resolves to
|
||||
* 'fulfilled' or 'rejected' independently — one source failing CANNOT
|
||||
* cascade into the others. This is the core degraded-mode contract the
|
||||
* panel guarantees.
|
||||
*/
|
||||
export function buildOverviewState(
|
||||
hormuz: PromiseSettledResult<HormuzMin | null | undefined>,
|
||||
euGas: PromiseSettledResult<EuGasMin | null | undefined>,
|
||||
brent: PromiseSettledResult<BrentResultMin | null | undefined>,
|
||||
disruptions: PromiseSettledResult<DisruptionsMin | null | undefined>,
|
||||
now: number,
|
||||
): OverviewState {
|
||||
return {
|
||||
hormuz: hormuz.status === 'fulfilled' && hormuz.value && hormuz.value.status
|
||||
? { status: 'fulfilled', value: { status: hormuz.value.status }, fetchedAt: now }
|
||||
: { status: 'rejected' },
|
||||
euGas: euGas.status === 'fulfilled' && euGas.value && !euGas.value.unavailable && (euGas.value.fillPct ?? 0) > 0
|
||||
? {
|
||||
status: 'fulfilled',
|
||||
value: {
|
||||
fillPct: euGas.value.fillPct as number,
|
||||
fillPctChange1d: euGas.value.fillPctChange1d ?? 0,
|
||||
},
|
||||
fetchedAt: now,
|
||||
}
|
||||
: { status: 'rejected' },
|
||||
brent: (() => {
|
||||
if (brent.status !== 'fulfilled' || !brent.value || !brent.value.data || brent.value.data.length === 0) {
|
||||
return { status: 'rejected' as const };
|
||||
}
|
||||
const q = brent.value.data[0];
|
||||
if (!q || q.price === null) return { status: 'rejected' as const };
|
||||
return {
|
||||
status: 'fulfilled' as const,
|
||||
value: { price: q.price, change: q.change ?? 0 },
|
||||
fetchedAt: now,
|
||||
};
|
||||
})(),
|
||||
activeDisruptions: disruptions.status === 'fulfilled' && disruptions.value && !disruptions.value.upstreamUnavailable
|
||||
? {
|
||||
status: 'fulfilled',
|
||||
value: { count: (disruptions.value.events ?? []).filter((e) => !e.endAt).length },
|
||||
fetchedAt: now,
|
||||
}
|
||||
: { status: 'rejected' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for tests: count tiles that are in degraded ('rejected') state.
|
||||
*/
|
||||
export function countDegradedTiles(state: OverviewState): number {
|
||||
return Object.values(state).filter((t) => t.status === 'rejected').length;
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export { PipelineStatusPanel } from './PipelineStatusPanel';
|
||||
export { StorageFacilityMapPanel } from './StorageFacilityMapPanel';
|
||||
export { FuelShortagePanel } from './FuelShortagePanel';
|
||||
export { EnergyDisruptionsPanel } from './EnergyDisruptionsPanel';
|
||||
export { EnergyRiskOverviewPanel } from './EnergyRiskOverviewPanel';
|
||||
export * from './ClimateNewsPanel';
|
||||
export * from './DiseaseOutbreaksPanel';
|
||||
export * from './SocialVelocityPanel';
|
||||
|
||||
@@ -110,6 +110,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:storage-facility-map', keywords: ['storage', 'storage facilities', 'strategic storage', 'spr', 'lng', 'lng terminals', 'ugs', 'tank farms', 'storage atlas'], label: 'Panel: Strategic Storage Atlas', icon: '\u{1F6E2}\uFE0F', category: 'panels' },
|
||||
{ id: 'panel:fuel-shortages', keywords: ['fuel shortages', 'shortage', 'petrol shortage', 'diesel shortage', 'jet fuel shortage', 'rationing', 'stations closed'], label: 'Panel: Global Fuel Shortage Registry', icon: '⛽', category: 'panels' },
|
||||
{ id: 'panel:energy-disruptions', keywords: ['energy disruptions', 'disruption log', 'disruption timeline', 'energy events', 'sanctions events', 'pipeline sabotage', 'nord stream sabotage', 'drone strike', 'force majeure', 'mechanical failure'], label: 'Panel: Energy Disruptions Log', icon: '\u{1F4A5}', category: 'panels' },
|
||||
{ id: 'panel:energy-risk-overview', keywords: ['risk overview', 'energy risk', 'executive overview', 'energy dashboard', 'hormuz status', 'eu gas fill', 'brent price', 'crisis day', 'energy executive'], label: 'Panel: Global Energy Risk Overview', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:gov', keywords: ['government', 'gov'], label: 'Panel: Government', icon: '\u{1F3DB}\uFE0F', category: 'panels' },
|
||||
{ id: 'panel:policy', keywords: ['policy', 'ai policy', 'regulation', 'tech policy'], label: 'Panel: AI Policy & Regulation', icon: '\u{1F4DC}', category: 'panels' },
|
||||
{ id: 'panel:thinktanks', keywords: ['think tanks', 'thinktanks', 'analysis'], label: 'Panel: Think Tanks', icon: '\u{1F9E0}', category: 'panels' },
|
||||
|
||||
@@ -81,6 +81,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
'storage-facility-map': { name: 'Strategic Storage Atlas', enabled: true, priority: 2 },
|
||||
'fuel-shortages': { name: 'Global Fuel Shortage Registry', enabled: true, priority: 2 },
|
||||
'energy-disruptions': { name: 'Energy Disruptions Log', enabled: true, priority: 2 },
|
||||
'energy-risk-overview': { name: 'Global Energy Risk Overview', enabled: false, priority: 2 },
|
||||
'gulf-economies': { name: 'Gulf Economies', enabled: false, priority: 2 },
|
||||
'consumer-prices': { name: 'Consumer Prices', enabled: false, priority: 2 },
|
||||
'grocery-basket': { name: 'Grocery Index', enabled: false, priority: 2 },
|
||||
@@ -929,6 +930,7 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
// ============================================
|
||||
const ENERGY_PANELS: Record<string, PanelConfig> = {
|
||||
map: { name: 'Energy Atlas Map', enabled: true, priority: 1 },
|
||||
'energy-risk-overview': { name: 'Global Energy Risk Overview', enabled: true, priority: 1 },
|
||||
'chokepoint-strip': { name: 'Chokepoint Status', enabled: true, priority: 1 },
|
||||
'pipeline-status': { name: 'Oil & Gas Pipeline Status', enabled: true, priority: 1 },
|
||||
'storage-facility-map': { name: 'Strategic Storage Atlas', enabled: true, priority: 1 },
|
||||
@@ -1242,7 +1244,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
marketsFinance: {
|
||||
labelKey: 'header.panelCatMarketsFinance',
|
||||
panelKeys: ['commodities', 'energy-complex', 'pipeline-status', 'storage-facility-map', 'fuel-shortages', 'energy-disruptions', 'hormuz-tracker', 'energy-crisis', 'markets', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
panelKeys: ['commodities', 'energy-complex', 'energy-risk-overview', 'pipeline-status', 'storage-facility-map', 'fuel-shortages', 'energy-disruptions', 'hormuz-tracker', 'energy-crisis', 'markets', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
},
|
||||
topical: {
|
||||
labelKey: 'header.panelCatTopical',
|
||||
|
||||
418
tests/energy-risk-overview-panel.test.mts
Normal file
418
tests/energy-risk-overview-panel.test.mts
Normal file
@@ -0,0 +1,418 @@
|
||||
// @ts-check
|
||||
//
|
||||
// Tests for src/components/EnergyRiskOverviewPanel.ts — the executive
|
||||
// overview panel composing 5 existing data sources with degraded-mode
|
||||
// fallback. The single most important behavior is that one slow/failing
|
||||
// source does NOT freeze the others (Promise.allSettled, never .all).
|
||||
//
|
||||
// Test strategy:
|
||||
//
|
||||
// 1. Color/threshold/label helpers are PINNED inline — they encode product
|
||||
// decisions (importer-leaning Brent inversion, Hormuz status enum
|
||||
// rejection of the wrong-cased triplet) and shouldn't drift via a
|
||||
// copy-paste edit in the panel file.
|
||||
//
|
||||
// 2. The state-building logic is extracted into
|
||||
// `src/components/_energy-risk-overview-state.ts` so we can import
|
||||
// and exercise it end-to-end without pulling in the panel's Vite-only
|
||||
// transitive deps (i18n's `import.meta.glob`, etc). This is the
|
||||
// "real component test" Codex review #3398 P2 asked for: it imports
|
||||
// the production state builder the panel actually uses.
|
||||
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test, describe } from 'node:test';
|
||||
import { buildOverviewState, countDegradedTiles } from '../src/components/_energy-risk-overview-state.ts';
|
||||
|
||||
// Pure helpers extracted from the panel for unit testing. The actual panel
|
||||
// uses these inline; this file pins their contract so future edits can't
|
||||
// silently change semantics (e.g. flipping the Brent up=red convention).
|
||||
|
||||
function hormuzColor(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
closed: '#e74c3c',
|
||||
disrupted: '#e74c3c',
|
||||
restricted: '#f39c12',
|
||||
open: '#27ae60',
|
||||
};
|
||||
return map[status] ?? '#7f8c8d';
|
||||
}
|
||||
|
||||
function euGasColor(fillPct: number): string {
|
||||
if (fillPct < 30) return '#e74c3c';
|
||||
if (fillPct < 50) return '#f39c12';
|
||||
return '#27ae60';
|
||||
}
|
||||
|
||||
function brentColor(change: number): string {
|
||||
// Atlas reader is energy-importer-leaning: oil price UP = red (bad);
|
||||
// DOWN = green (relief). Inverted from a default market panel.
|
||||
return change >= 0 ? '#e74c3c' : '#27ae60';
|
||||
}
|
||||
|
||||
function activeDisruptionsColor(n: number): string {
|
||||
if (n === 0) return '#27ae60';
|
||||
if (n < 5) return '#f39c12';
|
||||
return '#e74c3c';
|
||||
}
|
||||
|
||||
function freshnessLabel(youngestMs: number, nowMs: number): string {
|
||||
const ageMin = Math.floor((nowMs - youngestMs) / 60_000);
|
||||
if (ageMin <= 0) return 'just now';
|
||||
if (ageMin === 1) return '1 min ago';
|
||||
return `${ageMin} min ago`;
|
||||
}
|
||||
|
||||
function crisisDayLabel(crisisStartMs: number, nowMs: number): string {
|
||||
if (!Number.isFinite(crisisStartMs)) return '—';
|
||||
const days = Math.floor((nowMs - crisisStartMs) / 86_400_000);
|
||||
if (days < 0) return 'pending';
|
||||
return `Day ${days}`;
|
||||
}
|
||||
|
||||
describe('EnergyRiskOverviewPanel — Hormuz status color', () => {
|
||||
test("'closed' and 'disrupted' both render red (severity equivalent)", () => {
|
||||
assert.equal(hormuzColor('closed'), '#e74c3c');
|
||||
assert.equal(hormuzColor('disrupted'), '#e74c3c');
|
||||
});
|
||||
|
||||
test("'restricted' renders amber", () => {
|
||||
assert.equal(hormuzColor('restricted'), '#f39c12');
|
||||
});
|
||||
|
||||
test("'open' renders green", () => {
|
||||
assert.equal(hormuzColor('open'), '#27ae60');
|
||||
});
|
||||
|
||||
test('unknown status falls back to neutral gray (degraded sentinel)', () => {
|
||||
// If the upstream enum ever drifts (e.g. someone adds 'minor-incident'),
|
||||
// the panel must not throw — gray sentinel is the fallback.
|
||||
assert.equal(hormuzColor('weird-new-state'), '#7f8c8d');
|
||||
});
|
||||
|
||||
test('rejects the wrong-cased triplet from earlier drafts', () => {
|
||||
// 'normal'|'reduced'|'critical' was the WRONG enum. None of those values
|
||||
// are valid; all should fall to gray sentinel.
|
||||
assert.equal(hormuzColor('normal'), '#7f8c8d');
|
||||
assert.equal(hormuzColor('reduced'), '#7f8c8d');
|
||||
assert.equal(hormuzColor('critical'), '#7f8c8d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — EU Gas color thresholds', () => {
|
||||
test('< 30% fill → red', () => {
|
||||
assert.equal(euGasColor(28), '#e74c3c');
|
||||
assert.equal(euGasColor(0), '#e74c3c');
|
||||
assert.equal(euGasColor(29.9), '#e74c3c');
|
||||
});
|
||||
|
||||
test('30%–49% fill → amber', () => {
|
||||
assert.equal(euGasColor(30), '#f39c12');
|
||||
assert.equal(euGasColor(42), '#f39c12');
|
||||
assert.equal(euGasColor(49.9), '#f39c12');
|
||||
});
|
||||
|
||||
test('≥ 50% fill → green', () => {
|
||||
assert.equal(euGasColor(50), '#27ae60');
|
||||
assert.equal(euGasColor(90), '#27ae60');
|
||||
assert.equal(euGasColor(100), '#27ae60');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — Brent color (importer-leaning inversion)', () => {
|
||||
test('positive change → red (oil up = bad for importers)', () => {
|
||||
assert.equal(brentColor(0.5), '#e74c3c');
|
||||
assert.equal(brentColor(10), '#e74c3c');
|
||||
assert.equal(brentColor(0), '#e74c3c'); // exact zero → red (no-change is neutral-bearish)
|
||||
});
|
||||
|
||||
test('negative change → green', () => {
|
||||
assert.equal(brentColor(-0.5), '#27ae60');
|
||||
assert.equal(brentColor(-12), '#27ae60');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — active disruptions color', () => {
|
||||
test('0 active → green', () => {
|
||||
assert.equal(activeDisruptionsColor(0), '#27ae60');
|
||||
});
|
||||
|
||||
test('1-4 active → amber', () => {
|
||||
assert.equal(activeDisruptionsColor(1), '#f39c12');
|
||||
assert.equal(activeDisruptionsColor(4), '#f39c12');
|
||||
});
|
||||
|
||||
test('5+ active → red', () => {
|
||||
assert.equal(activeDisruptionsColor(5), '#e74c3c');
|
||||
assert.equal(activeDisruptionsColor(50), '#e74c3c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — freshness label', () => {
|
||||
test('age 0 → "just now"', () => {
|
||||
const now = Date.now();
|
||||
assert.equal(freshnessLabel(now, now), 'just now');
|
||||
});
|
||||
|
||||
test('age 1 minute → "1 min ago"', () => {
|
||||
const now = Date.now();
|
||||
assert.equal(freshnessLabel(now - 60_000, now), '1 min ago');
|
||||
});
|
||||
|
||||
test('age 5 minutes → "5 min ago"', () => {
|
||||
const now = Date.now();
|
||||
assert.equal(freshnessLabel(now - 5 * 60_000, now), '5 min ago');
|
||||
});
|
||||
|
||||
test('age slightly under 1 min still shows "just now"', () => {
|
||||
const now = Date.now();
|
||||
assert.equal(freshnessLabel(now - 30_000, now), 'just now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — crisis-day counter', () => {
|
||||
test('today exactly 0 days from start → "Day 0"', () => {
|
||||
const start = Date.UTC(2026, 3, 25); // 2026-04-25
|
||||
const now = Date.UTC(2026, 3, 25, 12, 0, 0); // same day, noon
|
||||
assert.equal(crisisDayLabel(start, now), 'Day 0');
|
||||
});
|
||||
|
||||
test('5 days after start → "Day 5"', () => {
|
||||
const start = Date.UTC(2026, 3, 25);
|
||||
const now = Date.UTC(2026, 3, 30);
|
||||
assert.equal(crisisDayLabel(start, now), 'Day 5');
|
||||
});
|
||||
|
||||
test('default 2026-02-23 start gives a positive day count today', () => {
|
||||
const start = Date.parse('2026-02-23T00:00:00Z');
|
||||
const now = Date.parse('2026-04-25T12:00:00Z');
|
||||
assert.equal(crisisDayLabel(start, now), 'Day 61');
|
||||
});
|
||||
|
||||
test('NaN start (mis-configured env) → "—" sentinel', () => {
|
||||
assert.equal(crisisDayLabel(NaN, Date.now()), '—');
|
||||
});
|
||||
|
||||
test('future-dated start → "pending" sentinel', () => {
|
||||
const start = Date.now() + 86_400_000; // tomorrow
|
||||
assert.equal(crisisDayLabel(start, Date.now()), 'pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnergyRiskOverviewPanel — degraded-mode contract', () => {
|
||||
// The real panel uses Promise.allSettled and renders each tile
|
||||
// independently. We pin the contract here as a state-shape guarantee:
|
||||
// if all four upstream signals fail, the panel must still produce
|
||||
// 6 tiles (4 data + freshness + crisis-day), with the 4 data tiles
|
||||
// each marked data-degraded. We assert this against a stub state.
|
||||
|
||||
function renderTileShape(state: 'fulfilled' | 'rejected'): { degraded: boolean; visible: boolean } {
|
||||
return {
|
||||
visible: true, // every tile renders regardless
|
||||
degraded: state === 'rejected', // failed tiles get the data-degraded marker
|
||||
};
|
||||
}
|
||||
|
||||
test('all-fail state still produces 6 visible tiles', () => {
|
||||
const tiles = [
|
||||
renderTileShape('rejected'), // hormuz
|
||||
renderTileShape('rejected'), // euGas
|
||||
renderTileShape('rejected'), // brent
|
||||
renderTileShape('rejected'), // active disruptions
|
||||
// freshness + crisis day always visible (computed locally)
|
||||
renderTileShape('fulfilled'),
|
||||
renderTileShape('fulfilled'),
|
||||
];
|
||||
assert.equal(tiles.filter(t => t.visible).length, 6);
|
||||
assert.equal(tiles.filter(t => t.degraded).length, 4);
|
||||
});
|
||||
|
||||
test('one-fail state shows 1 degraded tile and 5 normal', () => {
|
||||
const tiles = [
|
||||
renderTileShape('fulfilled'),
|
||||
renderTileShape('rejected'), // EU gas down
|
||||
renderTileShape('fulfilled'),
|
||||
renderTileShape('fulfilled'),
|
||||
renderTileShape('fulfilled'),
|
||||
renderTileShape('fulfilled'),
|
||||
];
|
||||
assert.equal(tiles.filter(t => t.degraded).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Real state-builder tests — import the SAME helper the panel uses (per
|
||||
// review #3398 P2). Exercises the Promise.allSettled → OverviewState
|
||||
// translation that the panel's fetchData() relies on.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = 1735000000000; // fixed clock so fetchedAt assertions are deterministic
|
||||
|
||||
function fulfilled<T>(value: T): PromiseFulfilledResult<T> {
|
||||
return { status: 'fulfilled', value };
|
||||
}
|
||||
function rejected(reason = new Error('test')): PromiseRejectedResult {
|
||||
return { status: 'rejected', reason };
|
||||
}
|
||||
|
||||
describe('EnergyRiskOverviewPanel — buildOverviewState (real component logic)', () => {
|
||||
test('all four sources fulfilled → 0 degraded tiles', () => {
|
||||
const state = buildOverviewState(
|
||||
fulfilled({ status: 'open' }),
|
||||
fulfilled({ unavailable: false, fillPct: 75, fillPctChange1d: 0.5 }),
|
||||
fulfilled({ data: [{ price: 88.5, change: -0.3 }] }),
|
||||
fulfilled({ upstreamUnavailable: false, events: [{ endAt: null }, { endAt: '2026-01-01' }, { endAt: null }] }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(countDegradedTiles(state), 0);
|
||||
assert.equal(state.hormuz.status, 'fulfilled');
|
||||
assert.equal(state.hormuz.value?.status, 'open');
|
||||
assert.equal(state.euGas.value?.fillPct, 75);
|
||||
assert.equal(state.brent.value?.price, 88.5);
|
||||
assert.equal(state.activeDisruptions.value?.count, 2, 'only events with endAt === null are active');
|
||||
assert.equal(state.hormuz.fetchedAt, NOW);
|
||||
});
|
||||
|
||||
test('all four sources rejected → 4 degraded tiles, no throw, no cascade', () => {
|
||||
// The single most important behavior: Promise.allSettled never throws,
|
||||
// every tile resolves to a state independently. This is the core
|
||||
// degraded-mode contract — one source failing CANNOT cascade.
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(countDegradedTiles(state), 4);
|
||||
for (const t of Object.values(state)) {
|
||||
assert.equal(t.status, 'rejected');
|
||||
assert.equal(t.fetchedAt, undefined, 'rejected tiles must not carry a fetchedAt');
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: hormuz fulfilled, others rejected → only hormuz tile populated', () => {
|
||||
const state = buildOverviewState(
|
||||
fulfilled({ status: 'disrupted' }),
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(countDegradedTiles(state), 3);
|
||||
assert.equal(state.hormuz.status, 'fulfilled');
|
||||
assert.equal(state.hormuz.value?.status, 'disrupted');
|
||||
});
|
||||
|
||||
test('euGas with unavailable: true → degraded (treats sentinel as failure)', () => {
|
||||
// The euGas service returns a sentinel `{ unavailable: true, ... }`
|
||||
// shape on relay outage. The panel must NOT show those zeros as a
|
||||
// valid 0% fill — that would be a false alarm.
|
||||
const state = buildOverviewState(
|
||||
fulfilled({ status: 'open' }),
|
||||
fulfilled({ unavailable: true, fillPct: 0, fillPctChange1d: 0 }),
|
||||
fulfilled({ data: [{ price: 88, change: 0 }] }),
|
||||
fulfilled({ upstreamUnavailable: false, events: [] }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.euGas.status, 'rejected');
|
||||
});
|
||||
|
||||
test('euGas with fillPct=0 → degraded (treated as no-data)', () => {
|
||||
// 0% fill is not a legitimate state in the EU storage cycle; treating
|
||||
// it as fulfilled would render a misleading "EU GAS 0%" tile in red.
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
fulfilled({ unavailable: false, fillPct: 0, fillPctChange1d: 0 }),
|
||||
rejected(),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.euGas.status, 'rejected');
|
||||
});
|
||||
|
||||
test('brent with empty data array → degraded', () => {
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
rejected(),
|
||||
fulfilled({ data: [] }),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.brent.status, 'rejected');
|
||||
});
|
||||
|
||||
test('brent with first quote price=null → degraded (no-data sentinel)', () => {
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
rejected(),
|
||||
fulfilled({ data: [{ price: null, change: 0 }] }),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.brent.status, 'rejected');
|
||||
});
|
||||
|
||||
test('disruptions with upstreamUnavailable: true → degraded', () => {
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
fulfilled({ upstreamUnavailable: true, events: [] }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.activeDisruptions.status, 'rejected');
|
||||
});
|
||||
|
||||
test('disruptions ongoing-only filter: only events with endAt===null count', () => {
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
fulfilled({
|
||||
upstreamUnavailable: false,
|
||||
events: [
|
||||
{ endAt: null }, // ongoing
|
||||
{ endAt: '2026-04-20' }, // resolved
|
||||
{ endAt: undefined }, // ongoing (undefined is falsy too)
|
||||
{ endAt: '' }, // ongoing (empty string is falsy)
|
||||
{ endAt: null }, // ongoing
|
||||
],
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.activeDisruptions.value?.count, 4);
|
||||
});
|
||||
|
||||
test('hormuz fulfilled but value.status missing → degraded (sentinel for malformed response)', () => {
|
||||
// Defense-in-depth: a bad shape from the upstream relay shouldn't
|
||||
// render an empty Hormuz tile that says "undefined".
|
||||
const state = buildOverviewState(
|
||||
fulfilled({} as { status?: string }),
|
||||
rejected(),
|
||||
rejected(),
|
||||
rejected(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.hormuz.status, 'rejected');
|
||||
});
|
||||
|
||||
test('one slow source rejecting must not cascade to fulfilled siblings', () => {
|
||||
// This is the exact failure mode review #3398 P2 was checking the
|
||||
// panel for. With Promise.all, one rejection would short-circuit the
|
||||
// whole batch. With Promise.allSettled (which the panel uses) and
|
||||
// buildOverviewState (which the panel calls), each tile resolves
|
||||
// independently. Pin that contract.
|
||||
const state = buildOverviewState(
|
||||
rejected(),
|
||||
fulfilled({ unavailable: false, fillPct: 50, fillPctChange1d: 0 }),
|
||||
fulfilled({ data: [{ price: 80, change: 1 }] }),
|
||||
fulfilled({ upstreamUnavailable: false, events: [] }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(state.hormuz.status, 'rejected');
|
||||
assert.equal(state.euGas.status, 'fulfilled');
|
||||
assert.equal(state.brent.status, 'fulfilled');
|
||||
assert.equal(state.activeDisruptions.status, 'fulfilled');
|
||||
assert.equal(countDegradedTiles(state), 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user