test: add config, format_msg, and Bot API tests

New test_config.py: merge, load, resolve_config tests.
Extend test_irc.py: format_msg edge cases (colon, empty, multi-param).
Extend test_plugin.py: Bot API via FakeConnection, _split_utf8 tests.
Test count: 92 -> 120.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 03:27:18 +01:00
parent b48c289403
commit 8129b79cdb
3 changed files with 216 additions and 1 deletions

93
tests/test_config.py Normal file
View File

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

View File

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

View File

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