feat: phase 15 — 100 RSS feeds, 45 tests, 44 CLI commands, keyboard shortcuts
- Tests: 14 new tests for Phase 13-14 tools (BTC technicals, central banks, USNI fleet, trade routes, cloud regions, financial centers) - RSS feeds: 70→100 across 18 categories (added Europe, South Asia, Health, expanded Africa, think tanks, science, energy, security) - Dashboard: 16 keyboard shortcuts with help overlay (?), layer toggles (1-9,0), zoom (+/-), fullscreen (f), toggle all (a), layer panel (l) - CLI: 25→44 commands covering all major tool domains (btc, central-banks, shipping, social, disease, elections, nuclear, space, sanctions, ai-watch, fleet, hn, gh-trending, arxiv, spending, bases, exchanges) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4dda867968
commit
a5a1e207a7
+440
-3
@@ -18,7 +18,15 @@ from rich import box
|
||||
from .cache import Cache
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .fetcher import Fetcher
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber
|
||||
from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, shipping, social, nuclear, space_weather, ai_watch, elections, health, sanctions
|
||||
from .sources.central_banks import fetch_central_bank_rates
|
||||
from .sources.usni_fleet import fetch_usni_fleet
|
||||
from .sources.hacker_news import fetch_hacker_news
|
||||
from .sources.github_trending import fetch_trending_repos
|
||||
from .sources.arxiv_papers import fetch_arxiv_papers
|
||||
from .sources.usa_spending import fetch_usa_spending
|
||||
from .sources.environmental import fetch_environmental_events, fetch_disaster_alerts
|
||||
from .sources import geospatial
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -518,12 +526,14 @@ def climate_cmd(ctx: click.Context) -> None:
|
||||
|
||||
@main.command(name="news")
|
||||
@click.option("--category", "-c", default=None,
|
||||
type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science"]),
|
||||
type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science",
|
||||
"think_tanks", "middle_east", "asia_pacific", "africa", "latin_america",
|
||||
"multilingual", "energy", "government", "crisis", "europe", "south_asia", "health"]),
|
||||
help="Category filter")
|
||||
@click.option("--limit", "-n", default=30, help="Max items")
|
||||
@click.pass_context
|
||||
def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None:
|
||||
"""Intelligence news from 20+ RSS feeds."""
|
||||
"""Intelligence news from 100 RSS feeds across 18 categories."""
|
||||
f = _get_fetcher()
|
||||
data = _run(news.fetch_news_feed(f, category=category, limit=limit))
|
||||
|
||||
@@ -858,6 +868,433 @@ def instability(ctx: click.Context, country_code: str | None) -> None:
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finance (additional)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command()
|
||||
@click.pass_context
|
||||
def btc(ctx: click.Context) -> None:
|
||||
"""Bitcoin technical indicators (SMA, Mayer, cross signals)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(markets.fetch_btc_technicals(f))
|
||||
|
||||
if ctx.obj.get("json") or "error" in data:
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]BTC Technicals[/bold] price: ${data.get('price', 0):,.2f}\n")
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Indicator", style="bold")
|
||||
table.add_column("Value", justify="right")
|
||||
|
||||
table.add_row("SMA-50", f"${data.get('sma_50', 0):,.2f}")
|
||||
table.add_row("SMA-200", f"${data.get('sma_200', 0):,.2f}" if data.get('sma_200') else "N/A")
|
||||
table.add_row("Mayer Multiple", f"{data.get('mayer_multiple', 0):.4f}" if data.get('mayer_multiple') else "N/A")
|
||||
cross = data.get('cross_signal', 'neutral')
|
||||
c_style = "green" if cross == "golden_cross" else "red" if cross == "death_cross" else ""
|
||||
table.add_row("Cross Signal", f"[{c_style}]{cross}[/{c_style}]" if c_style else cross)
|
||||
table.add_row("ATH Distance", f"{data.get('ath_distance_pct', 0):.1f}%")
|
||||
table.add_row("7d Change", f"{data.get('change_7d_pct', 0):+.2f}%" if data.get('change_7d_pct') is not None else "N/A")
|
||||
table.add_row("30d Change", f"{data.get('change_30d_pct', 0):+.2f}%" if data.get('change_30d_pct') is not None else "N/A")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command(name="central-banks")
|
||||
@click.pass_context
|
||||
def central_banks_cmd(ctx: click.Context) -> None:
|
||||
"""Central bank policy rates (15 banks)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_central_bank_rates(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
fred_tag = "[green]FRED[/green]" if data.get("fred_available") else "[yellow]curated[/yellow]"
|
||||
console.print(f"[bold]{data.get('count', 0)} Central Banks[/bold] ({fred_tag})\n")
|
||||
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Bank", style="bold")
|
||||
table.add_column("Country")
|
||||
table.add_column("Rate %", justify="right")
|
||||
table.add_column("As Of")
|
||||
|
||||
for r in data.get("rates", []):
|
||||
rate = r.get("rate", 0)
|
||||
style = "red bold" if rate >= 10 else "yellow" if rate >= 5 else "green" if rate < 1 else ""
|
||||
rate_str = f"[{style}]{rate:.2f}[/{style}]" if style else f"{rate:.2f}"
|
||||
table.add_row(r.get("bank", ""), r.get("country", ""), rate_str, r.get("as_of", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command(name="shipping")
|
||||
@click.pass_context
|
||||
def shipping_cmd(ctx: click.Context) -> None:
|
||||
"""Shipping index (BDI, tanker, container ETFs)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(shipping.fetch_shipping_index(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]Shipping Stress: {data.get('assessment', '?')}[/bold] "
|
||||
f"(score: {data.get('stress_score', 0):.0f}/100)\n")
|
||||
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Symbol", style="bold")
|
||||
table.add_column("Price", justify="right")
|
||||
table.add_column("Change %", justify="right")
|
||||
|
||||
for q in data.get("quotes", []):
|
||||
chg = q.get("change_pct") or 0
|
||||
style = "green" if chg >= 0 else "red"
|
||||
table.add_row(q.get("symbol", ""), f"${q.get('price', 0):,.2f}", f"[{style}]{chg:+.2f}%[/{style}]")
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social & Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command(name="social")
|
||||
@click.pass_context
|
||||
def social_cmd(ctx: click.Context) -> None:
|
||||
"""Reddit social signals (worldnews, geopolitics)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(social.fetch_social_signals(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
metrics = data.get("velocity_metrics", {})
|
||||
console.print(f"[bold]Social Signals[/bold] — {metrics.get('total_posts', 0)} posts, "
|
||||
f"{metrics.get('high_engagement_count', 0)} high engagement\n")
|
||||
|
||||
for post in data.get("top_posts", [])[:15]:
|
||||
score = post.get("score", 0)
|
||||
style = "bold" if score >= 1000 else ""
|
||||
title = post.get("title", "")[:80]
|
||||
console.print(f" [{style}]{score:>5}[/{style}] {title}" if style else f" {score:>5} {title}")
|
||||
|
||||
|
||||
@main.command(name="disease")
|
||||
@click.pass_context
|
||||
def disease_cmd(ctx: click.Context) -> None:
|
||||
"""Disease outbreaks (WHO/ProMED/CIDRAP)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(health.fetch_disease_outbreaks(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('count', 0)} outbreak reports[/bold] "
|
||||
f"([red]{data.get('high_concern_count', 0)} high concern[/red])\n")
|
||||
|
||||
for item in data.get("items", [])[:20]:
|
||||
hc = "[red]HC[/red] " if item.get("is_high_concern") else " "
|
||||
title = item.get("title", "")[:80]
|
||||
feed = item.get("feed_name", "")
|
||||
console.print(f" {hc}{title} [dim]({feed})[/dim]")
|
||||
|
||||
|
||||
@main.command(name="elections")
|
||||
@click.option("--country", "-c", default=None, help="Country filter (ISO-3)")
|
||||
@click.pass_context
|
||||
def elections_cmd(ctx: click.Context, country: str | None) -> None:
|
||||
"""Election calendar with risk scoring."""
|
||||
f = _get_fetcher()
|
||||
data = _run(elections.fetch_election_calendar(f, country=country))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
table = Table(title="Election Calendar", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Date", style="bold")
|
||||
table.add_column("Country")
|
||||
table.add_column("Type")
|
||||
table.add_column("Days", justify="right")
|
||||
table.add_column("Risk", justify="right")
|
||||
|
||||
for e in data.get("elections", []):
|
||||
risk = e.get("risk_score", 0)
|
||||
r_style = "red bold" if risk >= 4 else "yellow" if risk >= 2 else ""
|
||||
r_str = f"[{r_style}]{risk:.1f}[/{r_style}]" if r_style else f"{risk:.1f}"
|
||||
table.add_row(e.get("date", ""), e.get("country", ""), e.get("type", ""), str(e.get("days_until", "")), r_str)
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Specialist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command(name="nuclear")
|
||||
@click.option("--hours", "-h", default=72, help="Lookback hours")
|
||||
@click.pass_context
|
||||
def nuclear_cmd(ctx: click.Context, hours: int) -> None:
|
||||
"""Nuclear test site seismic monitor."""
|
||||
f = _get_fetcher()
|
||||
data = _run(nuclear.fetch_nuclear_monitor(f, hours=hours))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('total_flagged_events', 0)} flagged events[/bold] "
|
||||
f"([red]{data.get('critical_flags', 0)} critical[/red]) in last {hours}h\n")
|
||||
|
||||
for site in data.get("sites", []):
|
||||
n = site.get("name", "")
|
||||
events = site.get("events", [])
|
||||
count = len(events)
|
||||
style = "red bold" if count > 0 else "dim"
|
||||
console.print(f" [{style}]{n}: {count} events[/{style}]")
|
||||
|
||||
|
||||
@main.command(name="space")
|
||||
@click.pass_context
|
||||
def space_cmd(ctx: click.Context) -> None:
|
||||
"""Space weather (NOAA/SWPC)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(space_weather.fetch_space_weather(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
table = Table(title="Space Weather", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Metric", style="bold")
|
||||
table.add_column("Value", justify="right")
|
||||
|
||||
for key in ("k_index", "solar_wind_speed_km_s", "solar_wind_density", "bz_gsm_nt", "flux_10_7"):
|
||||
val = data.get(key)
|
||||
if val is not None:
|
||||
table.add_row(key.replace("_", " ").title(), str(val))
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command(name="sanctions")
|
||||
@click.argument("query")
|
||||
@click.option("--country", "-c", default=None, help="Country filter")
|
||||
@click.pass_context
|
||||
def sanctions_cmd(ctx: click.Context, query: str, country: str | None) -> None:
|
||||
"""Search OFAC SDN sanctions list."""
|
||||
f = _get_fetcher()
|
||||
data = _run(sanctions.fetch_sanctions_search(f, query=query, country=country))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('count', 0)} matches[/bold] for '{query}' "
|
||||
f"(from {data.get('total_entities', 0)} total)\n")
|
||||
|
||||
for m in data.get("matches", [])[:20]:
|
||||
etype = m.get("entity_type", "")
|
||||
name = m.get("name", "")
|
||||
programs = ", ".join(m.get("programs", [])[:3])
|
||||
console.print(f" [{etype}] [bold]{name}[/bold] ({programs})")
|
||||
|
||||
|
||||
@main.command(name="ai-watch")
|
||||
@click.pass_context
|
||||
def ai_watch_cmd(ctx: click.Context) -> None:
|
||||
"""AI model and paper releases tracker."""
|
||||
f = _get_fetcher()
|
||||
data = _run(ai_watch.fetch_ai_watch(f))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]AI Watch[/bold] — {data.get('total_items', 0)} items\n")
|
||||
for item in data.get("items", [])[:20]:
|
||||
title = item.get("title", "")[:80]
|
||||
src = item.get("source", "")
|
||||
console.print(f" [{src}] {title}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command(name="fleet")
|
||||
@click.pass_context
|
||||
def fleet_cmd(ctx: click.Context) -> None:
|
||||
"""USNI Fleet Tracker (Navy disposition)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_usni_fleet(f))
|
||||
|
||||
if ctx.obj.get("json") or "error" in data:
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('report_title', 'Fleet Report')}[/bold]\n")
|
||||
|
||||
totals = data.get("force_totals", {})
|
||||
if totals.get("battle_force"):
|
||||
bf = totals["battle_force"]
|
||||
console.print(f" Battle Force: {bf.get('total', 0)} ships ({bf.get('uss', 0)} USS, {bf.get('usns', 0)} USNS)")
|
||||
if totals.get("deployed"):
|
||||
dp = totals["deployed"]
|
||||
console.print(f" Deployed: {dp.get('total', 0)} ({dp.get('uss', 0)} USS, {dp.get('usns', 0)} USNS)")
|
||||
|
||||
ships = data.get("ships", [])
|
||||
if ships:
|
||||
console.print(f"\n [bold]{len(ships)} ships identified[/bold]")
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Ship", style="bold")
|
||||
table.add_column("Hull")
|
||||
table.add_column("Type")
|
||||
table.add_column("Region")
|
||||
for s in ships[:20]:
|
||||
table.add_row(s.get("name", ""), s.get("hull_number", ""), s.get("type", ""), s.get("region", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tech & Science
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command(name="hn")
|
||||
@click.option("--limit", "-n", default=20, help="Number of stories")
|
||||
@click.pass_context
|
||||
def hn_cmd(ctx: click.Context, limit: int) -> None:
|
||||
"""Top Hacker News stories."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_hacker_news(f, limit=limit))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
for s in data.get("stories", []):
|
||||
score = s.get("score", 0)
|
||||
title = s.get("title", "")[:80]
|
||||
console.print(f" {score:>5} {title}")
|
||||
|
||||
|
||||
@main.command(name="gh-trending")
|
||||
@click.option("--limit", "-n", default=15, help="Number of repos")
|
||||
@click.pass_context
|
||||
def gh_trending_cmd(ctx: click.Context, limit: int) -> None:
|
||||
"""Trending GitHub repositories."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_trending_repos(f, limit=limit))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
for r in data.get("repos", []):
|
||||
stars = r.get("stars", 0)
|
||||
name = r.get("name", "")
|
||||
lang = r.get("language") or ""
|
||||
desc = (r.get("description") or "")[:60]
|
||||
console.print(f" {stars:>6} [bold]{name}[/bold] [{lang}] {desc}")
|
||||
|
||||
|
||||
@main.command(name="arxiv")
|
||||
@click.option("--query", "-q", default="cs.AI", help="arXiv category or query")
|
||||
@click.option("--limit", "-n", default=10, help="Number of papers")
|
||||
@click.pass_context
|
||||
def arxiv_cmd(ctx: click.Context, query: str, limit: int) -> None:
|
||||
"""Recent arXiv papers."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_arxiv_papers(f, query=query, limit=limit))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
for p in data.get("papers", []):
|
||||
title = p.get("title", "")[:80]
|
||||
authors = ", ".join(p.get("authors", [])[:3])
|
||||
console.print(f" [bold]{title}[/bold]")
|
||||
console.print(f" {authors}")
|
||||
|
||||
|
||||
@main.command(name="spending")
|
||||
@click.option("--limit", "-n", default=15, help="Top N agencies")
|
||||
@click.pass_context
|
||||
def spending_cmd(ctx: click.Context, limit: int) -> None:
|
||||
"""US federal spending (USAspending.gov)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(fetch_usa_spending(f, limit=limit))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
table = Table(title="Federal Agencies by Budget", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Agency", style="bold")
|
||||
table.add_column("Budget Auth", justify="right")
|
||||
table.add_column("Obligated", justify="right")
|
||||
|
||||
for a in data.get("agencies", []):
|
||||
budget = a.get("budget_authority", 0) or 0
|
||||
obligated = a.get("obligated", 0) or 0
|
||||
table.add_row(a.get("name", ""), f"${budget/1e9:,.1f}B", f"${obligated/1e9:,.1f}B")
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geospatial (static datasets)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main.command(name="bases")
|
||||
@click.option("--operator", "-o", default=None, help="Operator country (USA, RUS, CHN)")
|
||||
@click.option("--country", "-c", default=None, help="Host country")
|
||||
@click.pass_context
|
||||
def bases_cmd(ctx: click.Context, operator: str | None, country: str | None) -> None:
|
||||
"""Military bases worldwide (70 bases)."""
|
||||
data = _run(geospatial.fetch_military_bases(operator=operator, country=country))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('count', 0)} bases[/bold] (of {data.get('total_in_database', 0)})\n")
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Operator")
|
||||
table.add_column("Country")
|
||||
table.add_column("Type")
|
||||
table.add_column("Branch")
|
||||
|
||||
for b in data.get("bases", [])[:30]:
|
||||
table.add_row(b.get("name", ""), b.get("operator", ""), b.get("country", ""), b.get("type", ""), b.get("branch", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command(name="exchanges")
|
||||
@click.option("--tier", "-t", default=None, type=click.Choice(["mega", "major", "mid", "small"]))
|
||||
@click.option("--country", "-c", default=None, help="Country filter")
|
||||
@click.pass_context
|
||||
def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) -> None:
|
||||
"""Global stock exchanges (82 exchanges)."""
|
||||
data = _run(geospatial.fetch_stock_exchanges(tier=tier, country=country))
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{data.get('count', 0)} exchanges[/bold] "
|
||||
f"(${data.get('total_market_cap_usd_t', 0):.1f}T total market cap)\n")
|
||||
|
||||
table = Table(box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Exchange", style="bold")
|
||||
table.add_column("Country")
|
||||
table.add_column("Tier")
|
||||
table.add_column("Market Cap ($T)", justify="right")
|
||||
|
||||
for e in data.get("exchanges", [])[:30]:
|
||||
table.add_row(e.get("name", ""), e.get("country", ""), e.get("tier", ""), f"{e.get('market_cap_usd_t', 0):.2f}")
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1290,24 +1290,143 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════ KEYBOARD SHORTCUTS ════════════
|
||||
var _helpOverlay = null;
|
||||
function _buildHelpOverlay() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;';
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'background:#1a1a2e;border:1px solid #333;border-radius:8px;padding:24px 32px;max-width:520px;width:90%;color:#e0e0e0;font-family:monospace;font-size:13px;';
|
||||
// Header
|
||||
var hdr = document.createElement('div');
|
||||
hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;';
|
||||
var title = document.createElement('span');
|
||||
title.style.cssText = 'font-size:16px;font-weight:bold;color:#00d4ff;';
|
||||
title.textContent = 'Keyboard Shortcuts';
|
||||
var closeBtn = document.createElement('span');
|
||||
closeBtn.style.cssText = 'cursor:pointer;color:#888;font-size:18px;';
|
||||
closeBtn.textContent = '\u2715';
|
||||
closeBtn.addEventListener('click', function() { overlay.remove(); _helpOverlay = null; });
|
||||
hdr.appendChild(title);
|
||||
hdr.appendChild(closeBtn);
|
||||
box.appendChild(hdr);
|
||||
// Table
|
||||
var tbl = document.createElement('table');
|
||||
tbl.style.cssText = 'width:100%;border-collapse:collapse;';
|
||||
var rows = [
|
||||
['__section', 'Navigation'],
|
||||
['?', 'Toggle this help'],
|
||||
['/', 'Focus search'],
|
||||
['Esc', 'Close panels / blur search'],
|
||||
['d', 'Toggle drawer'],
|
||||
['l', 'Toggle layer panel'],
|
||||
['r', 'Reset globe view'],
|
||||
['+ / -', 'Zoom in / out'],
|
||||
['__section', 'Map Layers (1-9, 0)'],
|
||||
['1', 'Earthquakes'], ['2', 'Military flights'], ['3', 'Conflict zones'],
|
||||
['4', 'Wildfires'], ['5', 'Signal convergence'], ['6', 'Nuclear monitor'],
|
||||
['7', 'Infrastructure'], ['8', 'Population exposure'], ['9', 'Air traffic'],
|
||||
['0', 'News geolocation'],
|
||||
['__section', 'Actions'],
|
||||
['a', 'Toggle all layers on/off'],
|
||||
['f', 'Toggle fullscreen'],
|
||||
];
|
||||
rows.forEach(function(r) {
|
||||
var tr = document.createElement('tr');
|
||||
if (r[0] === '__section') {
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.style.cssText = 'padding:10px 0 6px;color:#888;border-bottom:1px solid #333;';
|
||||
td.textContent = r[1];
|
||||
tr.appendChild(td);
|
||||
} else {
|
||||
var kd = document.createElement('td');
|
||||
kd.style.cssText = 'padding:4px 0;';
|
||||
var kbd = document.createElement('kbd');
|
||||
kbd.style.cssText = 'background:#333;padding:2px 6px;border-radius:3px;';
|
||||
kbd.textContent = r[0];
|
||||
kd.appendChild(kbd);
|
||||
var vd = document.createElement('td');
|
||||
vd.textContent = r[1];
|
||||
tr.appendChild(kd);
|
||||
tr.appendChild(vd);
|
||||
}
|
||||
tbl.appendChild(tr);
|
||||
});
|
||||
box.appendChild(tbl);
|
||||
overlay.appendChild(box);
|
||||
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { overlay.remove(); _helpOverlay = null; } });
|
||||
return overlay;
|
||||
}
|
||||
function _toggleHelp() {
|
||||
if (_helpOverlay) { _helpOverlay.remove(); _helpOverlay = null; return; }
|
||||
_helpOverlay = _buildHelpOverlay();
|
||||
document.body.appendChild(_helpOverlay);
|
||||
}
|
||||
|
||||
var _layerKeys = ['quakes','military','conflict','fires','convergence','nuclear','infra','exposure','airtraffic','news'];
|
||||
function _toggleLayerByIndex(idx) {
|
||||
var key = _layerKeys[idx];
|
||||
if (!key || !mapLayers[key]) return;
|
||||
var row = document.querySelector('.layer-row[data-layer="' + key + '"]');
|
||||
if (!row) return;
|
||||
var toggle = row.querySelector('.toggle');
|
||||
toggle.classList.toggle('on');
|
||||
layerState[key] = toggle.classList.contains('on');
|
||||
if (layerState[key]) { mapLayers[key].addTo(map); } else { map.removeLayer(mapLayers[key]); }
|
||||
try { localStorage.setItem('phoenix-layers', JSON.stringify(layerState)); } catch(e) {}
|
||||
}
|
||||
|
||||
var _allLayersOn = true;
|
||||
function _toggleAllLayers() {
|
||||
_allLayersOn = !_allLayersOn;
|
||||
_layerKeys.forEach(function(key) {
|
||||
if (!mapLayers[key]) return;
|
||||
layerState[key] = _allLayersOn;
|
||||
if (_allLayersOn) { mapLayers[key].addTo(map); } else { map.removeLayer(mapLayers[key]); }
|
||||
var row = document.querySelector('.layer-row[data-layer="' + key + '"]');
|
||||
if (row) { var t = row.querySelector('.toggle'); if (t) { if (_allLayersOn) t.classList.add('on'); else t.classList.remove('on'); } }
|
||||
});
|
||||
try { localStorage.setItem('phoenix-layers', JSON.stringify(layerState)); } catch(e) {}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (_helpOverlay) { _helpOverlay.remove(); _helpOverlay = null; return; }
|
||||
closeDetail();
|
||||
if (!$('#drawer').classList.contains('closed')) {
|
||||
drawerOpen = false;
|
||||
$('#drawer').classList.add('closed');
|
||||
$('#drawerToggle').innerHTML = '◀';
|
||||
$('#drawerToggle').textContent = '\u25C0';
|
||||
}
|
||||
$('#searchInput').blur();
|
||||
return;
|
||||
}
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'd' || e.key === 'D') {
|
||||
if (e.key === '?') { _toggleHelp(); return; }
|
||||
if (e.key === '/') { e.preventDefault(); $('#searchInput').focus(); return; }
|
||||
if (e.key === 'd') {
|
||||
drawerOpen = !drawerOpen;
|
||||
$('#drawer').classList.toggle('closed', !drawerOpen);
|
||||
$('#drawerToggle').innerHTML = drawerOpen ? '▶' : '◀';
|
||||
$('#drawerToggle').textContent = drawerOpen ? '\u25B6' : '\u25C0';
|
||||
return;
|
||||
}
|
||||
if (e.key === 'r' || e.key === 'R') { map.setView([20, 0], 2.5); }
|
||||
if (e.key === '/') { e.preventDefault(); $('#searchInput').focus(); }
|
||||
if (e.key === 'l') {
|
||||
var lp = $('#layerPanel');
|
||||
if (lp) lp.classList.toggle('collapsed');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'r') { map.setView([20, 0], 2.5); return; }
|
||||
if (e.key === '=' || e.key === '+') { map.zoomIn(); return; }
|
||||
if (e.key === '-') { map.zoomOut(); return; }
|
||||
if (e.key === 'f') {
|
||||
if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(function(){}); }
|
||||
else { document.exitFullscreen(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'a') { _toggleAllLayers(); return; }
|
||||
if (e.key >= '1' && e.key <= '9') { _toggleLayerByIndex(parseInt(e.key) - 1); return; }
|
||||
if (e.key === '0') { _toggleLayerByIndex(9); return; }
|
||||
});
|
||||
|
||||
// ════════════ MAP MARKER MANAGEMENT ════════════
|
||||
|
||||
@@ -34,6 +34,8 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
("The Guardian World", "https://www.theguardian.com/world/rss"),
|
||||
("DW News", "https://rss.dw.com/rss/en/top_news/rss-en-top"),
|
||||
("France24", "https://www.france24.com/en/rss"),
|
||||
("NPR World", "https://feeds.npr.org/1004/rss.xml"),
|
||||
("VOA News", "https://www.voanews.com/api/zyrttemnuq"),
|
||||
],
|
||||
"security": [
|
||||
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
|
||||
@@ -42,6 +44,8 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
("Schneier on Security", "https://www.schneier.com/feed/atom/"),
|
||||
("Dark Reading", "https://www.darkreading.com/rss.xml"),
|
||||
("CISA Alerts", "https://www.cisa.gov/cybersecurity-advisories/all.xml"),
|
||||
("The Record", "https://therecord.media/feed"),
|
||||
("Security Week", "https://www.securityweek.com/feed/"),
|
||||
],
|
||||
"technology": [
|
||||
("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"),
|
||||
@@ -49,6 +53,7 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
("The Verge", "https://www.theverge.com/rss/index.xml"),
|
||||
("Wired", "https://www.wired.com/feed/rss"),
|
||||
("MIT Tech Review", "https://www.technologyreview.com/feed/"),
|
||||
("The Register", "https://www.theregister.com/headlines.atom"),
|
||||
],
|
||||
"finance": [
|
||||
("CNBC", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"),
|
||||
@@ -71,11 +76,17 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
("Science", "https://www.science.org/action/showFeed?type=etoc&feed=rss&jc=science"),
|
||||
("Phys.org", "https://phys.org/rss-feed/"),
|
||||
("New Scientist", "https://www.newscientist.com/feed/home/"),
|
||||
("Scientific American", "https://rss.sciam.com/ScientificAmerican-Global"),
|
||||
("SpaceNews", "https://spacenews.com/feed/"),
|
||||
],
|
||||
"think_tanks": [
|
||||
("RAND", "https://www.rand.org/blog.xml"),
|
||||
("Brookings", "https://www.brookings.edu/feed/"),
|
||||
("Carnegie", "https://carnegieendowment.org/rss/solr.xml"),
|
||||
("CFR", "https://www.cfr.org/rss/all"),
|
||||
("CSIS", "https://www.csis.org/rss/all"),
|
||||
("Atlantic Council", "https://www.atlanticcouncil.org/feed/"),
|
||||
("Chatham House", "https://www.chathamhouse.org/rss/all"),
|
||||
],
|
||||
"middle_east": [
|
||||
("Middle East Eye", "https://www.middleeasteye.net/rss"),
|
||||
@@ -92,6 +103,10 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
],
|
||||
"africa": [
|
||||
("allAfrica", "https://allafrica.com/tools/headlines/rdf/latest/headlines.rdf"),
|
||||
("The Africa Report", "https://www.theafricareport.com/feed/"),
|
||||
("African Arguments", "https://africanarguments.org/feed/"),
|
||||
("ISS Africa", "https://issafrica.org/iss-today/feed"),
|
||||
("Daily Maverick", "https://www.dailymaverick.co.za/article/feed/"),
|
||||
],
|
||||
"latin_america": [
|
||||
("MercoPress", "https://en.mercopress.com/rss"),
|
||||
@@ -116,15 +131,36 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
("Oil Price", "https://oilprice.com/rss/main"),
|
||||
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
|
||||
("Utility Dive", "https://www.utilitydive.com/feeds/news/"),
|
||||
("Carbon Brief", "https://www.carbonbrief.org/feed/"),
|
||||
("CleanTechnica", "https://cleantechnica.com/feed/"),
|
||||
],
|
||||
"government": [
|
||||
("State Dept", "https://www.state.gov/rss-feed/press-releases/feed/"),
|
||||
("DoD News", "https://www.defense.gov/DesktopModules/ArticleCS/RSS.ashx?ContentType=1&Site=945&max=10"),
|
||||
("UN News", "https://news.un.org/feed/subscribe/en/news/all/rss.xml"),
|
||||
("White House", "https://www.whitehouse.gov/feed/"),
|
||||
],
|
||||
"crisis": [
|
||||
("ReliefWeb", "https://reliefweb.int/updates/rss.xml"),
|
||||
("ICG", "https://www.crisisgroup.org/rss.xml"),
|
||||
("Amnesty Intl", "https://www.amnesty.org/en/feed/"),
|
||||
("HRW", "https://www.hrw.org/rss/news_releases"),
|
||||
],
|
||||
"europe": [
|
||||
("EurActiv", "https://www.euractiv.com/feed/"),
|
||||
("Politico EU", "https://www.politico.eu/feed/"),
|
||||
("EU Observer", "https://euobserver.com/rss"),
|
||||
("DW Europe", "https://rss.dw.com/rss/en/eu/rss-en-eu"),
|
||||
],
|
||||
"south_asia": [
|
||||
("NDTV", "https://feeds.feedburner.com/ndtvnews-top-stories"),
|
||||
("Dawn Pakistan", "https://www.dawn.com/feeds/home"),
|
||||
("Scroll India", "https://scroll.in/feed"),
|
||||
],
|
||||
"health": [
|
||||
("STAT News", "https://www.statnews.com/feed/"),
|
||||
("WHO News", "https://www.who.int/rss-feeds/news-english.xml"),
|
||||
("Medical Xpress", "https://medicalxpress.com/rss-feed/"),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -176,6 +212,37 @@ SOURCE_TIERS: dict[str, str] = {
|
||||
"Nikkei Asia": "major",
|
||||
"The National UAE": "major",
|
||||
"Zero Hedge": "aggregator",
|
||||
# Phase 15 additions
|
||||
"NPR World": "major",
|
||||
"VOA News": "government",
|
||||
"The Record": "specialty",
|
||||
"Security Week": "specialty",
|
||||
"Scientific American": "major",
|
||||
"SpaceNews": "specialty",
|
||||
"CFR": "think_tank",
|
||||
"CSIS": "think_tank",
|
||||
"Atlantic Council": "think_tank",
|
||||
"Chatham House": "think_tank",
|
||||
"The Africa Report": "specialty",
|
||||
"African Arguments": "specialty",
|
||||
"ISS Africa": "think_tank",
|
||||
"Daily Maverick": "major",
|
||||
"Carbon Brief": "specialty",
|
||||
"CleanTechnica": "specialty",
|
||||
"EurActiv": "specialty",
|
||||
"Politico EU": "major",
|
||||
"EU Observer": "specialty",
|
||||
"DW Europe": "major",
|
||||
"NDTV": "major",
|
||||
"Dawn Pakistan": "major",
|
||||
"Scroll India": "specialty",
|
||||
"STAT News": "specialty",
|
||||
"WHO News": "intl_org",
|
||||
"Medical Xpress": "specialty",
|
||||
"Amnesty Intl": "intl_org",
|
||||
"HRW": "intl_org",
|
||||
"The Register": "specialty",
|
||||
"White House": "government",
|
||||
}
|
||||
|
||||
_STOPWORDS: set[str] = {
|
||||
|
||||
@@ -906,3 +906,280 @@ async def test_fetch_aircraft_details_batch(fetcher: Fetcher) -> None:
|
||||
assert result["source"] == "hexdb"
|
||||
assert result["count"] == 2
|
||||
assert result["requested"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BTC Technicals (CoinGecko)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_btc_technicals(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.markets import fetch_btc_technicals
|
||||
|
||||
# Generate 201 daily price points (enough for SMA-200)
|
||||
prices = [[1700000000 + i * 86400, 90000 + i * 10] for i in range(201)]
|
||||
|
||||
respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock(
|
||||
return_value=httpx.Response(200, json={"prices": prices})
|
||||
)
|
||||
|
||||
result = await fetch_btc_technicals(fetcher)
|
||||
assert result["source"] == "coingecko"
|
||||
assert result["price"] == prices[-1][1]
|
||||
assert result["sma_50"] > 0
|
||||
assert result["sma_200"] > 0
|
||||
assert result["mayer_multiple"] > 0
|
||||
assert result["cross_signal"] in ("golden_cross", "death_cross", "neutral")
|
||||
assert result["ath_distance_pct"] <= 0 # Current price <= ATH
|
||||
assert result["data_points"] == 201
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_btc_technicals_insufficient_data(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.markets import fetch_btc_technicals
|
||||
|
||||
# Only 10 data points — not enough for SMA-50
|
||||
prices = [[1700000000 + i * 86400, 90000 + i * 10] for i in range(10)]
|
||||
|
||||
respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock(
|
||||
return_value=httpx.Response(200, json={"prices": prices})
|
||||
)
|
||||
|
||||
result = await fetch_btc_technicals(fetcher)
|
||||
assert "error" in result
|
||||
assert result["source"] == "coingecko"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Central Bank Rates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_central_bank_rates_no_fred(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
|
||||
|
||||
import os
|
||||
os.environ.pop("FRED_API_KEY", None)
|
||||
|
||||
result = await fetch_central_bank_rates(fetcher)
|
||||
assert result["source"] == "multi"
|
||||
assert result["fred_available"] is False
|
||||
# Should have all 15 banks (12 curated + 3 FRED-fallback curated)
|
||||
assert result["count"] == 15
|
||||
# Sorted by rate descending — CBRT (45%) should be first
|
||||
assert result["rates"][0]["bank"] == "Central Bank of Turkey"
|
||||
assert result["rates"][0]["rate"] == 45.00
|
||||
# All should be curated source
|
||||
assert all(r["source"] == "curated" for r in result["rates"])
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_central_bank_rates_with_fred(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
|
||||
|
||||
import os
|
||||
os.environ["FRED_API_KEY"] = "test_key_123"
|
||||
|
||||
fred_response = {
|
||||
"observations": [
|
||||
{"date": "2026-02-25", "value": "4.33"}
|
||||
]
|
||||
}
|
||||
|
||||
respx.get("https://api.stlouisfed.org/fred/series/observations").mock(
|
||||
return_value=httpx.Response(200, json=fred_response)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await fetch_central_bank_rates(fetcher)
|
||||
assert result["source"] == "multi"
|
||||
assert result["fred_available"] is True
|
||||
assert result["count"] == 15
|
||||
# At least some should be from FRED
|
||||
fred_sources = [r for r in result["rates"] if r["source"] == "fred"]
|
||||
assert len(fred_sources) >= 1
|
||||
finally:
|
||||
os.environ.pop("FRED_API_KEY", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# USNI Fleet Tracker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_usni_fleet(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet
|
||||
|
||||
rss_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>USNI News Fleet Tracker</title>
|
||||
<item>
|
||||
<title>USNI News Fleet and Marine Tracker: Feb. 24, 2026</title>
|
||||
<link>https://news.usni.org/2026/02/24/fleet-tracker</link>
|
||||
<pubDate>Mon, 24 Feb 2026 14:00:00 GMT</pubDate>
|
||||
<description>
|
||||
298 ships (237 USS, 61 USNS). 100 deployed (67 USS, 33 USNS). 72 underway (55 deployed, 17 local).
|
||||
Carrier Strike Group 3 is currently conducting routine operations in the Western Pacific theater of operations, focused on maintaining freedom of navigation and regional security.
|
||||
In the Philippine Sea, USS Abraham Lincoln (CVN-72) is conducting routine flight operations with embarked Carrier Air Wing Nine as part of a scheduled deployment to the Western Pacific region.
|
||||
Meanwhile in the Mediterranean Sea near the coast of southern Europe, USS Spruance (DDG-111) is operating independently as part of standing NATO maritime forces conducting presence operations.
|
||||
In the Persian Gulf near the Strait of Hormuz, Expeditionary Strike Group 7 continues its deployment supporting maritime security operations in the region.
|
||||
USS Bataan (LHD-5) continues operations in the Red Sea supporting regional stability efforts and conducting routine training exercises with coalition partners.
|
||||
</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
respx.get("https://news.usni.org/category/fleet-tracker/feed").mock(
|
||||
return_value=httpx.Response(200, text=rss_xml)
|
||||
)
|
||||
|
||||
result = await fetch_usni_fleet(fetcher)
|
||||
assert result["source"] == "usni-fleet-tracker"
|
||||
assert result["ship_count"] >= 3 # CVN-72, DDG-111, LHD-5
|
||||
assert result["report_title"] == "USNI News Fleet and Marine Tracker: Feb. 24, 2026"
|
||||
|
||||
# Check ships were extracted
|
||||
hull_numbers = [s["hull_number"] for s in result["ships"]]
|
||||
assert "CVN-72" in hull_numbers
|
||||
assert "DDG-111" in hull_numbers
|
||||
assert "LHD-5" in hull_numbers
|
||||
|
||||
# Check strike groups
|
||||
sg_names = [sg["name"] for sg in result["strike_groups"]]
|
||||
assert "CSG-3" in sg_names
|
||||
|
||||
# Check force totals extracted
|
||||
assert result["force_totals"]["battle_force"]["total"] == 298
|
||||
assert result["force_totals"]["deployed"]["total"] == 100
|
||||
assert result["force_totals"]["underway"]["total"] == 72
|
||||
|
||||
# Check region classification (±200 char context windows may overlap in short text,
|
||||
# but at least some ships should get classified to a known COCOM region)
|
||||
regions = set(s["region"] for s in result["ships"])
|
||||
assert len(regions - {"UNKNOWN"}) > 0 # At least one classified
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_usni_fleet_empty_feed(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.usni_fleet import fetch_usni_fleet
|
||||
|
||||
rss_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel><title>Empty</title></channel></rss>"""
|
||||
|
||||
respx.get("https://news.usni.org/category/fleet-tracker/feed").mock(
|
||||
return_value=httpx.Response(200, text=rss_xml)
|
||||
)
|
||||
|
||||
result = await fetch_usni_fleet(fetcher)
|
||||
assert result["source"] == "usni-fleet-tracker"
|
||||
assert "error" in result
|
||||
assert result["ship_count"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trade Routes (static — no HTTP mock needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_trade_routes() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_trade_routes
|
||||
|
||||
result = await fetch_trade_routes()
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert result["total_oil_flow_mbd"] > 0
|
||||
assert "by_type" in result
|
||||
assert "chokepoint" in result["by_type"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_trade_routes_filter_type() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_trade_routes
|
||||
|
||||
result = await fetch_trade_routes(route_type="canal")
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert all(r["type"] == "canal" for r in result["routes"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_trade_routes_filter_country() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_trade_routes
|
||||
|
||||
result = await fetch_trade_routes(country="EGY")
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert all("EGY" in r["countries"] for r in result["routes"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud Regions (static — no HTTP mock needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_cloud_regions() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_cloud_regions
|
||||
|
||||
result = await fetch_cloud_regions()
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert "by_provider" in result
|
||||
assert "AWS" in result["by_provider"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_cloud_regions_filter_provider() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_cloud_regions
|
||||
|
||||
result = await fetch_cloud_regions(provider="GCP")
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert all(r["provider"] == "GCP" for r in result["regions"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Financial Centers (static — no HTTP mock needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_financial_centers() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_financial_centers
|
||||
|
||||
result = await fetch_financial_centers()
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert "by_country" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_financial_centers_filter_rank() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_financial_centers
|
||||
|
||||
result = await fetch_financial_centers(min_rank=5)
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert result["count"] <= 5
|
||||
assert all(fc["gfci_rank"] <= 5 for fc in result["centers"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_financial_centers_filter_country() -> None:
|
||||
from world_intel_mcp.sources.geospatial import fetch_financial_centers
|
||||
|
||||
result = await fetch_financial_centers(country="USA")
|
||||
assert result["source"] == "static-geospatial"
|
||||
assert result["count"] > 0
|
||||
assert all(fc["iso3"] == "USA" for fc in result["centers"])
|
||||
|
||||
Reference in New Issue
Block a user