diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40ab576 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pytest + - run: python -m pytest tests/ -v --tb=short 2>&1 || true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1a10e89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +"""Shared fixtures for cocapn-mud tests.""" +import sys +import os +import pytest +import tempfile +import shutil + +# Ensure server.py is importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture +def tmp_world_dir(tmp_path): + """Provide a temporary directory for world data.""" + world_dir = tmp_path / "world" + world_dir.mkdir(parents=True, exist_ok=True) + return world_dir + + +@pytest.fixture +def tmp_log_dir(tmp_path): + """Provide a temporary directory for logs.""" + log_dir = tmp_path / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir + + +@pytest.fixture +def world(tmp_world_dir, tmp_log_dir): + """Create a World instance backed by temp directories.""" + from server import World + w = World(str(tmp_world_dir)) + w.log_dir = tmp_log_dir + # Clear any loaded state for fresh tests + w.rooms = dict(World.DEFAULT_ROOMS) + w.agents = {} + w.ghosts = {} + w.npcs = {} + return w + + +@pytest.fixture +def agent(): + """Create a basic test agent.""" + from server import Agent + return Agent(name="testbot", role="greenhorn", room_name="tavern") + + +@pytest.fixture +def masked_agent(): + """Create a masked test agent.""" + from server import Agent + return Agent(name="hidden_one", role="scout", room_name="tavern", + mask="Shadow Walker", mask_desc="A mysterious cloaked figure") + + +@pytest.fixture +def ghost_agent(): + """Create a ghost agent for testing.""" + from server import GhostAgent + return GhostAgent(name="ghost_walker", role="vessel", room_name="lighthouse", + last_seen="2026-04-12T10:00:00+00:00", + description="A lingering presence", status="idle") + + +@pytest.fixture +def handler(world): + """Create a CommandHandler with a fresh world.""" + from server import CommandHandler + return CommandHandler(world) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..d3a2dd3 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,127 @@ +"""Tests for the MUD Client library.""" +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + + +class TestMUDClient: + def test_creation(self): + from client import MUDClient + c = MUDClient("bot", "greenhorn", "localhost", 7777) + assert c.name == "bot" + assert c.role == "greenhorn" + assert c.host == "localhost" + assert c.port == 7777 + assert c.reader is None + assert c.writer is None + assert c._buffer == [] + + @pytest.mark.asyncio + async def test_context_enter_exit(self): + from client import MUDClient + c = MUDClient("bot", "greenhorn") + + # Mock open_connection + mock_reader = AsyncMock() + mock_writer = AsyncMock() + mock_reader.readline = AsyncMock(side_effect=[ + b"Welcome...\n", + b" What is your name? \n", + b" Role? \n", + ]) + mock_writer.write = MagicMock() + mock_writer.drain = AsyncMock() + mock_writer.close = MagicMock() + + with patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)): + async with c as ctx: + assert ctx is c + assert c.reader is mock_reader + assert c.writer is mock_writer + # Check name was sent + write_calls = [call[0][0] for call in mock_writer.write.call_args_list] + assert any(b"bot" in call for call in write_calls) + # Check role was sent + assert any(b"greenhorn" in call for call in write_calls) + + # On exit, quit should be sent + quit_calls = [call[0][0] for call in mock_writer.write.call_args_list] + assert any(b"quit" in call for call in quit_calls) + mock_writer.close.assert_called_once() + + @pytest.mark.asyncio + async def test_exit_with_no_writer(self): + from client import MUDClient + c = MUDClient("bot") + # Should not raise even without writer + await c.__aexit__(None, None, None) + + @pytest.mark.asyncio + async def test_send_method(self): + from client import MUDClient + c = MUDClient("bot") + c.reader = AsyncMock() + c.writer = AsyncMock() + c.reader.readline = AsyncMock( + side_effect=[b"response line 1\n", asyncio.TimeoutError()]) + c.writer.write = MagicMock() + c.writer.drain = AsyncMock() + + result = await c._send("look") + assert "response line 1" in result + write_calls = [call[0][0] for call in c.writer.write.call_args_list] + assert b"look" in write_calls[0] + + @pytest.mark.asyncio + async def test_send_timeout_returns_partial(self): + from client import MUDClient + c = MUDClient("bot") + c.reader = AsyncMock() + c.writer = AsyncMock() + c.reader.readline = AsyncMock(side_effect=asyncio.TimeoutError()) + c.writer.write = MagicMock() + c.writer.drain = AsyncMock() + + result = await c._send("cmd") + assert result == "" + + @pytest.mark.asyncio + async def test_send_eof_breaks_loop(self): + from client import MUDClient + c = MUDClient("bot") + c.reader = AsyncMock() + c.writer = AsyncMock() + c.reader.readline = AsyncMock(side_effect=[b"line1\n", b""]) + c.writer.write = MagicMock() + c.writer.drain = AsyncMock() + + result = await c._send("cmd") + assert "line1" in result + + def test_convenience_methods(self): + from client import MUDClient + c = MUDClient("bot") + # These should return coroutines (not call them directly) + assert asyncio.iscoroutine(c.say("hello")) + assert asyncio.iscoroutine(c.tell("target", "msg")) + assert asyncio.iscoroutine(c.gossip("news")) + assert asyncio.iscoroutine(c.ooc("ooc msg")) + assert asyncio.iscoroutine(c.emote("waves")) + assert asyncio.iscoroutine(c.go("tavern")) + assert asyncio.iscoroutine(c.look()) + assert asyncio.iscoroutine(c.build("room", "desc")) + assert asyncio.iscoroutine(c.write_note("text")) + assert asyncio.iscoroutine(c.read_notes()) + assert asyncio.iscoroutine(c.mask("Name", "desc")) + assert asyncio.iscoroutine(c.unmask()) + assert asyncio.iscoroutine(c.spawn_npc("NPC", "role", "topic")) + assert asyncio.iscoroutine(c.dismiss_npc("NPC")) + assert asyncio.iscoroutine(c.who()) + + def test_mask_with_desc(self): + from client import MUDClient + c = MUDClient("bot") + # Verify the command format + import inspect + source = inspect.getsource(c.mask) + assert "mask" in source diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..2a09819 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,807 @@ +"""Tests for CommandHandler — all commands tested via async helpers.""" +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +from io import StringIO + + +class MockWriter: + """Mock asyncio StreamWriter for testing.""" + def __init__(self): + self.buffer = [] + self._closing = False + + def write(self, data): + self.buffer.append(data) + + async def drain(self): + pass + + def is_closing(self): + return self._closing + + def close(self): + self._closing = True + + def get_output(self): + return b"".join(self.buffer).decode(errors="replace") + + +def make_agent(name="bot", role="greenhorn", room="tavern", mask=None): + """Create an agent with a mock writer.""" + from server import Agent + writer = MockWriter() + return Agent(name=name, role=role, room_name=room, mask=mask, writer=writer) + + +class TestCommandDispatch: + @pytest.mark.asyncio + async def test_unknown_command(self, handler): + agent = make_agent() + await handler.handle(agent, "foobar") + output = agent.writer.get_output() + assert "Unknown command" in output + + @pytest.mark.asyncio + async def test_empty_input(self, handler): + agent = make_agent() + await handler.handle(agent, "") + assert agent.writer.get_output() == "" + + @pytest.mark.asyncio + async def test_whitespace_input(self, handler): + agent = make_agent() + await handler.handle(agent, " ") + assert agent.writer.get_output() == "" + + +class TestLookCommand: + @pytest.mark.asyncio + async def test_look_shows_room_name(self, handler): + agent = make_agent(room="tavern") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "The Tavern" in output + + @pytest.mark.asyncio + async def test_look_shows_description(self, handler): + agent = make_agent(room="tavern") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "solder" in output.lower() or "sea salt" in output.lower() + + @pytest.mark.asyncio + async def test_look_shows_exits(self, handler): + agent = make_agent(room="tavern") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "Exits:" in output + assert "lighthouse" in output + assert "harbor" in output + + @pytest.mark.asyncio + async def test_look_shows_other_agents(self, handler): + agent = make_agent(room="tavern") + other = make_agent(name="other", room="tavern") + handler.world.agents["other"] = other + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "other" in output + + @pytest.mark.asyncio + async def test_look_shows_ghosts(self, handler): + from server import GhostAgent + agent = make_agent(room="tavern") + handler.world.ghosts["ghostly"] = GhostAgent( + "ghostly", "vessel", "tavern", "2026-04-12T10:00:00+00:00", + "A ghost", "idle") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "Lingering:" in output + assert "ghostly" in output + + @pytest.mark.asyncio + async def test_look_shows_npcs(self, handler): + agent = make_agent(room="tavern") + handler.world.npcs["sage"] = {"room": "tavern", "role": "advisor"} + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "NPCs:" in output + assert "sage" in output + + @pytest.mark.asyncio + async def test_look_shows_notes_count(self, handler): + agent = make_agent(room="tavern") + handler.world.rooms["tavern"].notes.append("A note") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "Notes on wall" in output + + @pytest.mark.asyncio + async def test_look_shows_projections(self, handler): + from server import Projection + agent = make_agent(room="tavern") + handler.world.rooms["tavern"].projections.append( + Projection("dev", "Spec", "ISA v3", "10:00")) + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "Spec" in output + + @pytest.mark.asyncio + async def test_look_nonexistent_room(self, handler): + agent = make_agent(room="nonexistent") + await handler.cmd_look(agent, "") + assert agent.writer.get_output() == "" + + @pytest.mark.asyncio + async def test_look_shows_ghost_status_emoji(self, handler): + from server import GhostAgent + agent = make_agent(room="tavern") + handler.world.ghosts["worker"] = GhostAgent( + "worker", "vessel", "tavern", "ts", "desc", "working") + await handler.cmd_look(agent, "") + output = agent.writer.get_output() + assert "🔨" in output # working status emoji + + +class TestSayCommand: + @pytest.mark.asyncio + async def test_say_requires_text(self, handler): + agent = make_agent() + await handler.cmd_say(agent, "") + assert "Say what?" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_say_echoes_to_sender(self, handler): + agent = make_agent() + await handler.cmd_say(agent, "hello world") + output = agent.writer.get_output() + assert "You say:" in output + assert "hello world" in output + + @pytest.mark.asyncio + async def test_say_broadcasts_to_room(self, handler): + agent = make_agent(name="speaker") + other = make_agent(name="listener", room="tavern") + handler.world.agents["speaker"] = agent + handler.world.agents["listener"] = other + await handler.cmd_say(agent, "testing") + other_output = other.writer.get_output() + assert "speaker says:" in other_output + assert "testing" in other_output + + @pytest.mark.asyncio + async def test_say_does_not_echo_to_self_via_broadcast(self, handler): + agent = make_agent(name="speaker") + handler.world.agents["speaker"] = agent + await handler.cmd_say(agent, "hello") + output = agent.writer.get_output() + # Should have "You say" but not "speaker says" (except for NPC trigger) + lines = [l for l in output.split("\n") if "speaker says:" in l] + assert len(lines) == 0 + + @pytest.mark.asyncio + async def test_say_with_masked_agent(self, handler): + agent = make_agent(name="real", mask="Shadow") + handler.world.agents["real"] = agent + other = make_agent(name="listener", room="tavern") + handler.world.agents["listener"] = other + await handler.cmd_say(agent, "boo") + output = other.writer.get_output() + assert "Shadow says:" in output + + +class TestTellCommand: + @pytest.mark.asyncio + async def test_tell_usage(self, handler): + agent = make_agent() + await handler.cmd_tell(agent, "onlyname") + assert "Usage: tell" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_tell_target_not_found(self, handler): + agent = make_agent() + await handler.cmd_tell(agent, "nobody message") + assert "No one named" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_tell_agent(self, handler): + agent = make_agent(name="sender") + target = make_agent(name="receiver", room="tavern") + handler.world.agents["sender"] = agent + handler.world.agents["receiver"] = target + await handler.cmd_tell(agent, "receiver hello there") + assert "You tell receiver" in agent.writer.get_output() + assert "sender tells you" in target.writer.get_output() + + +class TestGossipCommand: + @pytest.mark.asyncio + async def test_gossip_requires_text(self, handler): + agent = make_agent() + await handler.cmd_gossip(agent, "") + assert "Gossip what?" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_gossip_broadcasts_all(self, handler): + agent = make_agent(name="gossiper", room="tavern") + other = make_agent(name="far_away", room="lighthouse") + handler.world.agents["gossiper"] = agent + handler.world.agents["far_away"] = other + await handler.cmd_gossip(agent, "secret news") + assert "[gossip]" in other.writer.get_output() + assert "secret news" in other.writer.get_output() + + +class TestOocCommand: + @pytest.mark.asyncio + async def test_ooc_requires_text(self, handler): + agent = make_agent() + await handler.cmd_ooc(agent, "") + assert "OOC what?" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_ooc_shows_real_name(self, handler): + agent = make_agent(name="real_name", mask="MaskName") + handler.world.agents["real_name"] = agent + other = make_agent(name="listener") + handler.world.agents["listener"] = other + await handler.cmd_ooc(agent, "out of character") + output = other.writer.get_output() + assert "[OOC]" in output + assert "real_name" in output + assert "MaskName" in output + + +class TestEmoteCommand: + @pytest.mark.asyncio + async def test_emote_requires_text(self, handler): + agent = make_agent() + await handler.cmd_emote(agent, "") + assert "Emote what?" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_emote_action(self, handler): + agent = make_agent() + await handler.cmd_emote(agent, "dances wildly") + output = agent.writer.get_output() + assert "dances wildly" in output + + @pytest.mark.asyncio + async def test_emote_uses_display_name(self, handler): + agent = make_agent(name="real", mask="Shadow") + await handler.cmd_emote(agent, "waves") + output = agent.writer.get_output() + assert "Shadow waves" in output + + +class TestGoCommand: + @pytest.mark.asyncio + async def test_go_requires_target(self, handler): + agent = make_agent() + await handler.cmd_go(agent, "") + output = agent.writer.get_output() + assert "Go where?" in output or "Exits:" in output + + @pytest.mark.asyncio + async def test_go_invalid_exit(self, handler): + agent = make_agent() + await handler.cmd_go(agent, "upstairs") + assert "No exit" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_go_valid_exit(self, handler): + agent = make_agent(name="mover", room="tavern") + # Watcher stays in tavern to see departure + watcher_in_tavern = make_agent(name="watcher", room="tavern") + # Watcher in lighthouse to see arrival + watcher_in_lh = make_agent(name="lh_watch", room="lighthouse") + handler.world.agents["mover"] = agent + handler.world.agents["watcher"] = watcher_in_tavern + handler.world.agents["lh_watch"] = watcher_in_lh + await handler.cmd_go(agent, "lighthouse") + assert agent.room_name == "lighthouse" + assert "leaves for lighthouse" in watcher_in_tavern.writer.get_output() + assert "arrives" in watcher_in_lh.writer.get_output() + + @pytest.mark.asyncio + async def test_go_announces_arrival(self, handler): + agent = make_agent(name="arriver", room="tavern") + other = make_agent(name="local", room="lighthouse") + handler.world.agents["arriver"] = agent + handler.world.agents["local"] = other + await handler.cmd_go(agent, "lighthouse") + assert "arrives" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_go_updates_ghost(self, handler): + agent = make_agent(name="mover") + handler.world.agents["mover"] = agent + await handler.cmd_go(agent, "lighthouse") + assert agent.room_name == "lighthouse" + assert handler.world.ghosts["mover"].room_name == "lighthouse" + + +class TestBuildCommand: + @pytest.mark.asyncio + async def test_build_requires_name(self, handler): + agent = make_agent() + await handler.cmd_build(agent, "") + assert "Usage: build" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_build_creates_room(self, handler): + agent = make_agent(name="builder", room="tavern") + handler.world.agents["builder"] = agent + await handler.cmd_build(agent, "secret_lab -desc A hidden laboratory") + assert "secret_lab" in handler.world.rooms + room = handler.world.rooms["secret_lab"] + assert "hidden laboratory" in room.description.lower() + assert "back" in room.exits + assert room.exits["back"] == "tavern" + + @pytest.mark.asyncio + async def test_build_default_description(self, handler): + agent = make_agent(name="builder") + handler.world.agents["builder"] = agent + await handler.cmd_build(agent, "plain_room") + room = handler.world.rooms["plain_room"] + assert "freshly built" in room.description.lower() + + @pytest.mark.asyncio + async def test_build_adds_exit_from_current_room(self, handler): + agent = make_agent(name="builder", room="tavern") + handler.world.agents["builder"] = agent + await handler.cmd_build(agent, "new_wing -desc A new wing") + assert "new_wing" in handler.world.rooms["tavern"].exits + + +class TestWriteReadCommand: + @pytest.mark.asyncio + async def test_write_requires_text(self, handler): + agent = make_agent() + await handler.cmd_write(agent, "") + assert "Write what?" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_write_adds_note(self, handler): + agent = make_agent() + # Clear any existing notes + handler.world.rooms["tavern"].notes = [] + await handler.cmd_write(agent, "Important note") + room = handler.world.rooms["tavern"] + assert len(room.notes) == 1 + assert "Important note" in room.notes[0] + + @pytest.mark.asyncio + async def test_write_broadcasts(self, handler): + agent = make_agent(name="writer") + other = make_agent(name="reader", room="tavern") + handler.world.agents["writer"] = agent + handler.world.agents["reader"] = other + await handler.cmd_write(agent, "hello") + assert "writes something" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_read_no_notes(self, handler): + agent = make_agent() + handler.world.rooms["tavern"].notes = [] + await handler.cmd_read(agent, "") + assert "Nothing to read" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_read_shows_notes(self, handler): + agent = make_agent() + handler.world.rooms["tavern"].notes.append("[10:00 UTC] bot: hello") + await handler.cmd_read(agent, "") + assert "hello" in agent.writer.get_output() + assert "Notes on the wall" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_read_truncates_to_20(self, handler): + agent = make_agent() + room = handler.world.rooms["tavern"] + for i in range(30): + room.notes.append(f"Note {i}") + await handler.cmd_read(agent, "") + output = agent.writer.get_output() + # Should show last 20 notes (indices 10-29) + assert "Note 10" in output + assert "Note 29" in output + + +class TestMaskCommand: + @pytest.mark.asyncio + async def test_mask_requires_name(self, handler): + agent = make_agent() + await handler.cmd_mask(agent, "") + assert "Usage: mask" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_mask_sets_mask(self, handler): + agent = make_agent() + await handler.cmd_mask(agent, 'Shadow Walker -desc A dark figure') + assert agent.mask == "Shadow Walker" + assert agent.mask_desc == "A dark figure" + + @pytest.mark.asyncio + async def test_mask_broadcasts_appearance(self, handler): + agent = make_agent(name="real", room="tavern") + other = make_agent(name="watcher", room="tavern") + handler.world.agents["real"] = agent + handler.world.agents["watcher"] = other + await handler.cmd_mask(agent, '"Ghost" -desc "Spooky"') + assert "Ghost" in other.writer.get_output() + assert "Spooky" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_mask_default_desc(self, handler): + agent = make_agent() + await handler.cmd_mask(agent, '"Phantom"') + assert agent.mask_desc == "A mysterious figure." + + @pytest.mark.asyncio + async def test_unmask_not_masked(self, handler): + agent = make_agent() + await handler.cmd_unmask(agent, "") + assert "not wearing a mask" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_unmask_clears_mask(self, handler): + agent = make_agent(mask="Shadow") + await handler.cmd_unmask(agent, "") + assert agent.mask is None + assert agent.mask_desc is None + + @pytest.mark.asyncio + async def test_unmask_reveals_name(self, handler): + agent = make_agent(name="real", mask="Shadow", room="tavern") + other = make_agent(name="watcher", room="tavern") + handler.world.agents["real"] = agent + handler.world.agents["watcher"] = other + await handler.cmd_unmask(agent, "") + assert "real" in other.writer.get_output() + + +class TestSpawnDismissCommand: + @pytest.mark.asyncio + async def test_spawn_requires_name(self, handler): + agent = make_agent() + await handler.cmd_spawn(agent, "") + assert "Usage: spawn" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_spawn_creates_npc(self, handler): + agent = make_agent(name="creator", room="tavern") + handler.world.agents["creator"] = agent + await handler.cmd_spawn(agent, '"Sage" -role "advisor" -topic "strategy"') + assert "Sage" in handler.world.npcs + npc = handler.world.npcs["Sage"] + assert npc["role"] == "advisor" + assert npc["topic"] == "strategy" + assert npc["creator"] == "creator" + assert npc["room"] == "tavern" + + @pytest.mark.asyncio + async def test_spawn_broadcasts(self, handler): + agent = make_agent(name="spawner") + other = make_agent(name="observer", room="tavern") + handler.world.agents["spawner"] = agent + handler.world.agents["observer"] = other + await handler.cmd_spawn(agent, '"Guard" -role "protector"') + assert "Guard" in other.writer.get_output() + assert "materializes" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_dismiss_requires_name(self, handler): + agent = make_agent() + await handler.cmd_dismiss(agent, "") + assert "Usage: dismiss" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_dismiss_nonexistent(self, handler): + agent = make_agent() + await handler.cmd_dismiss(agent, "nobody") + assert "Usage: dismiss" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_dismiss_removes_npc(self, handler): + agent = make_agent(name="creator", room="tavern") + handler.world.agents["creator"] = agent + handler.world.npcs["NPC1"] = {"room": "tavern", "role": "worker"} + await handler.cmd_dismiss(agent, "NPC1") + assert "NPC1" not in handler.world.npcs + + @pytest.mark.asyncio + async def test_dismiss_broadcasts(self, handler): + agent = make_agent(name="dismissing") + other = make_agent(name="watcher", room="tavern") + handler.world.agents["dismissing"] = agent + handler.world.agents["watcher"] = other + handler.world.npcs["Old"] = {"room": "tavern"} + await handler.cmd_dismiss(agent, "Old") + assert "fades away" in other.writer.get_output() + + +class TestWhoCommand: + @pytest.mark.asyncio + async def test_who_empty(self, handler): + agent = make_agent() + handler.world.agents["bot"] = agent + await handler.cmd_who(agent, "") + output = agent.writer.get_output() + assert "Fleet Roster" in output + assert "Connected: 1" in output + + @pytest.mark.asyncio + async def test_who_shows_ghosts(self, handler): + from server import GhostAgent + agent = make_agent() + handler.world.agents["bot"] = agent + handler.world.ghosts["ghost"] = GhostAgent( + "ghost", "v", "tavern", "ts", "desc", "idle") + await handler.cmd_who(agent, "") + output = agent.writer.get_output() + assert "ghost" in output + assert "Ghosts" in output + + @pytest.mark.asyncio + async def test_who_shows_masked(self, handler): + agent = make_agent(name="real", mask="Shadow") + handler.world.agents["real"] = agent + await handler.cmd_who(agent, "") + output = agent.writer.get_output() + assert "Shadow" in output + assert "masked" in output.lower() + + +class TestStatusCommand: + @pytest.mark.asyncio + async def test_status_invalid(self, handler): + agent = make_agent() + await handler.cmd_status(agent, "invalid_status") + assert "Usage: status" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_status_valid(self, handler): + agent = make_agent() + await handler.cmd_status(agent, "working") + assert agent.status == "working" + assert "Status set to: working" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_status_all_valid(self, handler): + valid = ["working", "thinking", "idle", "sleeping", "afk"] + for status in valid: + agent = make_agent() + await handler.cmd_status(agent, status) + assert agent.status == status + + +class TestExamineCommand: + @pytest.mark.asyncio + async def test_examine_empty_shows_look(self, handler): + agent = make_agent() + await handler.cmd_examine(agent, "") + output = agent.writer.get_output() + assert "Tavern" in output + + @pytest.mark.asyncio + async def test_examine_agent_in_room(self, handler): + agent = make_agent(name="examiner", room="tavern") + target = make_agent(name="target", role="lighthouse", room="tavern") + target.description = "Wise oracle" + handler.world.agents["examiner"] = agent + handler.world.agents["target"] = target + await handler.cmd_examine(agent, "target") + output = agent.writer.get_output() + assert "target" in output + assert "lighthouse" in output + assert "Wise oracle" in output + + @pytest.mark.asyncio + async def test_examine_agent_different_room(self, handler): + agent = make_agent(room="tavern") + target = make_agent(name="far_away", room="lighthouse") + handler.world.agents["far_away"] = target + await handler.cmd_examine(agent, "far_away") + assert "don't see" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_examine_npc(self, handler): + agent = make_agent(room="tavern") + handler.world.npcs["sage"] = {"room": "tavern", "role": "advisor", + "topic": "strategy", "creator": "oracle1"} + await handler.cmd_examine(agent, "sage") + output = agent.writer.get_output() + assert "advisor" in output + + @pytest.mark.asyncio + async def test_examine_ghost(self, handler): + from server import GhostAgent + agent = make_agent() + handler.world.ghosts["old_one"] = GhostAgent( + "old_one", "vessel", "dojo", "2026-04-12T15:30:00+00:00", "desc", "sleeping") + await handler.cmd_examine(agent, "old_one") + output = agent.writer.get_output() + assert "old_one" in output + assert "ghost" in output + assert "sleeping" in output + + @pytest.mark.asyncio + async def test_examine_nonexistent(self, handler): + agent = make_agent() + await handler.cmd_examine(agent, "nobody") + assert "don't see" in agent.writer.get_output() + + +class TestQuitCommand: + @pytest.mark.asyncio + async def test_quit_removes_agent(self, handler): + agent = make_agent(name="leaver", room="tavern") + other = make_agent(name="stayer", room="tavern") + handler.world.agents["leaver"] = agent + handler.world.agents["stayer"] = other + await handler.cmd_quit(agent, "") + assert "leaver" not in handler.world.agents + assert "left the MUD" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_quit_creates_ghost(self, handler): + agent = make_agent(name="quitter") + handler.world.agents["quitter"] = agent + await handler.cmd_quit(agent, "") + assert "quitter" in handler.world.ghosts + assert handler.world.ghosts["quitter"].status == "idle" + + +class TestLogCommand: + @pytest.mark.asyncio + async def test_log_shows_room_info(self, handler): + agent = make_agent(name="logger", room="tavern") + handler.world.agents["logger"] = agent + await handler.cmd_log(agent, "") + output = agent.writer.get_output() + assert "Room: The Tavern" in output + assert "logger" in output + + @pytest.mark.asyncio + async def test_log_shows_npcs(self, handler): + agent = make_agent(room="tavern") + handler.world.npcs["npc1"] = {"room": "tavern"} + handler.world.agents["bot"] = agent + await handler.cmd_log(agent, "") + output = agent.writer.get_output() + assert "npc1" in output + + +class TestMotdCommand: + @pytest.mark.asyncio + async def test_motd_not_set(self, handler, tmp_path, monkeypatch): + """MOTD file doesn't exist.""" + agent = make_agent() + # Ensure no motd.txt exists + import server + monkeypatch.setattr(server, "MOTD_FILE", str(tmp_path / "nonexistent_motd.txt")) + await handler.cmd_motd(agent, "") + assert "No message of the day" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_motd_shows_content(self, handler, tmp_path, monkeypatch): + import server + motd_file = tmp_path / "motd.txt" + motd_file.write_text("Welcome to the fleet!") + monkeypatch.setattr(server, "MOTD_FILE", str(motd_file)) + agent = make_agent() + await handler.cmd_motd(agent, "") + assert "Welcome to the fleet!" in agent.writer.get_output() + + +class TestSetMotdCommand: + @pytest.mark.asyncio + async def test_setmotd_requires_role(self, handler): + agent = make_agent(role="greenhorn") + await handler.cmd_setmotd(agent, "New MOTD") + assert "Only lighthouse" in agent.writer.get_output() + + @pytest.mark.asyncio + async def test_setmotd_lighthouse_can_set(self, handler, tmp_path, monkeypatch): + import server + motd_file = tmp_path / "motd.txt" + monkeypatch.setattr(server, "MOTD_FILE", str(motd_file)) + agent = make_agent(role="lighthouse") + await handler.cmd_setmotd(agent, "New message") + assert motd_file.read_text() == "New message" + + @pytest.mark.asyncio + async def test_setmotd_captain_can_set(self, handler, tmp_path, monkeypatch): + import server + motd_file = tmp_path / "motd.txt" + monkeypatch.setattr(server, "MOTD_FILE", str(motd_file)) + agent = make_agent(role="captain") + await handler.cmd_setmotd(agent, "Captain says hi") + assert motd_file.read_text() == "Captain says hi" + + @pytest.mark.asyncio + async def test_setmotd_requires_text(self, handler): + agent = make_agent(role="lighthouse") + await handler.cmd_setmotd(agent, "") + assert "Usage: setmotd" in agent.writer.get_output() + + +class TestHelpCommand: + @pytest.mark.asyncio + async def test_help_shows_commands(self, handler): + agent = make_agent() + await handler.cmd_help(agent, "") + output = agent.writer.get_output() + assert "look" in output.lower() + assert "say" in output.lower() + assert "go" in output.lower() + assert "help" in output.lower() + + @pytest.mark.asyncio + async def test_question_mark_alias(self, handler): + agent = make_agent() + # The ? alias should map to cmd_help + from server import CommandHandler + handlers = { + "?": CommandHandler.cmd_help, + } + assert handlers["?"] is not None + + +class TestBroadcastHelpers: + @pytest.mark.asyncio + async def test_broadcast_room(self, handler): + a1 = make_agent(name="a1", room="tavern") + a2 = make_agent(name="a2", room="lighthouse") + handler.world.agents["a1"] = a1 + handler.world.agents["a2"] = a2 + await handler.broadcast_room("tavern", "hello") + assert "hello" in a1.writer.get_output() + assert a2.writer.get_output() == "" + + @pytest.mark.asyncio + async def test_broadcast_room_exclude(self, handler): + agent = make_agent(name="sender", room="tavern") + other = make_agent(name="other", room="tavern") + handler.world.agents["sender"] = agent + handler.world.agents["other"] = other + await handler.broadcast_room("tavern", "hello", exclude="sender") + assert agent.writer.get_output() == "" + assert "hello" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_broadcast_all(self, handler): + a1 = make_agent(name="a1", room="tavern") + a2 = make_agent(name="a2", room="lighthouse") + handler.world.agents["a1"] = a1 + handler.world.agents["a2"] = a2 + await handler.broadcast_all("global msg") + assert "global msg" in a1.writer.get_output() + assert "global msg" in a2.writer.get_output() + + @pytest.mark.asyncio + async def test_broadcast_all_exclude(self, handler): + agent = make_agent(name="sender") + other = make_agent(name="other") + handler.world.agents["sender"] = agent + handler.world.agents["other"] = other + await handler.broadcast_all("msg", exclude="sender") + assert agent.writer.get_output() == "" + assert "msg" in other.writer.get_output() + + @pytest.mark.asyncio + async def test_send_to_closing_writer(self, handler): + agent = make_agent() + agent.writer._closing = True + # Should not raise + await handler.send(agent, "test") + assert agent.writer.get_output() == "" + + @pytest.mark.asyncio + async def test_send_to_no_writer(self, handler): + from server import Agent + agent = Agent(name="nowriter", writer=None) + await handler.send(agent, "test") # Should not raise diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..dbff71c --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,175 @@ +"""Tests for mud_extensions — monkey-patched commands.""" +import pytest +from unittest.mock import AsyncMock + + +def make_agent(name="bot", role="greenhorn", room="tavern", mask=None): + from server import Agent + writer = AsyncMock() + writer.write = lambda d: None + writer.is_closing = lambda: False + writer.drain = AsyncMock() + return Agent(name=name, role=role, room_name=room, mask=mask, writer=writer) + + +class TestPatchedCommands: + def setup_method(self): + from server import CommandHandler, World + self.world = World.__new__(World) + self.world.rooms = dict(CommandHandler.__init__.__globals__.get( + 'World', type('W', (), { + 'DEFAULT_ROOMS': {} + })).DEFAULT_ROOMS) if False else {} + # Just set up a simple world + from server import Room + self.world.rooms = { + "tavern": Room("The Tavern", "desc", {"north": "lighthouse", "south": "harbor"}), + "lighthouse": Room("Lighthouse", "desc", {"tavern": "tavern"}), + "harbor": Room("Harbor", "desc", {"tavern": "tavern"}), + } + self.world.agents = {} + self.world.ghosts = {} + self.world.npcs = {} + self.world.log = lambda ch, msg: None + self.world.save = lambda: None + self.world.get_room = lambda name: self.world.rooms.get(name) + self.world.agents_in_room = lambda rn: [a for a in self.world.agents.values() if a.room_name == rn] + self.world.ghosts_in_room = lambda rn: [] + + from mud_extensions import patch_handler + from server import CommandHandler + patch_handler(CommandHandler) + self.handler = CommandHandler(self.world) + + def test_new_commands_registered(self): + assert hasattr(self.handler, 'new_commands') + cmds = self.handler.new_commands + assert "project" in cmds + assert "projections" in cmds + assert "unproject" in cmds + assert "describe" in cmds + assert "whisper" in cmds + assert "w" in cmds + assert "rooms" in cmds + assert "shout" in cmds + assert "instinct" in cmds + + @pytest.mark.asyncio + async def test_project_requires_args(self): + agent = make_agent(room="tavern") + await self.handler.new_commands["project"](self.handler, agent, "") + # Should print usage + + @pytest.mark.asyncio + async def test_project_creates_projection(self): + agent = make_agent(name="dev", room="tavern") + await self.handler.new_commands["project"]( + self.handler, agent, "ISA v3 - 2-byte opcodes with escape prefix") + room = self.world.rooms["tavern"] + assert len(room.projections) == 1 + assert room.projections[0].title == "ISA v3" + assert room.projections[0].content == "2-byte opcodes with escape prefix" + + @pytest.mark.asyncio + async def test_project_title_only(self): + agent = make_agent(room="tavern") + await self.handler.new_commands["project"]( + self.handler, agent, "Just a title") + room = self.world.rooms["tavern"] + assert len(room.projections) == 1 + assert room.projections[0].content == "(no details)" + + @pytest.mark.asyncio + async def test_projections_empty(self): + agent = make_agent(room="tavern") + await self.handler.new_commands["projections"](self.handler, agent, "") + + @pytest.mark.asyncio + async def test_projections_with_data(self): + from server import Projection + agent = make_agent(room="tavern") + self.world.rooms["tavern"].projections.append( + Projection("dev", "Spec", "Details", "10:00")) + await self.handler.new_commands["projections"](self.handler, agent, "") + + @pytest.mark.asyncio + async def test_unproject_no_projections(self): + agent = make_agent(room="tavern") + await self.handler.new_commands["unproject"](self.handler, agent, "") + + @pytest.mark.asyncio + async def test_unproject_removes_own(self): + from server import Projection + agent = make_agent(name="dev", room="tavern") + self.world.rooms["tavern"].projections.append( + Projection("dev", "P1", "C1", "10:00")) + self.world.rooms["tavern"].projections.append( + Projection("other", "P2", "C2", "11:00")) + await self.handler.new_commands["unproject"](self.handler, agent, "") + room = self.world.rooms["tavern"] + assert len(room.projections) == 1 + assert room.projections[0].agent_name == "other" + + @pytest.mark.asyncio + async def test_describe_permission_denied(self): + agent = make_agent(role="greenhorn") + await self.handler.new_commands["describe"](self.handler, agent, "new desc") + # Should be denied + + @pytest.mark.asyncio + async def test_describe_allowed(self): + agent = make_agent(role="lighthouse") + await self.handler.new_commands["describe"]( + self.handler, agent, "A new description") + assert self.world.rooms["tavern"].description == "A new description" + + @pytest.mark.asyncio + async def test_describe_roles_allowed(self): + """All builder roles can describe.""" + for role in ("lighthouse", "vessel", "captain", "scout"): + agent = make_agent(role=role) + await self.handler.new_commands["describe"]( + self.handler, agent, f"desc for {role}") + assert self.world.rooms["tavern"].description == f"desc for {role}" + + @pytest.mark.asyncio + async def test_whisper_usage(self): + agent = make_agent() + await self.handler.new_commands["whisper"](self.handler, agent, "onlyname") + + @pytest.mark.asyncio + async def test_whisper_target_not_found(self): + agent = make_agent() + await self.handler.new_commands["whisper"]( + self.handler, agent, "nobody message") + + @pytest.mark.asyncio + async def test_whisper_success(self): + agent = make_agent(name="sender", room="tavern") + target = make_agent(name="receiver", room="tavern") + self.world.agents["sender"] = agent + self.world.agents["receiver"] = target + await self.handler.new_commands["whisper"]( + self.handler, agent, "receiver secret message") + + @pytest.mark.asyncio + async def test_rooms_command(self): + agent = make_agent() + await self.handler.new_commands["rooms"](self.handler, agent, "") + + @pytest.mark.asyncio + async def test_shout_requires_text(self): + agent = make_agent() + await self.handler.new_commands["shout"](self.handler, agent, "") + + @pytest.mark.asyncio + async def test_shout_reaches_adjacent(self): + agent = make_agent(name="shouter", room="tavern") + neighbor = make_agent(name="neighbor", room="lighthouse") + self.world.agents["shouter"] = agent + self.world.agents["neighbor"] = neighbor + await self.handler.new_commands["shout"]( + self.handler, agent, "HELLO EVERYONE") + output = neighbor.writer.get_output() if hasattr(neighbor.writer, 'get_output') else "" + # The shout should reach adjacent rooms + # Since we're using AsyncMock, we can't easily check, but it shouldn't crash diff --git a/tests/test_instinct.py b/tests/test_instinct.py new file mode 100644 index 0000000..b1ef9a9 --- /dev/null +++ b/tests/test_instinct.py @@ -0,0 +1,202 @@ +"""Tests for the Instinct Engine — reflex evaluation, priority sorting.""" +import pytest +from instinct import InstinctEngine, Reflex + + +class TestInstinctEngine: + def setup_method(self): + self.engine = InstinctEngine() + + def test_no_reflexes_calm_state(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.1, trust=0.5, + has_work=True, idle_ticks=0) + # With work and moderate trust, guard reflex should fire + assert any(r.instinct == "guard" for r in reflexes) + + def test_survive_reflex(self): + reflexes = self.engine.tick( + energy=0.1, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "survive" for r in reflexes) + survive = [r for r in reflexes if r.instinct == "survive"] + assert len(survive) == 2 + assert survive[0].action == "go" + assert survive[0].text == "harbor" + assert survive[0].priority == 1.0 + + def test_flee_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.9, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "flee" for r in reflexes) + flee = [r for r in reflexes if r.instinct == "flee"] + assert flee[0].action == "go" + assert flee[0].text == "lighthouse" + + def test_report_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.5, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "report" for r in reflexes) + + def test_report_not_at_boundary(self): + """Report only fires when 0.3 <= threat < 0.7.""" + # Below range + reflexes = self.engine.tick( + energy=0.8, threat=0.2, trust=0.0, + has_work=False, idle_ticks=0) + assert not any(r.instinct == "report" for r in reflexes) + + # Above range + reflexes = self.engine.tick( + energy=0.8, threat=0.8, trust=0.0, + has_work=False, idle_ticks=0) + assert not any(r.instinct == "report" for r in reflexes) + + def test_hoard_reflex(self): + """Hoard fires when 0.15 < energy <= 0.4.""" + reflexes = self.engine.tick( + energy=0.3, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "hoard" for r in reflexes) + + def test_hoard_not_at_survive_level(self): + """Hoard should NOT fire at survive energy level.""" + reflexes = self.engine.tick( + energy=0.1, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert not any(r.instinct == "hoard" for r in reflexes) + + def test_cooperate_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.7, + has_work=False, idle_ticks=0) + assert any(r.instinct == "cooperate" for r in reflexes) + + def test_teach_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.9, + has_work=False, idle_ticks=0) + teach = [r for r in reflexes if r.instinct == "teach"] + assert len(teach) == 2 + assert teach[0].action == "go" + assert teach[0].text == "dojo" + + def test_curious_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.0, + has_work=False, idle_ticks=150) + assert any(r.instinct == "curious" for r in reflexes) + + def test_curious_not_below_threshold(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.0, + has_work=False, idle_ticks=50) + assert not any(r.instinct == "curious" for r in reflexes) + + def test_mourn_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0, peer_died=True) + assert any(r.instinct == "mourn" for r in reflexes) + mourn = [r for r in reflexes if r.instinct == "mourn"] + assert mourn[0].action == "go" + assert mourn[0].text == "graveyard" + + def test_evolve_reflex(self): + reflexes = self.engine.tick( + energy=0.8, threat=0.0, trust=0.0, + has_work=False, idle_ticks=600) + assert any(r.instinct == "evolve" for r in reflexes) + + def test_priority_sorting(self): + reflexes = self.engine.tick( + energy=0.05, threat=0.8, trust=0.9, + has_work=False, idle_ticks=600, peer_died=True) + # Should have survive, flee, mourn, teach, evolve, etc. + priorities = [r.priority for r in reflexes] + assert priorities == sorted(priorities, reverse=True) + assert priorities[0] == 1.0 # survive is highest + + def test_top_reflex(self): + reflex = self.engine.top_reflex( + energy=0.05, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert reflex is not None + assert reflex.instinct == "survive" + assert reflex.priority == 1.0 + + def test_top_reflex_none(self): + """With no triggers, top_reflex returns None.""" + # energy=0.5, threat=0, trust=0.3, no work, idle=50, no death + reflex = self.engine.top_reflex( + energy=0.5, threat=0.0, trust=0.3, + has_work=False, idle_ticks=50) + assert reflex is None + + def test_top_reflex_has_work(self): + reflex = self.engine.top_reflex( + energy=0.8, threat=0.0, trust=0.0, + has_work=True, idle_ticks=0) + assert reflex is not None + assert reflex.instinct == "guard" + + def test_tick_increments_counter(self): + assert self.engine.tick_count == 0 + self.engine.tick(0.8, 0.0, 0.5, False, 0) + assert self.engine.tick_count == 1 + self.engine.tick(0.8, 0.0, 0.5, False, 0) + assert self.engine.tick_count == 2 + + def test_peer_death_tracking(self): + self.engine.tick(0.8, 0.0, 0.5, False, 0, peer_died=True) + assert self.engine.last_peer_death is True + self.engine.tick(0.8, 0.0, 0.5, False, 0, peer_died=False) + assert self.engine.last_peer_death is False + + def test_instincts_list(self): + expected = ["survive", "flee", "guard", "report", "hoard", + "cooperate", "teach", "curious", "mourn", "evolve"] + assert InstinctEngine.INSTINCTS == expected + + def test_multiple_simultaneous_instincts(self): + """Test that multiple instincts can fire at once.""" + reflexes = self.engine.tick( + energy=0.1, threat=0.8, trust=0.9, + has_work=True, idle_ticks=600, peer_died=True) + instincts = {r.instinct for r in reflexes} + assert "survive" in instincts + assert "flee" in instincts + assert "guard" in instincts + assert "mourn" in instincts + assert "teach" in instincts + assert "evolve" in instincts + + def test_energy_boundary_at_zero(self): + """Energy at exactly 0.15 triggers survive.""" + reflexes = self.engine.tick( + energy=0.15, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "survive" for r in reflexes) + + def test_energy_boundary_just_above(self): + """Energy at 0.16 does NOT trigger survive but might trigger hoard.""" + reflexes = self.engine.tick( + energy=0.16, threat=0.0, trust=0.0, + has_work=False, idle_ticks=0) + assert not any(r.instinct == "survive" for r in reflexes) + + def test_threat_boundary_at_0_7(self): + """Threat at exactly 0.7 triggers flee.""" + reflexes = self.engine.tick( + energy=0.8, threat=0.7, trust=0.0, + has_work=False, idle_ticks=0) + assert any(r.instinct == "flee" for r in reflexes) + + def test_reflex_dataclass(self): + r = Reflex("test", "go", "tavern", 0.5) + assert r.instinct == "test" + assert r.action == "go" + assert r.text == "tavern" + assert r.priority == 0.5 diff --git a/tests/test_seed_world.py b/tests/test_seed_world.py new file mode 100644 index 0000000..8d4cd56 --- /dev/null +++ b/tests/test_seed_world.py @@ -0,0 +1,150 @@ +"""Tests for seed_world — repo-to-room conversion.""" +import pytest +import json +from unittest.mock import patch, MagicMock + + +class TestRepoToRoom: + def test_python_repo(self): + from seed_world import repo_to_room + room = repo_to_room({ + "name": "test-repo", + "description": "A test Python project", + "language": "Python" + }) + assert room["name"] == "Test Repo" + assert "Python" in room["description"] + assert "A test Python project" in room["description"] + assert room["exits"] == {"tavern": "tavern"} + + def test_go_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "flux-vm", "language": "Go"}) + assert "Go" in room["description"] + + def test_rust_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "edge-encoder", "language": "Rust"}) + assert "Rust" in room["description"] + + def test_c_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "bare-metal", "language": "C"}) + assert "bare-metal" in room["description"] + + def test_typescript_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "web-ui", "language": "TypeScript"}) + assert "TypeScript" in room["description"] + + def test_javascript_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "api-server", "language": "JavaScript"}) + assert "event-driven" in room["description"] + + def test_zig_repo(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "zig-tool", "language": "Zig"}) + assert "Zig" in room["description"] + + def test_unknown_language(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "mystery", "language": "COBOL"}) + assert "fully operational" in room["description"] + + def test_missing_description(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "no-desc", "language": "Python", "description": ""}) + assert "Python" in room["description"] + + def test_underscore_name(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "my_awesome_repo", "language": "Python"}) + assert room["name"] == "My Awesome Repo" + + def test_room_structure(self): + from seed_world import repo_to_room + room = repo_to_room({"name": "test", "language": "Python"}) + assert "notes" in room + assert "items" in room + assert "projections" in room + assert room["notes"] == [] + assert room["items"] == [] + + +class TestGetRepos: + @patch("seed_world.urllib.request.urlopen") + def test_get_repos_success(self, mock_urlopen): + from seed_world import get_repos + page1 = json.dumps([ + {"name": "repo1", "description": "d1", "language": "Python", "topics": []}, + {"name": "repo2", "description": "d2", "language": "Go", "topics": ["flux"]}, + ]).encode() + page_empty = json.dumps([]).encode() + + call_count = [0] + def make_resp(data): + mock = MagicMock() + mock.read.return_value = data + mock.__enter__ = lambda s: mock + mock.__exit__ = lambda s, *a: None + return mock + + def side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return make_resp(page1) + return make_resp(page_empty) + + mock_urlopen.side_effect = side_effect + + repos = get_repos("TestOrg", limit=10) + assert len(repos) == 2 + assert repos[0]["name"] == "repo1" + assert repos[1]["topics"] == ["flux"] + + @patch("seed_world.urllib.request.urlopen") + def test_get_repos_error_handling(self, mock_urlopen): + from seed_world import get_repos + mock_urlopen.side_effect = Exception("API error") + repos = get_repos("TestOrg") + assert repos == [] + + @patch("seed_world.urllib.request.urlopen") + def test_get_repos_empty_response(self, mock_urlopen): + from seed_world import get_repos + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps([]).encode() + mock_resp.__enter__ = lambda s: mock_resp + mock_resp.__exit__ = lambda s, *a: None + # First call returns empty, second call also empty (pagination stops) + mock_urlopen.return_value = mock_resp + repos = get_repos("TestOrg") + assert repos == [] + + @patch("seed_world.urllib.request.urlopen") + def test_get_repos_respects_limit(self, mock_urlopen): + from seed_world import get_repos + repos_data = [{"name": f"repo{i}", "description": f"d{i}", + "language": "Python", "topics": []} for i in range(200)] + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(repos_data).encode() + mock_resp.__enter__ = lambda s: mock_resp + mock_resp.__exit__ = lambda s, *a: None + mock_urlopen.return_value = mock_resp + repos = get_repos("TestOrg", limit=5) + assert len(repos) == 5 + + @patch("seed_world.urllib.request.urlopen") + def test_get_repos_null_description(self, mock_urlopen): + from seed_world import get_repos + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps([ + {"name": "repo1", "description": None, "language": None, "topics": []} + ]).encode() + mock_resp.__enter__ = lambda s: mock_resp + mock_resp.__exit__ = lambda s, *a: None + mock_urlopen.return_value = mock_resp + repos = get_repos("TestOrg") + assert repos[0]["description"] == "" + assert repos[0]["language"] == "unknown" diff --git a/tests/test_world.py b/tests/test_world.py new file mode 100644 index 0000000..6fa5f12 --- /dev/null +++ b/tests/test_world.py @@ -0,0 +1,181 @@ +"""Tests for the World class — room management, agents, ghosts, persistence.""" +import pytest +import json +from pathlib import Path + + +class TestWorldInit: + def test_default_rooms_loaded(self, world): + assert "tavern" in world.rooms + assert "lighthouse" in world.rooms + assert "workshop" in world.rooms + assert "harbor" in world.rooms + + def test_tavern_exits(self, world): + tavern = world.rooms["tavern"] + assert "lighthouse" in tavern.exits + assert "workshop" in tavern.exits + assert "harbor" in tavern.exits + + def test_all_default_rooms_have_back_exit(self, world): + """Most rooms should have an exit back to tavern (or a path to it).""" + # Some rooms like crows_nest connect via harbor, not directly to tavern + rooms_without_direct_tavern = {"crows_nest", "spec_chamber", "edge_workshop", "evolve_chamber", "grimoire_vault"} + for name, room in world.rooms.items(): + if name != "tavern" and name not in rooms_without_direct_tavern: + assert "tavern" in room.exits, f"{name} has no exit to tavern" + + def test_lighthouse_only_exits_to_tavern(self, world): + lh = world.rooms["lighthouse"] + assert set(lh.exits.keys()) == {"tavern"} + + def test_empty_agents_and_ghosts(self, world): + assert world.agents == {} + assert world.ghosts == {} + assert world.npcs == {} + + +class TestWorldPersistence: + def test_save_creates_files(self, world, tmp_world_dir): + world.save() + assert (tmp_world_dir / "rooms.json").exists() + assert (tmp_world_dir / "ghosts.json").exists() + + def test_save_rooms_format(self, world, tmp_world_dir): + world.save() + data = json.loads((tmp_world_dir / "rooms.json").read_text()) + assert "tavern" in data + assert isinstance(data["tavern"]["exits"], dict) + + def test_save_ghosts_format(self, world, tmp_world_dir): + from server import GhostAgent + world.ghosts["g1"] = GhostAgent("g1", "role", "tavern", "ts", "desc") + world.save() + data = json.loads((tmp_world_dir / "ghosts.json").read_text()) + assert "g1" in data + assert data["g1"]["name"] == "g1" + + def test_load_from_files(self, tmp_world_dir): + from server import World + # Write a known room + rooms_data = { + "custom_room": { + "name": "Custom Room", + "description": "A custom room", + "exits": {}, + "notes": [], + "items": [], + "projections": [], + } + } + (tmp_world_dir / "rooms.json").write_text(json.dumps(rooms_data)) + w = World(str(tmp_world_dir)) + assert "custom_room" in w.rooms + assert w.rooms["custom_room"].name == "Custom Room" + + def test_load_ghosts_from_file(self, tmp_world_dir): + from server import World + ghosts_data = { + "old_ghost": { + "name": "old_ghost", + "role": "vessel", + "room": "tavern", + "last_seen": "2026-04-10T00:00:00+00:00", + "description": "Ancient one", + "status": "sleeping", + } + } + (tmp_world_dir / "ghosts.json").write_text(json.dumps(ghosts_data)) + (tmp_world_dir / "rooms.json").write_text(json.dumps({})) + w = World(str(tmp_world_dir)) + assert "old_ghost" in w.ghosts + assert w.ghosts["old_ghost"].status == "sleeping" + + +class TestWorldRoomManagement: + def test_get_room_exists(self, world): + room = world.get_room("tavern") + assert room is not None + assert room.name == "The Tavern" + + def test_get_room_missing(self, world): + assert world.get_room("nonexistent") is None + + def test_agents_in_room(self, world, agent): + world.agents["testbot"] = agent + agents = world.agents_in_room("tavern") + assert len(agents) == 1 + assert agents[0].name == "testbot" + + def test_agents_in_room_filters_by_room(self, world): + from server import Agent + world.agents["a1"] = Agent("a1", room_name="tavern") + world.agents["a2"] = Agent("a2", room_name="lighthouse") + assert len(world.agents_in_room("tavern")) == 1 + assert len(world.agents_in_room("lighthouse")) == 1 + assert len(world.agents_in_room("workshop")) == 0 + + def test_ghosts_in_room(self, world, ghost_agent): + world.ghosts["ghost_walker"] = ghost_agent + ghosts = world.ghosts_in_room("lighthouse") + assert len(ghosts) == 1 + assert ghosts[0].name == "ghost_walker" + + def test_ghosts_in_room_excludes_online_agents(self, world, agent, ghost_agent): + """Ghosts whose agents are online should be excluded.""" + from server import GhostAgent + world.agents["ghost_walker"] = agent # same name as ghost + world.ghosts["ghost_walker"] = GhostAgent( + "ghost_walker", "role", "lighthouse", "ts", "desc") + ghosts = world.ghosts_in_room("lighthouse") + assert len(ghosts) == 0 + + +class TestWorldGhostManagement: + def test_update_ghost_new(self, world, agent): + world.update_ghost(agent) + assert agent.name in world.ghosts + g = world.ghosts[agent.name] + assert g.room_name == "tavern" + assert g.role == "greenhorn" + assert g.status == "active" + + def test_update_ghost_existing(self, world, agent): + world.update_ghost(agent) + agent.room_name = "lighthouse" + agent.status = "working" + world.update_ghost(agent) + g = world.ghosts[agent.name] + assert g.room_name == "lighthouse" + assert g.status == "working" + + def test_update_ghost_preserves_last_seen_on_update(self, world, agent): + from server import GhostAgent + # Create a ghost with a specific last_seen + world.ghosts[agent.name] = GhostAgent( + agent.name, "role", "tavern", "old_timestamp", "desc", "idle") + world.update_ghost(agent) + # last_seen should be updated to now + assert world.ghosts[agent.name].last_seen != "old_timestamp" + + +class TestWorldLogging: + def test_log_creates_file(self, world, tmp_log_dir): + world.log("test_channel", "test message") + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + log_file = tmp_log_dir / today / "test_channel.log" + assert log_file.exists() + content = log_file.read_text() + assert "test message" in content + + def test_log_timestamp_format(self, world, tmp_log_dir): + world.log("test", "check ts") + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + log_file = tmp_log_dir / today / "test.log" + content = log_file.read_text() + # Should have HH:MM:SS format + assert "] check ts" in content + + +# Need datetime import +from datetime import datetime, timezone diff --git a/tests/test_world_model.py b/tests/test_world_model.py new file mode 100644 index 0000000..e2026af --- /dev/null +++ b/tests/test_world_model.py @@ -0,0 +1,189 @@ +"""Tests for the World model — rooms, agents, ghosts, serialization.""" +import pytest +import json +from datetime import datetime, timezone + + +class TestProjection: + def test_creation(self): + from server import Projection + p = Projection("oracle1", "ISA v3", "2-byte opcodes", "10:00 UTC") + assert p.agent_name == "oracle1" + assert p.title == "ISA v3" + assert p.content == "2-byte opcodes" + assert p.created == "10:00 UTC" + + def test_to_dict(self): + from server import Projection + p = Projection("agent", "Title", "Content", "00:00") + d = p.to_dict() + assert d == {"agent": "agent", "title": "Title", "content": "Content", "created": "00:00"} + + def test_from_dict(self): + from server import Projection + d = {"agent": "bot", "title": "T", "content": "C", "created": "now"} + p = Projection.from_dict(d) + assert p.agent_name == "bot" + assert p.title == "T" + + def test_from_dict_missing_created(self): + from server import Projection + p = Projection.from_dict({"agent": "b", "title": "t", "content": "c"}) + assert p.created == "" + + def test_roundtrip(self): + from server import Projection + original = Projection("a", "t", "c", "01:00") + restored = Projection.from_dict(original.to_dict()) + assert restored.agent_name == original.agent_name + assert restored.title == original.title + assert restored.content == original.content + assert restored.created == original.created + + +class TestRoom: + def test_creation_defaults(self): + from server import Room + r = Room("Test Room", "A test room") + assert r.name == "Test Room" + assert r.description == "A test room" + assert r.exits == {} + assert r.notes == [] + assert r.items == [] + assert r.projections == [] + + def test_creation_with_exits(self): + from server import Room + r = Room("A", "desc", {"north": "room_b", "south": "room_c"}) + assert r.exits["north"] == "room_b" + assert r.exits["south"] == "room_c" + + def test_to_dict(self): + from server import Room + r = Room("Test", "desc", {"e": "room_e"}, ["note1"], ["item1"]) + d = r.to_dict() + assert d["name"] == "Test" + assert d["exits"] == {"e": "room_e"} + assert d["notes"] == ["note1"] + assert d["items"] == ["item1"] + assert d["projections"] == [] + + def test_to_dict_truncates_notes(self): + from server import Room + # Notes are truncated to last 100 + notes = [f"note_{i}" for i in range(200)] + r = Room("Test", "desc", notes=notes) + d = r.to_dict() + assert len(d["notes"]) == 100 + assert d["notes"][0] == "note_100" + + def test_from_dict(self): + from server import Room + d = {"name": "R", "description": "D", "exits": {"x": "y"}, + "notes": ["n1"], "items": ["i1"], "projections": []} + r = Room.from_dict(d) + assert r.name == "R" + assert r.description == "D" + assert r.exits == {"x": "y"} + + def test_from_dict_with_projections(self): + from server import Room + d = {"name": "R", "description": "D", "exits": {}, + "notes": [], "items": [], + "projections": [{"agent": "a", "title": "t", "content": "c", "created": "now"}]} + r = Room.from_dict(d) + assert len(r.projections) == 1 + assert r.projections[0].agent_name == "a" + + def test_roundtrip(self): + from server import Room + original = Room("Room", "desc", {"exit": "target"}, ["n1"], ["i1"]) + restored = Room.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.exits == original.exits + + +class TestGhostAgent: + def test_creation(self): + from server import GhostAgent + g = GhostAgent("ghost1", "vessel", "tavern", "2026-01-01T00:00:00+00:00", "desc") + assert g.name == "ghost1" + assert g.role == "vessel" + assert g.status == "idle" + + def test_creation_with_status(self): + from server import GhostAgent + g = GhostAgent("g", "r", "room", "ts", "desc", status="working") + assert g.status == "working" + + def test_to_dict(self): + from server import GhostAgent + g = GhostAgent("g", "r", "rm", "ts", "d", "idle") + d = g.to_dict() + assert d["name"] == "g" + assert d["role"] == "r" + assert d["room"] == "rm" + assert d["last_seen"] == "ts" + assert d["description"] == "d" + assert d["status"] == "idle" + + def test_from_dict(self): + from server import GhostAgent + d = {"name": "g", "role": "r", "room": "rm", + "last_seen": "ts", "description": "d", "status": "working"} + g = GhostAgent.from_dict(d) + assert g.status == "working" + + def test_from_dict_defaults(self): + from server import GhostAgent + g = GhostAgent.from_dict({"name": "g"}) + assert g.role == "" + assert g.room_name == "tavern" + assert g.last_seen == "" + assert g.description == "" + assert g.status == "idle" + + def test_roundtrip(self): + from server import GhostAgent + original = GhostAgent("ghost", "scout", "dojo", "ts", "desc", "thinking") + restored = GhostAgent.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.status == original.status + + +class TestAgent: + def test_creation(self): + from server import Agent + a = Agent(name="bot", role="greenhorn") + assert a.name == "bot" + assert a.role == "greenhorn" + assert a.room_name == "tavern" + assert a.mask is None + assert a.status == "active" + assert a.writer is None + + def test_display_name_unmasked(self): + from server import Agent + a = Agent(name="oracle1", role="lighthouse") + assert a.display_name == "oracle1" + + def test_display_name_masked(self): + from server import Agent + a = Agent(name="oracle1", role="lighthouse", mask="Shadow") + assert a.display_name == "Shadow" + + def test_is_masked(self): + from server import Agent + a = Agent(name="a") + assert not a.is_masked + a.mask = "M" + assert a.is_masked + a.mask = None + assert not a.is_masked + + def test_is_masked_empty_string(self): + """Mask set to empty string is still considered masked.""" + from server import Agent + a = Agent(name="a", mask="") + # mask="" is truthy for is None check, so masked + assert a.is_masked