This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/integrations/test_gabs_observer.py
2026-03-23 19:06:11 +00:00

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