diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3d9b476 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,93 @@ +"""Tests for configuration loading and merging.""" + +import copy +from pathlib import Path + +import pytest + +from derp.config import DEFAULTS, _merge, load, resolve_config + + +class TestMerge: + """Test deep merge logic.""" + + def test_flat_override(self): + base = {"a": 1, "b": 2} + result = _merge(base, {"b": 99}) + assert result == {"a": 1, "b": 99} + + def test_nested_merge(self): + base = {"server": {"host": "a", "port": 1}} + result = _merge(base, {"server": {"port": 2}}) + assert result == {"server": {"host": "a", "port": 2}} + + def test_list_replacement(self): + base = {"channels": ["#old"]} + result = _merge(base, {"channels": ["#new"]}) + assert result == {"channels": ["#new"]} + + def test_empty_override(self): + base = {"a": 1, "b": 2} + result = _merge(base, {}) + assert result == {"a": 1, "b": 2} + + def test_new_keys(self): + base = {"a": 1} + result = _merge(base, {"b": 2}) + assert result == {"a": 1, "b": 2} + + def test_base_not_mutated(self): + base = {"server": {"host": "original"}} + original = copy.deepcopy(base) + _merge(base, {"server": {"host": "changed"}}) + assert base == original + + +class TestLoad: + """Test TOML file loading.""" + + def test_minimal_toml(self, tmp_path: Path): + config_file = tmp_path / "test.toml" + config_file.write_text('[server]\nnick = "testbot"\n') + result = load(config_file) + # Overridden value + assert result["server"]["nick"] == "testbot" + # Default preserved + assert result["server"]["host"] == "irc.libera.chat" + assert result["server"]["port"] == 6697 + + def test_full_override(self, tmp_path: Path): + config_file = tmp_path / "test.toml" + config_file.write_text( + '[server]\nhost = "custom.irc"\nport = 6667\n' + '[bot]\nprefix = "."\n' + ) + result = load(config_file) + assert result["server"]["host"] == "custom.irc" + assert result["server"]["port"] == 6667 + assert result["bot"]["prefix"] == "." + # Unset defaults still present + assert result["bot"]["channels"] == ["#test"] + + +class TestResolveConfig: + """Test config path resolution and fallback.""" + + def test_explicit_path(self, tmp_path: Path): + config_file = tmp_path / "explicit.toml" + config_file.write_text('[server]\nnick = "explicit"\n') + result = resolve_config(str(config_file)) + assert result["server"]["nick"] == "explicit" + + def test_no_file_fallback(self, tmp_path: Path, monkeypatch: "pytest.MonkeyPatch"): + # Run from tmp_path so config/derp.toml isn't found + monkeypatch.chdir(tmp_path) + result = resolve_config(str(tmp_path / "nonexistent.toml")) + # Should fall back to DEFAULTS + assert result["server"]["host"] == DEFAULTS["server"]["host"] + assert result["bot"]["prefix"] == DEFAULTS["bot"]["prefix"] + + def test_defaults_not_mutated(self): + original = copy.deepcopy(DEFAULTS) + resolve_config(None) + assert DEFAULTS == original diff --git a/tests/test_irc.py b/tests/test_irc.py index b67ee16..39f4674 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -128,3 +128,17 @@ class TestFormat: def test_pong(self): assert format_msg("PONG", "server.example.com") == "PONG :server.example.com" + + def test_trailing_starts_with_colon(self): + result = format_msg("PRIVMSG", "#ch", ":)") + assert result == "PRIVMSG #ch ::)" + + def test_empty_trailing(self): + # Empty string has no space and no leading colon, but head exists + result = format_msg("PRIVMSG", "#ch", "") + assert result == "PRIVMSG #ch " + + def test_multi_param_mode(self): + # No space in tail, not starting with colon, head exists -> no colon + result = format_msg("MODE", "#ch", "+o", "nick") + assert result == "MODE #ch +o nick" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 116e27a..bf4689e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,9 +1,10 @@ """Tests for the plugin system.""" +import asyncio import textwrap from pathlib import Path -from derp.bot import _AMBIGUOUS, Bot +from derp.bot import _AMBIGUOUS, Bot, _split_utf8 from derp.irc import Message from derp.plugin import PluginRegistry, command, event from derp.state import StateStore @@ -545,3 +546,110 @@ class TestStateStore: store.close() store2 = StateStore(db_path) assert store2.get("plug", "persist") == "yes" + + +class _FakeConnection: + """Minimal IRCConnection stand-in that captures sent lines.""" + + def __init__(self): + self.sent: list[str] = [] + + async def send(self, line: str) -> None: + self.sent.append(line) + + +def _make_test_bot() -> Bot: + """Create a Bot with a FakeConnection for testing the public API.""" + config = { + "server": {"host": "localhost", "port": 6667, "tls": False, + "nick": "test", "user": "test", "realname": "test"}, + "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, + } + bot = Bot(config, PluginRegistry()) + bot.conn = _FakeConnection() # type: ignore[assignment] + return bot + + +class TestBotAPI: + """Test Bot public API methods via FakeConnection.""" + + def test_send(self): + bot = _make_test_bot() + asyncio.run(bot.send("#ch", "hello world")) + assert bot.conn.sent == ["PRIVMSG #ch :hello world"] + + def test_send_single_word(self): + bot = _make_test_bot() + asyncio.run(bot.send("#ch", "hello")) + assert bot.conn.sent == ["PRIVMSG #ch hello"] + + def test_send_multiline(self): + bot = _make_test_bot() + asyncio.run(bot.send("#ch", "line one\nline two")) + assert len(bot.conn.sent) == 2 + assert bot.conn.sent[0] == "PRIVMSG #ch :line one" + assert bot.conn.sent[1] == "PRIVMSG #ch :line two" + + def test_reply_channel(self): + bot = _make_test_bot() + msg = Message(raw="", prefix="nick!u@h", nick="nick", + command="PRIVMSG", params=["#ch", "hi"], tags={}) + asyncio.run(bot.reply(msg, "hello there")) + assert bot.conn.sent == ["PRIVMSG #ch :hello there"] + + def test_reply_pm(self): + bot = _make_test_bot() + msg = Message(raw="", prefix="nick!u@h", nick="nick", + command="PRIVMSG", params=["testbot", "hi"], tags={}) + asyncio.run(bot.reply(msg, "hello there")) + assert bot.conn.sent == ["PRIVMSG nick :hello there"] + + def test_join(self): + bot = _make_test_bot() + asyncio.run(bot.join("#test")) + assert bot.conn.sent == ["JOIN :#test"] + + def test_kick(self): + bot = _make_test_bot() + asyncio.run(bot.kick("#ch", "baduser", "bad behavior")) + assert bot.conn.sent == ["KICK #ch baduser :bad behavior"] + + def test_mode(self): + bot = _make_test_bot() + asyncio.run(bot.mode("#ch", "+o", "nick")) + assert bot.conn.sent == ["MODE #ch +o nick"] + + def test_set_topic(self): + bot = _make_test_bot() + asyncio.run(bot.set_topic("#ch", "new topic")) + assert bot.conn.sent == ["TOPIC #ch :new topic"] + + +class TestSplitUtf8: + """Test UTF-8 safe message splitting.""" + + def test_short_text(self): + assert _split_utf8("hello", 100) == ["hello"] + + def test_ascii_split(self): + text = "a" * 20 + chunks = _split_utf8(text, 10) + assert len(chunks) == 2 + assert all(len(c.encode("utf-8")) <= 10 for c in chunks) + assert "".join(chunks) == text + + def test_multibyte_boundary(self): + # Each char is 3 bytes in UTF-8 + text = "\u00e9" * 10 # e-acute, 2 bytes each -> 20 bytes total + chunks = _split_utf8(text, 7) + recombined = "".join(chunks) + assert recombined == text + for chunk in chunks: + assert len(chunk.encode("utf-8")) <= 7 + + def test_empty(self): + assert _split_utf8("", 10) == [""] + + def test_exact_fit(self): + text = "abcde" + assert _split_utf8(text, 5) == ["abcde"]