Files
worldmonitor/tests/resilience-release-gate.test.mts
Elie Habib c48ceea463 feat(resilience): PR 2 dimension wiring — split reserveAdequacy + add sovereignFiscalBuffer (#3324)
* 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.
2026-04-23 09:01:30 +04:00

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}`,
);
});
});