"""架构与启动性能基线脚本的 CLI 行为测试。""" import json from pathlib import Path import pytest from scripts.architecture import baseline as architecture_baseline from scripts.startup import performance as startup_performance def _performance_sample( *, loaded_app_module_count: int = 5, ) -> dict: """构造区分环境诊断值与稳定宿主导入契约的性能样本。""" return { "schema_version": 2, "generated_at": "2026-08-21T00:00:00+00:00", "platform": "test", "python": "3.12", "repeat": 1, "targets": { "app.factory": { "loaded_app_module_count": loaded_app_module_count, "max_ms": 100.0, "median_ms": 90.0, "min_ms": 80.0, "samples_ms": [90.0], } }, "lifecycle": { "scope": "isolated no-op", "modes": { "normal": { "enabled_component_count": 2, "enabled_components": ["后台任务登记器", "路由"], "samples": [ { "threads_before": 1, "threads_after": 1, "tasks_before": 1, "tasks_after": 1, "database_connections_started": 0, } ], } }, }, } def _transaction_sample(methods: list[dict[str, str]]) -> dict: """构造最小事务债务 fixture,供单向 ratchet 行为测试。""" return { "schema_version": 2, "scope": "app/db/models and app/db/oper transaction ownership debt", "model_decorators": { "count": len(methods), "by_kind": { "async_db_query": 0, "async_db_update": 0, "db_query": 0, "db_update": len(methods), }, "methods": methods, }, "model_transaction_calls": {"count": 0, "calls": []}, "model_session_factories": {"count": 0, "calls": []}, "oper_transaction_calls": {"count": 0, "calls": []}, "oper_session_factories": {"count": 0, "calls": []}, } def _configuration_sample( settings_files: list[str], oper_calls: list[dict[str, str]], foundational_entries: list[dict[str, str]] | None = None, composition_entries: list[dict[str, str]] | None = None, ) -> dict: """构造最小配置债务 fixture,供单向 ratchet 行为测试。""" foundational_entries = foundational_entries or [] composition_entries = composition_entries or [] return { "schema_version": 1, "scope": { "root": "app", "excluded": ["app/plugins", "app/sdk", "app/runtime/compat"], }, "settings_imports": { "count": len(settings_files), "files": settings_files, }, "system_config_oper_constructions": { "count": len(oper_calls), "calls": oper_calls, }, "foundational_settings_boundaries": { "count": len(foundational_entries), "entries": foundational_entries, }, "composition_root_oper_boundaries": { "count": len(composition_entries), "entries": composition_entries, }, } def test_architecture_legacy_action_requires_scope(capsys): """旧操作未明确宿主或插件范围时必须拒绝执行。""" with pytest.raises(SystemExit) as error: architecture_baseline.parse_args(["--check"]) assert error.value.code == 2 assert "必须同时指定 --scope" in capsys.readouterr().err def test_architecture_legacy_action_maps_to_scoped_operation(capsys): """兼容期旧参数应提示弃用并映射到唯一范围。""" args = architecture_baseline.parse_args(["--check", "--scope", "host"]) assert args.check_host is True assert "请改用 --check-host" in capsys.readouterr().err def test_architecture_plugin_action_requires_repository(capsys): """插件基线操作缺少独立插件仓时必须在扫描前失败。""" with pytest.raises(SystemExit) as error: architecture_baseline.parse_args(["--check-plugins"]) assert error.value.code == 2 assert "必须指定 --plugin-repo" in capsys.readouterr().err def test_architecture_diagnostics_only_support_host_check(capsys): """源码位置诊断不得与写操作或插件扫描混用。""" with pytest.raises(SystemExit) as error: architecture_baseline.parse_args(["--write-host", "--diagnostics"]) assert error.value.code == 2 assert "只能与 --check-host" in capsys.readouterr().err def test_architecture_report_only_supports_check_operations(capsys, tmp_path: Path): """审查报告不能与 fixture 写操作混用。""" with pytest.raises(SystemExit) as error: architecture_baseline.parse_args( ["--write-host", "--report", str(tmp_path / "report.json")] ) assert error.value.code == 2 assert "只能与检查操作" in capsys.readouterr().err def test_runtime_semantics_ignore_line_changes_but_keep_call_count(tmp_path: Path): """旧 fixture 的行号变化不影响门禁,重复调用次数仍属于语义。""" baseline_path = tmp_path / "runtime-contract-baseline.json" old_value = { "schema_version": 1, "run_module": { "method_count": 1, "call_count": 2, "dynamic_call_count": 0, "methods": { "search": [ {"caller": "app.chain.search", "line": 10, "mode": "sync"}, {"caller": "app.chain.search", "line": 11, "mode": "sync"}, ] }, "dynamic_calls": [], }, "events": { "event_count": 1, "producer_count": 1, "consumer_count": 0, "events": { "EventType.NoticeMessage": { "producers": [{"caller": "app.chain.message", "line": 20}], "consumers": [], } }, "dynamic_producers": [], "dynamic_consumers": [], }, "sdk_exports": {}, "compat_manifest": {}, } moved_value = json.loads(json.dumps(old_value)) moved_value["run_module"]["methods"]["search"][0]["line"] = 1000 moved_value["run_module"]["methods"]["search"][1]["line"] = 1001 moved_value["events"]["events"]["EventType.NoticeMessage"]["producers"][0][ "line" ] = 2000 old_semantic = architecture_baseline.semantic_baseline(baseline_path, old_value) moved_semantic = architecture_baseline.semantic_baseline( baseline_path, moved_value, ) assert old_semantic == moved_semantic assert old_semantic["run_module"]["methods"]["search"] == [ {"caller": "app.chain.search", "mode": "sync", "count": 2} ] def test_plugin_provenance_does_not_participate_in_semantic_gate(tmp_path: Path): """插件仓 revision 和源码摘要变化不应伪装成 ABI 变化。""" baseline_path = tmp_path / "official-plugin-baseline.json" first = { "schema_version": 3, "scope": {"repository": "MoviePilot-Plugins", "roots": ["plugins.v3"]}, "provenance": { "head": "a" * 40, "python_file_count": 1, "source_sha256": "a" * 64, }, "imports": {}, "hooks": {}, "api_routes": {}, } second = json.loads(json.dumps(first)) second["provenance"] = { "head": "b" * 40, "python_file_count": 2, "source_sha256": "b" * 64, } assert architecture_baseline.semantic_baseline( baseline_path, first, ) == architecture_baseline.semantic_baseline(baseline_path, second) def test_collect_sdk_exports_uses_explicit_all_and_resolves_aliases( tmp_path: Path, monkeypatch, ) -> None: """SDK 快照只记录 __all__,并把顶层赋值恢复为真实导出目标。""" sdk_root = tmp_path / "app" / "sdk" sdk_root.mkdir(parents=True) (sdk_root / "demo.py").write_text( "from __future__ import annotations\n" "from typing import Any\n" "from app.foundation.crypto import CryptoJsUtils\n\n" "encrypt = CryptoJsUtils.encrypt\n\n" "def launch():\n" " return None\n\n" "def accidental():\n" " return None\n\n" "__all__ = ['CryptoJsUtils', 'encrypt', 'launch']\n", encoding="utf-8", ) monkeypatch.setattr(architecture_baseline, "APP_ROOT", tmp_path / "app") exports = architecture_baseline.collect_sdk_exports()["app.sdk.demo"] assert exports == [ { "name": "CryptoJsUtils", "kind": "import", "target": "app.foundation.crypto.CryptoJsUtils", }, { "name": "encrypt", "kind": "alias", "target": "app.foundation.crypto.CryptoJsUtils.encrypt", }, {"name": "launch", "kind": "FunctionDef", "target": ""}, ] def test_collect_sdk_exports_treats_missing_all_as_no_public_contract( tmp_path: Path, monkeypatch, ) -> None: """未显式声明 __all__ 的 SDK 模块不得泄漏实现期导入。""" sdk_root = tmp_path / "app" / "sdk" sdk_root.mkdir(parents=True) (sdk_root / "internal.py").write_text( "from typing import Any\n\n" "def helper():\n" " return Any\n", encoding="utf-8", ) monkeypatch.setattr(architecture_baseline, "APP_ROOT", tmp_path / "app") assert architecture_baseline.collect_sdk_exports() == {"app.sdk.internal": []} def test_official_plugin_baseline_includes_effective_v3_default_fallback( tmp_path: Path, ) -> None: """V3 插件快照应补入专用索引未接管的 package.json 兼容实现。""" plugin_repo = tmp_path / "MoviePilot-Plugins" sources = { "plugins.v3/current/__init__.py": "from app.sdk.events import Event\n", "plugins.v2/legacy/__init__.py": "from app.core.event import Event\n", "plugins/shared/__init__.py": "from app.log import logger\n", "plugins/current/__init__.py": "from app.unused.current import Value\n", "plugins/legacy/__init__.py": "from app.unused.legacy import Value\n", "plugins/rejected/__init__.py": "from app.unused.rejected import Value\n", } for relative, source in sources.items(): path = plugin_repo / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(source, encoding="utf-8") (plugin_repo / "package.v3.json").write_text( json.dumps({"Current": {"version": "3.0.0"}}), encoding="utf-8" ) (plugin_repo / "package.v2.json").write_text( json.dumps({"Legacy": {"version": "2.0.0"}}), encoding="utf-8" ) (plugin_repo / "package.json").write_text( json.dumps( { "Current": {"v2": True}, "Legacy": {"v2": True}, "Shared": {"v2": True}, "Rejected": {"v2": True, "v3": False}, } ), encoding="utf-8", ) baseline = architecture_baseline.collect_official_plugin_baseline(plugin_repo) assert baseline["scope"] == { "repository": "MoviePilot-Plugins", "roots": ["plugins.v2", "plugins.v3", "plugins"], "default_plugins": ["Shared"], } assert baseline["provenance"]["python_file_count"] == 3 assert baseline["imports"]["app.log"]["files"] == [ "plugins/shared/__init__.py" ] assert not any(module.startswith("app.unused") for module in baseline["imports"]) def test_plugin_v2_fixture_migrates_before_semantic_comparison(tmp_path: Path): """旧插件 fixture 应在内存中迁移,不能因 schema 排列变化误报 ABI。""" baseline_path = tmp_path / "official-plugin-baseline.json" old_value = { "schema_version": 2, "source": { "repository": "MoviePilot-Plugins", "head": "a" * 40, "roots": ["plugins.v2", "plugins.v3"], "python_file_count": 1, "source_sha256": "a" * 64, }, "imports": {"app.sdk.logging": {"file_count": 1, "files": ["x.py"]}}, "hooks": {}, "api_routes": {}, } new_value = { "schema_version": 3, "scope": { "repository": "MoviePilot-Plugins", "roots": ["plugins.v2", "plugins.v3"], }, "provenance": { "head": "b" * 40, "python_file_count": 2, "source_sha256": "b" * 64, }, "imports": old_value["imports"], "hooks": {}, "api_routes": {}, } assert architecture_baseline.semantic_baseline( baseline_path, old_value, ) == architecture_baseline.semantic_baseline(baseline_path, new_value) def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None: """事务债务低水位允许下降,替换或新增 Model 自动事务仍必须失败。""" first = { "decorator": "db_update", "file": "app/db/models/demo.py", "method": "Demo.save", } second = { "decorator": "db_update", "file": "app/db/models/demo.py", "method": "Demo.delete", } expected = _transaction_sample([first]) assert architecture_baseline.transaction_ratchet_matches( expected, _transaction_sample([]), ) assert not architecture_baseline.transaction_ratchet_matches( expected, _transaction_sample([first, second]), ) assert not architecture_baseline.transaction_ratchet_matches( expected, _transaction_sample([second]), ) def test_configuration_ratchet_allows_removal_but_rejects_new_access() -> None: """配置债务低水位允许下降,但新增或换位置的直接访问必须失败。""" existing_call = {"file": "app/startup/demo.py", "name": "SystemConfigOper"} new_call = {"file": "app/application/demo.py", "name": "SystemConfigOper"} expected = _configuration_sample(["app/application/old.py"], [existing_call]) assert architecture_baseline.configuration_ratchet_matches( expected, _configuration_sample([], []), ) assert not architecture_baseline.configuration_ratchet_matches( expected, _configuration_sample( ["app/application/old.py", "app/application/new.py"], [existing_call], ), ) assert not architecture_baseline.configuration_ratchet_matches( expected, _configuration_sample(["app/application/old.py"], [new_call]), ) def test_configuration_ratchet_rejects_new_approved_boundaries() -> None: """批准边界同样只能减少,不能借新增理由把新债务改名为豁免。""" existing = {"file": "app/db/base.py", "reason": "model declaration"} added = {"file": "app/db/new.py", "reason": "new exemption"} expected = _configuration_sample([], [], [existing]) assert architecture_baseline.configuration_ratchet_matches( expected, _configuration_sample([], [], []), ) assert not architecture_baseline.configuration_ratchet_matches( expected, _configuration_sample([], [], [existing, added]), ) def test_architecture_write_host_only_updates_host_files( tmp_path: Path, monkeypatch, capsys, ): """宿主写操作不得连带覆盖官方插件 fixture。""" dependency_path = tmp_path / "dependency.json" runtime_path = tmp_path / "runtime.json" transaction_path = tmp_path / "transaction.json" plugin_path = tmp_path / "plugin.json" monkeypatch.setattr( architecture_baseline, "DEPENDENCY_BASELINE_PATH", dependency_path, ) monkeypatch.setattr( architecture_baseline, "RUNTIME_BASELINE_PATH", runtime_path, ) monkeypatch.setattr( architecture_baseline, "TRANSACTION_BASELINE_PATH", transaction_path, ) monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path) monkeypatch.setattr( architecture_baseline, "collect_dependency_baseline", lambda: {"scope": "host-dependency"}, ) monkeypatch.setattr( architecture_baseline, "collect_runtime_baseline", lambda: {"scope": "host-runtime"}, ) monkeypatch.setattr( architecture_baseline, "collect_transaction_debt_baseline", lambda: {"scope": "host-transaction"}, ) assert architecture_baseline.main(["--write-host"]) == 0 assert json.loads(dependency_path.read_text()) == {"scope": "host-dependency"} assert json.loads(runtime_path.read_text()) == {"scope": "host-runtime"} assert json.loads(transaction_path.read_text()) == { "scope": "host-transaction" } assert not plugin_path.exists() output = capsys.readouterr().out assert "即将写入" in output assert "dependency.json" in output assert "runtime.json" in output assert "transaction.json" in output def test_architecture_write_plugins_only_updates_plugin_file( tmp_path: Path, monkeypatch, ): """插件写操作不得修改宿主依赖和运行契约 fixture。""" plugin_repo = tmp_path / "MoviePilot-Plugins" (plugin_repo / "plugins.v2").mkdir(parents=True) (plugin_repo / "plugins.v3").mkdir() dependency_path = tmp_path / "dependency.json" runtime_path = tmp_path / "runtime.json" transaction_path = tmp_path / "transaction.json" plugin_path = tmp_path / "plugin.json" monkeypatch.setattr( architecture_baseline, "DEPENDENCY_BASELINE_PATH", dependency_path, ) monkeypatch.setattr( architecture_baseline, "RUNTIME_BASELINE_PATH", runtime_path, ) monkeypatch.setattr( architecture_baseline, "TRANSACTION_BASELINE_PATH", transaction_path, ) monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path) assert architecture_baseline.main( ["--write-plugins", "--plugin-repo", str(plugin_repo)] ) == 0 assert plugin_path.is_file() assert not dependency_path.exists() assert not runtime_path.exists() assert not transaction_path.exists() def test_architecture_plugin_check_writes_review_report_only_when_requested( tmp_path: Path, monkeypatch, ): """跨仓检查失败时应产出语义差异报告,并保持 fixture 不变。""" plugin_repo = tmp_path / "MoviePilot-Plugins" plugin_repo.mkdir() baseline_path = tmp_path / "official-plugin-baseline.json" report_path = tmp_path / "report.json" expected = { "schema_version": 3, "scope": {"repository": "MoviePilot-Plugins", "roots": ["plugins.v3"]}, "provenance": { "head": "a" * 40, "python_file_count": 1, "source_sha256": "a" * 64, }, "imports": {}, "hooks": {}, "api_routes": {}, } actual = json.loads(json.dumps(expected)) actual["provenance"]["head"] = "b" * 40 actual["imports"] = { "app.sdk.logging": {"file_count": 1, "files": ["plugins.v3/demo.py"]} } baseline_path.write_text(json.dumps(expected), encoding="utf-8") content_before = baseline_path.read_bytes() monkeypatch.setattr( architecture_baseline, "PLUGIN_BASELINE_PATH", baseline_path, ) monkeypatch.setattr( architecture_baseline, "collect_official_plugin_baseline", lambda _repository: actual, ) assert architecture_baseline.main( [ "--check-plugins", "--plugin-repo", str(plugin_repo), "--report", str(report_path), ] ) == 1 report = json.loads(report_path.read_text(encoding="utf-8")) assert baseline_path.read_bytes() == content_before assert report["checks"][0]["semantic_match"] is False assert report["checks"][0]["added"] == [ { "path": "$.imports.app.sdk.logging", "value": {"file_count": 1, "files": ["plugins.v3/demo.py"]}, } ] def test_performance_default_print_does_not_write_fixture( tmp_path: Path, monkeypatch, capsys, ): """性能脚本无操作参数时只打印采样,不得创建输出文件。""" output = tmp_path / "performance.json" sample = _performance_sample() monkeypatch.setattr(startup_performance, "DEFAULT_OUTPUT", output) monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample) assert startup_performance.main(["--repeat", "1"]) == 0 assert not output.exists() assert json.loads(capsys.readouterr().out) == sample def test_performance_check_is_read_only(tmp_path: Path, monkeypatch, capsys): """性能检查应使用现有 fixture 且保持文件内容不变。""" output = tmp_path / "performance.json" sample = _performance_sample() output.write_text(json.dumps(sample), encoding="utf-8") content_before = output.read_bytes() monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample) assert startup_performance.main( ["--check", "--repeat", "1", "--output", str(output)] ) == 0 assert output.read_bytes() == content_before output_text = capsys.readouterr().out assert "启动性能采样:app.factory 中位数 90.0ms / 预算 690.0ms" in output_text assert "检查通过" in output_text def test_performance_write_requires_explicit_action(tmp_path: Path, monkeypatch): """只有显式 write 操作才允许写入性能 fixture。""" output = tmp_path / "performance.json" sample = _performance_sample() monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample) assert startup_performance.main( ["--write", "--repeat", "1", "--output", str(output)] ) == 0 assert json.loads(output.read_text(encoding="utf-8")) == sample def test_performance_check_reports_loaded_app_module_drift(): """稳定的宿主冷导入模块数量变化必须产生可诊断失败。""" expected = _performance_sample(loaded_app_module_count=5) actual = _performance_sample(loaded_app_module_count=6) errors = startup_performance.check_baseline(expected, actual) assert errors == ["app.factory 加载宿主模块数变化:5 -> 6"] def test_performance_check_ignores_environment_provenance_drift(): """Python 与平台 provenance 差异不应改变宿主冷导入语义契约。""" expected = _performance_sample() actual = _performance_sample() actual["python"] = "3.14" actual["platform"] = "another-platform" assert startup_performance.check_baseline(expected, actual) == [] def test_performance_check_keeps_hosted_runner_jitter_margin(): """跨平台托管 runner 可使用 15% 抖动带,但超过最终预算仍必须失败。""" expected = _performance_sample() within_margin = _performance_sample() within_margin["targets"]["app.factory"]["median_ms"] = 690.0 above_margin = _performance_sample() above_margin["targets"]["app.factory"]["median_ms"] = 690.001 assert startup_performance.check_baseline(expected, within_margin) == [] assert startup_performance.check_baseline(expected, above_margin) == [ "app.factory 冷导入中位数 690.001ms 超过预算 690.0ms" ] def test_performance_check_reports_lifecycle_component_replacement(): """组件数量未变时,生命周期成员或顺序变化仍必须失败。""" expected = _performance_sample() actual = _performance_sample() actual["lifecycle"]["modes"]["normal"]["enabled_components"] = [ "后台任务登记器", "数据库准备", ] errors = startup_performance.check_baseline(expected, actual) assert errors == ["normal 模式生命周期组件集合或顺序已变化"] @pytest.mark.parametrize("safe_mode", (False, True)) def test_performance_lifecycle_probe_preserves_task_registry(safe_mode: bool): """隔离探针必须真实装配并释放 lifespan 依赖的 TaskRegistry。""" sample = startup_performance.measure_lifecycle(safe_mode) assert "后台任务登记器" in sample["enabled_components"] assert sample["enabled_component_count"] == len(sample["enabled_components"]) assert sample["threads_after"] == sample["threads_before"] assert sample["tasks_after"] == sample["tasks_before"] assert sample["database_connections_started"] == 0