"""F7 Inbox JSONL Watcher 单元测试 按测试计划 test-plan-v2.6.md §F7: - T1: 写入+消费(P0) - T2: truncate(P0) - T3: 并发写入(P0) - T4: 损坏行恢复(P1) - T5: 空文件处理(P1) """ import asyncio import json import threading import pytest from pathlib import Path from src.daemon.inbox import InboxWatcher @pytest.fixture def inbox_path(tmp_path): return tmp_path / "inbox" / "daemon.jsonl" @pytest.fixture def watcher(inbox_path): return InboxWatcher(inbox_path, watch_interval=0.05) # --------------------------------------------------------------------------- # T1: 写入+消费 # --------------------------------------------------------------------------- class TestWriteConsume: def test_single_event(self, inbox_path): """写入单条事件 → 消费到""" InboxWatcher.write_event(inbox_path, {"type": "task_done", "task_id": "t1", "agent": "a"}) watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 1 assert events[0]["type"] == "task_done" assert events[0]["task_id"] == "t1" def test_multiple_events(self, inbox_path): """写入多条事件 → 全部消费到""" for i in range(5): InboxWatcher.write_event(inbox_path, {"type": "progress", "task_id": f"t{i}", "pct": i * 20}) watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 5 assert events[0]["task_id"] == "t0" assert events[4]["task_id"] == "t4" def test_consume_increments_counter(self, inbox_path): """total_processed 递增""" watcher = InboxWatcher(inbox_path) assert watcher.total_processed == 0 InboxWatcher.write_event(inbox_path, {"type": "test"}) asyncio.run(watcher.poll()) assert watcher.total_processed == 1 InboxWatcher.write_event(inbox_path, {"type": "test2"}) InboxWatcher.write_event(inbox_path, {"type": "test3"}) asyncio.run(watcher.poll()) assert watcher.total_processed == 3 def test_consume_no_file_returns_empty(self, inbox_path): """文件不存在时返回空列表""" watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert events == [] def test_batch_write(self, inbox_path): """批量写入""" evts = [{"type": "batch", "seq": i} for i in range(10)] InboxWatcher.write_events(inbox_path, evts) watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 10 # --------------------------------------------------------------------------- # T2: truncate # --------------------------------------------------------------------------- class TestTruncate: def test_file_cleared_after_consume(self, inbox_path): """消费后文件被清空""" InboxWatcher.write_event(inbox_path, {"type": "test"}) assert inbox_path.exists() assert inbox_path.stat().st_size > 0 watcher = InboxWatcher(inbox_path) asyncio.run(watcher.poll()) # 文件存在但为空 assert inbox_path.exists() assert inbox_path.stat().st_size == 0 def test_second_consume_returns_empty(self, inbox_path): """二次消费无新事件""" InboxWatcher.write_event(inbox_path, {"type": "test"}) watcher = InboxWatcher(inbox_path) events1 = asyncio.run(watcher.poll()) assert len(events1) == 1 events2 = asyncio.run(watcher.poll()) assert len(events2) == 0 def test_write_after_truncate(self, inbox_path): """truncate 后可以继续写入""" InboxWatcher.write_event(inbox_path, {"type": "first"}) watcher = InboxWatcher(inbox_path) asyncio.run(watcher.poll()) InboxWatcher.write_event(inbox_path, {"type": "second"}) events = asyncio.run(watcher.poll()) assert len(events) == 1 assert events[0]["type"] == "second" # --------------------------------------------------------------------------- # T3: 并发写入 # --------------------------------------------------------------------------- class TestConcurrentWrite: def test_multi_agent_concurrent(self, inbox_path): """多 Agent 同时写入不同事件,全部消费到""" n_agents = 5 n_events_per_agent = 10 barrier = threading.Barrier(n_agents) def writer(agent_id): barrier.wait() for i in range(n_events_per_agent): InboxWatcher.write_event(inbox_path, { "agent": agent_id, "seq": i, }) threads = [threading.Thread(target=writer, args=(f"agent-{j}",)) for j in range(n_agents)] for t in threads: t.start() for t in threads: t.join(timeout=5) watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == n_agents * n_events_per_agent # 每个 agent 的事件都到齐 agents_seen = {e["agent"] for e in events} assert len(agents_seen) == n_agents # --------------------------------------------------------------------------- # T4: 损坏行恢复(P1) # --------------------------------------------------------------------------- class TestBadLineRecovery: def test_skip_invalid_json(self, inbox_path): """非法 JSON 行跳过不崩溃""" inbox_path.parent.mkdir(parents=True, exist_ok=True) with open(inbox_path, "w") as f: f.write('{"type": "good"}\n') f.write('not json at all\n') f.write('{"type": "also_good"}\n') f.write('{broken json\n') watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 2 assert events[0]["type"] == "good" assert events[1]["type"] == "also_good" def test_non_dict_json_skipped(self, inbox_path): """合法 JSON 但非 dict 也跳过""" inbox_path.parent.mkdir(parents=True, exist_ok=True) with open(inbox_path, "w") as f: f.write('{"type": "good"}\n') f.write('[1, 2, 3]\n') f.write('"just a string"\n') f.write('42\n') watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 1 assert events[0]["type"] == "good" def test_empty_lines_skipped(self, inbox_path): """空行不影响""" inbox_path.parent.mkdir(parents=True, exist_ok=True) with open(inbox_path, "w") as f: f.write('\n\n{"type": "good"}\n\n\n') watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert len(events) == 1 # --------------------------------------------------------------------------- # T5: 空文件处理(P1) # --------------------------------------------------------------------------- class TestEmptyFile: def test_empty_file_no_events(self, inbox_path): """空文件返回空列表""" inbox_path.parent.mkdir(parents=True, exist_ok=True) inbox_path.touch() watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert events == [] def test_whitespace_only_file(self, inbox_path): """只有空白的文件返回空列表""" inbox_path.parent.mkdir(parents=True, exist_ok=True) inbox_path.write_text(" \n \n \n") watcher = InboxWatcher(inbox_path) events = asyncio.run(watcher.poll()) assert events == [] # --------------------------------------------------------------------------- # Watcher 生命周期 # --------------------------------------------------------------------------- class TestWatcherLifecycle: def test_start_stop(self, watcher, inbox_path): """可以启动和停止""" async def run(): await watcher.start() assert watcher.is_running await asyncio.sleep(0.2) await watcher.stop() assert not watcher.is_running asyncio.run(run()) def test_auto_consume_via_callback(self, inbox_path): """watcher 循环自动消费并通过回调传递事件""" collected = [] async def on_event(event): collected.append(event) watcher = InboxWatcher(inbox_path, process_callback=on_event, watch_interval=0.05) async def run(): await watcher.start() # 写入事件 InboxWatcher.write_event(inbox_path, {"type": "auto"}) await asyncio.sleep(0.3) # 等待 watcher 消费 await watcher.stop() asyncio.run(run()) assert len(collected) >= 1 assert collected[0]["type"] == "auto"