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>
129 lines
3.7 KiB
Python
129 lines
3.7 KiB
Python
"""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
|