mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(energy-atlas): seed-side countries[] denorm + CountryDeepDive row (§R #5 = B)
Per plan §R/#5 decision B: denormalise countries[] at seed time on each
disruption event so CountryDeepDivePanel can filter events per country
without an asset-registry round trip. Schema join (pipeline/storage
→ event.assetId) happens once in the weekly cron, not on every panel
render. The alternative (client-side join) was rejected because it
couples UI logic to asset-registry internals and duplicates the join
for every surface that wants a per-country filter.
Changes:
- `proto/.../list_energy_disruptions.proto`: add `repeated string
countries = 15` to EnergyDisruptionEntry with doc comment tying it
to the plan decision and the always-non-empty invariant.
- `scripts/_energy-disruption-registry.mjs`:
• Load pipeline-gas + pipeline-oil + storage-facilities registries
once per seed cycle; index by id.
• `deriveCountriesForEvent()` resolves assetId to {fromCountry,
toCountry, transitCountries} (pipeline) or {country} (storage),
deduped + alpha-sorted so byte-diff stability holds.
• `buildPayload()` attaches the computed countries[] to every
event before writing.
• `validateRegistry()` now requires non-empty countries[] of
ISO2 codes. Combined with the seeder's `emptyDataIsFailure:
true`, this surfaces orphaned assetIds loudly — the next cron
tick fails validation and seed-meta stays stale, tripping
health alarms.
- `scripts/data/energy-disruptions.json`: fix two orphaned assetIds
that the new join caught:
• `cpc-force-majeure-2022`: `cpc-pipeline` → `cpc` (matches the
entry in pipelines-oil.json).
• `pdvsa-designation-2019`: `ve-petrol-2026-q1` (non-existent) →
`venezuela-anzoategui-puerto-la-cruz`.
- `server/.../list-energy-disruptions.ts`: project countries[] into
the RPC response via coerceStringArray. Legacy pre-denorm rows
surface as empty array (always present on wire, length 0 => old).
- `src/components/CountryDeepDivePanel.ts`: add 4th Atlas row —
"Energy disruptions in {iso2}" — filtered by `iso2 ∈ countries[]`.
Failure is silent; EnergyDisruptionsPanel (upcoming) is the
primary disruption surface.
- `tests/energy-disruptions-registry.test.mts`: switch to validating
the buildPayload output (post-denorm), add §R #5 B invariant
tests, plus a raw-JSON invariant ensuring curators don't hand-edit
countries[] (it's derived, not declared).
Proto regen note: `make generate` currently fails with a duplicate
openapi plugin collision in buf.gen.yaml (unrelated bug — 3 plugin
entries emit to the same out dir). Worked around by temporarily
trimming buf.gen.yaml to just the TS plugins for this regen. Added
only the `countries: string[]` wire field to both service_client and
service_server; no other generated-file drift in this PR.
* chore(proto): regenerate openapi specs for countries[] field
Runs `make generate` with the sebuf v0.11.1 plugin now correctly
resolved via the PATH fix (cherry-picked from fix/makefile-generate-path-prefix).
The new `countries` field on EnergyDisruptionEntry propagates into:
- docs/api/SupplyChainService.openapi.yaml (primary per-service spec)
- docs/api/SupplyChainService.openapi.json (machine-readable variant)
- docs/api/worldmonitor.openapi.yaml (consolidated bundle)
No TypeScript drift beyond the already-committed service_client.ts /
service_server.ts updates in 80797e7cc.
* fix(energy-atlas): drop highlightEventId emission (review P2)
Codex P2: loadDisruptionsForCountry dispatched `highlightEventId` but
neither PipelineStatusPanel nor StorageFacilityMapPanel consumes it
(the openDetailHandler reads only pipelineId / facilityId). The UI's
implicit promise (event-specific highlighting) wasn't delivered —
clickthrough was asset-generic, and the extra wire field was a
misleading API surface.
Fix: emit only {pipelineId, facilityId} in the dispatched detail.
Row click opens the asset drawer; user sees the full per-asset
disruption timeline and locates the event visually.
Symmetric fix for PR #3378's EnergyDisruptionsPanel — both emitters
now match the drawer contract exactly. Re-add `highlightEventId`
here when the drawer panels ship matching consumer code
(openDetailHandler accepts it, loadDetail stores it,
renderDisruptionTimeline scrolls + emphasises the matching event).
Typecheck clean, test:data 6698/6698 pass.
* fix(energy-atlas): collision detection + abort signal + label clamp (review P2)
Three Codex P2 findings on PR #3377:
1. `loadAssetRegistries()` spread-merged gas + oil pipelines, silently
overwriting entries on id collision. No collision today, but a
curator adding a pipeline under the same id to both files would
cause `deriveCountriesForEvent` to return wrong-commodity country
data with no test flagging it.
Fix: explicit merge loop that throws on duplicate id. The next
cron tick fails validation, seed-meta stays stale, health alarms
fire — same loud-failure pattern the rest of the seeder uses.
2. `loadDisruptionsForCountry` didn't thread `this.signal` through
the RPC fetch shim. The stale-closure guard (`currentCode !== iso2`)
discarded stale RESULTS, but the in-flight request couldn't be
cancelled when the user switched countries or closed the panel.
Fix: wrap globalThis.fetch with { signal: this.signal } in the
client factory, matching the signal lifecycle the rest of the
panel already uses.
3. `shortDescription` values up to 200 chars rendered without
ellipsis in the compact Atlas row, overflowing the row layout.
Fix: new `truncateDisruptionLabel` helper clamps to 80 chars with
ellipsis. Full text still accessible via click-through to the
asset drawer.
Typecheck clean, test:data 6698/6698 pass.
159 lines
5.7 KiB
TypeScript
159 lines
5.7 KiB
TypeScript
// @ts-check
|
||
import { strict as assert } from 'node:assert';
|
||
import { test, describe } from 'node:test';
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
validateRegistry,
|
||
recordCount,
|
||
buildPayload,
|
||
ENERGY_DISRUPTIONS_CANONICAL_KEY,
|
||
MAX_STALE_MIN,
|
||
} from '../scripts/_energy-disruption-registry.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const raw = readFileSync(resolve(__dirname, '../scripts/data/energy-disruptions.json'), 'utf-8');
|
||
const rawRegistry = JSON.parse(raw) as { events: Record<string, any> };
|
||
// validateRegistry checks the buildPayload output (the denormalised shape
|
||
// the seeder actually writes to Redis), not the raw JSON on disk. Since
|
||
// plan §R/#5 decision B, buildPayload attaches countries[] per event; the
|
||
// raw file intentionally omits that field so a curator can edit events
|
||
// without manually computing affected countries.
|
||
const registry = buildPayload() as { events: Record<string, any> };
|
||
|
||
describe('energy-disruptions registry — schema', () => {
|
||
test('registry passes validateRegistry', () => {
|
||
assert.equal(validateRegistry(registry), true);
|
||
});
|
||
|
||
test('canonical key is stable', () => {
|
||
assert.equal(ENERGY_DISRUPTIONS_CANONICAL_KEY, 'energy:disruptions:v1');
|
||
});
|
||
|
||
test('at least 8 events (MIN floor)', () => {
|
||
assert.ok(recordCount(registry) >= 8);
|
||
});
|
||
|
||
test('MAX_STALE_MIN is 2× weekly cron', () => {
|
||
assert.equal(MAX_STALE_MIN, 20_160);
|
||
});
|
||
});
|
||
|
||
describe('energy-disruptions registry — identity + enums', () => {
|
||
test('every event.id matches its object key', () => {
|
||
for (const [key, e] of Object.entries(registry.events)) {
|
||
assert.equal(e.id, key, `${key} -> id=${e.id}`);
|
||
}
|
||
});
|
||
|
||
test('every assetType is pipeline or storage', () => {
|
||
const valid = new Set(['pipeline', 'storage']);
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(valid.has(e.assetType), `${e.id}: bad assetType`);
|
||
}
|
||
});
|
||
|
||
test('every eventType is in the valid set', () => {
|
||
const valid = new Set(['sabotage', 'sanction', 'maintenance', 'mechanical', 'weather', 'commercial', 'war', 'other']);
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(valid.has(e.eventType), `${e.id}: bad eventType ${e.eventType}`);
|
||
}
|
||
});
|
||
|
||
test('endAt is null or not earlier than startAt', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
if (e.endAt === null) continue;
|
||
const start = Date.parse(e.startAt);
|
||
const end = Date.parse(e.endAt);
|
||
assert.ok(end >= start, `${e.id}: endAt < startAt`);
|
||
}
|
||
});
|
||
|
||
test('every event references a non-empty assetId', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(typeof e.assetId === 'string' && e.assetId.length > 0, `${e.id}: empty assetId`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('energy-disruptions registry — evidence', () => {
|
||
test('every event has at least one source', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(e.sources.length > 0, `${e.id}: no sources`);
|
||
}
|
||
});
|
||
|
||
test('every source has valid sourceType + http(s) url', () => {
|
||
const valid = new Set(['regulator', 'operator', 'press', 'ais-relay', 'satellite']);
|
||
for (const e of Object.values(registry.events)) {
|
||
for (const s of e.sources) {
|
||
assert.ok(valid.has(s.sourceType), `${e.id}: bad sourceType ${s.sourceType}`);
|
||
assert.ok(s.url.startsWith('http'), `${e.id}: url not http(s)`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('classifierConfidence within 0..1', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(e.classifierConfidence >= 0 && e.classifierConfidence <= 1, `${e.id}: bad confidence`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('energy-disruptions registry — countries[] denorm (§R #5 B)', () => {
|
||
test('every event in buildPayload output has non-empty countries[]', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
assert.ok(
|
||
Array.isArray(e.countries) && e.countries.length > 0,
|
||
`${e.id}: empty countries[] — assetId may be orphaned`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('every country code is ISO-3166-1 alpha-2 uppercase', () => {
|
||
for (const e of Object.values(registry.events)) {
|
||
for (const c of e.countries) {
|
||
assert.ok(/^[A-Z]{2}$/.test(c), `${e.id}: bad country code ${c}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('raw JSON on disk does NOT carry countries[] (source of truth is the join)', () => {
|
||
for (const e of Object.values(rawRegistry.events)) {
|
||
assert.equal(e.countries, undefined, `${e.id}: raw JSON should not pre-compute countries[]`);
|
||
}
|
||
});
|
||
|
||
test('nord-stream-1-sabotage-2022 resolves to [DE, RU]', () => {
|
||
const nord = registry.events['nord-stream-1-sabotage-2022'];
|
||
assert.ok(nord, 'nord-stream-1-sabotage-2022 missing from registry');
|
||
assert.deepEqual(nord.countries, ['DE', 'RU']);
|
||
});
|
||
});
|
||
|
||
describe('energy-disruptions registry — validateRegistry rejects bad input', () => {
|
||
test('rejects empty object', () => {
|
||
assert.equal(validateRegistry({}), false);
|
||
});
|
||
|
||
test('rejects null', () => {
|
||
assert.equal(validateRegistry(null), false);
|
||
});
|
||
|
||
test('rejects endAt earlier than startAt', () => {
|
||
const bad = JSON.parse(JSON.stringify(registry));
|
||
const firstKey = Object.keys(bad.events)[0]!;
|
||
bad.events[firstKey].startAt = '2024-06-01T00:00:00Z';
|
||
bad.events[firstKey].endAt = '2020-01-01T00:00:00Z';
|
||
assert.equal(validateRegistry(bad), false);
|
||
});
|
||
|
||
test('rejects unknown eventType', () => {
|
||
const bad = JSON.parse(JSON.stringify(registry));
|
||
const firstKey = Object.keys(bad.events)[0]!;
|
||
bad.events[firstKey].eventType = 'telekinesis';
|
||
assert.equal(validateRegistry(bad), false);
|
||
});
|
||
});
|