Skip to content

Commit 83cfcc5

Browse files
committed
test: pin observable outputs for top mutation survivors
Mutation run (fresh baseline): 3609 mutants, 2754 killed, 831 survived, 4 timeouts. Survivors clustered where tests exercised edge cases but never asserted the happy-path payload/state. - test_observable_effects.py: full transfer snapshot field assertions, get_bot create/cache/default-config/unknown-server/connect-failure paths, manager config defaults, _register_transfer merge window, passive-DCC incomplete_suffix path shaping - test_aiodcc.py: assert chat dccmsg event payload (was only assert_called), add partial-line buffering test - test_transfer_handler.py: percent truncation at a boundary where a different scaling factor would change the result - mutation.yml: persist mutmut-stats.json across nightly runs; a full run does not fit in 90 min, so without resume the job could never converge
1 parent a8d95be commit 83cfcc5

4 files changed

Lines changed: 365 additions & 1 deletion

File tree

‎.github/workflows/mutation.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ jobs:
2727
- name: Set up Python environment
2828
run: uv sync
2929

30+
- name: Restore mutmut stats
31+
# A full run does not fit in one job; the stats file lets each
32+
# nightly run resume where the previous one stopped. Mutmut keys
33+
# results by function hash, so changed code is re-checked anyway.
34+
uses: actions/cache@v6
35+
with:
36+
path: mutants/mutmut-stats.json
37+
key: mutmut-stats-${{ github.run_id }}
38+
restore-keys: |
39+
mutmut-stats-
40+
3041
- name: Run mutmut
3142
run: |
3243
cp config.json.sample config.json

‎tests/unit/test_aiodcc.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,30 @@ def test_dcc_connection_process_data_chat(dcc_connection, mock_reactor):
128128
test_data = b"test message\n"
129129
dcc_connection.process_data(test_data)
130130

131-
mock_reactor._handle_event.assert_called()
131+
mock_reactor._handle_event.assert_called_once()
132+
event = mock_reactor._handle_event.call_args[0][1]
133+
assert event.type == "dccmsg"
134+
assert event.source == "127.0.0.1"
135+
assert event.target is None
136+
assert event.arguments == ["test message"]
137+
138+
139+
def test_dcc_connection_process_data_chat_buffers_partial_line(dcc_connection, mock_reactor):
140+
"""A line without a terminator stays buffered; the next feed completes it."""
141+
dcc_connection.dcctype = "chat"
142+
dcc_connection.peeraddress = "127.0.0.1"
143+
dcc_connection.passive = False
144+
dcc_connection.connected = True
145+
dcc_connection.buffer = NonStrictDecodingLineBuffer()
146+
147+
dcc_connection.process_data(b"partial")
148+
mock_reactor._handle_event.assert_not_called()
149+
150+
dcc_connection.process_data(b" complete\nnext\n")
151+
assert mock_reactor._handle_event.call_count == 2
152+
first, second = (c[0][1] for c in mock_reactor._handle_event.call_args_list)
153+
assert first.arguments == ["partial complete"]
154+
assert second.arguments == ["next"]
132155

133156

