test: 清理重复单元测试并记录审计依据

This commit is contained in:
jxxghp
2026-09-06 07:58:07 +08:00
parent be41517520
commit 49414ffad2
8 changed files with 70 additions and 132 deletions
+55
View File
@@ -0,0 +1,55 @@
# 后端测试清理审计
## 范围与方法
- 日期:2026-09-06。
- 基准:`v3``976cecce8f72291516be74419cfc23922e99eef0`,开始工作时本地与 `origin/v3` 一致。
-`tests/test_*.py` 进行 AST 静态筛查,共 739 个文件、7,311 个测试函数/方法定义;该数字不是参数化展开后的 pytest 用例数。
- 筛查空执行体、同名覆盖、完全重复执行体、重复参数化输入、无直接断言和语句包含关系,再人工核对入口、fixture、输入、断言与状态差异。
- 未发现纯空测试或重复参数化输入。静态相似性只用于找候选,不是自动删除规则;本轮不声称所有测试均已完成逐条业务审计。
## 已清理
仅删除以下 6 个冗余测试定义,保留全部独立断言和被测入口。生产代码、公共测试设施、数据库迁移、兼容清单和质量门槛均不修改。
| 文件 | 删除用例 | 保留覆盖及依据 |
| --- | --- | --- |
| `tests/test_agent_background_output.py` | `test_background_non_streaming_does_not_send_by_default` | 同类的 `test_background_non_streaming_captures_without_sending_when_capture_only` 执行体完全相同。两者都显式设置 `ReplyMode.CAPTURE_ONLY`,旧名称实际没有验证默认值。 |
| `tests/test_agent_summarization_streaming.py` | `test_real_agent_does_not_recompact_small_tool_result_during_same_loop` | `test_real_agent_executes_compacted_tool_call_once` 使用相同真实 graph、模型响应及工具,已同时断言工具执行一次、摘要一次、模型调用两次和最终结果。合并说明以保留两种回归意图。 |
| `tests/test_fs_proxy_copy.py` | `test_worker_still_standalone_after_streaming_support` | `tests/test_fs_proxy.py::test_worker_does_not_import_app_package` 对同一 worker 源码做完全相同的断言;同文件还保留独立子进程启动验证。删除后顺带移除不再使用的 `Path` 导入。 |
| `tests/test_media_interaction.py` | `test_noai_prefix_preserves_traditional_interaction_priority_after_search` | `test_message_routes_text_reply_to_media_interaction_before_ai` 执行体完全相同。删除用例并未发送 `/noai`,只构造同一会话后发送 `1`;实际 `/noai` 创建会话的专项测试保留。 |
| `tests/test_plugin_database_lifecycle.py` | `test_remove_plugin_only_releases_the_database` | `test_stop_releases_the_database_and_never_destroys_it` 执行体完全相同,都只调用 `lifecycle.stop`。真正的卸载、删除数据及分身库销毁测试保留。 |
| `tests/test_metamusic.py` | `test_apply_title_keeps_single_word_title` | `test_apply_title_keeps_artist_abbreviation_dots` 使用相同输入 `E.S.Posthumus - Maraboot`,已有完全相同的曲名断言并额外断言艺术家。合并说明以保留单词曲名不应被当作发布组的回归意图。 |
## 未机械删除
- 架构、模块/事件合同、SDK/旧导入 ABI、数据库迁移测试:它们约束当前仍需支持的入口或升级路径,不是历史重构完成后即可删除的临时测试。
- `test_media_interaction.py` 的过期敏感输入测试:直接过期与被另一用户触发清理后的迟到输入属于不同状态,不能因后者包含前者的语句就删掉前者。
- `test_transfer_job_manager.py` 的相同元数据任务测试:是否调用 `migrate_task` 会改变作业关联状态,两条路径分别保留。
- `test_chain_external_ports.py` 的未装配与重复初始化/重置测试:即使最终都断言端口未配置,之前是否装配过的状态不同。
- `test_security_utils.py` 的公网 DNS 允许与异步缓存测试:保留独立同步安全决策与跨入口缓存契约,不以覆盖率重叠替代行为判断。
- PostgreSQL、平台和 Rust 条件跳过测试:条件代表受支持的运行环境,不能把本机未执行误判为失效。
- 无直接断言候选中,迁移验证、辅助断言、兼容导入和“不抛异常”测试仍有契约意义;不批量删除。
- `test_agent_image_support.py` 的显式关闭图片能力用例与布尔配置用例存在重叠,但属于存量 `TestCase` 文件。本轮不为删除一个分支用例触发整文件转换,后续修改该文件时按“改到即转”一起合并。
## 验证
所有命令均使用项目 `.venv` 对应的 `uv run --locked --no-sync` 环境。
| 验证 | 结果 |
| --- | --- |
| Agent 输出/压缩、文件代理、媒体交互、插件数据库生命周期,含保留 worker 用例的 6 个文件 | 清理前 160 passed,清理后 155 passed |
| `test_metamusic.py` | 清理前 86 passed,清理后 85 passed |
| 第一组生产代码覆盖 | 前后逐文件 `executed_lines``executed_branches` 集合完全一致,均覆盖 35,597 行、1,228 条分支 |
| 音乐标题组生产代码覆盖 | 前后逐文件集合完全一致,均覆盖 23,538 行、325 条分支 |
| 保留用例 AST 复核 | 排除 docstring 后,全部保留用例的函数定义一致;仅删除表中 6 个定义 |
| 清理后重复扫描 | 7,305 个测试定义,按本轮 AST 规则未再发现完全重复的执行体 |
| `python tests/run.py -q --durations=10` | 4 个分片全部通过,合计 8,437 passed、9 skipped |
| 改动 Python 文件 Pylint | 10.00/10,无诊断 |
| `git diff --check` | 通过 |
提交前已快进同步到 `be415175208c2f7a3c1ebf556cc80c72cf322e10`,上游音乐修复未触及本次清理文件。重新运行清理涉及的 7 个测试文件及上游音乐相关的 5 个测试文件,结果为 539 passed;上表的扫描数量和覆盖率仍对应审计基准,不混用不同提交的测量结果。
覆盖采集前后都有 `sysmon` 不支持动态 contexts 的提示,因此这里只比较汇总行/分支集合,不宣称逐用例 context 数据完整。全量运行结束阶段出现子进程/文件句柄的 `ResourceWarning`,不影响退出码;提示中的子进程在检查时已退出。本轮没有为消除警告修改公共测试设施。
覆盖率对比是辅助证据,删除决定仍以上述相同输入、状态和断言映射为准。本机为 macOS/Python 3.14,不写入 Ubuntu canonical 覆盖率基线。
+11
View File
@@ -123,6 +123,17 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
| `setUpClass` / `tearDownClass` | `@pytest.fixture(scope="class")` 或模块级 fixture |
| `@unittest.skipIf(c, r)` | `@pytest.mark.skipif(c, reason=r)` |
## 存量测试清理
删除测试需要明确指出保留的等价覆盖,不能只依据文件年代、执行耗时、名称相似或覆盖率百分比。
- **可删除的重复**:被测入口、输入、fixture、参数化和执行前状态一致,且保留用例包含全部断言。完全相同的执行体可保留一份;同输入的断言子集可并入更完整用例的说明。
- **不能自动删除的相似用例**:同步与异步入口、冷启动与重复重置、过期清理前后、迁移前后、不同平台或可选依赖条件,均可能拥有独立行为。
- **没有直接 `assert` 不等于空测试**`pytest.raises`、辅助断言、迁移 round-trip、导入兼容和“不抛异常”都是有效契约;只有名称或注释声称存在的覆盖则需要进一步核实。
- **删除后验证**:运行受影响文件,核对保留用例及生产代码行/分支覆盖;跨领域清理再运行 `python tests/run.py`。不降低覆盖率基线、不新增 skip、不削弱架构或兼容门禁。
首轮扫描范围、删除映射和保留理由见 [后端测试清理审计](testing-cleanup.md)。
## 排查测试问题
- **收集报错(collection error**:多为 import 期副作用或顶层桩污染。优先改成真实 import(conftest 已隔离临时库,真实 `settings`/helper 可加载)+ 方法内 patch,而不是靠事后还原(收集期污染发生在 import 那一刻,事后还原太晚)。
+2 -22
View File
@@ -81,28 +81,7 @@ def _fake_activity_log_middleware(tool=None):
class TestAgentBackgroundOutput:
async def test_background_non_streaming_does_not_send_by_default(self):
agent = MoviePilotAgent(session_id="bg-test", user_id="system")
agent.channel = None
agent.source = None
agent.reply_mode = ReplyMode.CAPTURE_ONLY
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
with patch.object(memory_manager, "save_agent_messages") as save_messages:
await agent._execute_agent([])
agent.send_agent_message.assert_not_awaited()
save_messages.assert_not_called()
assert agent._streamed_output == "后台结果"
"""验证后台任务的回复策略、流式结束行为和工具装配。"""
async def test_non_streaming_image_unsupported_error_sends_friendly_notice(self):
agent = MoviePilotAgent(session_id="image-test", user_id="user-1")
@@ -322,6 +301,7 @@ class TestAgentBackgroundOutput:
assert agent._streamed_output == "后台结果"
async def test_background_non_streaming_captures_without_sending_when_capture_only(self):
"""显式捕获模式保留最终结果,但不发送消息或保存渠道历史。"""
agent = MoviePilotAgent(session_id="bg-test", user_id="system")
agent.channel = None
agent.source = None
+1 -36
View File
@@ -817,42 +817,7 @@ def test_real_agent_does_not_compact_request_below_threshold():
def test_real_agent_executes_compacted_tool_call_once():
"""压缩不得重试主模型或重复执行工具事务"""
calls = []
@tool
def record_value(value: str) -> str:
"""记录工具调用次数。"""
calls.append(value)
return value
summarizer = _CountingSummaryLLM("summary")
model = _RecordingChatModel(
responses=[
AIMessage(
content="",
tool_calls=[{"name": "record_value", "args": {"value": "once"}, "id": "call-1"}],
),
AIMessage(content="工具完成"),
],
profile={"max_input_tokens": 2048},
)
graph = _real_compaction_graph(
model=model,
summarizer=summarizer,
tools=[record_value],
)
result = asyncio.run(graph.ainvoke({"messages": _oversized_final_request_history()}))
assert calls == ["once"]
assert summarizer.calls == 1
assert len(model.seen_messages) == 2
assert result["messages"][-1].content == "工具完成"
def test_real_agent_does_not_recompact_small_tool_result_during_same_loop():
"""小工具结果不会让同一轮请求重新压缩。"""
"""压缩只执行一次工具事务,小工具结果也不得触发同轮二次压缩"""
calls = []
@tool
-12
View File
@@ -10,7 +10,6 @@ worker 每秒上报一次进度作为心跳,父进程判定的是**两次上
"""
import os
import time
from pathlib import Path
import pytest
@@ -189,14 +188,3 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
assert proxy.copy(src, dst, cancel_cb=lambda: True, chunk_size=1024) is False
finally:
proxy.close()
def test_worker_still_standalone_after_streaming_support():
"""
加了流式协议之后 worker 仍须只依赖标准库——一旦引入 app 导入链,
强杀后的重启成本会从毫秒级涨到秒级,整个代理方案就不成立了。
"""
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker
-37
View File
@@ -1589,43 +1589,6 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled():
assert len(request.items) == 2
def test_noai_prefix_preserves_traditional_interaction_priority_after_search():
"""通过 /noai 进入传统交互后,后续选择应继续优先走传统交互。"""
chain = MessageChain()
chain.runtime_config = replace(
chain.runtime_config,
ai_agent_enable=True,
ai_agent_global=True,
)
request = media_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Wechat,
source="wechat-test",
username="tester",
action="Search",
keyword="星际穿越",
title="星际穿越",
meta=_build_meta("星际穿越"),
items=[MediaInfo(title="星际穿越", year="2014")],
)
assert request is not None
with patch.object(chain, "_record_user_message"), patch(
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
channel=NotificationChannel.Wechat,
source="wechat-test",
userid="10001",
username="tester",
text="1",
)
handle_text.assert_called_once()
handle_ai.assert_not_called()
def test_callback_routes_to_media_interaction_chain():
"""媒体按钮回调应路由到媒体交互链。"""
chain = MessageChain()
+1 -8
View File
@@ -835,7 +835,7 @@ def test_apply_title_scene_dot_with_symbols():
def test_apply_title_keeps_artist_abbreviation_dots():
"""点分隔少于 3 处的艺术家缩写点号不应被归一"""
"""保留艺术家缩写点号,无音质标记的单词曲名也不能被当成发布组剔除"""
meta = parse_title("E.S.Posthumus - Maraboot")
assert meta.artists == ["E.S.Posthumus"]
@@ -862,13 +862,6 @@ def test_apply_title_va_scene_prefix():
assert meta.year == 2019
def test_apply_title_keeps_single_word_title():
"""无音质标记时「艺术家 - 单词曲名」的曲名不应被当发布组标签剔除。"""
meta = parse_title("E.S.Posthumus - Maraboot")
assert meta.title == "Maraboot"
def test_apply_title_keeps_title_with_quality_tokens():
"""存在音质标记时,空格连字符后的单词曲名也不应被当发布组标签剔除。"""
meta = parse_title("Yes - Aurora [Bonus Tracks Edition, 24-bit Hi-Res] (2026) [FLAC]")
-17
View File
@@ -330,23 +330,6 @@ def test_clone_uninstall_destroys_only_the_clone_database():
assert destroyed == ["DemoPluginwork"]
def test_remove_plugin_only_releases_the_database():
"""从内存移除插件的内部路径即 stop,只释放不销毁。"""
calls: list[tuple] = []
plugin_cls = _make_plugin_class("DemoPlugin")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
lifecycle.start("DemoPlugin")
lifecycle.stop("DemoPlugin")
assert ("release", "DemoPlugin") in calls
assert not any(call[0] == "destroy" for call in calls)
def test_close_database_releases_plugin_databases_before_the_host_engine(monkeypatch):
"""进程关停时先释放插件库,再释放宿主同步引擎。"""
calls: list[str] = []