mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(supply-chain): Sprint 1 — Route Explorer wrapper RPC
Adds an internal wrapper around the vendor-only route-intelligence
compute so the upcoming Route Explorer UI can call it from a browser
PRO session instead of forcing an X-WorldMonitor-Key API gate.
Backend:
- New proto get-route-explorer-lane.proto with GetRouteExplorerLane{Request,Response}
- New handler server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts
- New static lookup tables _route-explorer-static-tables.ts:
TRANSIT_DAYS_BY_ROUTE_ID, FREIGHT_USD_BY_CARGO_TYPE,
BYPASS_CORRIDOR_GEOMETRY_BY_ID — covers all 5 land-bridge corridors
plus every sea-alternative corridor with hand-curated coordinates
- Wired into supply-chain handler.ts service dispatcher
- Cache key ROUTE_EXPLORER_LANE_KEY in cache-keys.ts (NOT in BOOTSTRAP_KEYS)
- Gateway entry: PREMIUM_RPC_PATHS + RPC_CACHE_TIER 'slow-browser'
- Premium path entry in src/shared/premium-paths.ts so browser PRO auth attaches
Response contract enriches route-intelligence with:
- primaryRouteGeometry polyline from TRADE_ROUTES (lon/lat pairs)
- fromPort/toPort coords on every bypass option so the client can call
MapContainer.setBypassRoutes directly without geometry lookups
- status: 'active' | 'proposed' | 'unavailable' derived from corridor notes
to honestly label kra_canal_future and black_sea_western_ports
- estTransitDaysRange + estFreightUsdPerTeuRange from static tables
- noModeledLane: true when origin/destination clusters share no routes
Client wrapper fetchRouteExplorerLane added to src/services/supply-chain/index.ts.
Tests: tests/route-explorer-lane.test.mts — 30-query smoke matrix
(10 country pairs × 3 HS2 codes), structural assertions only, no
hard-coded transit/cost values. Test exposes a pure computeLane()
function with an injectable status map so it does not need Redis.
Gap report (from smoke run): 12 of 30 queries fall back to a synthetic
primaryRouteId because the destination's port cluster has no shared route
with the origin (US-JP, ZA-IN, CL-CN, TR-DE × 3 HS2 each). These pairs
return noModeledLane:true; Sprint 3 will render an empty-state for them.
Plan: docs/plans/2026-04-11-001-feat-worldwide-route-explorer-plan.md
* fix(route-explorer): address PR #2980 review findings
P1: bypass warRiskTier was hard-coded to WAR_RISK_TIER_NORMAL, dropping
the live risk signal from chokepoint status. Now derived from the
statusMap via the corridor's primaryChokepointId.
P2: freight fallback in emptyResponse and client-side empty payload used
a cargo-agnostic container range for all cargo types. Removed the ranges
entirely from fallback/noModeledLane responses; they are only present
when the lane is actually modeled.
Suggestion: when noModeledLane is true, the response now returns empty
primaryRouteId, empty geometry, empty exposures, empty bypasses, and
omits transit/freight ranges. Previously it returned plausible-looking
synthetic data from the origin's first route which could mislead the UI.
Tests updated to assert the noModeledLane contract: empty fields when
the flag is set, non-empty ranges only when the lane is modeled.
* fix(route-explorer): cargo-aware route ranking + bypass waypoint risk
P1: primary route selection was order-dependent, picking whichever
shared route the origin cluster listed first. Mixed clusters like
CN/JP could return an energy lane for a container request. Now ranks
shared routes by cargo-category compatibility (container→container,
tanker→energy, bulk→bulk, roro→container) before selecting.
P1: bypass warRiskTier was copied from the primary chokepoint instead
of derived from the corridor's own waypointChokepointIds. This
overstated risk for alternatives like Cape of Good Hope whose waypoints
may have a lower risk tier. Now uses max-tier across waypoint
chokepoints, matching get-bypass-options.ts logic.
Suggestion: placeholder corridors with addedTransitDays=0 (like
gibraltar_no_bypass, cape_of_good_hope_is_bypass) are now filtered out.
Previously they could surface as active alternatives.
Regression tests added:
- CN→JP tanker: asserts energy route is selected over container route
- CN→DE with faked Suez=CRITICAL / Cape=NORMAL: asserts Cape bypass
shows NORMAL, not CRITICAL
- ES→EG: asserts zero-transit-day placeholders are excluded
* fix(route-explorer): scope exposures to primary route + narrow placeholder filter
P1: chokepointExposures and bypassOptions were computed from the full
sharedRoutes set, mixing data from energy/container corridors into a
single response. Now scoped to the cargo-ranked primaryRouteId only,
matching the proto contract that exposures are "on the primary route."
P2: the addedTransitDays === 0 filter was too broad and removed
kra_canal_future (a proposed bypass with real modeling). Narrowed to an
explicit PLACEHOLDER_CORRIDOR_IDS set (gibraltar_no_bypass,
cape_of_good_hope_is_bypass) so proposed zero-day corridors survive and
are surfaced with CORRIDOR_STATUS_PROPOSED.
Regression tests:
- chokepointExposures follow primaryRouteId (CN->JP container)
- kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes
- placeholder filter still excludes explicit placeholders
* fix(route-explorer): address PR #2980 review comments
1. Unavailable corridors without waypoints (e.g. black_sea_western_ports)
now derive WAR_RISK_TIER_WAR_ZONE from their CORRIDOR_STATUS_UNAVAILABLE
status, instead of returning WAR_RISK_TIER_UNSPECIFIED. Corridors with
waypointChokepointIds still use max-tier across those waypoints.
2. Added fixture test with non-empty status map (suez=75/HIGH,
malacca=30/ELEVATED) so disruptionScore and warRiskTier assertions are
not trivially satisfied by the empty-map default path.
3. Documented the single-chokepoint bypass design gap in the test gap report:
bypassOptions only cover the primary chokepoint; multi-chokepoint routes
show exposure for all but bypass guidance for only the top one. Sprint 3
will decide whether to expand to top-N or add a UI hint.
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
/**
|
||
* Smoke test matrix for `get-route-explorer-lane`.
|
||
*
|
||
* Calls the pure `computeLane` function (no Redis, no premium gate) for 30
|
||
* representative country pairs × HS2 codes and asserts on response *structure*
|
||
* — not hard-coded transit/cost values, which would drift as the underlying
|
||
* static tables change.
|
||
*
|
||
* The matrix also doubles as a gap report: any pair with empty
|
||
* `chokepointExposures` or `bypassOptions` is logged so Sprint 3/5 can plan
|
||
* empty-state work.
|
||
*/
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { computeLane } from '../server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts';
|
||
import type { GetRouteExplorerLaneRequest } from '../src/generated/server/worldmonitor/supply_chain/v1/service_server.ts';
|
||
|
||
const PAIRS: Array<[string, string, string]> = [
|
||
['CN', 'DE', 'high-volume baseline'],
|
||
['US', 'JP', 'transpacific'],
|
||
['IR', 'CN', 'Hormuz-dependent'],
|
||
['BR', 'NL', 'Atlantic'],
|
||
['AU', 'KR', 'Pacific'],
|
||
['ZA', 'IN', 'Cape of Good Hope'],
|
||
['EG', 'IT', 'Mediterranean short-haul'],
|
||
['NG', 'CN', 'Africa to Asia crude'],
|
||
['CL', 'CN', 'South America to Asia copper'],
|
||
['TR', 'DE', 'semi-landlocked, tests land-bridge path'],
|
||
];
|
||
|
||
const HS2_CODES = ['27', '85', '10'];
|
||
|
||
const VALID_WAR_RISK_TIERS = new Set([
|
||
'WAR_RISK_TIER_UNSPECIFIED',
|
||
'WAR_RISK_TIER_NORMAL',
|
||
'WAR_RISK_TIER_ELEVATED',
|
||
'WAR_RISK_TIER_HIGH',
|
||
'WAR_RISK_TIER_CRITICAL',
|
||
'WAR_RISK_TIER_WAR_ZONE',
|
||
]);
|
||
|
||
const VALID_STATUSES = new Set([
|
||
'CORRIDOR_STATUS_UNSPECIFIED',
|
||
'CORRIDOR_STATUS_ACTIVE',
|
||
'CORRIDOR_STATUS_PROPOSED',
|
||
'CORRIDOR_STATUS_UNAVAILABLE',
|
||
]);
|
||
|
||
interface GapRow {
|
||
pair: string;
|
||
hs2: string;
|
||
primaryRouteId: string;
|
||
noModeledLane: boolean;
|
||
exposures: number;
|
||
bypasses: number;
|
||
reason: string;
|
||
}
|
||
|
||
const gapRows: GapRow[] = [];
|
||
|
||
describe('get-route-explorer-lane smoke matrix (30 queries)', () => {
|
||
for (const [fromIso2, toIso2, reason] of PAIRS) {
|
||
for (const hs2 of HS2_CODES) {
|
||
it(`${fromIso2} -> ${toIso2}, HS ${hs2} (${reason})`, async () => {
|
||
const req: GetRouteExplorerLaneRequest = {
|
||
fromIso2,
|
||
toIso2,
|
||
hs2,
|
||
cargoType: hs2 === '27' ? 'tanker' : hs2 === '10' ? 'bulk' : 'container',
|
||
};
|
||
|
||
// Pass an empty chokepoint-status map so the test does not depend on
|
||
// a live Redis cache. War risk + disruption come back as defaults.
|
||
const res = await computeLane(req, new Map());
|
||
|
||
// Echoed inputs
|
||
assert.equal(res.fromIso2, fromIso2);
|
||
assert.equal(res.toIso2, toIso2);
|
||
assert.equal(res.hs2, hs2);
|
||
|
||
// Cargo type echoed and valid
|
||
assert.match(res.cargoType, /^(container|tanker|bulk|roro)$/);
|
||
|
||
// primaryRouteId is either non-empty OR noModeledLane is set
|
||
if (!res.primaryRouteId) {
|
||
assert.equal(
|
||
res.noModeledLane,
|
||
true,
|
||
'empty primaryRouteId requires noModeledLane=true',
|
||
);
|
||
}
|
||
|
||
// primaryRouteGeometry is an array (may be empty when no modeled lane)
|
||
assert.ok(Array.isArray(res.primaryRouteGeometry));
|
||
for (const pt of res.primaryRouteGeometry) {
|
||
assert.equal(typeof pt.lon, 'number');
|
||
assert.equal(typeof pt.lat, 'number');
|
||
assert.ok(Number.isFinite(pt.lon));
|
||
assert.ok(Number.isFinite(pt.lat));
|
||
}
|
||
|
||
// chokepointExposures is an array of well-formed entries
|
||
assert.ok(Array.isArray(res.chokepointExposures));
|
||
for (const e of res.chokepointExposures) {
|
||
assert.equal(typeof e.chokepointId, 'string');
|
||
assert.equal(typeof e.chokepointName, 'string');
|
||
assert.equal(typeof e.exposurePct, 'number');
|
||
assert.ok(e.exposurePct >= 0 && e.exposurePct <= 100);
|
||
}
|
||
|
||
// bypassOptions is an array of well-formed entries
|
||
assert.ok(Array.isArray(res.bypassOptions));
|
||
for (const b of res.bypassOptions) {
|
||
assert.equal(typeof b.id, 'string');
|
||
assert.equal(typeof b.name, 'string');
|
||
assert.equal(typeof b.type, 'string');
|
||
assert.equal(typeof b.addedTransitDays, 'number');
|
||
assert.equal(typeof b.addedCostMultiplier, 'number');
|
||
assert.ok(VALID_STATUSES.has(b.status));
|
||
assert.ok(b.fromPort, 'bypass option must include fromPort');
|
||
assert.ok(b.toPort, 'bypass option must include toPort');
|
||
assert.equal(typeof b.fromPort.lon, 'number');
|
||
assert.equal(typeof b.fromPort.lat, 'number');
|
||
assert.equal(typeof b.toPort.lon, 'number');
|
||
assert.equal(typeof b.toPort.lat, 'number');
|
||
assert.ok(Number.isFinite(b.fromPort.lon));
|
||
assert.ok(Number.isFinite(b.toPort.lon));
|
||
}
|
||
|
||
// war risk tier is in the known enum set
|
||
assert.ok(
|
||
VALID_WAR_RISK_TIERS.has(res.warRiskTier),
|
||
`unexpected warRiskTier: ${res.warRiskTier}`,
|
||
);
|
||
|
||
// disruption score is a finite number in [0, 100]
|
||
assert.equal(typeof res.disruptionScore, 'number');
|
||
assert.ok(res.disruptionScore >= 0 && res.disruptionScore <= 100);
|
||
|
||
// transit + freight ranges: present and well-formed when lane is modeled;
|
||
// omitted when noModeledLane is true (no synthetic estimates)
|
||
if (!res.noModeledLane) {
|
||
assert.ok(res.estTransitDaysRange, 'modeled lane must include transit range');
|
||
assert.ok(res.estFreightUsdPerTeuRange, 'modeled lane must include freight range');
|
||
assert.ok(Number.isFinite(res.estTransitDaysRange.min));
|
||
assert.ok(Number.isFinite(res.estTransitDaysRange.max));
|
||
assert.ok(res.estTransitDaysRange.min <= res.estTransitDaysRange.max);
|
||
assert.ok(res.estFreightUsdPerTeuRange.min <= res.estFreightUsdPerTeuRange.max);
|
||
} else {
|
||
assert.equal(res.primaryRouteId, '', 'noModeledLane must have empty primaryRouteId');
|
||
assert.equal(res.primaryRouteGeometry.length, 0, 'noModeledLane must have empty geometry');
|
||
assert.equal(res.chokepointExposures.length, 0, 'noModeledLane must have empty exposures');
|
||
assert.equal(res.bypassOptions.length, 0, 'noModeledLane must have empty bypasses');
|
||
}
|
||
|
||
// fetchedAt is an ISO string
|
||
assert.equal(typeof res.fetchedAt, 'string');
|
||
assert.ok(res.fetchedAt.length > 0);
|
||
|
||
// Record gap-report metadata for the run summary
|
||
gapRows.push({
|
||
pair: `${fromIso2}->${toIso2}`,
|
||
hs2,
|
||
primaryRouteId: res.primaryRouteId,
|
||
noModeledLane: res.noModeledLane,
|
||
exposures: res.chokepointExposures.length,
|
||
bypasses: res.bypassOptions.length,
|
||
reason,
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
it('gap report summary (informational, never fails)', () => {
|
||
// Print a compact gap report so plan reviewers can see which pairs
|
||
// returned synthetic / empty data.
|
||
if (gapRows.length === 0) {
|
||
// No-op when run before the matrix above (test ordering is preserved)
|
||
return;
|
||
}
|
||
const noLane = gapRows.filter((r) => r.noModeledLane);
|
||
const emptyExposures = gapRows.filter((r) => r.exposures === 0);
|
||
const emptyBypasses = gapRows.filter((r) => r.bypasses === 0);
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
`\n[gap report] ${gapRows.length} queries | ${noLane.length} synthetic-fallback | ${emptyExposures.length} empty exposures | ${emptyBypasses.length} empty bypasses`,
|
||
);
|
||
if (noLane.length > 0) {
|
||
// eslint-disable-next-line no-console
|
||
console.log(' synthetic-fallback pairs:');
|
||
for (const r of noLane) {
|
||
// eslint-disable-next-line no-console
|
||
console.log(` ${r.pair} HS${r.hs2} -> ${r.primaryRouteId || '(none)'}`);
|
||
}
|
||
}
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
'\n[design gap] bypassOptions are only computed for the primary chokepoint (highest exposurePct).' +
|
||
'\nMulti-chokepoint routes (e.g. CN->DE via Malacca + Suez) show exposure data for both but' +
|
||
'\nbypass guidance only for the primary one. Sprint 3 should decide: expand to top-N chokepoints,' +
|
||
'\nor show a "see also" hint in the UI.',
|
||
);
|
||
// Always passes; informational only.
|
||
assert.ok(true);
|
||
});
|
||
|
||
it('cargo-aware route selection: CN->JP tanker picks energy route over container', async () => {
|
||
const res = await computeLane(
|
||
{ fromIso2: 'CN', toIso2: 'JP', hs2: '27', cargoType: 'tanker' },
|
||
new Map(),
|
||
);
|
||
if (!res.noModeledLane && res.primaryRouteId) {
|
||
const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts');
|
||
const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId);
|
||
assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`);
|
||
assert.equal(
|
||
route.category,
|
||
'energy',
|
||
`tanker request should prefer an energy route, got ${route.category} (${res.primaryRouteId})`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('bypass warRiskTier derives from waypoint chokepoints, not primary', async () => {
|
||
const fakeStatus = new Map<string, { id: string; warRiskTier: string }>([
|
||
['suez', { id: 'suez', warRiskTier: 'WAR_RISK_TIER_CRITICAL' }],
|
||
['cape_of_good_hope', { id: 'cape_of_good_hope', warRiskTier: 'WAR_RISK_TIER_NORMAL' }],
|
||
]);
|
||
const res = await computeLane(
|
||
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
|
||
fakeStatus as Map<string, any>,
|
||
);
|
||
const capeBypass = res.bypassOptions.find((b) => b.id === 'suez_cape_of_good_hope');
|
||
if (capeBypass) {
|
||
assert.equal(
|
||
capeBypass.warRiskTier,
|
||
'WAR_RISK_TIER_NORMAL',
|
||
'Cape bypass should reflect its own waypoint risk (NORMAL), not the primary chokepoint (CRITICAL)',
|
||
);
|
||
}
|
||
});
|
||
|
||
it('placeholder corridors are excluded but proposed zero-day corridors survive', async () => {
|
||
const res = await computeLane(
|
||
{ fromIso2: 'ES', toIso2: 'EG', hs2: '85', cargoType: 'container' },
|
||
new Map(),
|
||
);
|
||
const placeholder = res.bypassOptions.find((b) =>
|
||
b.id === 'gibraltar_no_bypass' || b.id === 'cape_of_good_hope_is_bypass',
|
||
);
|
||
assert.equal(placeholder, undefined, 'explicit placeholder corridors should be filtered out');
|
||
});
|
||
|
||
it('kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes', async () => {
|
||
const res = await computeLane(
|
||
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
|
||
new Map(),
|
||
);
|
||
const kra = res.bypassOptions.find((b) => b.id === 'kra_canal_future');
|
||
if (kra) {
|
||
assert.equal(
|
||
kra.status,
|
||
'CORRIDOR_STATUS_PROPOSED',
|
||
'kra_canal_future should be surfaced as proposed, not filtered out',
|
||
);
|
||
}
|
||
});
|
||
|
||
it('disruptionScore and warRiskTier reflect injected status map', async () => {
|
||
const fakeStatus = new Map<string, { id: string; disruptionScore?: number; warRiskTier?: string }>([
|
||
['suez', { id: 'suez', disruptionScore: 75, warRiskTier: 'WAR_RISK_TIER_HIGH' }],
|
||
['malacca_strait', { id: 'malacca_strait', disruptionScore: 30, warRiskTier: 'WAR_RISK_TIER_ELEVATED' }],
|
||
]);
|
||
const res = await computeLane(
|
||
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
|
||
fakeStatus as Map<string, any>,
|
||
);
|
||
if (res.noModeledLane) return;
|
||
assert.ok(res.disruptionScore > 0, 'disruptionScore should reflect injected data, not default to 0');
|
||
assert.notEqual(res.warRiskTier, 'WAR_RISK_TIER_NORMAL', 'warRiskTier should reflect injected data');
|
||
});
|
||
|
||
it('unavailable corridor without waypoints gets WAR_RISK_TIER_WAR_ZONE', async () => {
|
||
const fakeStatus = new Map<string, { id: string; warRiskTier?: string }>([
|
||
['kerch_strait', { id: 'kerch_strait', warRiskTier: 'WAR_RISK_TIER_WAR_ZONE' }],
|
||
]);
|
||
const res = await computeLane(
|
||
{ fromIso2: 'RU', toIso2: 'TR', hs2: '27', cargoType: 'tanker' },
|
||
fakeStatus as Map<string, any>,
|
||
);
|
||
const unavailable = res.bypassOptions.find((b) => b.status === 'CORRIDOR_STATUS_UNAVAILABLE');
|
||
if (unavailable) {
|
||
assert.equal(
|
||
unavailable.warRiskTier,
|
||
'WAR_RISK_TIER_WAR_ZONE',
|
||
'unavailable corridors without waypoints should derive WAR_ZONE from status',
|
||
);
|
||
}
|
||
});
|
||
|
||
it('chokepointExposures and bypassOptions follow the primaryRouteId', async () => {
|
||
const res = await computeLane(
|
||
{ fromIso2: 'CN', toIso2: 'JP', hs2: '85', cargoType: 'container' },
|
||
new Map(),
|
||
);
|
||
if (res.noModeledLane || !res.primaryRouteId) return;
|
||
const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts');
|
||
const { CHOKEPOINT_REGISTRY } = await import('../server/_shared/chokepoint-registry.ts');
|
||
const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId);
|
||
assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`);
|
||
const routeChokepointIds = new Set(
|
||
CHOKEPOINT_REGISTRY
|
||
.filter((cp: { routeIds: string[] }) => cp.routeIds.includes(res.primaryRouteId))
|
||
.map((cp: { id: string }) => cp.id),
|
||
);
|
||
for (const exp of res.chokepointExposures) {
|
||
assert.ok(
|
||
routeChokepointIds.has(exp.chokepointId),
|
||
`chokepoint ${exp.chokepointId} is not on the primary route ${res.primaryRouteId}`,
|
||
);
|
||
}
|
||
});
|
||
});
|