Files
sanguo_moziplus_v2/tests/test_inbox.py
T
2026-05-17 05:54:26 +08:00

264 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""F7 Inbox JSONL Watcher 单元测试
按测试计划 test-plan-v2.6.md §F7
- T1: 写入+消费(P0
- T2: truncateP0
- 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"