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.
* feat(route-explorer): Sprint 2 — modal shell + URL state + keyboard system
Adds the standalone Route Explorer modal as a CMD+K command. Sprint 2
ships the SHELL only — no API calls yet, tab panels render placeholders.
Sprint 3 will wire the tabs to get-route-explorer-lane.
Components (src/components/RouteExplorer/):
- url-state.ts: parse / serialize / writeExplorerUrl with silent fallback
on invalid values. Format: ?explorer=from:CN,to:DE,hs:85,cargo:container,tab:1
- RouteExplorer.utils.ts: getAllCountries (197 port-clustered ISO2s with
Intl.DisplayNames + flag emoji), getAllHs2 (~50 sectors), filter helpers,
inferCargoFromHs2 mapping
- CountryPicker.ts: keyboard-first typeahead, ↑/↓/Enter/Esc, max 50 results
- Hs2Picker.ts: same pattern over HS2 chapters
- CargoTypeDropdown.ts: native select with auto-infer indicator badge
- KeyboardHelp.ts: ? cheat-sheet overlay
- RouteExplorer.ts: full-screen modal with focus trap. State synced to URL
via history.replaceState. Singleton via getRouteExplorer(). DEV-only
__routeExplorerTestHook installed for E2E in Sprint 6
Keyboard model:
- Esc: close picker, then close modal
- Tab/Shift+Tab: focus trap inside modal
- F/T/P: jump to From/To/Product picker (scoped to modal+non-text-input)
- S: swap From ↔ To
- 1-4: switch tabs (Current/Alternatives/Land/Impact)
- ?: open KeyboardHelp overlay
- Cmd+, / Ctrl+,: copy share URL to clipboard
Single-letter bindings check document.activeElement and ignore the keystroke
when an input/textarea/contenteditable is focused, so they don't collide
with picker typeahead.
CMD+K integration:
- src/config/commands.ts: new view:route-explorer entry with category 'view',
ship-wheel icon, label 'Route Explorer — plan a shipment'
- src/app/search-manager.ts: handleCommand 'view' branch lazy-imports
RouteExplorer module and calls .open() on the singleton
Tests (tests/route-explorer-*.test.mts):
- url-state: 19 cases — parse, serialize, roundtrip, invalid-value fallback
- pickers: 17 cases — country list shape + sort, filter behavior, HS2 list,
cargo inference for HS 27/10/12/26/87/89/85
- keyboard: 3 module-surface smoke tests (full focus-trap behavior is
exercised by Sprint 6 Playwright E2E since it needs a real DOM)
Known follow-up: tab panels are placeholders. Sprint 3 wires them to the
RPC and adds the left-rail summary card.
Plan: docs/plans/2026-04-11-001-feat-worldwide-route-explorer-plan.md
Stack: depends on PR #2980 (Sprint 1 wrapper RPC)
* fix(route-explorer): address PR #2982 review findings
P1: wire onCancel on CountryPicker and Hs2Picker so Esc inside a
focused picker blurs the input first, allowing the modal-level Esc
handler to fire on the next press. Previously Esc was a no-op inside
pickers because the modal handler returned early for focused inputs.
P2: include SELECT in the form-control guard (renamed from
isTextInputFocused to isFormControlFocused). The cargo dropdown is a
native select whose keyboard interaction was hijacked by single-letter
shortcuts (F/T/P/S/1-4). Now the guard exempts all form controls with
native keyboard behavior.
Suggestion: reset helpOverlay in close() so the ? overlay can reopen
cleanly on the next modal open. Previously a stale reference prevented
reopening.
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
/**
|
|
* Roundtrip + edge-case tests for the Route Explorer URL state module.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
parseExplorerUrl,
|
|
serializeExplorerUrl,
|
|
DEFAULT_EXPLORER_STATE,
|
|
type ExplorerUrlState,
|
|
} from '../src/components/RouteExplorer/url-state.ts';
|
|
|
|
describe('parseExplorerUrl', () => {
|
|
it('returns defaults for empty search string', () => {
|
|
const out = parseExplorerUrl('');
|
|
assert.deepEqual(out, DEFAULT_EXPLORER_STATE);
|
|
});
|
|
|
|
it('returns defaults when explorer param is missing', () => {
|
|
const out = parseExplorerUrl('?other=value');
|
|
assert.deepEqual(out, DEFAULT_EXPLORER_STATE);
|
|
});
|
|
|
|
it('parses a complete state string', () => {
|
|
const out = parseExplorerUrl('?explorer=from:CN,to:DE,hs:85,cargo:container,tab:2');
|
|
assert.deepEqual(out, {
|
|
fromIso2: 'CN',
|
|
toIso2: 'DE',
|
|
hs2: '85',
|
|
cargo: 'container',
|
|
tab: 2,
|
|
});
|
|
});
|
|
|
|
it('uppercases ISO2 codes', () => {
|
|
const out = parseExplorerUrl('?explorer=from:cn,to:de');
|
|
assert.equal(out.fromIso2, 'CN');
|
|
assert.equal(out.toIso2, 'DE');
|
|
});
|
|
|
|
it('lowercases cargo type', () => {
|
|
const out = parseExplorerUrl('?explorer=cargo:CONTAINER');
|
|
assert.equal(out.cargo, 'container');
|
|
});
|
|
|
|
it('drops invalid ISO2 codes silently', () => {
|
|
const out = parseExplorerUrl('?explorer=from:USA,to:1');
|
|
assert.equal(out.fromIso2, null);
|
|
assert.equal(out.toIso2, null);
|
|
});
|
|
|
|
it('drops invalid HS2 codes silently', () => {
|
|
const out = parseExplorerUrl('?explorer=hs:abc');
|
|
assert.equal(out.hs2, null);
|
|
});
|
|
|
|
it('drops invalid cargo type silently', () => {
|
|
const out = parseExplorerUrl('?explorer=cargo:rocket');
|
|
assert.equal(out.cargo, null);
|
|
});
|
|
|
|
it('drops invalid tab silently', () => {
|
|
const out = parseExplorerUrl('?explorer=tab:99');
|
|
assert.equal(out.tab, 1);
|
|
});
|
|
|
|
it('accepts partial state', () => {
|
|
const out = parseExplorerUrl('?explorer=from:CN,hs:27');
|
|
assert.equal(out.fromIso2, 'CN');
|
|
assert.equal(out.toIso2, null);
|
|
assert.equal(out.hs2, '27');
|
|
assert.equal(out.cargo, null);
|
|
assert.equal(out.tab, 1);
|
|
});
|
|
|
|
it('does not throw on malformed param', () => {
|
|
const out = parseExplorerUrl('?explorer=garbage:::');
|
|
assert.deepEqual(out, DEFAULT_EXPLORER_STATE);
|
|
});
|
|
});
|
|
|
|
describe('serializeExplorerUrl', () => {
|
|
it('returns null for default state', () => {
|
|
assert.equal(serializeExplorerUrl(DEFAULT_EXPLORER_STATE), null);
|
|
});
|
|
|
|
it('serializes complete state', () => {
|
|
const state: ExplorerUrlState = {
|
|
fromIso2: 'CN',
|
|
toIso2: 'DE',
|
|
hs2: '85',
|
|
cargo: 'container',
|
|
tab: 2,
|
|
};
|
|
assert.equal(serializeExplorerUrl(state), 'from:CN,to:DE,hs:85,cargo:container,tab:2');
|
|
});
|
|
|
|
it('omits tab=1 from output', () => {
|
|
const state: ExplorerUrlState = { ...DEFAULT_EXPLORER_STATE, fromIso2: 'CN', tab: 1 };
|
|
assert.equal(serializeExplorerUrl(state), 'from:CN');
|
|
});
|
|
|
|
it('omits null fields', () => {
|
|
const state: ExplorerUrlState = { ...DEFAULT_EXPLORER_STATE, fromIso2: 'CN', hs2: '85' };
|
|
assert.equal(serializeExplorerUrl(state), 'from:CN,hs:85');
|
|
});
|
|
});
|
|
|
|
describe('roundtrip', () => {
|
|
const cases: ExplorerUrlState[] = [
|
|
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargo: 'container', tab: 1 },
|
|
{ fromIso2: 'IR', toIso2: 'CN', hs2: '27', cargo: 'tanker', tab: 3 },
|
|
{ fromIso2: null, toIso2: null, hs2: '10', cargo: 'bulk', tab: 4 },
|
|
{ fromIso2: 'BR', toIso2: 'NL', hs2: null, cargo: null, tab: 2 },
|
|
];
|
|
|
|
for (const state of cases) {
|
|
it(`roundtrips ${JSON.stringify(state)}`, () => {
|
|
const serialized = serializeExplorerUrl(state);
|
|
assert.ok(serialized, 'expected non-null serialization');
|
|
const parsed = parseExplorerUrl(`?explorer=${serialized}`);
|
|
assert.deepEqual(parsed, state);
|
|
});
|
|
}
|
|
});
|