mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(seeds): Eurostat house prices + quarterly debt + industrial production Adds three new Eurostat overlay seeders covering all 27 EU members plus EA20 and EU27_2020 aggregates (issue #3028): - prc_hpi_a (annual house price index, 10y sparkline, TTL 35d) key: economic:eurostat:house-prices:v1 complements BIS WS_SPP (#3026) for the Housing cycle tile - gov_10q_ggdebt (quarterly gov debt %GDP, 8q sparkline, TTL 14d) key: economic:eurostat:gov-debt-q:v1 upgrades National Debt card cadence from annual IMF to quarterly for EU - sts_inpr_m (monthly industrial production, 12m sparkline, TTL 5d) key: economic:eurostat:industrial-production:v1 feeds "Real economy pulse" sparkline on Economic Indicators card Shared JSON-stat parser in scripts/_eurostat-utils.mjs handles the EL/GR and EA20 geo quirks and returns full time series for sparklines. Wires each seeder into bootstrap (SLOW_KEYS), health registries (keys + seed-meta thresholds matched to cadence), macro seed bundle, cache-keys shared module, and the MCP tool registry (get_eu_housing_cycle, get_eu_quarterly_gov_debt, get_eu_industrial_production). MCP tool count updated to 31. Tests cover JSON-stat parsing, sparkline ordering, EU-only coverage gating (non-EU geos return null so panels never render blank tiles), validator thresholds, and registry wiring across all surfaces. https://claude.ai/code/session_01Tgm6gG5yUMRoc2LRAKvmza * fix(bootstrap): register new Eurostat keys in tiers, defer consumers Adds eurostatHousePrices/GovDebtQ/IndProd to BOOTSTRAP_TIERS ('slow') to match SLOW_KEYS in api/bootstrap.js, and lists them as PENDING_CONSUMERS in the hydration coverage test (panel wiring lands in follow-up). * fix(eurostat): raise seeder coverage thresholds to catch partial publishes The three Eurostat overlay seeders (house prices, quarterly gov debt, monthly industrial production) all validated with makeValidator(10) against a fixed 29-geo universe (EU27 + EA20 + EU27_2020). A bad run returning only 10-15 geos would pass validation and silently publish a snapshot missing most of the EU. Raise thresholds to near-complete coverage, with a small margin for geos with patchy reporting: - house prices (annual): 10 -> 24 - gov debt (quarterly): 10 -> 24 - industrial prod (monthly): 10 -> 22 (monthly is slightly patchier) Add a guard test that asserts every overlay seeder keeps its threshold >=22 so this regression can't reappear. * fix(seed-health): register 3 Eurostat seed-meta entries house-prices, gov-debt-q, industrial-production were wired in api/health.js SEED_META but missing from api/seed-health.js SEED_DOMAINS, so /api/seed-health would not surface their freshness. intervalMin = health.js maxStaleMin / 2 per convention. --------- Co-authored-by: Claude <noreply@anthropic.com>
213 lines
8.3 KiB
JavaScript
213 lines
8.3 KiB
JavaScript
/**
|
|
* Tests for issue #3028 Eurostat overlay seeders:
|
|
* - prc_hpi_a (house prices, annual)
|
|
* - gov_10q_ggdebt (gov debt, quarterly)
|
|
* - sts_inpr_m (industrial production, monthly)
|
|
*
|
|
* Covers:
|
|
* - JSON-stat parser (single-country extraction, series ordering, Greece/EA20 quirks)
|
|
* - EU-only coverage gating (non-EU geos return null so panels don't render blanks)
|
|
* - Registry wiring (bootstrap + health + MCP)
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
import {
|
|
parseEurostatSeries,
|
|
makeValidator,
|
|
EU_GEOS,
|
|
} from '../scripts/_eurostat-utils.mjs';
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
/**
|
|
* Build a minimal JSON-stat v2 response for testing, with 2 geos and 3 time periods.
|
|
*/
|
|
function jsonStatFixture({ geos = ['DE', 'FR'], times = ['2022', '2023', '2024'], values }) {
|
|
const geoIndex = {};
|
|
geos.forEach((g, i) => { geoIndex[g] = i; });
|
|
const timeIndex = {};
|
|
times.forEach((t, i) => { timeIndex[t] = i; });
|
|
// Build flat value object indexed by (geo_pos * times.length + time_pos).
|
|
const value = {};
|
|
geos.forEach((_, gi) => {
|
|
times.forEach((_, ti) => {
|
|
const idx = gi * times.length + ti;
|
|
const v = values[gi]?.[ti];
|
|
if (v !== undefined && v !== null) value[idx] = v;
|
|
});
|
|
});
|
|
return {
|
|
id: ['geo', 'time'],
|
|
size: [geos.length, times.length],
|
|
dimension: {
|
|
geo: { category: { index: geoIndex } },
|
|
time: { category: { index: timeIndex } },
|
|
},
|
|
value,
|
|
};
|
|
}
|
|
|
|
describe('Eurostat JSON-stat parser (issue #3028)', () => {
|
|
it('extracts a sorted series plus latest/prior for the requested geo', () => {
|
|
const data = jsonStatFixture({
|
|
geos: ['DE', 'FR'],
|
|
times: ['2022', '2023', '2024'],
|
|
values: [
|
|
[120.5, 125.8, 128.1], // DE
|
|
[115.0, 118.3, 121.7], // FR
|
|
],
|
|
});
|
|
|
|
const de = parseEurostatSeries(data, 'DE');
|
|
assert.ok(de, 'DE parse should succeed');
|
|
assert.equal(de.value, 128.1);
|
|
assert.equal(de.priorValue, 125.8);
|
|
assert.equal(de.date, '2024');
|
|
assert.equal(de.series.length, 3);
|
|
assert.deepEqual(
|
|
de.series.map((p) => p.date),
|
|
['2022', '2023', '2024'],
|
|
'series must be sorted ascending by period',
|
|
);
|
|
|
|
const fr = parseEurostatSeries(data, 'FR');
|
|
assert.equal(fr.value, 121.7, 'FR must have its own value, not DE mixed in');
|
|
});
|
|
|
|
it('returns null for a geo that is not in the response (non-EU gating)', () => {
|
|
const data = jsonStatFixture({
|
|
geos: ['DE', 'FR'],
|
|
times: ['2024'],
|
|
values: [[100.0], [101.0]],
|
|
});
|
|
// US is not an EU geo — panels must not render blank tiles for it.
|
|
assert.equal(parseEurostatSeries(data, 'US'), null);
|
|
assert.equal(parseEurostatSeries(data, 'JP'), null);
|
|
});
|
|
|
|
it('handles the Greece EL quirk (not ISO GR) and EA20 aggregate', () => {
|
|
const data = jsonStatFixture({
|
|
geos: ['EL', 'EA20'],
|
|
times: ['2023-Q3', '2023-Q4', '2024-Q1'],
|
|
values: [
|
|
[168.1, 167.5, 166.9],
|
|
[90.1, 89.8, 89.2],
|
|
],
|
|
});
|
|
const el = parseEurostatSeries(data, 'EL');
|
|
assert.ok(el, 'Greece must parse under EL, not GR');
|
|
assert.equal(el.value, 166.9);
|
|
assert.equal(parseEurostatSeries(data, 'GR'), null, 'ISO GR must not resolve to Greece');
|
|
|
|
const ea = parseEurostatSeries(data, 'EA20');
|
|
assert.ok(ea, 'Euro Area EA20 aggregate must parse');
|
|
assert.equal(ea.value, 89.2);
|
|
});
|
|
|
|
it('skips null observations and picks the latest non-null value', () => {
|
|
const data = jsonStatFixture({
|
|
geos: ['IT'],
|
|
times: ['2024-01', '2024-02', '2024-03'],
|
|
values: [[100.0, 101.5, null]],
|
|
});
|
|
const it = parseEurostatSeries(data, 'IT');
|
|
assert.ok(it);
|
|
assert.equal(it.value, 101.5, 'latest should skip null trailing observation');
|
|
assert.equal(it.date, '2024-02');
|
|
assert.equal(it.series.length, 2, 'null observations are dropped from series');
|
|
});
|
|
|
|
it('returns null on malformed / empty responses', () => {
|
|
assert.equal(parseEurostatSeries(null, 'DE'), null);
|
|
assert.equal(parseEurostatSeries({}, 'DE'), null);
|
|
assert.equal(parseEurostatSeries({ value: {} }, 'DE'), null);
|
|
});
|
|
});
|
|
|
|
describe('EU coverage list (issue #3028)', () => {
|
|
it('covers all 27 EU members plus EA20 and EU27_2020 aggregates', () => {
|
|
assert.equal(EU_GEOS.length, 29);
|
|
// Spot-check the ones called out explicitly in the issue.
|
|
for (const g of ['IE', 'PT', 'EL', 'HU', 'RO', 'DK', 'FI', 'BG', 'SK', 'SI', 'LT', 'LV', 'EE', 'LU', 'HR', 'MT', 'CY']) {
|
|
assert.ok(EU_GEOS.includes(g), `EU_GEOS must include ${g}`);
|
|
}
|
|
assert.ok(EU_GEOS.includes('EA20'), 'EU_GEOS must include EA20 (post-2023 Euro Area)');
|
|
assert.ok(EU_GEOS.includes('EU27_2020'), 'EU_GEOS must include EU27_2020 aggregate');
|
|
assert.ok(!EU_GEOS.includes('GR'), 'EU_GEOS must use EL not ISO GR for Greece');
|
|
});
|
|
});
|
|
|
|
describe('Seeder validator (issue #3028)', () => {
|
|
it('rejects payloads below the minimum country threshold', () => {
|
|
const validate = makeValidator(10);
|
|
assert.equal(validate({ countries: {} }), false);
|
|
assert.equal(validate({ countries: { DE: {}, FR: {}, IT: {} } }), false);
|
|
const big = Object.fromEntries(EU_GEOS.slice(0, 15).map((g) => [g, {}]));
|
|
assert.equal(validate({ countries: big }), true);
|
|
});
|
|
|
|
it('each Eurostat overlay seeder enforces near-complete EU coverage', async () => {
|
|
// Guard against regressions: a bad Eurostat run that returns only a
|
|
// handful of geos must NOT be accepted as a valid snapshot. Universe is
|
|
// fixed at 29 (EU27 + EA20 + EU27_2020); require >=22 geos across all
|
|
// three seeders so no seeder can publish a snapshot missing most of the EU.
|
|
const files = [
|
|
'scripts/seed-eurostat-house-prices.mjs',
|
|
'scripts/seed-eurostat-gov-debt-q.mjs',
|
|
'scripts/seed-eurostat-industrial-production.mjs',
|
|
];
|
|
for (const rel of files) {
|
|
const src = await readFile(resolve(ROOT, rel), 'utf8');
|
|
const match = src.match(/makeValidator\((\d+)\)/);
|
|
assert.ok(match, `${rel} must call makeValidator(N)`);
|
|
const n = Number(match[1]);
|
|
assert.ok(
|
|
n >= 22,
|
|
`${rel} makeValidator threshold ${n} too low — EU universe is 29, must be >=22 to catch partial-coverage failures`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Registry wiring (issue #3028)', () => {
|
|
it('bootstrap.js exposes the three new Eurostat overlay keys', async () => {
|
|
const src = await readFile(resolve(ROOT, 'api/bootstrap.js'), 'utf8');
|
|
assert.match(src, /eurostatHousePrices:\s*'economic:eurostat:house-prices:v1'/);
|
|
assert.match(src, /eurostatGovDebtQ:\s*'economic:eurostat:gov-debt-q:v1'/);
|
|
assert.match(src, /eurostatIndProd:\s*'economic:eurostat:industrial-production:v1'/);
|
|
// Must also be registered in the SLOW_KEYS tier.
|
|
assert.match(src, /'eurostatHousePrices'/);
|
|
assert.match(src, /'eurostatGovDebtQ'/);
|
|
assert.match(src, /'eurostatIndProd'/);
|
|
});
|
|
|
|
it('health.js maps each new key to a seed-meta freshness check', async () => {
|
|
const src = await readFile(resolve(ROOT, 'api/health.js'), 'utf8');
|
|
assert.match(src, /eurostatHousePrices:\s*\{[^}]*seed-meta:economic:eurostat-house-prices/);
|
|
assert.match(src, /eurostatGovDebtQ:\s*\{[^}]*seed-meta:economic:eurostat-gov-debt-q/);
|
|
assert.match(src, /eurostatIndProd:\s*\{[^}]*seed-meta:economic:eurostat-industrial-production/);
|
|
});
|
|
|
|
it('MCP tool registry exposes the three new EU overlay tools', async () => {
|
|
const src = await readFile(resolve(ROOT, 'api/mcp.ts'), 'utf8');
|
|
assert.match(src, /name: 'get_eu_housing_cycle'/);
|
|
assert.match(src, /name: 'get_eu_quarterly_gov_debt'/);
|
|
assert.match(src, /name: 'get_eu_industrial_production'/);
|
|
assert.match(src, /'economic:eurostat:house-prices:v1'/);
|
|
assert.match(src, /'economic:eurostat:gov-debt-q:v1'/);
|
|
assert.match(src, /'economic:eurostat:industrial-production:v1'/);
|
|
});
|
|
|
|
it('macro bundle runner includes the three new scripts with distinct seed-meta keys', async () => {
|
|
const src = await readFile(resolve(ROOT, 'scripts/seed-bundle-macro.mjs'), 'utf8');
|
|
assert.match(src, /seed-eurostat-house-prices\.mjs/);
|
|
assert.match(src, /seed-eurostat-gov-debt-q\.mjs/);
|
|
assert.match(src, /seed-eurostat-industrial-production\.mjs/);
|
|
});
|
|
});
|