forked from Rockachopa/Timmy-time-dashboard
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""Unit tests for the Bannerlord GABS client and observer.
|
|
|
|
All tests are offline — no real TCP connection is made. Sockets are
|
|
mocked or substituted with in-process fakes.
|
|
|
|
Refs: #1093 (M1 Observer), #1091 (Epic)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from integrations.bannerlord.gabs_client import GabsClient, GabsError
|
|
|
|
# ── GabsClient unit tests ─────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_response(result: object = None, error: dict | None = None, req_id: int = 1) -> bytes:
|
|
"""Encode a JSON-RPC 2.0 response as newline-delimited bytes."""
|
|
resp: dict = {"jsonrpc": "2.0", "id": req_id}
|
|
if error is not None:
|
|
resp["error"] = error
|
|
else:
|
|
resp["result"] = result
|
|
return (json.dumps(resp) + "\n").encode()
|
|
|
|
|
|
def _mock_socket(response_bytes: bytes) -> MagicMock:
|
|
"""Return a MagicMock socket that yields *response_bytes* from recv()."""
|
|
sock = MagicMock(spec=socket.socket)
|
|
# First recv returns the full response, subsequent calls return b"" (EOF)
|
|
sock.recv.side_effect = [response_bytes, b""]
|
|
return sock
|
|
|
|
|
|
class TestGabsClientCall:
|
|
def test_successful_call_returns_result(self, tmp_path):
|
|
"""call() returns the result field on a successful JSON-RPC response."""
|
|
expected = {"day": 42, "season": "spring"}
|
|
response = _make_response(result=expected)
|
|
|
|
with patch("socket.create_connection") as mock_conn:
|
|
mock_conn.return_value = _mock_socket(response)
|
|
client = GabsClient()
|
|
result = client.call("core/get_game_state")
|
|
|
|
assert result == expected
|
|
|
|
def test_rpc_error_raises_gabs_error(self):
|
|
"""call() raises GabsError when the server returns an error object."""
|
|
error = {"code": -32601, "message": "Method not found"}
|
|
response = _make_response(error=error)
|
|
|
|
with patch("socket.create_connection") as mock_conn:
|
|
mock_conn.return_value = _mock_socket(response)
|
|
client = GabsClient()
|
|
with pytest.raises(GabsError, match="Method not found"):
|
|
client.call("unknown/method")
|
|
|
|
def test_tcp_failure_raises_gabs_error(self):
|
|
"""call() raises GabsError when TCP connection is refused."""
|
|
with patch("socket.create_connection", side_effect=OSError("Connection refused")):
|
|
client = GabsClient()
|
|
with pytest.raises(GabsError, match="TCP connect"):
|
|
client.call("ping")
|
|
|
|
def test_malformed_json_raises_gabs_error(self):
|
|
"""call() raises GabsError when the server sends invalid JSON."""
|
|
with patch("socket.create_connection") as mock_conn:
|
|
bad_sock = MagicMock(spec=socket.socket)
|
|
bad_sock.recv.return_value = b"not valid json\n"
|
|
mock_conn.return_value = bad_sock
|
|
client = GabsClient()
|
|
with pytest.raises(GabsError, match="Malformed JSON"):
|
|
client.call("ping")
|
|
|
|
def test_connection_closed_early_raises_gabs_error(self):
|
|
"""call() raises GabsError when the server closes without sending \\n."""
|
|
with patch("socket.create_connection") as mock_conn:
|
|
bad_sock = MagicMock(spec=socket.socket)
|
|
# recv never sends a newline; returns empty bytes on second call
|
|
bad_sock.recv.side_effect = [b"partial", b""]
|
|
mock_conn.return_value = bad_sock
|
|
client = GabsClient()
|
|
with pytest.raises(GabsError, match="closed before response"):
|
|
client.call("ping")
|
|
|
|
def test_socket_is_closed_after_call(self):
|
|
"""The socket is closed even after a successful call."""
|
|
response = _make_response(result="pong")
|
|
mock_sock = _mock_socket(response)
|
|
|
|
with patch("socket.create_connection", return_value=mock_sock):
|
|
GabsClient().call("ping")
|
|
|
|
mock_sock.close.assert_called_once()
|
|
|
|
def test_socket_is_closed_after_error(self):
|
|
"""The socket is closed even when the server returns a JSON-RPC error."""
|
|
error = {"code": -1, "message": "fail"}
|
|
response = _make_response(error=error)
|
|
mock_sock = _mock_socket(response)
|
|
|
|
with patch("socket.create_connection", return_value=mock_sock):
|
|
with pytest.raises(GabsError):
|
|
GabsClient().call("something")
|
|
|
|
mock_sock.close.assert_called_once()
|
|
|
|
|
|
class TestGabsClientHighLevel:
|
|
def _patched_client(self, method_results: dict) -> GabsClient:
|
|
"""Return a GabsClient whose call() is stubbed with *method_results*."""
|
|
client = GabsClient()
|
|
client.call = MagicMock(side_effect=lambda m, **_: method_results.get(m))
|
|
return client
|
|
|
|
def test_ping_returns_true_on_success(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value=None)
|
|
assert client.ping() is True
|
|
|
|
def test_ping_returns_false_on_gabs_error(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(side_effect=GabsError("timeout"))
|
|
assert client.ping() is False
|
|
|
|
def test_get_game_state_returns_dict(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value={"day": 1, "season": "autumn"})
|
|
result = client.get_game_state()
|
|
assert result["day"] == 1
|
|
|
|
def test_get_game_state_returns_empty_dict_on_non_dict(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value=None)
|
|
assert client.get_game_state() == {}
|
|
|
|
def test_get_player_returns_dict(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value={"name": "Timmy", "level": 5})
|
|
result = client.get_player()
|
|
assert result["name"] == "Timmy"
|
|
|
|
def test_list_kingdoms_returns_list(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value=[{"name": "Empire"}, {"name": "Vlandia"}])
|
|
result = client.list_kingdoms()
|
|
assert len(result) == 2
|
|
|
|
def test_list_kingdoms_returns_empty_list_on_non_list(self):
|
|
client = GabsClient()
|
|
client.call = MagicMock(return_value=None)
|
|
assert client.list_kingdoms() == []
|
|
|
|
|
|
# ── BannerlordObserver unit tests ─────────────────────────────────────────────
|
|
|
|
|
|
class TestBannerlordObserver:
|
|
def test_journal_header_created_on_first_run(self, tmp_path):
|
|
"""ensure_journal_header creates the file if it does not exist."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
journal = tmp_path / "test_journal.md"
|
|
observer = BannerlordObserver(journal_path=str(journal))
|
|
observer._ensure_journal_header()
|
|
|
|
assert journal.exists()
|
|
content = journal.read_text()
|
|
assert "Bannerlord Journal" in content
|
|
assert "#1091" in content
|
|
|
|
def test_journal_header_not_overwritten(self, tmp_path):
|
|
"""ensure_journal_header does not overwrite an existing file."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
journal = tmp_path / "existing.md"
|
|
journal.write_text("# existing content\n")
|
|
observer = BannerlordObserver(journal_path=str(journal))
|
|
observer._ensure_journal_header()
|
|
|
|
assert journal.read_text() == "# existing content\n"
|
|
|
|
def test_append_to_journal(self, tmp_path):
|
|
"""_append_to_journal appends text to the journal file."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
journal = tmp_path / "journal.md"
|
|
journal.write_text("# header\n")
|
|
observer = BannerlordObserver(journal_path=str(journal))
|
|
observer._append_to_journal("\nentry text\n")
|
|
|
|
assert "entry text" in journal.read_text()
|
|
|
|
def test_poll_snapshot_returns_none_when_gabs_unreachable(self, tmp_path):
|
|
"""_poll_snapshot returns None when get_game_state fails."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
observer = BannerlordObserver(journal_path=str(tmp_path / "j.md"))
|
|
mock_client = MagicMock()
|
|
mock_client.get_game_state.side_effect = GabsError("refused")
|
|
|
|
result = observer._poll_snapshot(mock_client)
|
|
assert result is None
|
|
|
|
def test_poll_snapshot_partial_on_secondary_failure(self, tmp_path):
|
|
"""_poll_snapshot returns a snapshot even if hero/party calls fail."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
observer = BannerlordObserver(journal_path=str(tmp_path / "j.md"))
|
|
mock_client = MagicMock()
|
|
mock_client.get_game_state.return_value = {"day": 5}
|
|
mock_client.get_player.side_effect = GabsError("hero unavailable")
|
|
mock_client.get_player_party.side_effect = GabsError("party unavailable")
|
|
mock_client.list_kingdoms.return_value = [{"name": "Empire"}]
|
|
|
|
snapshot = observer._poll_snapshot(mock_client)
|
|
assert snapshot is not None
|
|
assert snapshot["game_state"]["day"] == 5
|
|
assert snapshot["player"] == {}
|
|
assert snapshot["player_party"] == {}
|
|
assert snapshot["kingdoms"][0]["name"] == "Empire"
|
|
|
|
def test_format_journal_entry_contains_key_fields(self, tmp_path):
|
|
"""_format_journal_entry includes hero name, day, and kingdom data."""
|
|
from datetime import UTC, datetime
|
|
|
|
from integrations.bannerlord.observer import _format_journal_entry
|
|
|
|
snapshot = {
|
|
"game_state": {"day": 7, "season": "winter", "campaign_phase": "early"},
|
|
"player": {
|
|
"name": "Timmy",
|
|
"clan": "Thalheimer",
|
|
"renown": 42,
|
|
"level": 3,
|
|
"gold": 1000,
|
|
},
|
|
"player_party": {"size": 25, "morale": 80, "food_days_left": 5},
|
|
"kingdoms": [{"name": "Vlandia", "ruler": "Derthert", "military_strength": 5000}],
|
|
}
|
|
ts = datetime(2026, 3, 23, 12, 0, 0, tzinfo=UTC)
|
|
entry = _format_journal_entry(snapshot, ts, entry_num=1)
|
|
|
|
assert "Entry #0001" in entry
|
|
assert "Day 7" in entry
|
|
assert "winter" in entry
|
|
assert "Timmy" in entry
|
|
assert "Thalheimer" in entry
|
|
assert "Vlandia" in entry
|
|
assert "Derthert" in entry
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_observe_stops_after_target_days(self, tmp_path):
|
|
"""observe(days=2) stops after 2 unique in-game days are logged."""
|
|
from integrations.bannerlord.observer import BannerlordObserver
|
|
|
|
journal = tmp_path / "j.md"
|
|
observer = BannerlordObserver(
|
|
poll_interval=0, # no sleep
|
|
journal_path=str(journal),
|
|
)
|
|
|
|
# Simulate two distinct in-game days across three polls
|
|
snapshots = [
|
|
{"game_state": {"day": 1}, "player": {}, "player_party": {}, "kingdoms": []},
|
|
{"game_state": {"day": 1}, "player": {}, "player_party": {}, "kingdoms": []},
|
|
{"game_state": {"day": 2}, "player": {}, "player_party": {}, "kingdoms": []},
|
|
]
|
|
call_count = 0
|
|
|
|
def fake_poll(client):
|
|
nonlocal call_count
|
|
if call_count >= len(snapshots):
|
|
return snapshots[-1]
|
|
snap = snapshots[call_count]
|
|
call_count += 1
|
|
return snap
|
|
|
|
observer._poll_snapshot = fake_poll
|
|
|
|
await observer.observe(days=2)
|
|
|
|
assert len(observer._days_observed) >= 2
|
|
assert journal.exists()
|
|
content = journal.read_text()
|
|
assert "Entry #" in content
|