mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#2970) Six user-facing display fixes that individually looked minor but together eroded trust in the Country Brief panel. 1. Incorrect chokepoint attribution per supplier. Intra-regional pairs (e.g. Greek/Italian refined petroleum to Turkey) overlapped on long pass-through routes like gulf-europe-oil, attributing Hormuz and Bab el-Mandeb to Mediterranean trade. Added a coastSide-based filter: when exporter and importer share the same coast, transit chokepoints are restricted to a regional whitelist (e.g. med -> bosphorus, gibraltar, suez only). 2. Self-imports. Rows where partnerIso2 equals the importer ISO2 are now filtered out of Product Imports. 3. "N/A" supplier rows. Unresolved ISO2 codes (seeder emits partnerIso2 = '' when a UN code does not map) are now dropped from the render instead of surfacing as "N/A" at 14-16% share. 4. Raw slug "hormuz_strait" in shock-scenario prose. buildAssessment() now resolves chokepoint IDs to their display names ("Strait of Hormuz", "Suez Canal", etc.) via a small local map. 5. Raw ISO2 "TR can bridge" in shock-scenario prose. buildAssessment() now uses Intl.DisplayNames to render country names, with a raw-code fallback if instantiation fails. 6. HS chapter numbers instead of sector names in Cost Shock table. The empty-skeleton branch of /api/supply-chain/v1/multi-sector-cost-shock was returning hs2Label = hs2 (raw code); it now uses MULTI_SECTOR_HS2_LABELS. Frontend also adds an HS2_SHORT_LABELS fallback so the table never shows raw codes even if hs2Label is empty. All 4973 data-suite tests pass. Closes #2970. * fix(country-brief): apply supplier filter to recommendations + empty state (#2970) Address PR #3032 review (P2): - The supplier filter (drop self-imports + unmapped ISO2) only reached the table; the recommendation pane still iterated the unfiltered enriched array, so hidden rows could still produce recommendation text and safeAlternative pointers. - Build a single visibleEnriched list and use it for both the row table and the recommendation pane. - Short-circuit to an explicit "No external suppliers in available trade data" empty state when filtering removes every row, so the detail area never goes silently blank. - Skip safeAlternative suggestions that would point at filtered-out partners (self or unmapped). * test(country-brief): defensive ISO2 assertion for ICU variation (#2970) Address PR #3032 review (P2): CLDR behaviour for unrecognised 2-letter codes like 'XZ' varies across ICU versions; allow either raw 'XZ' or the resolved 'Unknown Region' form.
250 lines
9.9 KiB
TypeScript
250 lines
9.9 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,
|
|
EFFECTIVE_COVER_DAYS_CAP,
|
|
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);
|
|
});
|
|
|
|
it('caps runaway cover days (#2971: 96 / 0.005 = 19,200 day absurdity)', () => {
|
|
// 96 days cover, 0.5% deficit (1 kbd loss of 200 kbd imports) -> raw output 19,200
|
|
const result = computeEffectiveCoverDays(96, false, 1, 200);
|
|
assert.equal(result, EFFECTIVE_COVER_DAYS_CAP);
|
|
});
|
|
|
|
it('does NOT cap legitimate ~365-day scenarios', () => {
|
|
// 90 days cover, 24.66% deficit (49.32 kbd loss of 200 kbd imports) -> raw 365
|
|
const result = computeEffectiveCoverDays(90, false, 49.32, 200);
|
|
assert.equal(result, 365);
|
|
assert.ok(result < EFFECTIVE_COVER_DAYS_CAP, 'natural 365 should not equal the cap');
|
|
});
|
|
|
|
it('renders indefinitely-bridgeable prose only at or above the cap', () => {
|
|
const msg = buildAssessment('FR', 'hormuz_strait', true, 0.5, EFFECTIVE_COVER_DAYS_CAP, 96, 25, []);
|
|
assert.ok(msg.includes('indefinitely bridgeable'));
|
|
assert.ok(!msg.match(/~\d{4,}\s+days/), 'should never print raw 4-digit day counts');
|
|
});
|
|
|
|
it('renders numeric prose for naturally-365-day scenarios (no cap regression)', () => {
|
|
const msg = buildAssessment('FR', 'hormuz_strait', true, 0.5, 365, 90, 25, []);
|
|
assert.ok(msg.includes('~365 days'));
|
|
assert.ok(!msg.includes('indefinitely bridgeable'));
|
|
});
|
|
});
|
|
|
|
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'));
|
|
// CLDR behaviour for unrecognised codes varies across ICU versions;
|
|
// most return the raw code, but some may resolve to "Unknown Region".
|
|
assert.ok(assessment.includes('XZ') || assessment.includes('Unknown Region'));
|
|
// chokepoint id is resolved to its display name ("Suez Canal")
|
|
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%'));
|
|
});
|
|
|
|
});
|
|
});
|