mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(supply-chain): Sprint 0 — chokepoint registry, HS2 sectors, war_risk_tier
- src/config/chokepoint-registry.ts: single source of truth for all 13
canonical chokepoints with displayName, relayName, portwatchName,
corridorRiskName, baselineId, shockModelSupported, routeIds, lat/lon
- src/config/hs2-sectors.ts: static dictionary for all 99 HS2 chapters
with category, shockModelSupported (true only for HS27), cargoType
- server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts: migrated to
derive CANONICAL_CHOKEPOINTS from chokepoint-registry; no data duplication
- src/config/geo.ts + src/types/index.ts: added chokepointId field to
StrategicWaterway interface and all 13 STRATEGIC_WATERWAYS entries
- src/components/MapPopup.ts: switched chokepoint matching from fragile
name.toLowerCase() to direct chokepointId === id comparison
- server/worldmonitor/intelligence/v1/_shock-compute.ts: migrated from old
IDs (hormuz/malacca/babelm) to canonical IDs (hormuz_strait/malacca_strait/
bab_el_mandeb); same for CHOKEPOINT_LNG_EXPOSURE
- proto/worldmonitor/supply_chain/v1/supply_chain_data.proto: added
WarRiskTier enum + war_risk_tier field (field 16) on ChokepointInfo
- get-chokepoint-status.ts: populates warRiskTier from ChokepointConfig.threatLevel
via new threatLevelToWarRiskTier() helper (FREE field, no PRO gate)
* feat(supply-chain): Sprint 1 — country chokepoint exposure index + sector ring
S1.1: scripts/shared/country-port-clusters.json
~130 country → {nearestRouteIds, coastSide} mappings derived from trade route
waypoints; covers all 6 seeded Comtrade reporters plus major trading nations.
S1.2: scripts/seed-hs2-chokepoint-exposure.mjs
Daily cron seeder. Pure computation — reads country-port-clusters.json,
scores each country against CHOKEPOINT_REGISTRY route overlap, writes
supply-chain:exposure:{iso2}:{hs2}:v1 keys + seed-meta (24h TTL).
S1.3: RPC get-country-chokepoint-index (PRO-gated, request-varying)
- proto: GetCountryChokepointIndexRequest/Response + ChokepointExposureEntry
- handler: isCallerPremium gate; cachedFetchJson 24h; on-demand for any iso2
- cache-keys.ts: CHOKEPOINT_EXPOSURE_KEY(iso2, hs2) constant
- health.js: chokepointExposure SEED_META entry (48h threshold)
- gateway.ts: slow-browser cache tier
- service client: fetchCountryChokepointIndex() exported
S1.4: Chokepoint popup HS2 sector ring chart (PRO-gated)
Static trade-sector breakdown (IEA/UNCTAD estimates) per 9 major chokepoints.
SVG donut ring + legend shown for PRO users; blurred lockout + gate-hit
analytics for free users. Wired into renderWaterwayPopup().
🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.49.0
Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
* fix(tests): update energy-shock-v2 tests to use canonical chokepoint IDs
CHOKEPOINT_EXPOSURE and CHOKEPOINT_LNG_EXPOSURE keys were migrated from
short IDs (hormuz, malacca, babelm) to canonical registry IDs
(hormuz_strait, malacca_strait, bab_el_mandeb) in Sprint 0.
Test fixtures were not updated at the time; fix them now.
* fix(tests): update energy-shock-seed chokepoint ID to canonical form
VALID_CHOKEPOINTS changed to canonical IDs in Sprint 0; the seed test
that checks valid IDs was not updated alongside it.
* fix(cache-keys): reword JSDoc comment to avoid confusing bootstrap test regex
The comment "NOT in BOOTSTRAP_CACHE_KEYS" caused the bootstrap.test.mjs
regex to match the comment rather than the actual export declaration,
resulting in 0 entries found. Rephrase to "excluded from bootstrap".
* fix(supply-chain): address P1 review findings for chokepoint exposure index
- Add get-country-chokepoint-index to PREMIUM_RPC_PATHS (CDN bypass)
- Validate iso2/hs2 params before Redis key construction (cache injection)
- Fix seeder TTL to 172800s (2× interval) and extend TTL on skipped lock
- Fix CHOKEPOINT_EXPOSURE_SEED_META_KEY to match seeder write key
- Render placeholder sectors behind blur gate (DOM data leakage)
- Document get-country-chokepoint-index in widget agent system prompts
* fix(lint): resolve Biome CI failures
- Add biome.json overrides to silence noVar in HTML inline scripts,
disable linting for public/ vendor/build artifacts and pro-test/
- Remove duplicate NG and MW keys from country-port-clusters.json
- Use import attributes (with) instead of deprecated assert syntax
* fix(build): drop JSON import attribute — esbuild rejects `with` syntax
---------
Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
/**
|
|
* Unit tests for computeEnergyShockScenario handler logic.
|
|
*
|
|
* Tests the pure computation functions imported from _shock-compute.ts (no Redis dependency).
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
clamp,
|
|
computeGulfShare,
|
|
computeEffectiveCoverDays,
|
|
buildAssessment,
|
|
GULF_PARTNER_CODES,
|
|
CHOKEPOINT_EXPOSURE,
|
|
VALID_CHOKEPOINTS,
|
|
} from '../server/worldmonitor/intelligence/v1/_shock-compute.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('energy shock scenario computation', () => {
|
|
describe('chokepoint validation', () => {
|
|
it('accepts all valid chokepoint IDs', () => {
|
|
for (const id of ['hormuz_strait', 'malacca_strait', 'suez', 'bab_el_mandeb']) {
|
|
assert.ok(VALID_CHOKEPOINTS.has(id), `Expected ${id} to be valid`);
|
|
}
|
|
});
|
|
|
|
it('rejects invalid chokepoint IDs', () => {
|
|
for (const id of ['panama', 'taiwan', '', 'xyz']) {
|
|
assert.ok(!VALID_CHOKEPOINTS.has(id), `Expected ${id} to be invalid`);
|
|
}
|
|
});
|
|
|
|
it('CHOKEPOINT_EXPOSURE contains all valid chokepoints', () => {
|
|
for (const id of VALID_CHOKEPOINTS) {
|
|
assert.ok(id in CHOKEPOINT_EXPOSURE, `Expected CHOKEPOINT_EXPOSURE to have key ${id}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('disruption_pct clamping', () => {
|
|
it('clamps disruption_pct below 10 to 10', () => {
|
|
assert.equal(clamp(Math.round(5), 10, 100), 10);
|
|
assert.equal(clamp(Math.round(0), 10, 100), 10);
|
|
});
|
|
|
|
it('clamps disruption_pct above 100 to 100', () => {
|
|
assert.equal(clamp(Math.round(150), 10, 100), 100);
|
|
assert.equal(clamp(Math.round(200), 10, 100), 100);
|
|
});
|
|
|
|
it('passes through valid disruption_pct values unchanged', () => {
|
|
for (const v of [10, 25, 50, 75, 100]) {
|
|
assert.equal(clamp(v, 10, 100), v);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gulf crude share calculation', () => {
|
|
it('returns hasData=false when no flows provided', () => {
|
|
const result = computeGulfShare([]);
|
|
assert.equal(result.share, 0);
|
|
assert.equal(result.hasData, false);
|
|
});
|
|
|
|
it('returns hasData=false when all flows have zero/negative tradeValueUsd', () => {
|
|
const flows = [
|
|
{ partnerCode: '682', tradeValueUsd: 0 },
|
|
{ partnerCode: '784', tradeValueUsd: -100 },
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 0);
|
|
assert.equal(result.hasData, false);
|
|
});
|
|
|
|
it('returns hasData=false when country has no Comtrade data (no numeric code mapping)', () => {
|
|
const ISO2_TO_COMTRADE: Record<string, string> = {
|
|
US: '842', CN: '156', RU: '643', IR: '364', IN: '356', TW: '158',
|
|
};
|
|
const unsupportedCountries = ['DE', 'FR', 'JP', 'KR', 'BR', 'SA'];
|
|
for (const code of unsupportedCountries) {
|
|
assert.equal(ISO2_TO_COMTRADE[code], undefined, `${code} should not have Comtrade mapping`);
|
|
}
|
|
});
|
|
|
|
it('GULF_PARTNER_CODES contains expected Gulf country codes', () => {
|
|
assert.ok(GULF_PARTNER_CODES.has('682'), 'SA should be in Gulf set');
|
|
assert.ok(GULF_PARTNER_CODES.has('784'), 'AE should be in Gulf set');
|
|
assert.ok(GULF_PARTNER_CODES.has('368'), 'IQ should be in Gulf set');
|
|
assert.ok(GULF_PARTNER_CODES.has('414'), 'KW should be in Gulf set');
|
|
assert.ok(GULF_PARTNER_CODES.has('364'), 'IR should be in Gulf set');
|
|
assert.ok(!GULF_PARTNER_CODES.has('643'), 'RU should NOT be in Gulf set');
|
|
});
|
|
|
|
it('returns share=1.0 and hasData=true when all imports are from Gulf partners', () => {
|
|
const flows = [
|
|
{ partnerCode: '682', tradeValueUsd: 1000 }, // SA
|
|
{ partnerCode: '784', tradeValueUsd: 500 }, // AE
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 1.0);
|
|
assert.equal(result.hasData, true);
|
|
});
|
|
|
|
it('returns share=0 and hasData=true when no imports are from Gulf partners', () => {
|
|
const flows = [
|
|
{ partnerCode: '124', tradeValueUsd: 1000 }, // Canada
|
|
{ partnerCode: '643', tradeValueUsd: 500 }, // Russia (not in Gulf set)
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 0);
|
|
assert.equal(result.hasData, true);
|
|
});
|
|
|
|
it('computes fractional Gulf share correctly', () => {
|
|
const flows = [
|
|
{ partnerCode: '682', tradeValueUsd: 300 }, // SA (Gulf)
|
|
{ partnerCode: '124', tradeValueUsd: 700 }, // Canada (non-Gulf)
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 0.3);
|
|
assert.equal(result.hasData, true);
|
|
});
|
|
|
|
it('ignores flows with zero or negative tradeValueUsd', () => {
|
|
const flows = [
|
|
{ partnerCode: '682', tradeValueUsd: 0 }, // Gulf but zero
|
|
{ partnerCode: '784', tradeValueUsd: -100 }, // Gulf but negative
|
|
{ partnerCode: '124', tradeValueUsd: 500 }, // Non-Gulf positive
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 0);
|
|
assert.equal(result.hasData, true);
|
|
});
|
|
|
|
it('accepts numeric partnerCode values', () => {
|
|
const flows = [
|
|
{ partnerCode: 682, tradeValueUsd: 1000 }, // SA as number
|
|
];
|
|
const result = computeGulfShare(flows);
|
|
assert.equal(result.share, 1.0);
|
|
assert.equal(result.hasData, true);
|
|
});
|
|
});
|
|
|
|
describe('effective cover days computation', () => {
|
|
it('returns -1 for net exporters', () => {
|
|
assert.equal(computeEffectiveCoverDays(90, true, 100, 500), -1);
|
|
});
|
|
|
|
it('returns raw daysOfCover when crudeLossKbd is 0', () => {
|
|
assert.equal(computeEffectiveCoverDays(90, false, 0, 500), 90);
|
|
});
|
|
|
|
it('returns raw daysOfCover when crudeImportsKbd is 0', () => {
|
|
assert.equal(computeEffectiveCoverDays(90, false, 50, 0), 90);
|
|
});
|
|
|
|
it('scales cover days by the loss ratio', () => {
|
|
// 90 days cover, 50% loss of 200 kbd imports = ratio 0.5
|
|
// effectiveCoverDays = round(90 / 0.5) = 180
|
|
const result = computeEffectiveCoverDays(90, false, 100, 200);
|
|
assert.equal(result, 180);
|
|
});
|
|
|
|
it('produces shorter cover days for higher loss ratios', () => {
|
|
// 90 days cover, 90% disruption of 200 kbd = 180 kbd loss, ratio 0.9
|
|
// effectiveCoverDays = round(90 / 0.9) = 100
|
|
const result = computeEffectiveCoverDays(90, false, 180, 200);
|
|
assert.equal(result, 100);
|
|
});
|
|
});
|
|
|
|
describe('assessment string branches', () => {
|
|
it('uses insufficient data message when dataAvailable is false', () => {
|
|
const assessment = buildAssessment('XZ', 'suez', false, 0, 0, 0, 50, []);
|
|
assert.ok(assessment.includes('Insufficient import data'));
|
|
assert.ok(assessment.includes('XZ'));
|
|
assert.ok(assessment.includes('suez'));
|
|
});
|
|
|
|
it('uses net-exporter branch when effectiveCoverDays === -1', () => {
|
|
const assessment = buildAssessment('SA', 'hormuz', true, 0.8, -1, 0, 50, []);
|
|
assert.ok(assessment.includes('net oil exporter'));
|
|
});
|
|
|
|
it('net-exporter branch takes priority over low-Gulf-share branch', () => {
|
|
const assessment = buildAssessment('NO', 'hormuz', true, 0.05, -1, 0, 50, []);
|
|
assert.ok(assessment.includes('net oil exporter'));
|
|
assert.ok(!assessment.includes('low Gulf crude dependence'));
|
|
});
|
|
|
|
it('uses low-dependence branch when gulfCrudeShare < 0.1', () => {
|
|
const assessment = buildAssessment('DE', 'hormuz', true, 0.05, 180, 90, 50, []);
|
|
assert.ok(assessment.includes('low Gulf crude dependence'));
|
|
assert.ok(assessment.includes('5%'));
|
|
});
|
|
|
|
it('uses IEA cover branch when effectiveCoverDays > 90', () => {
|
|
const assessment = buildAssessment('US', 'hormuz', true, 0.4, 180, 90, 50, []);
|
|
assert.ok(assessment.includes('bridge'));
|
|
assert.ok(assessment.includes('180 days'));
|
|
});
|
|
|
|
it('uses deficit branch when dataAvailable, gulfShare >= 0.1, effectiveCoverDays <= 90', () => {
|
|
const products = [
|
|
{ product: 'Diesel', deficitPct: 25.0 },
|
|
{ product: 'Jet fuel', deficitPct: 20.0 },
|
|
];
|
|
const assessment = buildAssessment('IN', 'malacca', true, 0.5, 60, 30, 75, products);
|
|
assert.ok(assessment.includes('faces'));
|
|
assert.ok(assessment.includes('25.0% diesel deficit'));
|
|
assert.ok(assessment.includes('25.0%'));
|
|
});
|
|
|
|
});
|
|
});
|