diff --git a/scripts/seed-resilience-static.mjs b/scripts/seed-resilience-static.mjs index 0338ee529..e252de19e 100644 --- a/scripts/seed-resilience-static.mjs +++ b/scripts/seed-resilience-static.mjs @@ -29,13 +29,13 @@ export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1'; export const RESILIENCE_STATIC_META_KEY = 'seed-meta:resilience:static'; export const RESILIENCE_STATIC_PREFIX = 'resilience:static:'; export const RESILIENCE_STATIC_TTL_SECONDS = 400 * 24 * 60 * 60; -export const RESILIENCE_STATIC_SOURCE_VERSION = 'resilience-static-v1'; +export const RESILIENCE_STATIC_SOURCE_VERSION = 'resilience-static-v2'; export const RESILIENCE_STATIC_WINDOW_CRON = '0 */4 1-3 10 *'; const LOCK_DOMAIN = 'resilience:static'; const LOCK_TTL_MS = 2 * 60 * 60 * 1000; -const TOTAL_DATASET_SLOTS = 8; -const COUNTRY_DATASET_FIELDS = ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea']; +const TOTAL_DATASET_SLOTS = 9; +const COUNTRY_DATASET_FIELDS = ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea', 'tradeToGdp']; const WGI_INDICATORS = ['VA.EST', 'PV.EST', 'GE.EST', 'RQ.EST', 'RL.EST', 'CC.EST']; const INFRASTRUCTURE_INDICATORS = ['EG.ELC.ACCS.ZS', 'IS.ROD.PAVE.ZS', 'EG.USE.ELEC.KH.PC']; const WHO_INDICATORS = { @@ -63,6 +63,7 @@ export function shouldSkipSeedYear(meta, seedYear = nowSeedYear()) { return Boolean( meta && meta.status === 'ok' + && meta.sourceVersion === RESILIENCE_STATIC_SOURCE_VERSION && Number(meta.seedYear) === seedYear && Number.isFinite(Number(meta.recordCount)) && Number(meta.recordCount) > 0, @@ -640,6 +641,27 @@ async function fetchAquastatDataset() { return buildAquastatWbMap(latest); } +const WB_TRADE_TO_GDP_INDICATOR = 'NE.TRD.GNFS.ZS'; + +export function buildTradeToGdpMap(latestByCountry) { + const byCountry = new Map(); + for (const [iso2, entry] of latestByCountry.entries()) { + byCountry.set(iso2, { + source: 'worldbank', + tradeToGdpPct: entry.value, + year: entry.year, + }); + } + if (byCountry.size === 0) throw new Error('World Bank trade-to-GDP returned no usable rows'); + return byCountry; +} + +async function fetchTradeToGdpDataset() { + const rows = await fetchWorldBankIndicatorRows(WB_TRADE_TO_GDP_INDICATOR, { mrv: '12' }); + const latest = selectLatestWorldBankByCountry(rows); + return buildTradeToGdpMap(latest); +} + export function finalizeCountryPayloads(datasetMaps, seedYear = nowSeedYear(), seededAt = new Date().toISOString()) { const merged = new Map(); @@ -770,6 +792,7 @@ async function fetchAllDatasetMaps() { { key: 'fao', fetcher: fetchFsinDataset }, { key: 'aquastat', fetcher: fetchAquastatDataset }, { key: 'iea', fetcher: fetchEnergyDependencyDataset }, + { key: 'tradeToGdp', fetcher: fetchTradeToGdpDataset }, ]; const results = await Promise.allSettled(adapters.map((adapter) => adapter.fetcher())); diff --git a/tests/helpers/resilience-fixtures.mts b/tests/helpers/resilience-fixtures.mts index f91bb6b2f..e9a911f92 100644 --- a/tests/helpers/resilience-fixtures.mts +++ b/tests/helpers/resilience-fixtures.mts @@ -31,6 +31,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = { fao: { peopleInCrisis: 10, phase: 'IPC Phase 1', year: 2025 }, aquastat: { indicator: 'Renewable water availability', value: 4000, year: 2024 }, iea: { energyImportDependency: { value: 15, year: 2024, source: 'IEA' } }, + tradeToGdp: { source: 'worldbank', tradeToGdpPct: 70, year: 2023 }, }, 'resilience:static:US': { wgi: { @@ -62,6 +63,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = { fao: { peopleInCrisis: 5000, phase: 'IPC Phase 2', year: 2025 }, aquastat: { indicator: 'Renewable water availability', value: 1500, year: 2024 }, iea: { energyImportDependency: { value: 25, year: 2024, source: 'IEA' } }, + tradeToGdp: { source: 'worldbank', tradeToGdpPct: 25, year: 2023 }, }, 'resilience:static:YE': { wgi: { @@ -93,6 +95,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = { fao: { peopleInCrisis: 2_000_000, phase: 'IPC Phase 4', year: 2025 }, aquastat: { indicator: 'Water stress', value: 85, year: 2024 }, iea: { energyImportDependency: { value: 95, year: 2024, source: 'IEA' } }, + tradeToGdp: { source: 'worldbank', tradeToGdpPct: 30, year: 2023 }, }, 'energy:mix:v1:NO': { iso2: 'NO', @@ -333,6 +336,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = { fao: { peopleInCrisis: 1_500_000, phase: 'IPC Phase 3', year: 2025 }, aquastat: { indicator: 'Water stress', value: 72, year: 2024 }, iea: null, // Eurostat is EU-only — Lebanon absent → energy import dependency unknown + tradeToGdp: { source: 'worldbank', tradeToGdpPct: 95, year: 2023 }, }, 'energy:mix:v1:LB': { iso2: 'LB', @@ -354,7 +358,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = { failedDatasets: [], seedYear: 2025, seededAt: '2026-04-03T00:00:00.000Z', - sourceVersion: 'resilience-static-v1', + sourceVersion: 'resilience-static-v2', }, }; diff --git a/tests/helpers/resilience-release-fixtures.mts b/tests/helpers/resilience-release-fixtures.mts index add145997..82a1cf675 100644 --- a/tests/helpers/resilience-release-fixtures.mts +++ b/tests/helpers/resilience-release-fixtures.mts @@ -236,7 +236,7 @@ export function buildReleaseGateFixtures(): ReleaseGateFixtureMap { failedDatasets: [], seedYear: 2025, seededAt: '2026-04-04T00:00:00.000Z', - sourceVersion: 'resilience-static-v1', + sourceVersion: 'resilience-static-v2', }, 'supply_chain:shipping_stress:v1': { stressScore: 18 }, 'supply_chain:transit-summaries:v1': { diff --git a/tests/resilience-static-seed.test.mjs b/tests/resilience-static-seed.test.mjs index 05d32ffb1..fec89adf0 100644 --- a/tests/resilience-static-seed.test.mjs +++ b/tests/resilience-static-seed.test.mjs @@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url'; import { RESILIENCE_STATIC_INDEX_KEY, RESILIENCE_STATIC_META_KEY, + RESILIENCE_STATIC_SOURCE_VERSION, buildFailureRefreshKeys, buildManifest, + buildTradeToGdpMap, countryRedisKey, createCountryResolvers, finalizeCountryPayloads, @@ -210,6 +212,28 @@ describe('resilience static seed CSV parsers', () => { assert.ok(result.get('DE')?.indicator?.includes('stress'), 'indicator must include "stress" to route correctly in scoreAquastatValue()'); }); }); + + describe('buildTradeToGdpMap', () => { + it('produces { source, tradeToGdpPct, year } shape for known countries', () => { + const input = new Map([ + ['NO', { value: 70.5, year: 2023 }], + ['US', { value: 25.3, year: 2023 }], + ['SG', { value: 318.2, year: 2023 }], + ]); + const result = buildTradeToGdpMap(input); + assert.equal(result.size, 3); + const no = result.get('NO'); + assert.ok(no != null); + assert.equal(no.source, 'worldbank'); + assert.equal(no.tradeToGdpPct, 70.5); + assert.equal(no.year, 2023); + assert.equal(result.get('SG')?.tradeToGdpPct, 318.2); + }); + + it('throws when input map is empty', () => { + assert.throws(() => buildTradeToGdpMap(new Map()), /no usable rows/); + }); + }); }); describe('resilience static seed parsers', () => { @@ -290,6 +314,9 @@ describe('resilience static seed payload assembly', () => { iea: new Map([ ['NO', { source: 'eurostat-nrg_ind_id', energyImportDependency: { value: -13.3, year: 2024, source: 'eurostat' } }], ]), + tradeToGdp: new Map([ + ['NO', { source: 'worldbank', tradeToGdpPct: 70.5, year: 2023 }], + ]), }, 2026, '2026-04-03T12:00:00.000Z'); assert.deepEqual([...payloads.keys()].sort(), ['NO', 'US', 'YE']); @@ -303,7 +330,8 @@ describe('resilience static seed payload assembly', () => { fao: null, aquastat: null, iea: { source: 'eurostat-nrg_ind_id', energyImportDependency: { value: -13.3, year: 2024, source: 'eurostat' } }, - coverage: { availableDatasets: 3, totalDatasets: 8, ratio: 0.375 }, + tradeToGdp: { source: 'worldbank', tradeToGdpPct: 70.5, year: 2023 }, + coverage: { availableDatasets: 4, totalDatasets: 9, ratio: 0.444 }, seedYear: 2026, seededAt: '2026-04-03T12:00:00.000Z', }); @@ -326,7 +354,7 @@ describe('resilience static seed payload assembly', () => { failedDatasets: ['aquastat', 'gpi'], seedYear: 2026, seededAt: '2026-04-03T12:00:00.000Z', - sourceVersion: 'resilience-static-v1', + sourceVersion: RESILIENCE_STATIC_SOURCE_VERSION, }); assert.deepEqual(buildFailureRefreshKeys(manifest), [ @@ -338,10 +366,13 @@ describe('resilience static seed payload assembly', () => { ]); }); - it('skips reruns only after a successful snapshot for the same seed year', () => { - assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150 }, 2026), true); - assert.equal(shouldSkipSeedYear({ status: 'error', seedYear: 2026, recordCount: 150 }, 2026), false); - assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2025, recordCount: 150 }, 2026), false); + it('skips reruns only after a successful snapshot for the same seed year and source version', () => { + const v = RESILIENCE_STATIC_SOURCE_VERSION; + assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150, sourceVersion: v }, 2026), true); + assert.equal(shouldSkipSeedYear({ status: 'error', seedYear: 2026, recordCount: 150, sourceVersion: v }, 2026), false); + assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2025, recordCount: 150, sourceVersion: v }, 2026), false); + assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150, sourceVersion: 'resilience-static-v1' }, 2026), false); + assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150 }, 2026), false); }); }); @@ -358,6 +389,7 @@ describe('recoverFailedDatasets', () => { wgi: new Map([['YE', { source: 'worldbank-wgi' }]]), infrastructure: new Map(), gpi: new Map(), rsf: new Map(), who: new Map(), fao: faoOverride, aquastat: new Map(), iea: new Map(), + tradeToGdp: new Map(), }; }