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

293 lines
12 KiB
Python
Raw Normal View History

"""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