diff --git a/docs/testing-cleanup.md b/docs/testing-cleanup.md new file mode 100644 index 000000000..5548f4095 --- /dev/null +++ b/docs/testing-cleanup.md @@ -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 覆盖率基线。 diff --git a/docs/testing.md b/docs/testing.md index 6ba9b05ec..509b65ded 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 那一刻,事后还原太晚)。 diff --git a/tests/test_agent_background_output.py b/tests/test_agent_background_output.py index 70683a95e..fa8b7c093 100644 --- a/tests/test_agent_background_output.py +++ b/tests/test_agent_background_output.py @@ -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 diff --git a/tests/test_agent_summarization_streaming.py b/tests/test_agent_summarization_streaming.py index 0b2ac85b5..80dc3e116 100644 --- a/tests/test_agent_summarization_streaming.py +++ b/tests/test_agent_summarization_streaming.py @@ -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 diff --git a/tests/test_fs_proxy_copy.py b/tests/test_fs_proxy_copy.py index 1d9fab2be..85d42d876 100644 --- a/tests/test_fs_proxy_copy.py +++ b/tests/test_fs_proxy_copy.py @@ -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 diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 4000a5045..0e2c4fed6 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -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() diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index d0224f1cc..7dde481ea 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -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]") diff --git a/tests/test_plugin_database_lifecycle.py b/tests/test_plugin_database_lifecycle.py index 06749ae20..0db78c28d 100644 --- a/tests/test_plugin_database_lifecycle.py +++ b/tests/test_plugin_database_lifecycle.py @@ -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] = []