mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(supply-chain): split chokepoint transit data + close silent zero-state cache
Production supply-chain panel was rendering 13 empty chokepoints because
the getChokepointStatus RPC silently cached zero-state for 5 minutes:
1. supply_chain:transit-summaries:v1 grew to ~500 KB (180d × 13 × 14 fields
of history per chokepoint).
2. REDIS_OP_TIMEOUT_MS is 1.5 s. Vercel Sydney edge → Upstash for a 500 KB
GET consistently exceeded the budget; getCachedJson caught the AbortError
and returned null.
3. The 500 KB portwatch fallback read hit the same timeout.
4. summaries = {} → every summaries[cp.id] was undefined → 13 chokepoints
got the zero-state default → cached as a non-null success response for
REDIS_CACHE_TTL (5 min) instead of NEG_SENTINEL (120 s).
Fix (one PR, per docs/plans/chokepoint-rpc-payload-split.md):
- ais-relay.cjs: split seedTransitSummaries output.
- supply_chain:transit-summaries:v1 — compact (~30 KB, no history).
- supply_chain:transit-summaries:history:v1:{id} — per chokepoint
(~35 KB each, 13 keys). Both under the 1.5 s Redis read budget.
- New RPC GetChokepointHistory: lazy-loaded on card expand.
- get-chokepoint-status.ts: drop the 500 KB portwatch/corridorrisk/
chokepoint_transits fallback reads. Treat a null transit-summaries
read as upstreamUnavailable=true so cachedFetchJson writes NEG_SENTINEL
(2 min) instead of a 5-min zero-state pin. Omit history from the
response (proto field stays declared; empty array).
- server/_shared/redis.ts: tag AbortError timeouts with [REDIS-TIMEOUT]
key=… timeoutMs=… so log drains / Sentry-Vercel integration pick up
large-payload timeouts instead of them being silently swallowed.
- SupplyChainPanel.ts + MapPopup.ts: lazy-fetch history on card expand
via fetchChokepointHistory; session-scoped cache; graceful "History
unavailable" on empty/error. PRO gating on the map popup unchanged.
- Gateway: cache-tier entry for /get-chokepoint-history (slow).
- Tests: regression guards for upstreamUnavailable gate + per-id key
shape + handler wiring + proto query annotations.
Audit included in plan: no other RPC consumer read stacks >200 KB
besides displacement:summary:v1:2026 (724 KB, same risk, flagged for
follow-up PR). wildfire:fires:v1 at 1.7 MB loads via bootstrap (3 s
timeout, different path) — monitor but out of scope.
Expected impact:
- supply_chain:chokepoints:v4 payload drops from ~508 KB to <100 KB.
- supply_chain:transit-summaries:v1 drops from ~502 KB to <50 KB.
- RPC Redis reads stay well under 1.5 s in the hot path.
- Silent zero-state pinning is now impossible: null reads → 2-min neg
cache → self-heal on next relay tick.
* fix(supply-chain): address PR #3185 review — stop caching empty/error + fix partial coverage
Two P1 regressions caught in review:
1. Client cache poisoning on empty/error (MapPopup.ts, SupplyChainPanel.ts)
Empty-array is truthy in JS, so MapPopup's `!cached && !inflight` branch
never fired once we cached []. Neither `cached && cached.length` fired
either — popup stuck on "Loading transit history..." for the session.
SupplyChainPanel had the explicit `cached && !cached.length` branch but
still never retried, so the same transient became session-sticky there too.
Fix: cache ONLY non-empty successful responses. Empty/error show the
"History unavailable" placeholder but leave the cache untouched, so the
next re-expand retries. The /get-chokepoint-history gateway tier is
"slow" (5-min CF edge cache) → retries stay cheap.
2. Partial portwatch coverage treated as healthy (ais-relay.cjs)
seedTransitSummaries iterated Object.entries(pw), so if seed-portwatch
dropped N of 13 chokepoints (ArcGIS reject/empty), summaries had <13 keys.
get-chokepoint-status upstreamUnavailable fires only on fully-empty
summaries, so the N missing chokepoints fell through to zero-state rows
that got pinned in cache for 5 minutes.
Fix: iterate CANONICAL_IDS (Object.keys(CHOKEPOINT_THREAT_LEVELS)) and
fill zero-state for any ID missing from pw. Shape is consistently 13
keys. Track pwCovered → envelope + seed-meta recordCount reflect real
upstream coverage (not shape size), so health.js can distinguish 13/13
healthy from 10/13 partial. Warn-log on shortfall.
Tests: new regression guards
- panel must NOT cache empty arrays (historyCache.set with []).
- writer must iterate CANONICAL_IDS, not Object.entries(pw).
- seed-meta recordCount binds to pwCovered.
5718/5718 data tests pass. typecheck + typecheck:api clean.
227 lines
10 KiB
JavaScript
227 lines
10 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const panelSrc = readFileSync(resolve(__dirname, '..', 'src', 'components', 'SupplyChainPanel.ts'), 'utf-8');
|
|
|
|
// Structural tests verify the transit chart mount/cleanup contract is implemented correctly.
|
|
// These test the source patterns rather than extracting and executing method bodies,
|
|
// which avoids fragile string-to-function compilation.
|
|
|
|
describe('SupplyChainPanel transit chart mount contract', () => {
|
|
|
|
it('render() calls clearTransitChart() before any content change', () => {
|
|
// The first line inside render() must clear previous chart state
|
|
const renderMatch = panelSrc.match(/private\s+render\(\)[\s\S]*?\{([\s\S]*?)this\.setContent/);
|
|
assert.ok(renderMatch, 'render method should exist and call setContent');
|
|
assert.ok(
|
|
renderMatch[1].includes('this.clearTransitChart()'),
|
|
'render must call clearTransitChart() before setContent to prevent stale chart references'
|
|
);
|
|
});
|
|
|
|
it('clearTransitChart() cancels timer, disconnects observer, and destroys chart', () => {
|
|
const clearStart = panelSrc.indexOf('clearTransitChart(): void {');
|
|
assert.ok(clearStart !== -1, 'clearTransitChart method should exist');
|
|
const body = panelSrc.slice(clearStart, clearStart + 300);
|
|
assert.ok(body.includes('clearTimeout'), 'must cancel pending timer');
|
|
assert.ok(body.includes('chartMountTimer') && body.includes('null'), 'must null the timer handle');
|
|
assert.ok(body.includes('disconnect'), 'must disconnect MutationObserver');
|
|
assert.ok(body.includes('transitChart.destroy'), 'must destroy the chart instance');
|
|
});
|
|
|
|
it('sets up MutationObserver when chokepoint is expanded', () => {
|
|
// After setContent, if activeTab is chokepoints and expandedChokepoint is set,
|
|
// a MutationObserver should be created to detect DOM readiness
|
|
assert.ok(
|
|
panelSrc.includes('new MutationObserver'),
|
|
'render must create a MutationObserver for chart mount detection'
|
|
);
|
|
assert.ok(
|
|
panelSrc.includes('.observe(this.content'),
|
|
'observer must watch this.content for childList mutations'
|
|
);
|
|
});
|
|
|
|
it('has a fallback timer for no-op renders where MutationObserver does not fire', () => {
|
|
// When setContent short-circuits (identical HTML), no mutation fires.
|
|
// A fallback timer ensures the chart still mounts.
|
|
const timerMatch = panelSrc.match(/this\.chartMountTimer\s*=\s*setTimeout\(/);
|
|
assert.ok(timerMatch, 'must schedule a fallback setTimeout for chart mount');
|
|
|
|
// The timer should have a reasonable delay (100-500ms)
|
|
const delayMatch = panelSrc.match(/chartMountTimer\s*=\s*setTimeout\([^,]+,\s*(\d+)\)/);
|
|
assert.ok(delayMatch, 'timer must have an explicit delay');
|
|
const delay = parseInt(delayMatch[1], 10);
|
|
assert.ok(delay >= 100 && delay <= 500, `timer delay ${delay}ms should be 100-500ms`);
|
|
});
|
|
|
|
it('fallback timer clears itself and disconnects observer after mounting', () => {
|
|
// Inside the fallback timer callback, after successful mount:
|
|
// 1. Disconnect the observer (no longer needed)
|
|
// 2. Set chartMountTimer = null (prevent double-cleanup)
|
|
const timerBody = panelSrc.match(/chartMountTimer\s*=\s*setTimeout\(\(\)\s*=>\s*\{([\s\S]*?)\},\s*\d+\)/);
|
|
assert.ok(timerBody, 'fallback timer callback should exist');
|
|
const body = timerBody[1];
|
|
assert.ok(body.includes('chartObserver') && body.includes('disconnect'), 'timer callback must disconnect observer');
|
|
assert.ok(body.includes('chartMountTimer = null'), 'timer callback must null the timer handle');
|
|
});
|
|
|
|
it('MutationObserver callback clears timer and disconnects itself after mounting', () => {
|
|
// Inside the MutationObserver callback, after successful mount:
|
|
// 1. Clear the fallback timer (prevent double-mount)
|
|
// 2. Disconnect self
|
|
const observerBody = panelSrc.match(/new MutationObserver\(\(\)\s*=>\s*\{([\s\S]*?)\}\)/);
|
|
assert.ok(observerBody, 'MutationObserver callback should exist');
|
|
const body = observerBody[1];
|
|
assert.ok(body.includes('clearTimeout') || body.includes('chartMountTimer'), 'observer callback must cancel fallback timer');
|
|
assert.ok(body.includes('disconnect'), 'observer callback must disconnect itself');
|
|
});
|
|
|
|
it('mountTransitChart lazy-loads history via fetchChokepointHistory and mounts on resolve', () => {
|
|
// After the payload-split: history is NOT part of the main status RPC.
|
|
// mountTransitChart must (1) find the chart element by cp name,
|
|
// (2) check a session cache, (3) call fetchChokepointHistory on miss,
|
|
// (4) mount the chart on the live element when the fetch resolves.
|
|
assert.ok(
|
|
panelSrc.includes('querySelector(`[data-chart-cp='),
|
|
'must query for chart container element by chokepoint name'
|
|
);
|
|
assert.ok(
|
|
panelSrc.includes('fetchChokepointHistory('),
|
|
'must lazy-fetch history via fetchChokepointHistory RPC'
|
|
);
|
|
assert.ok(
|
|
panelSrc.includes('this.historyCache'),
|
|
'must cache history results for the session (avoid refetch on re-expand)'
|
|
);
|
|
assert.ok(
|
|
panelSrc.includes('transitChart.mount('),
|
|
'must call transitChart.mount when history resolves'
|
|
);
|
|
});
|
|
|
|
it('does NOT cache empty/error results — session-sticky regression guard', () => {
|
|
// Caching [] or on error would poison the chokepoint for the whole
|
|
// session (transient miss → never retries). Only cache on non-empty
|
|
// success. Empty/error show the "unavailable" placeholder but leave
|
|
// the cache untouched so the next re-expand retries.
|
|
assert.ok(
|
|
!panelSrc.match(/historyCache\.set\([^,]+,\s*\[\]\)/),
|
|
'panel must NOT cache empty arrays'
|
|
);
|
|
// The success branch gates the set() on resp.history.length — match the
|
|
// conditional-set pattern inside the .then() block.
|
|
assert.ok(
|
|
/if\s*\(resp\.history\.length\)\s*\{[\s\S]*?historyCache\.set\(/.test(panelSrc),
|
|
'panel must only cache on resp.history.length > 0'
|
|
);
|
|
});
|
|
|
|
it('tab switch clears transit chart before re-rendering', () => {
|
|
// Clicking a different tab should clear chart state before rendering new tab
|
|
const tabHandler = panelSrc.match(/if\s*\(tab\)\s*\{([\s\S]*?)\n\s{8}return/);
|
|
assert.ok(tabHandler, 'tab click handler should exist');
|
|
const body = tabHandler[1];
|
|
assert.ok(body.includes('clearTransitChart'), 'tab switch must clear chart before render');
|
|
assert.ok(body.indexOf('clearTransitChart') < body.indexOf('render'), 'clearTransitChart must come before render()');
|
|
});
|
|
|
|
it('collapsing an expanded chokepoint clears the chart', () => {
|
|
// When expandedChokepoint is set to null (collapse), chart should be cleared
|
|
assert.ok(
|
|
panelSrc.includes('if (!newId) this.clearTransitChart()'),
|
|
'collapsing a chokepoint (newId=null) must clear the chart'
|
|
);
|
|
});
|
|
});
|
|
|
|
const serverSrc = readFileSync(resolve(__dirname, '..', 'server', 'worldmonitor', 'supply-chain', 'v1', 'get-chokepoint-status.ts'), 'utf-8');
|
|
|
|
describe('SupplyChainPanel restructure contract', () => {
|
|
|
|
it('activeHasData for shipping tab accepts chokepointData without FRED', () => {
|
|
const block = panelSrc.match(/const activeHasData[\s\S]*?;/);
|
|
assert.ok(block, 'activeHasData assignment should exist');
|
|
const shippingPart = block[0].slice(block[0].indexOf("'shipping'"));
|
|
assert.ok(
|
|
shippingPart.includes('chokepointData'),
|
|
'shipping activeHasData must check chokepointData (not just shippingData)'
|
|
);
|
|
});
|
|
|
|
it('renderShipping delegates to renderDisruptionSnapshot', () => {
|
|
const shippingMethod = panelSrc.match(/private\s+renderShipping\(\)[\s\S]*?\{([\s\S]*?)\n\s{2}\}/);
|
|
assert.ok(shippingMethod, 'renderShipping method should exist');
|
|
assert.ok(
|
|
shippingMethod[1].includes('renderDisruptionSnapshot()'),
|
|
'renderShipping must call renderDisruptionSnapshot'
|
|
);
|
|
assert.ok(
|
|
shippingMethod[1].includes('renderFredIndices()'),
|
|
'renderShipping must call renderFredIndices'
|
|
);
|
|
});
|
|
|
|
it('renderDisruptionSnapshot handles null chokepointData as loading state', () => {
|
|
const method = panelSrc.match(/private\s+renderDisruptionSnapshot\(\)[\s\S]*?\{([\s\S]*?)\n\s{2}\}/);
|
|
assert.ok(method, 'renderDisruptionSnapshot method should exist');
|
|
assert.ok(
|
|
method[1].includes('this.chokepointData === null'),
|
|
'must check for null chokepointData (loading state)'
|
|
);
|
|
assert.ok(
|
|
method[1].includes('loadingCorridors'),
|
|
'must show loading placeholder when chokepointData is null'
|
|
);
|
|
});
|
|
|
|
it('renderDisruptionSnapshot returns empty string for empty chokepoints', () => {
|
|
const method = panelSrc.match(/private\s+renderDisruptionSnapshot\(\)[\s\S]*?\{([\s\S]*?)\n\s{2}\}/);
|
|
assert.ok(method, 'renderDisruptionSnapshot method should exist');
|
|
assert.ok(
|
|
/if\s*\(!cps\?\.length\)\s*return\s*''/.test(method[1]),
|
|
'must return empty string when chokepoints array is empty'
|
|
);
|
|
});
|
|
|
|
it('chokepoint cards preserve data-cp-id and data-chart-cp attributes', () => {
|
|
assert.ok(
|
|
panelSrc.includes('data-cp-id="${escapeHtml(cp.name)}"'),
|
|
'cards must have data-cp-id for click delegation'
|
|
);
|
|
assert.ok(
|
|
panelSrc.includes('data-chart-cp="${escapeHtml(cp.name)}"'),
|
|
'expanded cards must have data-chart-cp for transit chart mount'
|
|
);
|
|
});
|
|
|
|
it('chokepoint description is conditionally hidden when empty', () => {
|
|
assert.ok(
|
|
panelSrc.includes("cp.description ? `<div class=\"trade-description\">"),
|
|
'description div must be conditional on non-empty description'
|
|
);
|
|
});
|
|
|
|
it('server description no longer contains riskSummary or warning count text', () => {
|
|
const descBlock = serverSrc.match(/const descriptions:\s*string\[\]\s*=\s*\[\];([\s\S]*?)description:\s*descriptions\.join/);
|
|
assert.ok(descBlock, 'description assembly block should exist');
|
|
const body = descBlock[1];
|
|
assert.ok(
|
|
!body.includes('riskSummary'),
|
|
'descriptions[] must not include riskSummary (it is in transitSummary)'
|
|
);
|
|
assert.ok(
|
|
!body.includes('Navigational warnings:'),
|
|
'descriptions[] must not include warning count text (use activeWarnings field)'
|
|
);
|
|
assert.ok(
|
|
!body.includes('AIS vessel disruptions:'),
|
|
'descriptions[] must not include disruption count text (use aisDisruptions field)'
|
|
);
|
|
});
|
|
});
|