From 4223141cf793615e42b57d17b2562245ee6ef3f2 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 17:49:40 +0200 Subject: [PATCH] Phase 1: public market + universe lookups via ESI 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) --- src/familiar/esi.py | 115 +++++++++++++++++++++++++++++++++ src/familiar/hubs.py | 42 ++++++++++++ src/familiar/lookup.py | 51 +++++++++++++++ src/familiar/market.py | 136 +++++++++++++++++++++++++++++++++++++++ src/familiar/server.py | 7 +- src/familiar/universe.py | 113 ++++++++++++++++++++++++++++++++ tests/conftest.py | 49 ++++++++++++++ tests/test_esi.py | 56 ++++++++++++++++ tests/test_lookup.py | 37 +++++++++++ tests/test_market.py | 128 ++++++++++++++++++++++++++++++++++++ tests/test_universe.py | 106 ++++++++++++++++++++++++++++++ 11 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 src/familiar/esi.py create mode 100644 src/familiar/hubs.py create mode 100644 src/familiar/lookup.py create mode 100644 src/familiar/market.py create mode 100644 src/familiar/universe.py create mode 100644 tests/conftest.py create mode 100644 tests/test_esi.py create mode 100644 tests/test_lookup.py create mode 100644 tests/test_market.py create mode 100644 tests/test_universe.py diff --git a/src/familiar/esi.py b/src/familiar/esi.py new file mode 100644 index 0000000..11010fe --- /dev/null +++ b/src/familiar/esi.py @@ -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 diff --git a/src/familiar/hubs.py b/src/familiar/hubs.py new file mode 100644 index 0000000..0b10d1e --- /dev/null +++ b/src/familiar/hubs.py @@ -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 diff --git a/src/familiar/lookup.py b/src/familiar/lookup.py new file mode 100644 index 0000000..a527306 --- /dev/null +++ b/src/familiar/lookup.py @@ -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 diff --git a/src/familiar/market.py b/src/familiar/market.py new file mode 100644 index 0000000..5a8dc53 --- /dev/null +++ b/src/familiar/market.py @@ -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) diff --git a/src/familiar/server.py b/src/familiar/server.py index 66bd2da..58bf5c2 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -11,7 +11,7 @@ import datetime as _dt from fastmcp import FastMCP -from . import __version__ +from . import __version__, market, universe mcp: FastMCP = FastMCP( name="familiar", @@ -38,3 +38,8 @@ def ping() -> dict[str, str]: "version": __version__, "time_utc": _dt.datetime.now(_dt.timezone.utc).isoformat(), } + + +# Phase 1: public market + universe lookups (no auth). +universe.register(mcp) +market.register(mcp) diff --git a/src/familiar/universe.py b/src/familiar/universe.py new file mode 100644 index 0000000..f7588b9 --- /dev/null +++ b/src/familiar/universe.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f96c7e8 --- /dev/null +++ b/tests/conftest.py @@ -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}"}) diff --git a/tests/test_esi.py b/tests/test_esi.py new file mode 100644 index 0000000..54d9dd7 --- /dev/null +++ b/tests/test_esi.py @@ -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) diff --git a/tests/test_lookup.py b/tests/test_lookup.py new file mode 100644 index 0000000..4f87480 --- /dev/null +++ b/tests/test_lookup.py @@ -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")) diff --git a/tests/test_market.py b/tests/test_market.py new file mode 100644 index 0000000..282f2bf --- /dev/null +++ b/tests/test_market.py @@ -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 diff --git a/tests/test_universe.py b/tests/test_universe.py new file mode 100644 index 0000000..2848b52 --- /dev/null +++ b/tests/test_universe.py @@ -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