Phase 1: public market + universe lookups via ESI
CI / lint (pull_request) Successful in 52s
CI / test (pull_request) Successful in 56s
CI / publish (pull_request) Has been skipped
CI / lint (push) Successful in 52s
CI / test (push) Successful in 56s
CI / publish (push) Failing after 34s

Add a thin async ESI client (httpx, descriptive User-Agent, uniform errors,
mockable transport) plus name<->id resolution. New read-only tools:

- universe: resolve_names, get_item, get_system, get_station, get_region, get_route
- market:   list_hubs, get_market_price, get_market_orders, compare_hubs

Trade-hub IDs (Jita/Amarr/Dodixie/Rens/Hek) are hardcoded constants. Market
aggregation (best buy/sell, spread, order book) lives in pure helpers. 30 unit
tests with mocked ESI; coverage 99%. SDE bulk-load deferred to a later phase.

Closes #2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LatteandClaude Opus 4.8 committed 2026-06-27 17:49:40 +02:00
1 parent 585acc766c
commit 4223141cf7
11 files changed
+839 -1

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
"""Thin async client for the public EVE Swagger Interface (ESI).
Phase 1 only touches public endpoints — no auth, no tokens. The client is a
small wrapper around httpx with a descriptive User-Agent (ESI asks for one) and
uniform error handling. It is built so tests can inject an ``httpx.MockTransport``
instead of hitting the live API.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from . import __version__
ESI_BASE = os.environ.get("FAMILIAR_ESI_BASE", "https://esi.evetech.net/latest")
DATASOURCE = os.environ.get("FAMILIAR_ESI_DATASOURCE", "tranquility")
USER_AGENT = (
f"familiar/{__version__} (+https://git.hiddenden.cafe/Hiddenden/familiar; read-only MCP)"
)
class EsiError(RuntimeError):
"""An ESI request failed. Carries the HTTP status when there was a response."""
def __init__(self, message: str, status: int | None = None) -> None:
super().__init__(message)
self.status = status
class EsiClient:
"""Async wrapper around one ESI base URL."""
def __init__(
self,
base_url: str = ESI_BASE,
*,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self._client = httpx.AsyncClient(
base_url=base_url,
transport=transport,
timeout=30.0,
headers={
"User-Agent": USER_AGENT,
"Accept": "application/json",
},
)
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
return await self._request("GET", path, params=params)
async def post(
self,
path: str,
json: Any | None = None,
params: dict[str, Any] | None = None,
) -> Any:
return await self._request("POST", path, params=params, json=json)
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
) -> Any:
merged = {"datasource": DATASOURCE, **(params or {})}
try:
resp = await self._client.request(method, path, params=merged, json=json)
except httpx.HTTPError as exc: # network/timeout
raise EsiError(f"ESI request to {path} failed: {exc}") from exc
if resp.status_code >= 400:
detail = _error_detail(resp)
raise EsiError(
f"ESI {method} {path} returned {resp.status_code}: {detail}",
status=resp.status_code,
)
if not resp.content:
return None
return resp.json()
async def aclose(self) -> None:
await self._client.aclose()
def _error_detail(resp: httpx.Response) -> str:
try:
body = resp.json()
except ValueError:
return resp.text[:200]
if isinstance(body, dict) and "error" in body:
return str(body["error"])
return str(body)[:200]
_default: EsiClient | None = None
def client() -> EsiClient:
"""Return the shared default client, creating it on first use."""
global _default
if _default is None:
_default = EsiClient()
return _default
def set_client(new: EsiClient | None) -> None:
"""Replace the shared client (used by tests to inject a mock transport)."""
global _default
_default = new
+42
View File
@@ -0,0 +1,42 @@
"""The major NPC trade hubs of New Eden, with stable ESI IDs.
These IDs are constant in the game data, so hardcoding them avoids a lookup
round-trip for the most common case ("price in Jita").
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Hub:
name: str
region_id: int
system_id: int
station_id: int
# Keyed by lowercase short name for easy lookup.
HUBS: dict[str, Hub] = {
"jita": Hub("Jita", region_id=10000002, system_id=30000142, station_id=60003760),
"amarr": Hub("Amarr", region_id=10000043, system_id=30002187, station_id=60008494),
"dodixie": Hub("Dodixie", region_id=10000032, system_id=30002659, station_id=60011866),
"rens": Hub("Rens", region_id=10000030, system_id=30002510, station_id=60004588),
"hek": Hub("Hek", region_id=10000042, system_id=30002053, station_id=60005686),
}
DEFAULT_HUB = "jita"
def resolve_hub(name: str | None) -> Hub:
"""Look up a hub by short name (case-insensitive). Defaults to Jita.
Raises ValueError with the list of valid hubs if the name is unknown.
"""
key = (name or DEFAULT_HUB).strip().lower()
hub = HUBS.get(key)
if hub is None:
valid = ", ".join(h.name for h in HUBS.values())
raise ValueError(f"Unknown trade hub {name!r}. Known hubs: {valid}.")
return hub
+51
View File
@@ -0,0 +1,51 @@
"""Name <-> ID resolution against ESI's ``/universe/ids/`` endpoint.
Shared by the universe and market tools so a user can say "Tritanium" or "Jita"
instead of a numeric ID.
"""
from __future__ import annotations
from .esi import client
# ESI /universe/ids/ groups results by category. We care about these.
_CATEGORY_KEYS = {
"inventory_types",
"systems",
"regions",
"constellations",
"stations",
}
def _as_id(value: str | int) -> int | None:
"""Return value as an int if it already is a numeric id, else None."""
if isinstance(value, int):
return value
text = str(value).strip()
return int(text) if text.isdigit() else None
async def resolve_name(name: str, *categories: str) -> tuple[int, str]:
"""Resolve a single name to ``(id, category)``.
``categories`` restricts which result groups are accepted, in priority
order (e.g. ``"inventory_types"``). With none given, any known category is
accepted. Raises ``LookupError`` if nothing matches.
"""
wanted = list(categories) or list(_CATEGORY_KEYS)
data = await client().post("/universe/ids/", json=[name])
for key in wanted:
matches = (data or {}).get(key) or []
if matches:
return matches[0]["id"], key
raise LookupError(f"No {' / '.join(wanted)} in EVE named {name!r}.")
async def resolve_id(value: str | int, *categories: str) -> int:
"""Coerce a name-or-id into a numeric id, resolving names via ESI."""
direct = _as_id(value)
if direct is not None:
return direct
resolved, _ = await resolve_name(str(value), *categories)
return resolved
+136
View File
@@ -0,0 +1,136 @@
"""Read-only market lookups via public ESI: prices, order book, hub spreads.
The tool functions are defined at module level (easy to import and test) and
wired onto the server in :func:`register`.
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
from .esi import client
from .hubs import HUBS, Hub, resolve_hub
from .lookup import resolve_id
def summarize_orders(orders: list[dict[str, Any]], station_id: int) -> dict[str, Any]:
"""Reduce a region's orders for one type to best buy/sell at one station.
Pure function (no I/O) so it is cheap to unit test.
"""
sells = [o for o in orders if not o["is_buy_order"] and o["location_id"] == station_id]
buys = [o for o in orders if o["is_buy_order"] and o["location_id"] == station_id]
best_sell = min((o["price"] for o in sells), default=None)
best_buy = max((o["price"] for o in buys), default=None)
spread = (
round(best_sell - best_buy, 2) if best_sell is not None and best_buy is not None else None
)
return {
"best_sell": best_sell,
"best_buy": best_buy,
"spread": spread,
"sell_orders": len(sells),
"buy_orders": len(buys),
"volume_on_sell": sum(o["volume_remain"] for o in sells),
"volume_on_buy": sum(o["volume_remain"] for o in buys),
}
def top_orders(
orders: list[dict[str, Any]], station_id: int, side: str, limit: int
) -> list[dict[str, Any]]:
"""Best ``limit`` orders of one side at a station (sells ascending price)."""
want_buy = side == "buy"
matched = [
o for o in orders if o["is_buy_order"] == want_buy and o["location_id"] == station_id
]
matched.sort(key=lambda o: o["price"], reverse=want_buy)
return [
{
"price": o["price"],
"volume_remain": o["volume_remain"],
"location_id": o["location_id"],
"range": o.get("range"),
}
for o in matched[:limit]
]
async def _region_orders(type_id: int, region_id: int) -> list[dict[str, Any]]:
data = await client().get(
f"/markets/{region_id}/orders/",
params={"type_id": type_id, "order_type": "all"},
)
return data or []
async def _price_at(type_id: int, hub: Hub) -> dict[str, Any]:
orders = await _region_orders(type_id, hub.region_id)
return {"type_id": type_id, "hub": hub.name, **summarize_orders(orders, hub.station_id)}
async def list_hubs() -> list[dict[str, Any]]:
"""List the known NPC trade hubs and their region/system/station IDs."""
return [
{
"name": h.name,
"region_id": h.region_id,
"system_id": h.system_id,
"station_id": h.station_id,
}
for h in HUBS.values()
]
async def get_market_price(item: str | int, hub: str = "Jita") -> dict[str, Any]:
"""Best buy/sell price and spread for an item at a trade hub.
``item`` is a name ("Tritanium") or type_id. ``hub`` is one of Jita, Amarr,
Dodixie, Rens, Hek (defaults to Jita).
"""
type_id = await resolve_id(item, "inventory_types")
return await _price_at(type_id, resolve_hub(hub))
async def get_market_orders(
item: str | int,
hub: str = "Jita",
side: str = "sell",
limit: int = 10,
) -> dict[str, Any]:
"""Order book: the best ``limit`` sell or buy orders for an item at a hub."""
side = "buy" if side.lower().startswith("b") else "sell"
type_id = await resolve_id(item, "inventory_types")
h = resolve_hub(hub)
orders = await _region_orders(type_id, h.region_id)
return {
"type_id": type_id,
"hub": h.name,
"side": side,
"orders": top_orders(orders, h.station_id, side, max(1, limit)),
}
async def compare_hubs(item: str | int) -> dict[str, Any]:
"""Compare an item's best sell/buy price across every known trade hub."""
type_id = await resolve_id(item, "inventory_types")
per_hub = [await _price_at(type_id, h) for h in HUBS.values()]
with_sell = [p for p in per_hub if p["best_sell"] is not None]
cheapest = min(with_sell, key=lambda p: p["best_sell"], default=None)
return {
"type_id": type_id,
"hubs": per_hub,
"cheapest_sell_hub": cheapest["hub"] if cheapest else None,
}
TOOLS = [list_hubs, get_market_price, get_market_orders, compare_hubs]
def register(mcp: FastMCP) -> None:
"""Register the market tools onto the given server."""
for fn in TOOLS:
mcp.tool(fn)
+6 -1
View File
@@ -11,7 +11,7 @@ import datetime as _dt
from fastmcp import FastMCP from fastmcp import FastMCP
from . import __version__ from . import __version__, market, universe
mcp: FastMCP = FastMCP( mcp: FastMCP = FastMCP(
name="familiar", name="familiar",
@@ -38,3 +38,8 @@ def ping() -> dict[str, str]:
"version": __version__, "version": __version__,
"time_utc": _dt.datetime.now(_dt.timezone.utc).isoformat(), "time_utc": _dt.datetime.now(_dt.timezone.utc).isoformat(),
} }
# Phase 1: public market + universe lookups (no auth).
universe.register(mcp)
market.register(mcp)
+113
View File
@@ -0,0 +1,113 @@
"""Read-only universe lookups via public ESI: items, systems, stations, routes.
The tool functions are defined at module level (so they are easy to import and
test) and wired onto the server in :func:`register`.
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
from .esi import client
from .lookup import resolve_id
ROUTE_FLAGS = {"shortest", "secure", "insecure"}
async def resolve_names(names: list[str]) -> dict[str, Any]:
"""Resolve EVE names (items, systems, regions, stations) to their IDs.
Pass a list of names; returns the raw ESI id/category groupings. Useful when
you need an ID for another lookup.
"""
return await client().post("/universe/ids/", json=names)
async def get_item(item: str | int) -> dict[str, Any]:
"""Look up an item/type by name or type_id (volume, group, description)."""
type_id = await resolve_id(item, "inventory_types")
data = await client().get(f"/universe/types/{type_id}/")
return {
"type_id": data["type_id"],
"name": data["name"],
"group_id": data.get("group_id"),
"volume": data.get("volume"),
"packaged_volume": data.get("packaged_volume"),
"description": data.get("description"),
}
async def get_system(system: str | int) -> dict[str, Any]:
"""Look up a solar system by name or id: security status, region, counts."""
system_id = await resolve_id(system, "systems")
data = await client().get(f"/universe/systems/{system_id}/")
sec = data.get("security_status")
return {
"system_id": data["system_id"],
"name": data["name"],
"constellation_id": data.get("constellation_id"),
"security_status": round(sec, 2) if isinstance(sec, (int, float)) else sec,
"security_class": data.get("security_class"),
"planets": len(data.get("planets") or []),
"stargates": len(data.get("stargates") or []),
}
async def get_station(station: str | int) -> dict[str, Any]:
"""Look up a station by name or id: which system, type, and services."""
station_id = await resolve_id(station, "stations")
data = await client().get(f"/universe/stations/{station_id}/")
return {
"station_id": data["station_id"],
"name": data["name"],
"system_id": data.get("system_id"),
"type_id": data.get("type_id"),
"services": data.get("services"),
}
async def get_region(region: str | int) -> dict[str, Any]:
"""Look up a region by name or id."""
region_id = await resolve_id(region, "regions")
data = await client().get(f"/universe/regions/{region_id}/")
return {
"region_id": data["region_id"],
"name": data["name"],
"constellations": len(data.get("constellations") or []),
"description": data.get("description"),
}
async def get_route(
origin: str | int,
destination: str | int,
prefer: str = "shortest",
) -> dict[str, Any]:
"""Number of jumps and the system path between two systems.
``prefer`` is one of "shortest", "secure" (high-sec where possible), or
"insecure". Names or system IDs are both accepted.
"""
flag = prefer if prefer in ROUTE_FLAGS else "shortest"
o = await resolve_id(origin, "systems")
d = await resolve_id(destination, "systems")
path = await client().get(f"/route/{o}/{d}/", params={"flag": flag})
systems = path or []
return {
"origin_id": o,
"destination_id": d,
"flag": flag,
"jumps": max(len(systems) - 1, 0),
"system_ids": systems,
}
TOOLS = [resolve_names, get_item, get_system, get_station, get_region, get_route]
def register(mcp: FastMCP) -> None:
"""Register the universe tools onto the given server."""
for fn in TOOLS:
mcp.tool(fn)
+49
View File
@@ -0,0 +1,49 @@
"""Shared test helpers: a mock ESI client backed by httpx.MockTransport."""
from __future__ import annotations
from collections.abc import Callable
import httpx
import pytest
from familiar import esi
Handler = Callable[[httpx.Request], httpx.Response]
@pytest.fixture
def mock_esi():
"""Install a mock ESI client driven by a request handler.
Usage::
def test_x(mock_esi):
def handler(request):
return httpx.Response(200, json={...})
mock_esi(handler)
...
The shared client is reset after the test.
"""
def _make(handler: Handler) -> esi.EsiClient:
client = esi.EsiClient(transport=httpx.MockTransport(handler))
esi.set_client(client)
return client
yield _make
esi.set_client(None)
def route(request: httpx.Request, fixtures: dict[str, object]) -> httpx.Response:
"""Match a request against ``{substring_in_path: json_body}`` and respond.
The special key ``"/universe/ids/"`` is matched for the name-resolution POST.
A missing match returns 404 so tests fail loudly.
"""
path = request.url.path
for needle, body in fixtures.items():
if needle in path:
return httpx.Response(200, json=body)
return httpx.Response(404, json={"error": f"no fixture for {path}"})
+56
View File
@@ -0,0 +1,56 @@
"""ESI client: error handling, empty bodies, datasource param."""
import asyncio
import httpx
import pytest
from familiar import esi
def test_get_raises_esi_error_on_http_error(mock_esi):
mock_esi(lambda req: httpx.Response(404, json={"error": "not found"}))
with pytest.raises(esi.EsiError) as exc:
asyncio.run(esi.client().get("/universe/types/999999/"))
assert exc.value.status == 404
assert "404" in str(exc.value)
def test_get_raises_on_network_error(mock_esi):
def boom(req):
raise httpx.ConnectError("boom")
mock_esi(boom)
with pytest.raises(esi.EsiError):
asyncio.run(esi.client().get("/anything/"))
def test_empty_body_returns_none(mock_esi):
mock_esi(lambda req: httpx.Response(200, content=b""))
assert asyncio.run(esi.client().get("/x/")) is None
def test_datasource_param_is_attached(mock_esi):
seen = {}
def handler(req):
seen["datasource"] = req.url.params.get("datasource")
return httpx.Response(200, json={"ok": True})
mock_esi(handler)
asyncio.run(esi.client().get("/x/"))
assert seen["datasource"] == "tranquility"
def test_error_detail_falls_back_to_text(mock_esi):
mock_esi(lambda req: httpx.Response(500, content=b"upstream exploded"))
with pytest.raises(esi.EsiError) as exc:
asyncio.run(esi.client().get("/x/"))
assert "upstream exploded" in str(exc.value)
def test_default_client_is_singleton():
esi.set_client(None)
first = esi.client()
assert esi.client() is first
esi.set_client(None)
+37
View File
@@ -0,0 +1,37 @@
"""Name <-> id resolution."""
import asyncio
import httpx
import pytest
from familiar import lookup
def test_resolve_id_passes_through_numeric():
assert asyncio.run(lookup.resolve_id(34)) == 34
assert asyncio.run(lookup.resolve_id("34")) == 34
def test_resolve_name_picks_requested_category(mock_esi):
body = {
"inventory_types": [{"id": 34, "name": "Tritanium"}],
"systems": [{"id": 30000142, "name": "Jita"}],
}
mock_esi(lambda req: httpx.Response(200, json=body))
assert asyncio.run(lookup.resolve_name("Tritanium", "inventory_types")) == (
34,
"inventory_types",
)
def test_resolve_id_resolves_name(mock_esi):
body = {"inventory_types": [{"id": 34, "name": "Tritanium"}]}
mock_esi(lambda req: httpx.Response(200, json=body))
assert asyncio.run(lookup.resolve_id("Tritanium", "inventory_types")) == 34
def test_resolve_name_raises_when_absent(mock_esi):
mock_esi(lambda req: httpx.Response(200, json={}))
with pytest.raises(LookupError):
asyncio.run(lookup.resolve_name("Nope", "inventory_types"))
+128
View File
@@ -0,0 +1,128 @@
"""Market lookup tools and pure aggregation helpers."""
import asyncio
import pytest
from familiar import hubs, market
from familiar.server import mcp
from conftest import route
JITA_STATION = 60003760
JITA_REGION = 10000002
def _orders():
return [
# Sells at Jita
{
"is_buy_order": False,
"location_id": JITA_STATION,
"price": 5.10,
"volume_remain": 100,
"range": "station",
},
{
"is_buy_order": False,
"location_id": JITA_STATION,
"price": 5.05,
"volume_remain": 200,
"range": "station",
},
# Buy at Jita
{
"is_buy_order": True,
"location_id": JITA_STATION,
"price": 4.90,
"volume_remain": 300,
"range": "region",
},
# Order at a different station should be ignored
{
"is_buy_order": False,
"location_id": 999,
"price": 1.0,
"volume_remain": 1,
"range": "station",
},
]
def test_summarize_orders_picks_best_and_ignores_other_stations():
s = market.summarize_orders(_orders(), JITA_STATION)
assert s["best_sell"] == 5.05
assert s["best_buy"] == 4.90
assert s["spread"] == 0.15
assert s["sell_orders"] == 2
assert s["volume_on_sell"] == 300
def test_summarize_orders_handles_empty():
s = market.summarize_orders([], JITA_STATION)
assert s["best_sell"] is None
assert s["spread"] is None
def test_top_orders_sorts_per_side():
sells = market.top_orders(_orders(), JITA_STATION, "sell", 10)
assert [o["price"] for o in sells] == [5.05, 5.10]
buys = market.top_orders(_orders(), JITA_STATION, "buy", 10)
assert [o["price"] for o in buys] == [4.90]
def test_get_market_price(mock_esi):
fixtures = {
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
f"/markets/{JITA_REGION}/orders/": _orders(),
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(market.get_market_price("Tritanium", hub="Jita"))
assert result["type_id"] == 34
assert result["hub"] == "Jita"
assert result["best_sell"] == 5.05
def test_get_market_orders_side_parsing(mock_esi):
fixtures = {f"/markets/{JITA_REGION}/orders/": _orders()}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(market.get_market_orders(34, hub="Jita", side="buy", limit=5))
assert result["side"] == "buy"
assert result["orders"][0]["price"] == 4.90
def test_compare_hubs_across_all_regions(mock_esi):
# Every region returns the same order book; cheapest sell is the same price
# everywhere, so the first hub (Jita) wins the tie.
def handler(req):
return route(
req,
{
"/orders/": _orders(),
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
},
)
mock_esi(handler)
result = asyncio.run(market.compare_hubs("Tritanium"))
assert len(result["hubs"]) == len(hubs.HUBS)
assert result["cheapest_sell_hub"] == "Jita"
def test_list_hubs():
result = asyncio.run(market.list_hubs())
names = {h["name"] for h in result}
assert {"Jita", "Amarr", "Dodixie", "Rens", "Hek"} == names
def test_resolve_hub_unknown_raises():
with pytest.raises(ValueError):
hubs.resolve_hub("Atlantis")
def test_resolve_hub_defaults_to_jita():
assert hubs.resolve_hub(None).name == "Jita"
def test_market_tools_are_registered():
tools = {t.name for t in asyncio.run(mcp.list_tools())}
assert {"list_hubs", "get_market_price", "get_market_orders", "compare_hubs"} <= tools
+106
View File
@@ -0,0 +1,106 @@
"""Universe lookup tools."""
import asyncio
import httpx
from familiar import universe
from familiar.server import mcp
from conftest import route
def test_get_item_resolves_name_then_fetches_type(mock_esi):
fixtures = {
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
"/universe/types/": {
"type_id": 34,
"name": "Tritanium",
"group_id": 18,
"volume": 0.01,
"packaged_volume": 0.01,
"description": "The most common ore type.",
},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_item("Tritanium"))
assert result["type_id"] == 34
assert result["name"] == "Tritanium"
assert result["volume"] == 0.01
def test_get_system_rounds_security_and_counts(mock_esi):
fixtures = {
"/universe/systems/": {
"system_id": 30000142,
"name": "Jita",
"constellation_id": 20000020,
"security_status": 0.945913,
"security_class": "D",
"planets": [{}, {}, {}],
"stargates": [1, 2],
},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_system(30000142))
assert result["security_status"] == 0.95
assert result["planets"] == 3
assert result["stargates"] == 2
def test_get_station(mock_esi):
fixtures = {
"/universe/stations/": {
"station_id": 60003760,
"name": "Jita IV - Moon 4 - Caldari Navy Assembly Plant",
"system_id": 30000142,
"type_id": 52678,
"services": ["market", "repair-facilities"],
},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_station(60003760))
assert result["system_id"] == 30000142
assert "market" in result["services"]
def test_get_region(mock_esi):
fixtures = {
"/universe/regions/": {
"region_id": 10000002,
"name": "The Forge",
"constellations": [1, 2, 3, 4],
"description": "A region.",
},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_region(10000002))
assert result["name"] == "The Forge"
assert result["constellations"] == 4
def test_get_route_counts_jumps(mock_esi):
fixtures = {"/route/": [30000142, 30000144, 30002187]}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_route(30000142, 30002187, prefer="secure"))
assert result["jumps"] == 2
assert result["flag"] == "secure"
def test_get_route_rejects_unknown_flag(mock_esi):
fixtures = {"/route/": [30000142]}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(universe.get_route(30000142, 30000142, prefer="banana"))
assert result["flag"] == "shortest"
assert result["jumps"] == 0
def test_resolve_names_passthrough(mock_esi):
body = {"systems": [{"id": 30000142, "name": "Jita"}]}
mock_esi(lambda req: httpx.Response(200, json=body))
result = asyncio.run(universe.resolve_names(["Jita"]))
assert result["systems"][0]["id"] == 30000142
def test_universe_tools_are_registered():
tools = {t.name for t in asyncio.run(mcp.list_tools())}
assert {"get_item", "get_system", "get_station", "get_region", "get_route"} <= tools