Files
worldmonitor/tests/energy-disruptions-registry.test.mts
Elie Habib 7c0c08ad89 feat(energy-atlas): seed-side countries[] denorm on disruptions + CountryDeepDive row (§R #5 = B) (#3377)
* 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.
2026-04-24 19:08:07 +04:00

159 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
});
});