mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(energy): golden-fixture upstream format tests for 6 seeders (V6-4) Add static fixture files (CSV/JSON) and golden-fixture test blocks to catch parser regressions when upstream data formats change. Covers: OWID energy mix, JODI oil, JODI gas, IEA oil stocks, PortWatch ArcGIS, and Ember monthly electricity. Also exports buildHistory from seed-portwatch.mjs for testability. * fix(tests): pin portwatch fixture lookup by date; remove duplicate fixture test files
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
COUNTRY_MAP,
|
|
IEA_90_DAY_OBLIGATION,
|
|
parseRecord,
|
|
buildIndex,
|
|
buildOilStocksAnalysis,
|
|
CANONICAL_KEY,
|
|
ANALYSIS_KEY,
|
|
} from '../scripts/seed-iea-oil-stocks.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURE_DIR = resolve(__dirname, 'fixtures');
|
|
|
|
const FIXED_TS = '2026-04-05T08:00:00.000Z';
|
|
|
|
describe('CANONICAL_KEY', () => {
|
|
it('is energy:iea-oil-stocks:v1:index', () => {
|
|
assert.equal(CANONICAL_KEY, 'energy:iea-oil-stocks:v1:index');
|
|
});
|
|
});
|
|
|
|
describe('COUNTRY_MAP', () => {
|
|
it('has exactly 32 entries', () => {
|
|
assert.equal(Object.keys(COUNTRY_MAP).length, 32);
|
|
});
|
|
|
|
it('maps ASCII Turkiye (IEA live payload spelling) to TR', () => {
|
|
assert.equal(COUNTRY_MAP['Turkiye'], 'TR');
|
|
assert.equal(COUNTRY_MAP['Türkiye'], undefined);
|
|
});
|
|
});
|
|
|
|
describe('parseRecord', () => {
|
|
it('parses a normal country record correctly', () => {
|
|
const record = {
|
|
countryName: 'Germany',
|
|
total: '130',
|
|
industry: '130',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null);
|
|
assert.equal(result.iso2, 'DE');
|
|
assert.equal(result.dataMonth, '2025-11');
|
|
assert.equal(result.daysOfCover, 130);
|
|
assert.equal(result.netExporter, false);
|
|
assert.equal(result.industryDays, 130);
|
|
assert.equal(result.publicDays, 0);
|
|
assert.equal(result.abroadDays, 0);
|
|
assert.equal(result.belowObligation, false);
|
|
assert.equal(result.obligationThreshold, IEA_90_DAY_OBLIGATION);
|
|
assert.equal(result.seededAt, FIXED_TS);
|
|
});
|
|
|
|
it('parses net exporter record correctly', () => {
|
|
const record = {
|
|
countryName: 'Norway',
|
|
total: 'Net Exporter',
|
|
industry: '0',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null);
|
|
assert.equal(result.iso2, 'NO');
|
|
assert.equal(result.daysOfCover, null);
|
|
assert.equal(result.netExporter, true);
|
|
assert.equal(result.belowObligation, false);
|
|
});
|
|
|
|
it('sets anomaly true and daysOfCover null when total > 500', () => {
|
|
const record = {
|
|
countryName: 'Estonia',
|
|
total: '11111',
|
|
industry: '11111',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null);
|
|
assert.equal(result.iso2, 'EE');
|
|
assert.equal(result.daysOfCover, null);
|
|
assert.equal(result.anomaly, true);
|
|
assert.equal(result.netExporter, false);
|
|
assert.equal(result.belowObligation, false);
|
|
});
|
|
|
|
it('sets belowObligation true when daysOfCover < 90', () => {
|
|
const record = {
|
|
countryName: 'Greece',
|
|
total: '75',
|
|
industry: '75',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null);
|
|
assert.equal(result.belowObligation, true);
|
|
assert.equal(result.daysOfCover, 75);
|
|
});
|
|
|
|
it('sets belowObligation false when daysOfCover >= 90', () => {
|
|
const record = {
|
|
countryName: 'France',
|
|
total: '90',
|
|
industry: '90',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null);
|
|
assert.equal(result.belowObligation, false);
|
|
assert.equal(result.daysOfCover, 90);
|
|
});
|
|
|
|
it('accepts "Turkiye" (no umlaut) as an alias for Türkiye', () => {
|
|
const record = {
|
|
countryName: 'Turkiye',
|
|
total: '95',
|
|
industry: '95',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
const result = parseRecord(record, FIXED_TS);
|
|
assert.ok(result !== null, 'Turkiye should not be dropped');
|
|
assert.equal(result.iso2, 'TR');
|
|
assert.equal(result.daysOfCover, 95);
|
|
});
|
|
|
|
it('returns null for unknown country name', () => {
|
|
const record = {
|
|
countryName: 'Atlantis',
|
|
total: '100',
|
|
industry: '100',
|
|
publicData: '0',
|
|
abroadIndustry: '0',
|
|
abroadPublic: '0',
|
|
yearMonth: 202511,
|
|
};
|
|
assert.equal(parseRecord(record, FIXED_TS), null);
|
|
});
|
|
});
|
|
|
|
describe('buildIndex', () => {
|
|
it('aggregates members array correctly', () => {
|
|
const members = [
|
|
{ iso2: 'DE', daysOfCover: 130, netExporter: false, belowObligation: false, industryDays: 130, publicDays: 0, abroadDays: 0, obligationThreshold: 90, seededAt: FIXED_TS, dataMonth: '2025-11' },
|
|
{ iso2: 'NO', daysOfCover: null, netExporter: true, belowObligation: false, industryDays: null, publicDays: null, abroadDays: null, obligationThreshold: 90, seededAt: FIXED_TS, dataMonth: '2025-11' },
|
|
{ iso2: 'GR', daysOfCover: 75, netExporter: false, belowObligation: true, industryDays: 75, publicDays: 0, abroadDays: 0, obligationThreshold: 90, seededAt: FIXED_TS, dataMonth: '2025-11' },
|
|
];
|
|
const index = buildIndex(members, '2025-11', FIXED_TS);
|
|
|
|
assert.equal(index.dataMonth, '2025-11');
|
|
assert.equal(index.updatedAt, FIXED_TS);
|
|
assert.equal(index.members.length, 3);
|
|
|
|
const de = index.members.find(m => m.iso2 === 'DE');
|
|
assert.ok(de);
|
|
assert.equal(de.daysOfCover, 130);
|
|
assert.equal(de.netExporter, false);
|
|
assert.equal(de.belowObligation, false);
|
|
|
|
const no = index.members.find(m => m.iso2 === 'NO');
|
|
assert.ok(no);
|
|
assert.equal(no.daysOfCover, null);
|
|
assert.equal(no.netExporter, true);
|
|
|
|
const gr = index.members.find(m => m.iso2 === 'GR');
|
|
assert.ok(gr);
|
|
assert.equal(gr.belowObligation, true);
|
|
});
|
|
|
|
it('index members only have iso2, daysOfCover, netExporter, belowObligation', () => {
|
|
const members = [
|
|
{ iso2: 'US', daysOfCover: 200, netExporter: false, belowObligation: false, industryDays: 200, publicDays: 0, abroadDays: 0, seededAt: FIXED_TS, dataMonth: '2025-11' },
|
|
];
|
|
const index = buildIndex(members, '2025-11', FIXED_TS);
|
|
const keys = Object.keys(index.members[0]);
|
|
assert.deepEqual(keys.sort(), ['belowObligation', 'daysOfCover', 'iso2', 'netExporter'].sort());
|
|
});
|
|
});
|
|
|
|
describe('ANALYSIS_KEY', () => {
|
|
it('is energy:oil-stocks-analysis:v1', () => {
|
|
assert.equal(ANALYSIS_KEY, 'energy:oil-stocks-analysis:v1');
|
|
});
|
|
});
|
|
|
|
describe('buildOilStocksAnalysis', () => {
|
|
const baseMember = (iso2, daysOfCover, netExporter = false, anomaly = false) => ({
|
|
iso2,
|
|
daysOfCover: anomaly ? null : daysOfCover,
|
|
netExporter,
|
|
belowObligation: !netExporter && !anomaly && daysOfCover !== null && daysOfCover < IEA_90_DAY_OBLIGATION,
|
|
anomaly: anomaly || undefined,
|
|
seededAt: FIXED_TS,
|
|
dataMonth: '2025-11',
|
|
});
|
|
|
|
it('returns correct shape', () => {
|
|
const members = [baseMember('DE', 130), baseMember('FR', 100)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.updatedAt, FIXED_TS);
|
|
assert.equal(result.dataMonth, '2025-11');
|
|
assert.ok(Array.isArray(result.ieaMembers));
|
|
assert.ok(Array.isArray(result.belowObligation));
|
|
assert.ok(result.regionalSummary);
|
|
assert.equal(result.shockScenario, null);
|
|
});
|
|
|
|
it('sorts ieaMembers by daysOfCover descending, netExporters last', () => {
|
|
const members = [
|
|
baseMember('JP', 47),
|
|
baseMember('DE', 130),
|
|
baseMember('NO', null, true),
|
|
baseMember('US', null, true),
|
|
baseMember('FR', 100),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
const isos = result.ieaMembers.map(m => m.iso2);
|
|
assert.equal(isos[0], 'DE');
|
|
assert.equal(isos[1], 'FR');
|
|
assert.equal(isos[2], 'JP');
|
|
// net exporters last (order between them is not guaranteed)
|
|
assert.ok(isos.indexOf('NO') > isos.indexOf('JP'));
|
|
assert.ok(isos.indexOf('US') > isos.indexOf('JP'));
|
|
});
|
|
|
|
it('assigns rank 1-indexed in order', () => {
|
|
const members = [baseMember('DE', 130), baseMember('FR', 100), baseMember('GR', 75)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.ieaMembers[0].rank, 1);
|
|
assert.equal(result.ieaMembers[1].rank, 2);
|
|
assert.equal(result.ieaMembers[2].rank, 3);
|
|
});
|
|
|
|
it('vsObligation is daysOfCover - 90 for normal members', () => {
|
|
const members = [baseMember('DE', 130), baseMember('GR', 75)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
const de = result.ieaMembers.find(m => m.iso2 === 'DE');
|
|
const gr = result.ieaMembers.find(m => m.iso2 === 'GR');
|
|
assert.equal(de.vsObligation, 40);
|
|
assert.equal(gr.vsObligation, -15);
|
|
});
|
|
|
|
it('vsObligation is null for netExporters', () => {
|
|
const members = [baseMember('NO', null, true)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.ieaMembers[0].vsObligation, null);
|
|
});
|
|
|
|
it('belowObligation array contains correct ISO2s', () => {
|
|
const members = [
|
|
baseMember('DE', 130),
|
|
baseMember('GR', 75),
|
|
baseMember('JP', 47),
|
|
baseMember('FR', 90),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.ok(result.belowObligation.includes('GR'));
|
|
assert.ok(result.belowObligation.includes('JP'));
|
|
assert.ok(!result.belowObligation.includes('DE'));
|
|
assert.ok(!result.belowObligation.includes('FR'));
|
|
});
|
|
|
|
it('excludes records with anomaly: true from ranking', () => {
|
|
const members = [
|
|
baseMember('DE', 130),
|
|
baseMember('EE', null, false, true),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
const isos = result.ieaMembers.map(m => m.iso2);
|
|
assert.ok(!isos.includes('EE'), 'anomaly member should be excluded');
|
|
assert.equal(isos.length, 1);
|
|
});
|
|
|
|
it('regional summary europe avgDays and minDays computed correctly', () => {
|
|
const members = [
|
|
baseMember('DE', 130),
|
|
baseMember('FR', 100),
|
|
baseMember('GR', 70),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
const eu = result.regionalSummary.europe;
|
|
assert.equal(eu.avgDays, Math.round((130 + 100 + 70) / 3));
|
|
assert.equal(eu.minDays, 70);
|
|
assert.equal(eu.countBelowObligation, 1);
|
|
});
|
|
|
|
it('regional summary asiaPacific avgDays and minDays computed correctly', () => {
|
|
const members = [
|
|
baseMember('JP', 171),
|
|
baseMember('AU', 47),
|
|
baseMember('KR', 110),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
const ap = result.regionalSummary.asiaPacific;
|
|
assert.equal(ap.avgDays, Math.round((171 + 47 + 110) / 3));
|
|
assert.equal(ap.minDays, 47);
|
|
assert.equal(ap.countBelowObligation, 1);
|
|
});
|
|
|
|
it('northAmerica netExporters counted correctly', () => {
|
|
const members = [
|
|
baseMember('CA', null, true),
|
|
baseMember('MX', null, true),
|
|
baseMember('US', null, true),
|
|
];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.regionalSummary.northAmerica.netExporters, 3);
|
|
});
|
|
|
|
it('obligationMet true for netExporter regardless of daysOfCover', () => {
|
|
const members = [baseMember('NO', null, true)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.ieaMembers[0].obligationMet, true);
|
|
});
|
|
|
|
it('obligationMet false when daysOfCover < 90', () => {
|
|
const members = [baseMember('GR', 75)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.ieaMembers[0].obligationMet, false);
|
|
});
|
|
|
|
it('obligationMet true when daysOfCover >= 90', () => {
|
|
const members = [baseMember('FR', 90)];
|
|
const result = buildOilStocksAnalysis(members, '2025-11', FIXED_TS);
|
|
assert.equal(result.ieaMembers[0].obligationMet, true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Golden fixture: upstream JSON format regression guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('golden fixture (IEA Oil Stocks JSON)', () => {
|
|
const fixture = JSON.parse(readFileSync(resolve(FIXTURE_DIR, 'iea-stocks-sample.json'), 'utf-8'));
|
|
|
|
it('fixture has records array with at least 2 entries', () => {
|
|
assert.ok(Array.isArray(fixture.records), 'records should be an array');
|
|
assert.ok(fixture.records.length >= 2, `expected >=2 records, got ${fixture.records.length}`);
|
|
});
|
|
|
|
it('each record has required upstream fields', () => {
|
|
for (const record of fixture.records) {
|
|
assert.ok('countryName' in record, 'countryName missing');
|
|
assert.ok('yearMonth' in record, 'yearMonth missing');
|
|
assert.ok('total' in record, 'total missing');
|
|
assert.ok('industry' in record, 'industry missing');
|
|
assert.ok('publicData' in record, 'publicData missing');
|
|
}
|
|
});
|
|
|
|
it('parseRecord produces valid output for Germany', () => {
|
|
const de = fixture.records.find(r => r.countryName === 'Germany');
|
|
assert.ok(de != null, 'Germany record missing in fixture');
|
|
const parsed = parseRecord(de, FIXED_TS);
|
|
assert.ok(parsed !== null);
|
|
assert.equal(parsed.iso2, 'DE');
|
|
assert.equal(parsed.daysOfCover, 130);
|
|
assert.equal(parsed.netExporter, false);
|
|
assert.equal(parsed.belowObligation, false);
|
|
});
|
|
|
|
it('parseRecord identifies Norway as net exporter', () => {
|
|
const no = fixture.records.find(r => r.countryName === 'Norway');
|
|
assert.ok(no != null, 'Norway record missing in fixture');
|
|
const parsed = parseRecord(no, FIXED_TS);
|
|
assert.ok(parsed !== null);
|
|
assert.equal(parsed.iso2, 'NO');
|
|
assert.equal(parsed.netExporter, true);
|
|
assert.equal(parsed.daysOfCover, null);
|
|
});
|
|
|
|
it('parseRecord computes abroadDays correctly for Japan', () => {
|
|
const jp = fixture.records.find(r => r.countryName === 'Japan');
|
|
assert.ok(jp != null, 'Japan record missing in fixture');
|
|
const parsed = parseRecord(jp, FIXED_TS);
|
|
assert.ok(parsed !== null);
|
|
assert.equal(parsed.iso2, 'JP');
|
|
assert.equal(parsed.abroadDays, 30);
|
|
});
|
|
});
|