mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(resilience): PR 2 dimension wiring — split reserveAdequacy + add sovereignFiscalBuffer Plan §3.4 follow-up to #3305 + #3319. Lands the scorer + dimension registration so the SWF seed from the Railway cron feeds a real score once the bake-in window closes. No weight rebalance yet (separate commit with Spearman sensitivity check), no health.js graduation yet (7-day ON_DEMAND window per feedback_health_required_key_needs_ railway_cron_first.md), no bootstrap wiring yet (follow-up PR). Shape of the change Retirement: - reserveAdequacy joins fuelStockDays in RESILIENCE_RETIRED_DIMENSIONS. The legacy scorer now mirrors scoreFuelStockDays: returns coverage=0 / imputationClass=null so the dimension is filtered out of the confidence / coverage averages via the registry filter in computeLowConfidence, computeOverallCoverage, and the widget's formatResilienceConfidence. Kept in RESILIENCE_DIMENSION_ORDER for structural continuity (tests, cached payload shape, registry membership). Indicator registry tier demoted to 'experimental'. Two new active dimensions: - liquidReserveAdequacy (replaces the liquid-reserves half of the retired reserveAdequacy). Same source (WB FI.RES.TOTL.MO, total reserves in months of imports) but re-anchored 1..12 months instead of 1..18. Twelve months ≈ IMF "full reserve adequacy" benchmark for a diversified emerging-market importer — the tighter ceiling prevents wealthy commodity-exporters from claiming outsized credit for on-paper reserve stocks that are not the relevant shock-absorption buffer. - sovereignFiscalBuffer. Reads resilience:recovery:sovereign-wealth:v1 (populated by scripts/seed-sovereign-wealth.mjs, landed in #3305 + wired into Railway cron in #3319). Computes the saturating transform: effectiveMonths = Σ [ aum/annualImports × 12 × access × liquidity × transparency ] score = 100 × (1 − exp(−effectiveMonths / 12)) Exponential saturation prevents Norway-type outliers (effective months in the 100s) from dominating the recovery pillar. Three code paths in scoreSovereignFiscalBuffer: 1. Seed key absent entirely → IMPUTE.recoverySovereignFiscalBuffer (score 50 / coverage 0.3 / unmonitored). Covers the Railway-cron bake-in window before the first successful tick. 2. Seed present, country NOT in manifest → score=0 with FULL coverage. Substantive absence, NOT imputation — per plan §3.4 "What happens to no-SWF countries." 0 × weight = 0 in the numerator, so the country correctly scores lower than SWF-holding peers on this dim. 3. Seed present, country in payload → saturating score, coverage derated by the partial-seed completeness signal (so a Mubadala or Temasek scrape drift on a multi-fund country shows up as lower confidence rather than a silently-understated total). Indicator registry: - Demoted recoveryReserveMonths (tied to retired reserveAdequacy) to tier='experimental'. - Added recoveryLiquidReserveMonths: WB FI.RES.TOTL.MO, anchors 1..12, tier='core', coverage=188. - Added recoverySovereignWealthEffectiveMonths: the new SWF signal, tier='experimental' for now because the manifest only has 8 funds (below the 180-core / 137-§3.6-gate threshold). Graduating to 'core' requires expanding the manifest past ~137 entries — a later PR. Tests updated - resilience-release-gate: 19→21 dim count; RETIRED_DIMENSIONS allow- list now includes reserveAdequacy alongside fuelStockDays. - resilience-dimension-scorers: scoreReserveAdequacy monotonicity + "high reserves score well" tests migrated to scoreLiquidReserve- Adequacy (same source, new 1..12 anchor). New retirement-shape test for scoreReserveAdequacy mirroring the PR 3 fuelStockDays retirement test. Four new scorer tests pin the three code paths of scoreSovereignFiscalBuffer (absent seed / no-SWF country / SWF country / partial-completeness derate). - resilience-scorers fixture: baseline 60.12→60.35, recovery-domain flat mean 47.33→48.75, overall 63.27→63.6. Each number commented with the driver (split adds liquidReserveAdequacy 18@1.0 + sovereign FiscalBuffer 50@0.3 at IMPUTE; retired reserveAdequacy drops out). - resilience-dimension-monotonicity: target scoreLiquidReserveAdequacy instead of scoreReserveAdequacy. - resilience-handlers: response-shape dim count 19→21. - resilience-indicator-registry: coverage 19→21 dimensions. - resilience-dimension-freshness: allowlisted the new sovereign-wealth seed-meta key in KNOWN_SEEDS_NOT_IN_HEALTH for the ON_DEMAND window. - resilience-methodology-lint HEADING_TO_DIMENSION: added the two new heading mappings. Methodology doc gets H4 sections for Liquid Reserve Adequacy and Sovereign Fiscal Buffer; Reserve Adequacy section is annotated as retired. - resilience-retired-dimensions-parity: client-side RESILIENCE_RETIRED_DIMENSION_IDS gets reserveAdequacy. Parser upgraded to strip inline `// …` comments from the array body so a future reviewer can drop a rationale next to an entry without breaking parity. - resilience-confidence-averaging: fixture updated to include both retired dims (reserveAdequacy + fuelStockDays) — confirms the registry filter correctly excludes BOTH from the visible coverage reading. Extraction harness (scripts/compare-resilience-current-vs-proposed.mjs): - recoveryLiquidReserveMonths: reads the same reserve-adequacy seed field as recoveryReserveMonths. - recoverySovereignWealthEffectiveMonths: reads the new SWF seed key on field totalEffectiveMonths. Absent-payload → 0 for correlation math (matches the substantive-no-SWF scorer branch). Out of scope for this commit (follow-ups) - Recovery-domain weight rebalance + Spearman sensitivity rerun against the PR 0 baseline. - health.js graduation (SEED_META entry + ON_DEMAND_KEYS removal) once Railway cron has ~7 days of clean runs. - api/bootstrap.js wiring once an RPC consumer needs the SWF data. - Manifest expansion past 137 countries so sovereignFiscalBuffer can graduate from tier='experimental' to tier='core'. Tests: 6573/6573 data-tier tests pass. Typecheck clean on both tsconfig configs. Biome clean on all touched files. * fix(resilience): PR 2 review — add widget labels for new dimensions P2 review finding on PR #3324. DIMENSION_LABELS in src/components/ resilience-widget-utils.ts covered only the old 19 dimension IDs, so the two new active dims (liquidReserveAdequacy, sovereignFiscalBuffer) would render with their raw internal IDs in the confidence grid for every country once the scorer started emitting them. The widget test at getResilienceDimensionLabel also asserted only the 19-label set, so the gap would have shipped silently. Fix: add user-facing short labels for both new dims. "Reserves" is already claimed by the retired reserveAdequacy, so the replacement disambiguates with "Liquid Reserves"; sovereignFiscalBuffer → "Sovereign Wealth" per the methodology doc H4 heading. Also added a regression guard — new test asserts EVERY id in RESILIENCE_DIMENSION_ORDER resolves to a non-id label. Any future dimension that ships without a matching DIMENSION_LABELS entry now fails CI loudly instead of leaking the ID into the UI. Tests: 502/502 resilience tests pass (+1 new coverage check). Typecheck clean on both configs. * fix(resilience): PR 2 review — remove dead IMPUTE.recoveryReserveAdequacy entry Greptile P2: the retired scoreReserveAdequacy stub no longer reads from IMPUTE (it hardcodes coverage=0 / imputationClass=null per the retirement pattern), making IMPUTE.recoveryReserveAdequacy dead code. Removed the entry + added a breadcrumb comment pointing at the replacement IMPUTE.recoveryLiquidReserveAdequacy. The second P2 (bootstrap.js not wired) is a deliberate non-goal — the reviewer explicitly flags "for visibility" since it's tracked in the PR body. No action this commit; bootstrap wiring lands alongside the SEED_META graduation after the ~7-day Railway-cron bake-in. Tests: 502/502 resilience tests still pass. Typecheck clean.
394 lines
19 KiB
TypeScript
394 lines
19 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
|
|
import { getResilienceRanking } from '../server/worldmonitor/resilience/v1/get-resilience-ranking.ts';
|
|
import { getResilienceScore } from '../server/worldmonitor/resilience/v1/get-resilience-score.ts';
|
|
import { scoreAllDimensions } from '../server/worldmonitor/resilience/v1/_dimension-scorers.ts';
|
|
import { buildResilienceChoroplethMap } from '../src/components/resilience-choropleth-utils.ts';
|
|
import { createRedisFetch } from './helpers/fake-upstash-redis.mts';
|
|
import {
|
|
EU27_COUNTRIES,
|
|
G20_COUNTRIES,
|
|
buildReleaseGateFixtures,
|
|
} from './helpers/resilience-release-fixtures.mts';
|
|
|
|
const REQUIRED_DIMENSION_COUNTRIES = ['US', 'GB', 'DE', 'FR', 'JP', 'CN', 'IN', 'BR', 'NG', 'LB', 'YE'] as const;
|
|
const CHOROPLETH_TARGET_COUNTRIES = [...new Set([...G20_COUNTRIES, ...EU27_COUNTRIES])];
|
|
const HIGH_SANITY_COUNTRIES = ['NO', 'CH', 'DK'] as const;
|
|
const LOW_SANITY_COUNTRIES = ['YE', 'SO', 'HT'] as const;
|
|
const SPARSE_CONFIDENCE_COUNTRIES = ['SS', 'ER'] as const;
|
|
|
|
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;
|
|
const fixtures = buildReleaseGateFixtures();
|
|
|
|
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;
|
|
});
|
|
|
|
function fixtureReader(key: string): Promise<unknown | null> {
|
|
return Promise.resolve(fixtures[key] ?? null);
|
|
}
|
|
|
|
function installRedisFixtures() {
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
|
|
delete process.env.VERCEL_ENV;
|
|
const redisState = createRedisFetch(fixtures);
|
|
globalThis.fetch = redisState.fetchImpl;
|
|
return redisState;
|
|
}
|
|
|
|
describe('resilience release gate', () => {
|
|
it('keeps all 21 dimension scorers non-placeholder for the required countries', async () => {
|
|
// PR 3 §3.5 retired fuelStockDays; PR 2 §3.4 retired reserveAdequacy
|
|
// (superseded by the liquidReserveAdequacy + sovereignFiscalBuffer
|
|
// split). Both scorers emit coverage=0 + imputationClass=null — the
|
|
// widget maps 'source-failure' to a "Source down" label, which would
|
|
// manufacture a false outage signal on every country for a deliberate
|
|
// construct retirement. Allow-list keeps the zero-coverage placeholder
|
|
// check enforcing on the OTHER 19 dimensions.
|
|
const RETIRED_DIMENSIONS = new Set(['fuelStockDays', 'reserveAdequacy']);
|
|
for (const countryCode of REQUIRED_DIMENSION_COUNTRIES) {
|
|
const scores = await scoreAllDimensions(countryCode, fixtureReader);
|
|
const entries = Object.entries(scores);
|
|
assert.equal(entries.length, 21, `${countryCode} should have all 21 resilience dimensions (19 active + 2 retired kept for structural continuity)`);
|
|
for (const [dimensionId, score] of entries) {
|
|
assert.ok(Number.isFinite(score.score), `${countryCode} ${dimensionId} should produce a numeric score`);
|
|
if (RETIRED_DIMENSIONS.has(dimensionId)) {
|
|
assert.equal(score.coverage, 0, `${countryCode} ${dimensionId} is retired and must stay at coverage=0`);
|
|
assert.equal(score.imputationClass, null, `${countryCode} ${dimensionId} retired dimensions must tag null imputationClass (not source-failure)`);
|
|
continue;
|
|
}
|
|
assert.ok(score.coverage > 0, `${countryCode} ${dimensionId} should not fall back to zero-coverage placeholder scoring`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps the seeded static keys for NO, US, and YE available in Redis', () => {
|
|
const { redis } = installRedisFixtures();
|
|
assert.ok(redis.has('resilience:static:NO'));
|
|
assert.ok(redis.has('resilience:static:US'));
|
|
assert.ok(redis.has('resilience:static:YE'));
|
|
});
|
|
|
|
it('keeps imputationShare below 0.5 for G20 countries and preserves score sanity anchors', async () => {
|
|
installRedisFixtures();
|
|
|
|
const g20Responses = await Promise.all(
|
|
G20_COUNTRIES.map((countryCode) =>
|
|
getResilienceScore({ request: new Request(`https://example.com?countryCode=${countryCode}`) } as never, { countryCode }),
|
|
),
|
|
);
|
|
|
|
const coveragePassing = g20Responses.filter((response) => response.imputationShare < 0.5);
|
|
assert.ok(coveragePassing.length >= 10, `expected at least 10 G20 countries with imputationShare < 0.5, got ${coveragePassing.length}`);
|
|
|
|
const highAnchors = await Promise.all(
|
|
HIGH_SANITY_COUNTRIES.map((countryCode) =>
|
|
getResilienceScore({ request: new Request(`https://example.com?countryCode=${countryCode}`) } as never, { countryCode }),
|
|
),
|
|
);
|
|
for (const response of highAnchors) {
|
|
assert.ok(response.overallScore >= 70, `${response.countryCode} should remain in the high-resilience band (domain-weighted average)`);
|
|
}
|
|
|
|
const lowAnchors = await Promise.all(
|
|
LOW_SANITY_COUNTRIES.map((countryCode) =>
|
|
getResilienceScore({ request: new Request(`https://example.com?countryCode=${countryCode}`) } as never, { countryCode }),
|
|
),
|
|
);
|
|
for (const response of lowAnchors) {
|
|
assert.ok(response.overallScore <= 35, `${response.countryCode} should remain in the low-resilience band (domain-weighted average)`);
|
|
}
|
|
});
|
|
|
|
it('marks sparse WHO/FAO countries as low confidence', async () => {
|
|
installRedisFixtures();
|
|
|
|
for (const countryCode of SPARSE_CONFIDENCE_COUNTRIES) {
|
|
const response = await getResilienceScore(
|
|
{ request: new Request(`https://example.com?countryCode=${countryCode}`) } as never,
|
|
{ countryCode },
|
|
);
|
|
assert.equal(response.lowConfidence, true, `${countryCode} should be flagged as low confidence`);
|
|
}
|
|
});
|
|
|
|
it('Lebanon (fragile) scores lower than South Africa (stressed)', async () => {
|
|
installRedisFixtures();
|
|
|
|
const [lb, za] = await Promise.all([
|
|
getResilienceScore({ request: new Request('https://example.com?countryCode=LB') } as never, { countryCode: 'LB' }),
|
|
getResilienceScore({ request: new Request('https://example.com?countryCode=ZA') } as never, { countryCode: 'ZA' }),
|
|
]);
|
|
|
|
assert.ok(
|
|
lb.overallScore < za.overallScore,
|
|
`Lebanon (fragile, ${lb.overallScore}) should score lower than South Africa (stressed, ${za.overallScore})`,
|
|
);
|
|
});
|
|
|
|
it('US is not low-confidence with full 9/9 dataset coverage', async () => {
|
|
installRedisFixtures();
|
|
|
|
const us = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=US') } as never,
|
|
{ countryCode: 'US' },
|
|
);
|
|
assert.equal(us.lowConfidence, false, `US has full 9/9 dataset coverage in fixtures and should not be flagged low-confidence`);
|
|
});
|
|
|
|
// T1.1 regression test (Phase 1 of the country-resilience reference-grade
|
|
// upgrade plan, docs/internal/country-resilience-upgrade-plan.md).
|
|
//
|
|
// The origin review document (docs/internal/upgrading-country-resilience.md)
|
|
// claims: "Norway and the US both hit 100 under current fixtures, which
|
|
// broke the intended ordering and exposed a ceiling effect at the top end
|
|
// of the ranking."
|
|
//
|
|
// Investigation outcome (2026-04-11): the claim does NOT reproduce.
|
|
//
|
|
// Measured scores under the current release-gate fixtures and the
|
|
// post-PR-#2847 domain-weighted-average formula:
|
|
//
|
|
// NO (elite tier) overallScore = 86.58, baseline 86.85, stress 84.36
|
|
// US (strong tier) overallScore = 72.80, baseline 73.15, stress 70.58
|
|
// Delta NO - US = 13.78 points
|
|
// Ceiling neither country approaches 100; all 6 domains stay
|
|
// well inside the [0, 100] clamp range
|
|
// (Note: the investigation was run at the 5-domain state before the
|
|
// recovery domain landed; the overall ordering finding held after the
|
|
// Phase 2 recovery-domain addition — rerun under current fixtures
|
|
// continues to produce no ceiling and preserves NO > US by ≥8 points.)
|
|
//
|
|
// The ordering elite > strong > stressed > fragile is preserved. There is
|
|
// no hard 100 ceiling in the scorer, and nothing in _dimension-scorers.ts
|
|
// can produce a top-of-ranking tie between NO and US given the 14-point
|
|
// quality differential wired into the fixtures.
|
|
//
|
|
// Conclusion: the origin-doc symptom is misattributed or stale (it likely
|
|
// predates PR #2847's formula revert or references an older fixture set).
|
|
// The origin-doc changelog will be updated in a trailing commit after
|
|
// PR #2938 (the reference-grade plan) merges, since the origin doc is
|
|
// part of that PR.
|
|
//
|
|
// This test pins the current correct behavior so any future regression to
|
|
// a real top-of-ranking ceiling bug is caught immediately by CI.
|
|
it('T1.1 regression: Norway and US do not both pin at 100 and preserve elite > strong ordering', async () => {
|
|
installRedisFixtures();
|
|
|
|
const [no, us] = await Promise.all([
|
|
getResilienceScore({ request: new Request('https://example.com?countryCode=NO') } as never, { countryCode: 'NO' }),
|
|
getResilienceScore({ request: new Request('https://example.com?countryCode=US') } as never, { countryCode: 'US' }),
|
|
]);
|
|
|
|
assert.ok(
|
|
no.overallScore < 100,
|
|
`Norway should not pin at the ceiling (overallScore=${no.overallScore})`,
|
|
);
|
|
assert.ok(
|
|
us.overallScore < 100,
|
|
`US should not pin at the ceiling (overallScore=${us.overallScore})`,
|
|
);
|
|
assert.ok(
|
|
no.overallScore > us.overallScore,
|
|
`Norway (elite fixture, ${no.overallScore}) should score higher than the US (strong fixture, ${us.overallScore})`,
|
|
);
|
|
// Guard against a near-tie that would still break meaningful ranking.
|
|
// Actual measured delta at commit time is 13.78 points; the threshold
|
|
// of 8 (about 60% of the measured delta) leaves room for fixture
|
|
// tuning while catching a tier-separation collapse before the ordering
|
|
// degrades into a near-tie. An earlier version of this test used a
|
|
// threshold of 3, which would have silently accepted a ~71% erosion
|
|
// of the elite-strong separation signal. Bumped in response to PR
|
|
// review feedback on #2941.
|
|
assert.ok(
|
|
no.overallScore - us.overallScore >= 8,
|
|
`Norway should lead the US by at least 8 points (NO=${no.overallScore}, US=${us.overallScore}, delta=${no.overallScore - us.overallScore})`,
|
|
);
|
|
});
|
|
|
|
it('produces complete ranking and choropleth entries for the full G20 + EU27 release set', async () => {
|
|
installRedisFixtures();
|
|
|
|
await Promise.all(
|
|
CHOROPLETH_TARGET_COUNTRIES.map((countryCode) =>
|
|
getResilienceScore({ request: new Request(`https://example.com?countryCode=${countryCode}`) } as never, { countryCode }),
|
|
),
|
|
);
|
|
|
|
const ranking = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
|
|
const relevantItems = ranking.items.filter((item) => CHOROPLETH_TARGET_COUNTRIES.includes(item.countryCode as typeof CHOROPLETH_TARGET_COUNTRIES[number]));
|
|
assert.equal(relevantItems.length, CHOROPLETH_TARGET_COUNTRIES.length);
|
|
assert.ok(relevantItems.every((item) => item.overallScore >= 0), 'release-gate countries should not fall back to blank ranking placeholders');
|
|
|
|
const choropleth = buildResilienceChoroplethMap(relevantItems);
|
|
for (const countryCode of CHOROPLETH_TARGET_COUNTRIES) {
|
|
assert.ok(choropleth.has(countryCode), `expected choropleth data for ${countryCode}`);
|
|
}
|
|
});
|
|
|
|
// T1.7 schema pass: the serialized ResilienceDimension now carries an
|
|
// imputationClass field that downstream consumers (widget icon column,
|
|
// methodology changelog) can use to distinguish stable-absence,
|
|
// unmonitored, source-failure, and not-applicable from observed data.
|
|
// This test pins the shape so the field is not silently dropped.
|
|
it('T1.7: every serialized ResilienceDimension carries an imputationClass field', async () => {
|
|
installRedisFixtures();
|
|
|
|
const response = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=US') } as never,
|
|
{ countryCode: 'US' },
|
|
);
|
|
|
|
const allDimensions = response.domains.flatMap((domain) => domain.dimensions);
|
|
assert.equal(allDimensions.length, 21, 'US response should carry all 21 dimensions (19 active + 2 retired)');
|
|
for (const dimension of allDimensions) {
|
|
assert.equal(
|
|
typeof dimension.imputationClass,
|
|
'string',
|
|
`dimension ${dimension.id} must carry a string imputationClass (got ${typeof dimension.imputationClass})`,
|
|
);
|
|
const valid = ['', 'stable-absence', 'unmonitored', 'source-failure', 'not-applicable'];
|
|
assert.ok(
|
|
valid.includes(dimension.imputationClass),
|
|
`dimension ${dimension.id} imputationClass="${dimension.imputationClass}" must be one of [${valid.join(', ')}]`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// T1.5 propagation pass: the serialized ResilienceDimension now carries
|
|
// a `freshness` payload aggregated across the dimension's constituent
|
|
// signals. PR #2947 shipped the classifier; this test pins the end-to-end
|
|
// response shape so the field is not silently dropped.
|
|
it('T1.5: every serialized ResilienceDimension carries a freshness payload', async () => {
|
|
installRedisFixtures();
|
|
|
|
const response = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=US') } as never,
|
|
{ countryCode: 'US' },
|
|
);
|
|
|
|
const allDimensions = response.domains.flatMap((domain) => domain.dimensions);
|
|
assert.equal(allDimensions.length, 21, 'US response should carry all 21 dimensions (19 active + 2 retired)');
|
|
const validLevels = ['', 'fresh', 'aging', 'stale'];
|
|
for (const dimension of allDimensions) {
|
|
assert.ok(dimension.freshness != null, `dimension ${dimension.id} must carry a freshness payload`);
|
|
const freshness = dimension.freshness!;
|
|
assert.equal(
|
|
typeof freshness.lastObservedAtMs,
|
|
'string',
|
|
`dimension ${dimension.id} freshness.lastObservedAtMs must be a string (proto int64), got ${typeof freshness.lastObservedAtMs}`,
|
|
);
|
|
assert.equal(
|
|
typeof freshness.staleness,
|
|
'string',
|
|
`dimension ${dimension.id} freshness.staleness must be a string`,
|
|
);
|
|
assert.ok(
|
|
validLevels.includes(freshness.staleness),
|
|
`dimension ${dimension.id} freshness.staleness="${freshness.staleness}" must be one of [${validLevels.join(', ')}]`,
|
|
);
|
|
// The serialized int64 string must parse cleanly to a non-negative
|
|
// integer so downstream consumers (widget badge, CMD+K Freshness
|
|
// column) can render it without defensive string handling.
|
|
const asNumber = Number(freshness.lastObservedAtMs);
|
|
assert.ok(Number.isFinite(asNumber), `lastObservedAtMs="${freshness.lastObservedAtMs}" must parse to a finite number`);
|
|
assert.ok(asNumber >= 0, `lastObservedAtMs="${freshness.lastObservedAtMs}" must be non-negative`);
|
|
}
|
|
});
|
|
|
|
// Phase 2 T2.1: the three-pillar schema is now the default (v2 flag
|
|
// flipped to true in PR #2993). The response carries schemaVersion="2.0"
|
|
// and a non-empty pillars array with the three-pillar structure.
|
|
it('T2.1: default response shape is v2 (pillars populated, schemaVersion="2.0")', async () => {
|
|
installRedisFixtures();
|
|
|
|
const response = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=US') } as never,
|
|
{ countryCode: 'US' },
|
|
);
|
|
|
|
assert.equal(
|
|
response.schemaVersion,
|
|
'2.0',
|
|
'with RESILIENCE_SCHEMA_V2_ENABLED default true (post Phase 2), response must report schemaVersion="2.0"',
|
|
);
|
|
assert.ok(
|
|
Array.isArray(response.pillars) && response.pillars.length === 3,
|
|
'v2 response must include 3 pillars (structural-readiness, live-shock-exposure, recovery-capacity)',
|
|
);
|
|
});
|
|
|
|
it('T2.1: v1 default response keeps every Phase 1 top-level field populated', async () => {
|
|
installRedisFixtures();
|
|
|
|
const response = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=US') } as never,
|
|
{ countryCode: 'US' },
|
|
);
|
|
|
|
// The plan promises one release cycle of parallel field population
|
|
// so widget / map layer / Country Brief consumers can migrate. Pin
|
|
// every field that the v1 widget reads so a future PR cannot drop
|
|
// them prematurely.
|
|
assert.equal(typeof response.overallScore, 'number');
|
|
assert.equal(typeof response.baselineScore, 'number');
|
|
assert.equal(typeof response.stressScore, 'number');
|
|
assert.equal(typeof response.stressFactor, 'number');
|
|
assert.equal(typeof response.level, 'string');
|
|
assert.ok(Array.isArray(response.domains));
|
|
assert.equal(response.domains.length, 6, 'v1 shape keeps all 6 domains under the top-level domains[] field');
|
|
assert.equal(typeof response.imputationShare, 'number');
|
|
assert.equal(typeof response.lowConfidence, 'boolean');
|
|
assert.equal(typeof response.dataVersion, 'string');
|
|
assert.equal(typeof response.trend, 'string');
|
|
assert.equal(typeof response.change30d, 'number');
|
|
});
|
|
|
|
it('T2.1: response carries the pillars and schemaVersion fields on the wire', async () => {
|
|
installRedisFixtures();
|
|
|
|
const response = await getResilienceScore(
|
|
{ request: new Request('https://example.com?countryCode=NO') } as never,
|
|
{ countryCode: 'NO' },
|
|
);
|
|
|
|
// Both fields must be present (not undefined) so downstream
|
|
// consumers can branch on schemaVersion without optional-chaining
|
|
// every read. proto3 defaults handle returning users gracefully.
|
|
assert.ok('pillars' in response, 'response must serialize the pillars field');
|
|
assert.ok('schemaVersion' in response, 'response must serialize the schemaVersion field');
|
|
assert.ok(Array.isArray(response.pillars));
|
|
assert.equal(typeof response.schemaVersion, 'string');
|
|
});
|
|
|
|
it('T1.7: fully imputed dimension serializes a non-empty imputationClass', async () => {
|
|
// XX has no fixture: every scorer will fall through to either null (no
|
|
// data at all) or imputation. scoreFoodWater requires resilience:static
|
|
// to be loaded before it imputes, so we supply a minimal static record
|
|
// with fao:null and aquastat:null to trigger the IPC impute path.
|
|
// This exercises the full pipeline: scorer → weightedBlend → buildDimensionList
|
|
// → ResilienceDimension → response.
|
|
const reader = async (key: string): Promise<unknown | null> => {
|
|
if (key === 'resilience:static:XX') return { fao: null, aquastat: null };
|
|
return null;
|
|
};
|
|
const scores = await scoreAllDimensions('XX', reader);
|
|
assert.equal(
|
|
scores.foodWater.imputationClass,
|
|
'stable-absence',
|
|
`foodWater with fao:null should be stable-absence at the scorer boundary, got ${scores.foodWater.imputationClass}`,
|
|
);
|
|
});
|
|
});
|