mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner Adds the simulation execution layer that consumes simulation-package.json and produces simulation-outcome.json for maritime chokepoint + energy/logistics theaters, closing the WorldMonitor → MiroFish handoff loop. Changes: - scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders, JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue with NX dedup lock, runSimulationWorker poll loop) - scripts/process-simulation-tasks.mjs: standalone worker entry point - proto: GetSimulationOutcome RPC + make generate - server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler - server/gateway.ts: slow tier for get-simulation-outcome - api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys - tests: 14 new tests for simulation runner functions * fix(simulation): address P1/P2 code review findings from PR #2220 Security (P1 #018): - sanitizeForPrompt() applied to all entity/seed fields interpolated into Round 1 prompt (entityId, class, stance, seedId, type, timing) - sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt - sanitizeForPrompt() + length caps applied to all LLM array fields written to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers) Validation (P1 #019): - Added validateRunId() regex guard - Applied in enqueueSimulationTask() and processNextSimulationTask() loop Type safety (P1 #020): - Added isOutcomePointer() and isPackagePointer() type guards in TS handlers - Replaced unsafe as-casts with runtime-validated guards in both handlers Correctness (P2 #022): - Log warning when pkgPointer.runId does not match task runId Architecture (P2 #024): - isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId - Call site simplified to pass theater directly Performance (P2 #025): - SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200 - Added max 3 initialReactions instruction to Round 1 prompt Maintainability (P2 #026): - Simulation pointer keys exported from server/_shared/cache-keys.ts - Both TS handlers import from shared location Documentation (P2 #027): - Strengthened runId no-op description in proto and OpenAPI spec * fix(todos): add blank lines around lists in markdown todo files * style(api): reformat openapi yaml to match linter output * test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage Two tests identified as missing during PR #2220 review: 1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId normalization added in the P1/P2 review pass. The existing tests only used the nested marketContext.topBucketId shape; this adds the flat root-field shape that arrives from the simulation-package.json JSON (selectedTheaters entries have topBucketId at root). 2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard, found:false NOT_FOUND return, found:true success path, note population on runId mismatch, and redis_unavailable error string. Follows the readSrc static-analysis pattern used elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test would require a test Redis instance).
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
/**
|
|
* Tests for server handler correctness after PR #106 review fixes.
|
|
*
|
|
* These tests verify:
|
|
* - Humanitarian summary handler rejects unmapped country codes
|
|
* - Humanitarian summary returns ISO-2 country_code (not ISO-3)
|
|
* - Hardcoded political context is removed from LLM prompts
|
|
* - Headline deduplication logic works correctly
|
|
* - Cache key builder produces deterministic output
|
|
* - Vessel snapshot handler has cache + in-flight dedup
|
|
*/
|
|
|
|
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 { deduplicateHeadlines } from '../server/worldmonitor/news/v1/dedup.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
// Helper to read a source file relative to project root
|
|
const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');
|
|
|
|
// ========================================================================
|
|
// 1. Humanitarian summary: country fallback + ISO-2 contract
|
|
// ========================================================================
|
|
|
|
describe('getHumanitarianSummary handler', () => {
|
|
const src = readSrc('server/worldmonitor/conflict/v1/get-humanitarian-summary.ts');
|
|
|
|
it('returns undefined when country has no ISO3 mapping (BLOCKING-1)', () => {
|
|
// Must have early return when no ISO3 mapping (before HAPI fetch)
|
|
assert.match(src, /if\s*\(\s*!iso3\s*\)\s*return\s+undefined/,
|
|
'Should return undefined when no ISO3 mapping exists');
|
|
// The countryCode branch must NOT fall back to Object.values(byCountry)[0]
|
|
// Extract only the "if (countryCode)" block for picking entry and verify no fallback
|
|
const pickSection = src.slice(
|
|
src.indexOf('// Pick the right country entry'),
|
|
src.indexOf('if (!entry) return undefined;'),
|
|
);
|
|
// Inside the countryCode branch, should NOT have Object.values(byCountry)[0] as fallback
|
|
const countryCodeBranch = pickSection.slice(0, pickSection.indexOf('} else {'));
|
|
assert.doesNotMatch(countryCodeBranch, /Object\.values\(byCountry\)\[0\]/,
|
|
'countryCode branch should not fallback to first entry');
|
|
});
|
|
|
|
it('returns ISO-2 country_code per proto contract (BLOCKING-2)', () => {
|
|
// Must NOT return ISO2_TO_ISO3[...] as countryCode
|
|
assert.doesNotMatch(src, /countryCode:\s*ISO2_TO_ISO3/,
|
|
'Should not return ISO-3 code in countryCode field');
|
|
// Should return the original countryCode (uppercased)
|
|
assert.match(src, /countryCode:\s*countryCode.*\.toUpperCase\(\)/,
|
|
'Should return original ISO-2 countryCode uppercased');
|
|
});
|
|
|
|
it('uses renamed conflict-event proto fields (MEDIUM-1)', () => {
|
|
assert.match(src, /conflictEventsTotal/,
|
|
'Should use conflictEventsTotal field');
|
|
assert.match(src, /conflictPoliticalViolenceEvents/,
|
|
'Should use conflictPoliticalViolenceEvents field');
|
|
assert.match(src, /conflictFatalities/,
|
|
'Should use conflictFatalities field');
|
|
assert.match(src, /referencePeriod/,
|
|
'Should use referencePeriod field');
|
|
assert.match(src, /conflictDemonstrations/,
|
|
'Should use conflictDemonstrations field');
|
|
// Old field names must not appear
|
|
assert.doesNotMatch(src, /populationAffected/,
|
|
'Should not reference old populationAffected field');
|
|
assert.doesNotMatch(src, /peopleInNeed/,
|
|
'Should not reference old peopleInNeed field');
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 2. Humanitarian summary proto: field semantics
|
|
// ========================================================================
|
|
|
|
describe('humanitarian_summary.proto', () => {
|
|
const proto = readSrc('proto/worldmonitor/conflict/v1/humanitarian_summary.proto');
|
|
|
|
it('has conflict-event field names instead of humanitarian field names', () => {
|
|
assert.match(proto, /conflict_events_total/);
|
|
assert.match(proto, /conflict_political_violence_events/);
|
|
assert.match(proto, /conflict_fatalities/);
|
|
assert.match(proto, /reference_period/);
|
|
assert.match(proto, /conflict_demonstrations/);
|
|
// Old names removed
|
|
assert.doesNotMatch(proto, /population_affected/);
|
|
assert.doesNotMatch(proto, /people_in_need/);
|
|
assert.doesNotMatch(proto, /internally_displaced/);
|
|
assert.doesNotMatch(proto, /food_insecurity_level/);
|
|
assert.doesNotMatch(proto, /water_access_pct/);
|
|
});
|
|
|
|
it('declares country_code as ISO-2', () => {
|
|
assert.match(proto, /ISO 3166-1 alpha-2/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 3. Hardcoded political context removed (LOW-1)
|
|
// ========================================================================
|
|
|
|
describe('LLM prompt political context (LOW-1)', () => {
|
|
const src = readSrc('server/worldmonitor/news/v1/_shared.ts');
|
|
|
|
it('does not contain hardcoded "Donald Trump" reference', () => {
|
|
assert.doesNotMatch(src, /Donald Trump/,
|
|
'Should not contain hardcoded political figure name');
|
|
});
|
|
|
|
it('uses date-based dynamic context instead', () => {
|
|
assert.match(src, /Provide geopolitical context appropriate for the current date/,
|
|
'Should instruct LLM to use current-date context');
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 4. Headline deduplication (ported logic test)
|
|
// ========================================================================
|
|
|
|
describe('headline deduplication', () => {
|
|
// Imports the real deduplicateHeadlines from dedup.mjs (shared with _shared.ts)
|
|
|
|
it('removes near-duplicate headlines', () => {
|
|
const headlines = [
|
|
'Russia launches missile strike on Ukrainian energy infrastructure targets',
|
|
'Russia launches missile strike on Ukrainian energy infrastructure overnight',
|
|
'EU approves new sanctions package against Russia',
|
|
];
|
|
// Words >= 4 chars for headline 1: russia, launches, missile, strike, ukrainian, energy, infrastructure, targets (8)
|
|
// Words >= 4 chars for headline 2: russia, launches, missile, strike, ukrainian, energy, infrastructure, overnight (8)
|
|
// Intersection: 7/8 = 0.875 > 0.6 threshold
|
|
const result = deduplicateHeadlines(headlines);
|
|
assert.equal(result.length, 2, 'Should deduplicate near-identical headlines');
|
|
assert.equal(result[0], headlines[0], 'Should keep the first occurrence');
|
|
assert.equal(result[1], headlines[2], 'Should keep the dissimilar headline');
|
|
});
|
|
|
|
it('keeps all unique headlines', () => {
|
|
const headlines = [
|
|
'Tech stocks rally on AI optimism',
|
|
'Federal Reserve holds interest rates steady',
|
|
'New climate report warns of tipping points',
|
|
];
|
|
const result = deduplicateHeadlines(headlines);
|
|
assert.equal(result.length, 3, 'All unique headlines should be kept');
|
|
});
|
|
|
|
it('handles empty input', () => {
|
|
assert.deepEqual(deduplicateHeadlines([]), []);
|
|
});
|
|
|
|
it('handles single headline', () => {
|
|
const result = deduplicateHeadlines(['Single headline here']);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 5. Cache key builder (determinism test)
|
|
// ========================================================================
|
|
|
|
describe('getCacheKey determinism', () => {
|
|
const src = readSrc('src/utils/summary-cache-key.ts');
|
|
const sharedSrc = readSrc('server/worldmonitor/news/v1/_shared.ts');
|
|
|
|
it('getCacheKey function exists and builds versioned keys', () => {
|
|
assert.match(src, /export function buildSummaryCacheKey\(/,
|
|
'buildSummaryCacheKey should be exported from shared module');
|
|
assert.match(sharedSrc, /getCacheKey/,
|
|
'_shared.ts should re-export getCacheKey');
|
|
assert.match(src, /CACHE_VERSION/,
|
|
'Should use CACHE_VERSION for cache key prefixing');
|
|
assert.match(src, /`summary:\$\{CACHE_VERSION\}:\$\{mode\}/,
|
|
'Cache key should include mode');
|
|
});
|
|
|
|
it('handles translate mode separately', () => {
|
|
assert.match(src, /if\s*\(mode\s*===\s*'translate'\)/,
|
|
'Should have separate key format for translate mode');
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 6. Vessel snapshot caching (structural verification)
|
|
// ========================================================================
|
|
|
|
describe('getVesselSnapshot caching (HIGH-1)', () => {
|
|
const src = readSrc('server/worldmonitor/maritime/v1/get-vessel-snapshot.ts');
|
|
|
|
it('has in-memory cache variables at module scope', () => {
|
|
assert.match(src, /let cachedSnapshot/);
|
|
assert.match(src, /let cacheTimestamp/);
|
|
assert.match(src, /let inFlightRequest/);
|
|
});
|
|
|
|
it('has 5-minute TTL cache', () => {
|
|
assert.match(src, /SNAPSHOT_CACHE_TTL_MS\s*=\s*300[_]?000/,
|
|
'TTL should be 5 minutes (300000ms)');
|
|
});
|
|
|
|
it('checks cache before calling relay', () => {
|
|
// fetchVesselSnapshot should check cachedSnapshot before fetchVesselSnapshotFromRelay
|
|
const cacheCheckIdx = src.indexOf('cachedSnapshot && (now - cacheTimestamp)');
|
|
const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay()');
|
|
assert.ok(cacheCheckIdx > -1, 'Should check cache');
|
|
assert.ok(relayCallIdx > -1, 'Should have relay fetch function');
|
|
assert.ok(cacheCheckIdx < relayCallIdx,
|
|
'Cache check should come before relay call');
|
|
});
|
|
|
|
it('has in-flight dedup via shared promise', () => {
|
|
assert.match(src, /if\s*\(inFlightRequest\)/,
|
|
'Should check for in-flight request');
|
|
assert.match(src, /inFlightRequest\s*=\s*fetchVesselSnapshotFromRelay/,
|
|
'Should assign in-flight promise');
|
|
assert.match(src, /inFlightRequest\s*=\s*null/,
|
|
'Should clear in-flight promise in finally block');
|
|
});
|
|
|
|
it('serves stale snapshot when relay fetch fails', () => {
|
|
assert.match(src, /return\s+result\s*\?\?\s*cachedSnapshot/,
|
|
'Should return stale cached snapshot when fresh relay fetch fails');
|
|
});
|
|
|
|
// NOTE: Full integration test (mocking fetch, verifying cache hits) requires
|
|
// a TypeScript-capable test runner. This structural test verifies the pattern.
|
|
});
|
|
|
|
// ========================================================================
|
|
// getSimulationOutcome handler — structural tests
|
|
// ========================================================================
|
|
|
|
describe('getSimulationOutcome handler', () => {
|
|
const src = readSrc('server/worldmonitor/forecast/v1/get-simulation-outcome.ts');
|
|
|
|
it('returns found:false (NOT_FOUND) when pointer is absent', () => {
|
|
// The handler must define a NOT_FOUND sentinel with found: false
|
|
assert.match(src, /found:\s*false/,
|
|
'NOT_FOUND constant should set found: false');
|
|
// And return it when the pointer is missing
|
|
assert.match(src, /return\s+NOT_FOUND/,
|
|
'Should return NOT_FOUND when key is absent');
|
|
});
|
|
|
|
it('uses isOutcomePointer type guard before accessing pointer fields', () => {
|
|
assert.match(src, /isOutcomePointer\(raw\)/,
|
|
'Should use isOutcomePointer type guard on getRawJson result');
|
|
// Guard must check string and number fields — not just truthy
|
|
assert.match(src, /typeof\s+o\[.runId.\]\s*===\s*'string'/,
|
|
'Type guard should verify runId is a string');
|
|
assert.match(src, /typeof\s+o\[.theaterCount.\]\s*===\s*'number'/,
|
|
'Type guard should verify theaterCount is a number');
|
|
});
|
|
|
|
it('returns found:true with all pointer fields on success', () => {
|
|
assert.match(src, /found:\s*true/,
|
|
'Success path should return found: true');
|
|
// Must propagate all pointer fields
|
|
assert.match(src, /outcomeKey:\s*pointer\.outcomeKey/,
|
|
'Success path should include outcomeKey from pointer');
|
|
assert.match(src, /theaterCount:\s*pointer\.theaterCount/,
|
|
'Success path should include theaterCount from pointer');
|
|
});
|
|
|
|
it('populates note when runId supplied but does not match pointer runId', () => {
|
|
assert.match(src, /req\.runId.*pointer\.runId/,
|
|
'Should compare req.runId with pointer.runId for note');
|
|
assert.match(src, /runId filter not yet active/,
|
|
'Note text should explain the Phase 3 deferral');
|
|
});
|
|
|
|
it('returns redis_unavailable error string on Redis failure', () => {
|
|
assert.match(src, /redis_unavailable/,
|
|
'Should return redis_unavailable on catch');
|
|
assert.match(src, /markNoCacheResponse.*catch|catch[\s\S]*?markNoCacheResponse/,
|
|
'Should mark no-cache on error to avoid caching error state');
|
|
});
|
|
});
|