mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(intelligence): read regional-snapshot latestKey as raw string
Regional Intelligence panel rendered "No snapshot available yet" for every
region despite the 6h cron writing per-region snapshots successfully. Root
cause: writer/reader encoding mismatch.
Writer (scripts/regional-snapshot/persist-snapshot.mjs:60) stores the
snapshot_id pointer via `['SET', latestKey, snapshotId]` — a BARE string,
not JSON.stringify'd. The seeder's own reader (line 97) reads it as-is
and works.
Vercel RPC handler used `getCachedJson(latestKey, true)`, which internally
does `JSON.parse(data.result)`. `JSON.parse('mena-20260421T000000-steady')`
throws; the try/catch silently returns null; handler returns {}; panel
renders empty.
Fix: new `getCachedRawString()` helper in server/_shared/redis.ts that
reads a Redis key as-is with no JSON.parse. Handler uses it for the
latestKey read (while still using getCachedJson for the snapshot-by-id
payload, which IS JSON.stringify'd). No writer or backfill change needed.
Regression guard: new structural test asserts the handler reads latestKey
specifically via getCachedRawString so a future refactor can't silently
revert to getCachedJson and re-break every region.
Health.js monitors the summary key (intelligence:regional-snapshots:
summary:v1), which stays green because summary writes succeed. Per-region
health probes would be worth adding as a follow-up.
* fix(redis): detect AbortSignal.timeout() as TimeoutError too
Greptile P2 on PR #3302: AbortSignal.timeout() throws a DOMException with
name='TimeoutError' on V8 runtimes (Vercel Edge included). The existing
isTimeout check only matched name==='AbortError' — what you'd get from a
manual controller.abort() — so the [REDIS-TIMEOUT] structured log never
fired. Every redis fetch timeout silently fell through to the generic
console.warn branch, eroding the Sentry drain we added specifically to
catch these per docs/plans/chokepoint-rpc-payload-split.md.
Fix both getCachedJson (pre-existing) and the new getCachedRawString by
matching TimeoutError OR AbortError — covers both the current
AbortSignal.timeout() path and any future switch to manual AbortController.
Pre-existing copy in getCachedJson fixed in the same edit since it's the
same file and the same observability hole.
* test(redis): update isTimeout regex to match new TimeoutError|AbortError check
Pre-push hook caught the brittle static-analysis test in
tests/get-chokepoint-history.test.mjs:83 that asserted the exact old
single-name pattern. Update the regex (and description) to cover both
TimeoutError and AbortError, matching the observability fix in the
previous commit.
509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
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 { adaptSnapshot } from '../server/worldmonitor/intelligence/v1/get-regional-snapshot';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const handlerSrc = readFileSync(
|
|
resolve(root, 'server/worldmonitor/intelligence/v1/get-regional-snapshot.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
const handlerIndexSrc = readFileSync(
|
|
resolve(root, 'server/worldmonitor/intelligence/v1/handler.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
const premiumPathsSrc = readFileSync(
|
|
resolve(root, 'src/shared/premium-paths.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
const gatewaySrc = readFileSync(
|
|
resolve(root, 'server/gateway.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
const protoSrc = readFileSync(
|
|
resolve(root, 'proto/worldmonitor/intelligence/v1/get_regional_snapshot.proto'),
|
|
'utf-8',
|
|
);
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// adaptSnapshot: snake_case -> camelCase adapter (the substantive logic)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('adaptSnapshot', () => {
|
|
it('maps all top-level region fields', () => {
|
|
const result = adaptSnapshot({
|
|
region_id: 'mena',
|
|
generated_at: 1_700_000_000_000,
|
|
});
|
|
assert.equal(result.regionId, 'mena');
|
|
assert.equal(result.generatedAt, 1_700_000_000_000);
|
|
});
|
|
|
|
it('defaults missing top-level fields to empty values', () => {
|
|
const result = adaptSnapshot({});
|
|
assert.equal(result.regionId, '');
|
|
assert.equal(result.generatedAt, 0);
|
|
assert.deepEqual(result.actors, []);
|
|
assert.deepEqual(result.leverageEdges, []);
|
|
assert.deepEqual(result.scenarioSets, []);
|
|
assert.deepEqual(result.transmissionPaths, []);
|
|
assert.deepEqual(result.evidence, []);
|
|
});
|
|
|
|
it('adapts SnapshotMeta fields', () => {
|
|
const result = adaptSnapshot({
|
|
meta: {
|
|
snapshot_id: 'abc123',
|
|
model_version: '0.1.0',
|
|
scoring_version: '1.0.0',
|
|
geography_version: '1.0.0',
|
|
snapshot_confidence: 0.85,
|
|
missing_inputs: ['forecast:predictions:v2'],
|
|
stale_inputs: [],
|
|
valid_until: 1_700_021_600_000,
|
|
trigger_reason: 'scheduled_6h',
|
|
narrative_provider: 'groq',
|
|
narrative_model: 'mixtral-8x7b',
|
|
},
|
|
});
|
|
assert.ok(result.meta);
|
|
assert.equal(result.meta.snapshotId, 'abc123');
|
|
assert.equal(result.meta.modelVersion, '0.1.0');
|
|
assert.equal(result.meta.scoringVersion, '1.0.0');
|
|
assert.equal(result.meta.snapshotConfidence, 0.85);
|
|
assert.deepEqual(result.meta.missingInputs, ['forecast:predictions:v2']);
|
|
assert.equal(result.meta.validUntil, 1_700_021_600_000);
|
|
assert.equal(result.meta.triggerReason, 'scheduled_6h');
|
|
assert.equal(result.meta.narrativeProvider, 'groq');
|
|
assert.equal(result.meta.narrativeModel, 'mixtral-8x7b');
|
|
});
|
|
|
|
it('adapts RegimeState', () => {
|
|
const result = adaptSnapshot({
|
|
regime: {
|
|
label: 'stressed_equilibrium',
|
|
previous_label: 'calm',
|
|
transitioned_at: 1_700_000_000_000,
|
|
transition_driver: 'diff-engine',
|
|
},
|
|
});
|
|
assert.ok(result.regime);
|
|
assert.equal(result.regime.label, 'stressed_equilibrium');
|
|
assert.equal(result.regime.previousLabel, 'calm');
|
|
assert.equal(result.regime.transitionedAt, 1_700_000_000_000);
|
|
assert.equal(result.regime.transitionDriver, 'diff-engine');
|
|
});
|
|
|
|
it('adapts BalanceVector with all 7 axes', () => {
|
|
const result = adaptSnapshot({
|
|
balance: {
|
|
coercive_pressure: 0.72,
|
|
domestic_fragility: 0.58,
|
|
capital_stress: 0.45,
|
|
energy_vulnerability: 0.3,
|
|
alliance_cohesion: 0.62,
|
|
maritime_access: 0.55,
|
|
energy_leverage: 0.8,
|
|
net_balance: 0.12,
|
|
pressures: [
|
|
{ axis: 'coercive_pressure', description: 'IRGC naval', magnitude: 0.72, evidence_ids: ['xss:1'], orientation: 'pressure' },
|
|
],
|
|
buffers: [
|
|
{ axis: 'energy_leverage', description: '6 producers', magnitude: 1.0, evidence_ids: [], orientation: 'buffer' },
|
|
],
|
|
},
|
|
});
|
|
assert.ok(result.balance);
|
|
assert.equal(result.balance.coercivePressure, 0.72);
|
|
assert.equal(result.balance.domesticFragility, 0.58);
|
|
assert.equal(result.balance.capitalStress, 0.45);
|
|
assert.equal(result.balance.energyVulnerability, 0.3);
|
|
assert.equal(result.balance.allianceCohesion, 0.62);
|
|
assert.equal(result.balance.maritimeAccess, 0.55);
|
|
assert.equal(result.balance.energyLeverage, 0.8);
|
|
assert.equal(result.balance.netBalance, 0.12);
|
|
assert.equal(result.balance.pressures.length, 1);
|
|
assert.equal(result.balance.pressures[0]?.axis, 'coercive_pressure');
|
|
assert.deepEqual(result.balance.pressures[0]?.evidenceIds, ['xss:1']);
|
|
assert.equal(result.balance.buffers.length, 1);
|
|
});
|
|
|
|
it('adapts ActorState array', () => {
|
|
const result = adaptSnapshot({
|
|
actors: [
|
|
{
|
|
actor_id: 'iran',
|
|
name: 'Iran',
|
|
role: 'aggressor',
|
|
leverage_domains: ['military', 'energy'],
|
|
leverage_score: 0.68,
|
|
delta: 0,
|
|
evidence_ids: ['forecast:f1'],
|
|
},
|
|
],
|
|
});
|
|
assert.equal(result.actors.length, 1);
|
|
const iran = result.actors[0];
|
|
assert.ok(iran);
|
|
assert.equal(iran.actorId, 'iran');
|
|
assert.equal(iran.name, 'Iran');
|
|
assert.equal(iran.role, 'aggressor');
|
|
assert.deepEqual(iran.leverageDomains, ['military', 'energy']);
|
|
assert.equal(iran.leverageScore, 0.68);
|
|
assert.deepEqual(iran.evidenceIds, ['forecast:f1']);
|
|
});
|
|
|
|
it('adapts LeverageEdge array', () => {
|
|
const result = adaptSnapshot({
|
|
leverage_edges: [
|
|
{
|
|
from_actor_id: 'russia',
|
|
to_actor_id: 'germany',
|
|
mechanism: 'energy_supply',
|
|
strength: 0.75,
|
|
evidence_ids: ['e1'],
|
|
},
|
|
],
|
|
});
|
|
assert.equal(result.leverageEdges.length, 1);
|
|
const edge = result.leverageEdges[0];
|
|
assert.ok(edge);
|
|
assert.equal(edge.fromActorId, 'russia');
|
|
assert.equal(edge.toActorId, 'germany');
|
|
assert.equal(edge.mechanism, 'energy_supply');
|
|
assert.equal(edge.strength, 0.75);
|
|
});
|
|
|
|
it('adapts ScenarioSet with nested lanes and transmissions', () => {
|
|
const result = adaptSnapshot({
|
|
scenario_sets: [
|
|
{
|
|
horizon: '24h',
|
|
lanes: [
|
|
{
|
|
name: 'escalation',
|
|
probability: 0.45,
|
|
trigger_ids: ['t1', 't2'],
|
|
consequences: ['price spike'],
|
|
transmissions: [
|
|
{
|
|
start: 'Hormuz threat',
|
|
mechanism: 'insurance spike',
|
|
end: 'Brent +$10',
|
|
severity: 'critical',
|
|
corridor_id: 'hormuz',
|
|
confidence: 0.85,
|
|
latency_hours: 24,
|
|
impacted_asset_class: 'crude',
|
|
impacted_regions: ['mena', 'east-asia'],
|
|
magnitude_low: 10,
|
|
magnitude_high: 25,
|
|
magnitude_unit: 'usd_bbl',
|
|
template_id: 'hormuz_blockade',
|
|
template_version: '1.0.0',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
assert.equal(result.scenarioSets.length, 1);
|
|
const set = result.scenarioSets[0];
|
|
assert.ok(set);
|
|
assert.equal(set.horizon, '24h');
|
|
assert.equal(set.lanes.length, 1);
|
|
const lane = set.lanes[0];
|
|
assert.ok(lane);
|
|
assert.equal(lane.name, 'escalation');
|
|
assert.equal(lane.probability, 0.45);
|
|
assert.deepEqual(lane.triggerIds, ['t1', 't2']);
|
|
assert.equal(lane.transmissions.length, 1);
|
|
const trans = lane.transmissions[0];
|
|
assert.ok(trans);
|
|
assert.equal(trans.corridorId, 'hormuz');
|
|
assert.equal(trans.latencyHours, 24);
|
|
assert.equal(trans.impactedAssetClass, 'crude');
|
|
assert.deepEqual(trans.impactedRegions, ['mena', 'east-asia']);
|
|
assert.equal(trans.magnitudeLow, 10);
|
|
assert.equal(trans.magnitudeHigh, 25);
|
|
assert.equal(trans.templateId, 'hormuz_blockade');
|
|
assert.equal(trans.templateVersion, '1.0.0');
|
|
});
|
|
|
|
it('adapts TriggerLadder with all three buckets and nested TriggerThreshold', () => {
|
|
const result = adaptSnapshot({
|
|
triggers: {
|
|
active: [
|
|
{
|
|
id: 'hormuz_transit_drop',
|
|
description: 'Hormuz transit drops',
|
|
threshold: {
|
|
metric: 'chokepoint:hormuz:transit_count',
|
|
operator: 'delta_lt',
|
|
value: -0.20,
|
|
window_minutes: 1440,
|
|
baseline: 'trailing_7d',
|
|
},
|
|
activated: true,
|
|
activated_at: 1_700_000_000_000,
|
|
scenario_lane: 'escalation',
|
|
evidence_ids: ['e1'],
|
|
},
|
|
],
|
|
watching: [],
|
|
dormant: [],
|
|
},
|
|
});
|
|
assert.ok(result.triggers);
|
|
assert.equal(result.triggers.active.length, 1);
|
|
assert.equal(result.triggers.watching.length, 0);
|
|
assert.equal(result.triggers.dormant.length, 0);
|
|
const trigger = result.triggers.active[0];
|
|
assert.ok(trigger);
|
|
assert.equal(trigger.id, 'hormuz_transit_drop');
|
|
assert.equal(trigger.activated, true);
|
|
assert.equal(trigger.activatedAt, 1_700_000_000_000);
|
|
assert.equal(trigger.scenarioLane, 'escalation');
|
|
assert.ok(trigger.threshold);
|
|
assert.equal(trigger.threshold.metric, 'chokepoint:hormuz:transit_count');
|
|
assert.equal(trigger.threshold.operator, 'delta_lt');
|
|
assert.equal(trigger.threshold.value, -0.20);
|
|
assert.equal(trigger.threshold.windowMinutes, 1440);
|
|
assert.equal(trigger.threshold.baseline, 'trailing_7d');
|
|
});
|
|
|
|
it('adapts MobilityState with nested airspace/flight/airport arrays', () => {
|
|
const result = adaptSnapshot({
|
|
mobility: {
|
|
airspace: [{ airspace_id: 'LLLL', status: 'restricted', reason: 'conflict' }],
|
|
flight_corridors: [{ corridor: 'Tehran-Baghdad', stress_level: 0.8, rerouted_flights_24h: 42 }],
|
|
airports: [{ icao: 'OIIE', name: 'Imam Khomeini', status: 'disrupted', disruption_reason: 'drills' }],
|
|
reroute_intensity: 0.35,
|
|
notam_closures: ['OIIX-A0042'],
|
|
},
|
|
});
|
|
assert.ok(result.mobility);
|
|
assert.equal(result.mobility.airspace.length, 1);
|
|
const airspace = result.mobility.airspace[0];
|
|
assert.ok(airspace);
|
|
assert.equal(airspace.airspaceId, 'LLLL');
|
|
assert.equal(result.mobility.flightCorridors.length, 1);
|
|
const corr = result.mobility.flightCorridors[0];
|
|
assert.ok(corr);
|
|
assert.equal(corr.reroutedFlights24h, 42);
|
|
assert.equal(result.mobility.airports.length, 1);
|
|
const airport = result.mobility.airports[0];
|
|
assert.ok(airport);
|
|
assert.equal(airport.disruptionReason, 'drills');
|
|
assert.equal(result.mobility.rerouteIntensity, 0.35);
|
|
assert.deepEqual(result.mobility.notamClosures, ['OIIX-A0042']);
|
|
});
|
|
|
|
it('adapts EvidenceItem array', () => {
|
|
const result = adaptSnapshot({
|
|
evidence: [
|
|
{
|
|
id: 'cii:IR',
|
|
type: 'cii_spike',
|
|
source: 'risk-scores',
|
|
summary: 'IR CII 78 (UP)',
|
|
confidence: 0.9,
|
|
observed_at: 1_700_000_000_000,
|
|
theater: '',
|
|
corridor: '',
|
|
},
|
|
],
|
|
});
|
|
assert.equal(result.evidence.length, 1);
|
|
const ev = result.evidence[0];
|
|
assert.ok(ev);
|
|
assert.equal(ev.id, 'cii:IR');
|
|
assert.equal(ev.type, 'cii_spike');
|
|
assert.equal(ev.source, 'risk-scores');
|
|
assert.equal(ev.observedAt, 1_700_000_000_000);
|
|
});
|
|
|
|
it('adapts RegionalNarrative with all 5 sections plus watch_items', () => {
|
|
const result = adaptSnapshot({
|
|
narrative: {
|
|
situation: { text: 'Situation text', evidence_ids: ['s1'] },
|
|
balance_assessment: { text: 'Balance text', evidence_ids: ['b1'] },
|
|
outlook_24h: { text: '24h outlook', evidence_ids: ['o24'] },
|
|
outlook_7d: { text: '7d outlook', evidence_ids: ['o7'] },
|
|
outlook_30d: { text: '30d outlook', evidence_ids: ['o30'] },
|
|
watch_items: [
|
|
{ text: 'Item 1', evidence_ids: ['w1'] },
|
|
{ text: 'Item 2', evidence_ids: ['w2'] },
|
|
],
|
|
},
|
|
});
|
|
assert.ok(result.narrative);
|
|
assert.equal(result.narrative.situation?.text, 'Situation text');
|
|
assert.deepEqual(result.narrative.situation?.evidenceIds, ['s1']);
|
|
assert.equal(result.narrative.balanceAssessment?.text, 'Balance text');
|
|
assert.equal(result.narrative.outlook24h?.text, '24h outlook');
|
|
assert.equal(result.narrative.outlook7d?.text, '7d outlook');
|
|
assert.equal(result.narrative.outlook30d?.text, '30d outlook');
|
|
assert.equal(result.narrative.watchItems.length, 2);
|
|
assert.equal(result.narrative.watchItems[0]?.text, 'Item 1');
|
|
});
|
|
|
|
it('is robust to missing nested fields (empty array defaults)', () => {
|
|
const result = adaptSnapshot({
|
|
region_id: 'mena',
|
|
generated_at: 1_700_000_000_000,
|
|
balance: {},
|
|
triggers: {},
|
|
mobility: {},
|
|
narrative: {},
|
|
});
|
|
assert.ok(result.balance);
|
|
assert.equal(result.balance.coercivePressure, 0);
|
|
assert.deepEqual(result.balance.pressures, []);
|
|
assert.ok(result.triggers);
|
|
assert.deepEqual(result.triggers.active, []);
|
|
assert.ok(result.mobility);
|
|
assert.deepEqual(result.mobility.airspace, []);
|
|
assert.ok(result.narrative);
|
|
assert.deepEqual(result.narrative.watchItems, []);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Handler structural checks (static analysis)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('get-regional-snapshot handler: structural checks', () => {
|
|
it('imports getCachedJson + getCachedRawString from redis helpers', () => {
|
|
// getCachedRawString reads the bare-string latestKey pointer (the seed
|
|
// writer stores snapshot_id via `SET key bareString` — JSON.parse would
|
|
// throw); getCachedJson reads the JSON-stringified snapshot-by-id payload.
|
|
// Both must be imported.
|
|
assert.match(handlerSrc, /import\s*\{[^}]*\bgetCachedJson\b[^}]*\}\s*from\s*'\.\.\/\.\.\/\.\.\/_shared\/redis'/);
|
|
assert.match(handlerSrc, /import\s*\{[^}]*\bgetCachedRawString\b[^}]*\}\s*from\s*'\.\.\/\.\.\/\.\.\/_shared\/redis'/);
|
|
});
|
|
|
|
it('reads latestKey via getCachedRawString (encoding contract)', () => {
|
|
// Guard the primary fix: if a future refactor swaps back to getCachedJson
|
|
// for the latestKey read, every region will silently render empty because
|
|
// JSON.parse throws on the bare snapshot_id string. See commit that
|
|
// replaced the getCachedJson+JSON.parse path with getCachedRawString.
|
|
assert.match(handlerSrc, /getCachedRawString\s*\(\s*latestKey\s*\)/);
|
|
});
|
|
|
|
it('uses the canonical :latest key prefix', () => {
|
|
assert.match(handlerSrc, /'intelligence:snapshot:v1:'/);
|
|
});
|
|
|
|
it('uses the canonical snapshot-by-id key prefix', () => {
|
|
assert.match(handlerSrc, /'intelligence:snapshot-by-id:v1:'/);
|
|
});
|
|
|
|
it('reads latest pointer then snapshot-by-id (two-hop lookup)', () => {
|
|
// latest resolved before snapKey construction
|
|
const latestIdx = handlerSrc.indexOf('latestKey');
|
|
const snapKeyIdx = handlerSrc.indexOf('snapKey');
|
|
assert.ok(latestIdx > 0 && snapKeyIdx > latestIdx, 'latest must resolve before snap lookup');
|
|
});
|
|
|
|
it('returns empty response on missing snapshot id', () => {
|
|
assert.match(handlerSrc, /if \(!snapshotId\) \{\s*return \{\}/);
|
|
});
|
|
|
|
it('returns empty response on missing persisted snapshot', () => {
|
|
assert.match(handlerSrc, /if \(!persisted \|\| typeof persisted !== 'object'\) \{\s*return \{\}/);
|
|
});
|
|
|
|
it('calls adaptSnapshot to produce the wire shape', () => {
|
|
assert.match(handlerSrc, /adaptSnapshot\(persisted\)/);
|
|
});
|
|
|
|
it('exports getRegionalSnapshot handler matching the service interface', () => {
|
|
assert.match(handlerSrc, /export const getRegionalSnapshot: IntelligenceServiceHandler\['getRegionalSnapshot'\]/);
|
|
});
|
|
});
|
|
|
|
describe('intelligence handler registration', () => {
|
|
it('imports getRegionalSnapshot from get-regional-snapshot module', () => {
|
|
assert.match(handlerIndexSrc, /import \{ getRegionalSnapshot \} from '\.\/get-regional-snapshot'/);
|
|
});
|
|
|
|
it('registers getRegionalSnapshot on the handler object', () => {
|
|
assert.match(handlerIndexSrc, /\s+getRegionalSnapshot,/);
|
|
});
|
|
});
|
|
|
|
describe('security wiring', () => {
|
|
it('adds the endpoint to PREMIUM_RPC_PATHS', () => {
|
|
assert.match(premiumPathsSrc, /'\/api\/intelligence\/v1\/get-regional-snapshot'/);
|
|
});
|
|
|
|
it('has a RPC_CACHE_TIER entry for route-parity (even though premium paths bypass it)', () => {
|
|
// At runtime the gateway checks PREMIUM_RPC_PATHS first and short-circuits
|
|
// to 'slow-browser' regardless of RPC_CACHE_TIER. The entry exists to satisfy
|
|
// tests/route-cache-tier.test.mjs which enforces that every generated GET
|
|
// route has an explicit tier, and documents the intended tier if the endpoint
|
|
// ever becomes non-premium.
|
|
assert.match(gatewaySrc, /'\/api\/intelligence\/v1\/get-regional-snapshot':\s*'slow'/);
|
|
});
|
|
});
|
|
|
|
describe('proto definition', () => {
|
|
it('declares the GetRegionalSnapshot RPC method', () => {
|
|
const serviceProtoSrc = readFileSync(
|
|
resolve(root, 'proto/worldmonitor/intelligence/v1/service.proto'),
|
|
'utf-8',
|
|
);
|
|
assert.match(serviceProtoSrc, /rpc GetRegionalSnapshot\(GetRegionalSnapshotRequest\) returns \(GetRegionalSnapshotResponse\)/);
|
|
});
|
|
|
|
it('validates region_id as strict lowercase kebab pattern (no trailing or consecutive hyphens)', () => {
|
|
// Pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
|
|
// - Starts with a lowercase letter
|
|
// - Each hyphen must be followed by at least one alphanumeric character
|
|
// - Rejects "mena-", "east-asia-", "foo--bar"
|
|
assert.match(
|
|
protoSrc,
|
|
/buf\.validate\.field\)\.string\.pattern = "\^\[a-z\]\[a-z0-9\]\*\(-\[a-z0-9\]\+\)\*\$"/,
|
|
);
|
|
});
|
|
|
|
it('defines RegionalSnapshot with all 13 top-level fields', () => {
|
|
assert.match(protoSrc, /message RegionalSnapshot \{/);
|
|
assert.match(protoSrc, /string region_id = 1/);
|
|
assert.match(protoSrc, /int64 generated_at = 2/);
|
|
assert.match(protoSrc, /SnapshotMeta meta = 3/);
|
|
assert.match(protoSrc, /RegimeState regime = 4/);
|
|
assert.match(protoSrc, /BalanceVector balance = 5/);
|
|
assert.match(protoSrc, /repeated ActorState actors = 6/);
|
|
assert.match(protoSrc, /repeated LeverageEdge leverage_edges = 7/);
|
|
assert.match(protoSrc, /repeated ScenarioSet scenario_sets = 8/);
|
|
assert.match(protoSrc, /repeated TransmissionPath transmission_paths = 9/);
|
|
assert.match(protoSrc, /TriggerLadder triggers = 10/);
|
|
assert.match(protoSrc, /MobilityState mobility = 11/);
|
|
assert.match(protoSrc, /repeated EvidenceItem evidence = 12/);
|
|
assert.match(protoSrc, /RegionalNarrative narrative = 13/);
|
|
});
|
|
|
|
it('defines BalanceVector with all 7 axes plus net_balance and drivers', () => {
|
|
assert.match(protoSrc, /double coercive_pressure = 1/);
|
|
assert.match(protoSrc, /double domestic_fragility = 2/);
|
|
assert.match(protoSrc, /double capital_stress = 3/);
|
|
assert.match(protoSrc, /double energy_vulnerability = 4/);
|
|
assert.match(protoSrc, /double alliance_cohesion = 5/);
|
|
assert.match(protoSrc, /double maritime_access = 6/);
|
|
assert.match(protoSrc, /double energy_leverage = 7/);
|
|
assert.match(protoSrc, /double net_balance = 8/);
|
|
});
|
|
});
|