134157
def test_dcc_connection_process_data_chat_too_large(dcc_connection, mock_reactor):
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
"""Observable-effect tests: pin externally visible outputs and state.
2+
3+
These exist because mutation testing showed the suite exercised edge cases
4+
without ever asserting the correct happy-path payload/state, letting whole
5+
clusters of behavior-changing mutants survive.
6+
"""
7+
8+
import time
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
11+
import pytest
12+
13+
from dccbot.app import IRCBotAPI
14+
from dccbot.ircbot import IRCBot
15+
from dccbot.manager import IRCBotManager
16+
from dccbot.transfers import create_pending_transfer
17+
18+
19+
class TestTransferSnapshotContent:
20+
"""_build_transfer_snapshot must expose every documented field."""
21+
22+
def _api(self, transfers: dict) -> IRCBotAPI:
23+
manager = MagicMock()
24+
manager.transfers = transfers
25+
return IRCBotAPI(config_file="config.json", bot_manager=manager)
26+
27+
def test_snapshot_contains_full_transfer_fields(self):
28+
"""A well-formed transfer produces one snapshot row with all fields."""
29+
transfer = {
30+
"server": "irc.example.com",
31+
"nick": "sender",
32+
"peer_address": "10.1.2.3",
33+
"peer_port": 5000,
34+
"size": 1_000_000,
35+
"bytes_received": 250_000,
36+
"offset": 50,
37+
"start_time": time.time() - 100,
38+
"last_progress_update": time.time() - 10,
39+
"last_progress_bytes_received": 150_000,
40+
"md5": "expected-md5",
41+
"file_md5": "file-md5",
42+
"status": "in_progress",
43+
"connected": True,
44+
}
45+
api = self._api({"file.bin": [transfer]})
46+
47+
snapshot = api._build_transfer_snapshot()
48+
49+
assert len(snapshot) == 1
50+
row = snapshot[0]
51+
assert row["server"] == "irc.example.com"
52+
assert row["filename"] == "file.bin"
53+
assert row["nick"] == "sender"
54+
assert row["host"] == "10.1.2.3:5000"
55+
assert row["size"] == 1_000_000
56+
assert row["received"] == 250_050 # bytes_received + offset
57+
assert row["md5"] == "expected-md5"
58+
assert row["file_md5"] == "file-md5"
59+
assert row["status"] == "in_progress"
60+
assert row["connected"] is True
61+
assert row["resumed"] is True # offset > 0
62+
assert row["error"] is None
63+
assert row["speed"] == pytest.approx(9.77, abs=0.5) # 100 KB over ~10 s
64+
assert row["speed_avg"] == pytest.approx(2.44, abs=0.5) # 250 KB over ~100 s
65+
66+
def test_snapshot_host_empty_without_peer_info(self):
67+
"""Missing peer address/port yields an empty host string."""
68+
api = self._api({"f.bin": [{"server": "s", "nick": "n", "size": 1}]})
69+
70+
row = api._build_transfer_snapshot()[0]
71+
assert row["host"] == ""
72+
73+
def test_snapshot_host_empty_when_port_missing(self):
74+
"""A peer address without a port still yields an empty host."""
75+
api = self._api({"f.bin": [{"server": "s", "nick": "n", "size": 1, "peer_address": "10.0.0.1"}]})
76+
77+
row = api._build_transfer_snapshot()[0]
78+
assert row["host"] == ""
79+
80+
def test_snapshot_iterates_all_files(self):
81+
"""Every file entry in the transfers map appears in the snapshot."""
82+
api = self._api({
83+
"a.bin": [{"server": "s", "nick": "n1", "size": 1}],
84+
"b.bin": [{"server": "s", "nick": "n2", "size": 2}],
85+
})
86+
87+
snapshot = api._build_transfer_snapshot()
88+
89+
assert {r["filename"] for r in snapshot} == {"a.bin", "b.bin"}
90+
assert {r["nick"] for r in snapshot} == {"n1", "n2"}
91+
92+
93+
class TestGetBot:
94+
"""IRCBotManager.get_bot observable behavior."""
95+
96+
def _manager(self, config: dict) -> IRCBotManager:
97+
manager = IRCBotManager.__new__(IRCBotManager)
98+
manager.config = config
99+
manager.bots = {}
100+
return manager
101+
102+
@pytest.mark.asyncio
103+
async def test_creates_and_caches_bot_for_configured_server(self):
104+
"""A configured server yields a connected bot cached under its name."""
105+
manager = self._manager({"servers": {"irc.example.com": {"nick": "b", "channels": []}}})
106+
107+
bot = MagicMock()
108+
bot.connect = AsyncMock()
109+
with patch("dccbot.manager.IRCBot", return_value=bot) as mock_cls:
110+
result = await manager.get_bot("IRC.EXAMPLE.COM")
111+
112+
assert result is bot
113+
assert manager.bots["irc.example.com"] is bot
114+
bot.connect.assert_awaited_once()
115+
args = mock_cls.call_args[0]
116+
assert args[0] == "irc.example.com"
117+
assert args[1] == {"nick": "b", "channels": []}
118+
assert args[2] == "./downloads" # default download path
119+
assert args[4] == 100 * 1024 * 1024 # default max_file_size
120+
assert args[5] is manager
121+
122+
@pytest.mark.asyncio
123+
async def test_falls_back_to_default_server_config(self):
124+
"""An unconfigured server uses default_server_config when present."""
125+
manager = self._manager({
126+
"servers": {},
127+
"default_server_config": {"nick": "d", "channels": ["#x"]},
128+
"default_download_path": "/srv/dl",
129+
})
130+
131+
bot = MagicMock()
132+
bot.connect = AsyncMock()
133+
with patch("dccbot.manager.IRCBot", return_value=bot) as mock_cls:
134+
result = await manager.get_bot("unknown.example.com")
135+
136+
assert result is bot
137+
args = mock_cls.call_args[0]
138+
assert args[1] == {"nick": "d", "channels": ["#x"]}
139+
assert args[2] == "/srv/dl"
140+
141+
@pytest.mark.asyncio
142+
async def test_unknown_server_without_default_raises(self):
143+
"""No server config and no default raises ValueError naming the server."""
144+
manager = self._manager({"servers": {"other": {"nick": "b"}}})
145+
146+
with pytest.raises(ValueError, match="missing.example.com"):
147+
await manager.get_bot("MISSING.EXAMPLE.COM")
148+
149+
assert manager.bots == {}
150+
151+
@pytest.mark.asyncio
152+
async def test_connect_failure_removes_bot_and_reraises(self):
153+
"""A failed connect must not leave a dead bot cached in self.bots."""
154+
manager = self._manager({"servers": {"s": {"nick": "b", "channels": []}}})
155+
156+
bot = MagicMock()
157+
bot.connect = AsyncMock(side_effect=OSError("refused"))
158+
with patch("dccbot.manager.IRCBot", return_value=bot):
159+
with pytest.raises(OSError):
160+
await manager.get_bot("s")
161+
162+
assert manager.bots == {}
163+
164+
@pytest.mark.asyncio
165+
async def test_returns_existing_bot_without_reconnect(self):
166+
"""A cached bot is returned as-is; connect is not called again."""
167+
manager = self._manager({"servers": {"s": {"nick": "b"}}})
168+
existing = MagicMock()
169+
manager.bots["s"] = existing
170+
171+
with patch("dccbot.manager.IRCBot") as mock_cls:
172+
result = await manager.get_bot("S")
173+
174+
assert result is existing
175+
mock_cls.assert_not_called()
176+
existing.connect.assert_not_called()
177+
178+
179+
class TestManagerInitDefaults:
180+
"""Constructor must apply documented config defaults."""
181+
182+
def test_defaults_applied(self, config_file_factory):
183+
manager = IRCBotManager(config_file_factory({"servers": {}}))
184+
185+
assert manager.server_idle_timeout == 1800
186+
assert manager.channel_idle_timeout == 1800
187+
assert manager.resume_timeout == 30
188+
assert manager.transfer_list_timeout == 86400
189+
assert manager.bots == {}
190+
assert manager.transfers == {}
191+
192+
def test_config_overrides_applied(self, config_file_factory):
193+
manager = IRCBotManager(config_file_factory({
194+
"servers": {},
195+
"server_idle_timeout": 60,
196+
"channel_idle_timeout": 120,
197+
"resume_timeout": 5,
198+
"transfer_list_timeout": 3600,
199+
}))
200+
201+
assert manager.server_idle_timeout == 60
202+
assert manager.channel_idle_timeout == 120
203+
assert manager.resume_timeout == 5
204+
assert manager.transfer_list_timeout == 3600
205+
206+
207+
class TestRegisterTransferMerge:
208+
"""_register_transfer merge-window and field-preservation semantics."""
209+
210+
def _bot(self, records: list | None = None) -> IRCBot:
211+
manager = MagicMock()
212+
manager.transfers = {"f.bin": records if records is not None else []}
213+
bot = IRCBot("irc.example.com", {"nick": "b", "channels": []}, "/tmp/dl", None, 1000, manager)
214+
return bot
215+
216+
def _pending(self, **overrides) -> dict:
217+
item = create_pending_transfer("f.bin", "sender", "irc.example.com", md5="m5")
218+
item.update(overrides)
219+
return item
220+
221+
def test_merges_recent_pending_preserving_md5_id_start(self, loop_patch):
222+
"""A pending record within 30s merges, keeping md5/id/start_time."""
223+
now = time.time()
224+
pending = self._pending(start_time=now - 10, id="pid-1", md5="m5")
225+
bot = self._bot([pending])
226+
new_item = {"nick": "sender", "server": "irc.example.com", "peer_address": "10.0.0.1", "peer_port": 5000, "status": "started"}
227+
228+
result = bot._register_transfer("f.bin", new_item, now)
229+
230+
assert result is pending
231+
assert result["peer_address"] == "10.0.0.1"
232+
assert result["md5"] == "m5"
233+
assert result["id"] == "pid-1"
234+
assert result["start_time"] == now - 10
235+
assert len(bot.bot_manager.transfers["f.bin"]) == 1
236+
237+
def test_no_merge_when_pending_expired(self, loop_patch):
238+
"""A pending record older than 30s does not merge; a new entry is appended."""
239+
now = time.time()
240+
pending = self._pending(start_time=now - 60)
241+
bot = self._bot([pending])
242+
new_item = {"nick": "sender", "server": "irc.example.com", "peer_address": "10.0.0.1", "status": "started"}
243+
244+
result = bot._register_transfer("f.bin", new_item, now)
245+
246+
assert result is new_item
247+
assert bot.bot_manager.transfers["f.bin"] == [pending, new_item]
248+
assert "md5" not in new_item
249+
250+
def test_no_merge_for_different_nick_or_server(self, loop_patch):
251+
"""Pending records for a different nick or server are not merged."""
252+
now = time.time()
253+
other = self._pending(nick="other")
254+
bot = self._bot([other])
255+
new_item = {"nick": "sender", "server": "irc.example.com", "peer_address": "10.0.0.1", "status": "started"}
256+
257+
bot._register_transfer("f.bin", new_item, now)
258+
259+
assert bot.bot_manager.transfers["f.bin"] == [other, new_item]
260+
assert other["peer_address"] is None # not merged with the new record
261+
262+
def test_no_merge_when_pending_has_peer(self, loop_patch):
263+
"""An in-flight record (peer_address set) is never treated as pending."""
264+
now = time.time()
265+
active = self._pending(start_time=now - 5, peer_address="10.9.9.9")
266+
bot = self._bot([active])
267+
new_item = {"nick": "sender", "server": "irc.example.com", "peer_address": "10.0.0.1", "status": "started"}
268+
269+
bot._register_transfer("f.bin", new_item, now)
270+
271+
assert bot.bot_manager.transfers["f.bin"] == [active, new_item]
272+
273+
274+
class TestPassiveDccDownloadPath:
275+
"""init_passive_dcc_connection path shaping is externally observable."""
276+
277+
@pytest.mark.asyncio
278+
async def test_incomplete_suffix_appended_to_download_path(self, tmp_path):
279+
"""Without explicit file_path the configured suffix is appended."""
280+
manager = MagicMock()
281+
manager.transfers = {}
282+
manager.config = {"incomplete_suffix": ".part", "passive_dcc_timeout": 0}
283+
bot = IRCBot("irc.example.com", {"nick": "b", "channels": []}, str(tmp_path), None, 1000, manager)
284+
bot.connection = MagicMock()
285+
mock_dcc = MagicMock()
286+
mock_dcc.listen = AsyncMock()
287+
mock_dcc.localaddress = "127.0.0.1"
288+
mock_dcc.localport = 5000
289+
290+
with patch.object(bot, "dcc", return_value=mock_dcc), patch.object(bot.loop, "create_task") as mock_create_task:
291+
bot.init_passive_dcc_connection("sender", "f.bin", 100)
292+
coro = mock_create_task.call_args[0][0]
293+
await coro
294+
295+
transfer = next(iter(bot.current_transfers.values()))
296+
assert transfer["file_path"].endswith("f.bin.part")
297+
298+
@pytest.mark.asyncio
299+
async def test_explicit_file_path_not_suffixed(self, tmp_path):
300+
"""A resume with an explicit file_path keeps that exact path."""
301+
manager = MagicMock()
302+
manager.transfers = {}
303+
manager.config = {"incomplete_suffix": ".part", "passive_dcc_timeout": 0}
304+
bot = IRCBot("irc.example.com", {"nick": "b", "channels": []}, str(tmp_path), None, 1000, manager)
305+
bot.connection = MagicMock()
306+
mock_dcc = MagicMock()
307+
mock_dcc.listen = AsyncMock()
308+
mock_dcc.localaddress = "127.0.0.1"
309+
mock_dcc.localport = 5000
310+
311+
explicit = str(tmp_path / "resume-target.bin")
312+
with patch.object(bot, "dcc", return_value=mock_dcc), patch.object(bot.loop, "create_task") as mock_create_task:
313+
bot.init_passive_dcc_connection("sender", "f.bin", 100, file_path=explicit, offset=10)
314+
coro = mock_create_task.call_args[0][0]
315+
await coro
316+
317+
transfer = next(iter(bot.current_transfers.values()))
318+
assert transfer["file_path"] == explicit

‎tests/unit/test_transfer_handler.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,18 @@ def test_update_progress_computes_percent():
690690
assert transfer["percent"] == 50
691691

692692

693+
def test_update_progress_truncates_at_exact_boundary():
694+
"""Percent must be 100*x/size truncated, not a different scaling factor."""
695+
bot = MagicMock()
696+
handler = TransferHandler(bot)
697+
transfer = _make_transfer(size=400)
698+
transfer["bytes_received"] = 199 # 100*199/400 = 49.75 -> 49; 101*x would give 50
699+
transfer["last_progress_update"] = time.time() - 60
700+
701+
handler._update_progress(transfer)
702+
assert transfer["percent"] == 49
703+
704+
693705
def test_update_progress_throttles_small_jumps():
694706
"""Small progress deltas within 5s must not trigger an update."""
695707
bot = MagicMock()

0 commit comments

Comments
 (0)