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