mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b) Add the recovery-capacity pillar with 6 new dimensions: - fiscalSpace: IMF GGR_G01_GDP_PT + GGXCNL_G01_GDP_PT + GGXWDG_NGDP_PT - reserveAdequacy: World Bank FI.RES.TOTL.MO - externalDebtCoverage: WB DT.DOD.DSTC.CD / FI.RES.TOTL.CD ratio - importConcentration: UN Comtrade HHI (stub seeder) - stateContinuity: derived from WGI + UCDP + displacement (no new fetch) - fuelStockDays: IEA/EIA (stub seeder, Enrichment tier) Each dimension has a scorer in _dimension-scorers.ts, registry entries in _indicator-registry.ts, methodology doc subsections, and fixture data. Seeders: fiscal-space (real, IMF WEO), reserve-adequacy (real, WB API), external-debt (real, WB API), import-hhi (stub), fuel-stocks (stub). Recovery domain weight is 0 until PR 4 (T2.3) ships the penalized weighted mean across pillars. The domain appears in responses structurally but does not affect the overall score. Bootstrap: STANDALONE_KEYS + SEED_META + EMPTY_DATA_OK_KEYS + ON_DEMAND_KEYS all updated in api/health.js. Source-failure mapping updated for stateContinuity (WGI adapter). Widget labels and LOCKED_PREVIEW updated. All 282 resilience tests pass, typecheck clean, methodology lint clean. * fix(resilience): ISO3→ISO2 normalization in WB recovery seeders (#2987 P1) Both seed-recovery-reserve-adequacy.mjs and seed-recovery-external-debt.mjs used countryiso3code from the World Bank API response then immediately rejected codes where length !== 2. WB returns ISO3 codes (USA, DEU, etc.), so all real rows were silently dropped and the feed was always empty. Fix: import scripts/shared/iso3-to-iso2.json and normalize before the length check. Also removed from EMPTY_DATA_OK_KEYS in health.js since empty results now indicate a real failure, not a structural absence. * fix(resilience): remove unused import + no-op overrides (#2987 review) * fix(test): update release-gate to expect 6 domains after recovery pillar
71 lines
3.8 KiB
TypeScript
71 lines
3.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
|
|
import { getResilienceScore } from '../server/worldmonitor/resilience/v1/get-resilience-score.ts';
|
|
import { createRedisFetch } from './helpers/fake-upstash-redis.mts';
|
|
import { RESILIENCE_FIXTURES } from './helpers/resilience-fixtures.mts';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
|
const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
const originalVercelEnv = process.env.VERCEL_ENV;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL;
|
|
else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl;
|
|
if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken;
|
|
if (originalVercelEnv == null) delete process.env.VERCEL_ENV;
|
|
else process.env.VERCEL_ENV = originalVercelEnv;
|
|
});
|
|
|
|
describe('resilience handlers', () => {
|
|
it('computes and caches a country score with domains, trend metadata, and history writes', async () => {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
|
|
delete process.env.VERCEL_ENV;
|
|
|
|
const { fetchImpl, redis, sortedSets } = createRedisFetch(RESILIENCE_FIXTURES);
|
|
sortedSets.set('resilience:history:v4:US', [
|
|
{ member: '2026-04-01:20', score: 20260401 },
|
|
{ member: '2026-04-02:30', score: 20260402 },
|
|
]);
|
|
globalThis.fetch = fetchImpl;
|
|
|
|
const response = await getResilienceScore({ request: new Request('https://example.com') } as never, {
|
|
countryCode: 'us',
|
|
});
|
|
|
|
assert.equal(response.countryCode, 'US');
|
|
assert.equal(response.domains.length, 6);
|
|
assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 19);
|
|
assert.ok(response.overallScore > 0 && response.overallScore <= 100);
|
|
assert.equal(response.level, response.overallScore >= 70 ? 'high' : response.overallScore >= 40 ? 'medium' : 'low');
|
|
assert.equal(response.trend, 'rising');
|
|
assert.ok(response.change30d > 0);
|
|
assert.equal(typeof response.lowConfidence, 'boolean');
|
|
assert.ok(response.imputationShare >= 0 && response.imputationShare <= 1, `imputationShare out of bounds: ${response.imputationShare}`);
|
|
assert.equal(typeof response.baselineScore, 'number', 'baselineScore should be present');
|
|
assert.equal(typeof response.stressScore, 'number', 'stressScore should be present');
|
|
assert.equal(typeof response.stressFactor, 'number', 'stressFactor should be present');
|
|
assert.ok(response.baselineScore >= 0 && response.baselineScore <= 100, `baselineScore out of bounds: ${response.baselineScore}`);
|
|
assert.ok(response.stressScore >= 0 && response.stressScore <= 100, `stressScore out of bounds: ${response.stressScore}`);
|
|
assert.ok(response.stressFactor >= 0 && response.stressFactor <= 0.5, `stressFactor out of bounds: ${response.stressFactor}`);
|
|
assert.equal(response.dataVersion, '2024-04-03', 'dataVersion should be the ISO date from seed-meta fetchedAt');
|
|
|
|
const cachedScore = redis.get('resilience:score:v8:US');
|
|
assert.ok(cachedScore, 'expected score cache to be written');
|
|
assert.equal(JSON.parse(cachedScore || '{}').countryCode, 'US');
|
|
|
|
const history = sortedSets.get('resilience:history:v4:US') ?? [];
|
|
assert.ok(history.some((entry) => entry.member.startsWith(today + ':')), 'expected today history member to be written');
|
|
|
|
await getResilienceScore({ request: new Request('https://example.com') } as never, {
|
|
countryCode: 'US',
|
|
});
|
|
assert.equal((sortedSets.get('resilience:history:v4:US') ?? []).length, history.length, 'cache hit must not append history');
|
|
});
|
|
});
|