mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: scope architecture baseline operations
This commit is contained in:
@@ -1352,8 +1352,10 @@ done_when: []
|
|||||||
当前可复现命令:
|
当前可复现命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./.venv/bin/python scripts/startup/performance.py --repeat 3
|
./.venv/bin/python scripts/startup/performance.py --write --repeat 3
|
||||||
./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins
|
./.venv/bin/python scripts/architecture/baseline.py --check-host
|
||||||
|
./.venv/bin/python scripts/architecture/baseline.py \
|
||||||
|
--check-plugins --plugin-repo ../MoviePilot-Plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.4 2026-08-18 当前验证快照(收口批次)
|
### 11.4 2026-08-18 当前验证快照(收口批次)
|
||||||
@@ -1361,7 +1363,7 @@ done_when: []
|
|||||||
| 范围 | 命令 | 结果 |
|
| 范围 | 命令 | 结果 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 |
|
| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 |
|
||||||
| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,024 边 |
|
| 架构与插件快照 | 分别运行 `--check-host` 与 `--check-plugins --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,024 边 |
|
||||||
| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed |
|
| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed |
|
||||||
| 前端类型检查 | `yarn typecheck` | 通过 |
|
| 前端类型检查 | `yarn typecheck` | 通过 |
|
||||||
| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed |
|
| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed |
|
||||||
|
|||||||
@@ -668,53 +668,101 @@ def write_json(path: Path, value: dict[str, Any]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_json(path: Path, actual: dict[str, Any]) -> bool:
|
def _display_path(path: Path) -> Path:
|
||||||
"""比较当前扫描结果和已提交基线并输出可执行提示。"""
|
try:
|
||||||
|
return path.relative_to(PROJECT_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def check_json(
|
||||||
|
path: Path,
|
||||||
|
actual: dict[str, Any],
|
||||||
|
*,
|
||||||
|
write_hint: str,
|
||||||
|
) -> bool:
|
||||||
|
"""比较当前扫描结果和已提交基线并输出限定范围的更新提示。"""
|
||||||
expected = json.loads(path.read_text(encoding="utf-8"))
|
expected = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if expected == actual:
|
if expected == actual:
|
||||||
return True
|
return True
|
||||||
print(
|
print(
|
||||||
f"架构基线已变化:{path.relative_to(PROJECT_ROOT)};"
|
f"架构基线已变化:{_display_path(path)};"
|
||||||
"确认变更符合边界后运行 scripts/architecture/baseline.py --write",
|
f"确认变更符合边界后运行 scripts/architecture/baseline.py {write_hint}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
||||||
"""解析基线写入、校验和外部插件仓参数。"""
|
"""解析限定宿主或插件范围的基线操作参数。"""
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
action = parser.add_mutually_exclusive_group(required=True)
|
action = parser.add_mutually_exclusive_group(required=True)
|
||||||
action.add_argument("--write", action="store_true", help="写入当前架构基线")
|
action.add_argument("--check-host", action="store_true", help="校验宿主架构基线")
|
||||||
action.add_argument("--check", action="store_true", help="校验当前架构基线")
|
action.add_argument("--check-plugins", action="store_true", help="校验官方插件基线")
|
||||||
|
action.add_argument("--write-host", action="store_true", help="写入宿主架构基线")
|
||||||
|
action.add_argument("--write-plugins", action="store_true", help="写入官方插件基线")
|
||||||
|
action.add_argument("--check", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
action.add_argument("--write", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scope",
|
||||||
|
choices=("host", "plugins"),
|
||||||
|
help="旧 --check/--write 的必填兼容范围",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--plugin-repo",
|
"--plugin-repo",
|
||||||
type=Path,
|
type=Path,
|
||||||
help="可选的独立 MoviePilot-Plugins 仓路径",
|
help="官方插件操作所需的独立 MoviePilot-Plugins 仓路径",
|
||||||
)
|
)
|
||||||
return parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
if args.check or args.write:
|
||||||
|
if not args.scope:
|
||||||
|
parser.error("旧 --check/--write 已弃用,必须同时指定 --scope host|plugins")
|
||||||
|
replacement = f"--{'check' if args.check else 'write'}-{args.scope}"
|
||||||
|
print(
|
||||||
|
f"警告:--{'check' if args.check else 'write'} --scope {args.scope} "
|
||||||
|
f"已弃用,请改用 {replacement}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
setattr(args, f"{'check' if args.check else 'write'}_{args.scope}", True)
|
||||||
|
plugin_action = args.check_plugins or args.write_plugins
|
||||||
|
if plugin_action and not args.plugin_repo:
|
||||||
|
parser.error("插件基线操作必须指定 --plugin-repo")
|
||||||
|
if not plugin_action and args.plugin_repo:
|
||||||
|
parser.error("--plugin-repo 只能用于插件基线操作")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
"""执行本仓基线以及可选官方插件基线的写入或校验。"""
|
"""只对显式选择的宿主或插件基线执行检查或写入。"""
|
||||||
args = parse_args()
|
args = parse_args(argv)
|
||||||
baselines = [
|
host_action = args.check_host or args.write_host
|
||||||
(DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()),
|
if host_action:
|
||||||
(RUNTIME_BASELINE_PATH, collect_runtime_baseline()),
|
baselines = [
|
||||||
]
|
(DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()),
|
||||||
if args.plugin_repo:
|
(RUNTIME_BASELINE_PATH, collect_runtime_baseline()),
|
||||||
|
]
|
||||||
|
write_hint = "--write-host"
|
||||||
|
else:
|
||||||
plugin_repo = args.plugin_repo.resolve()
|
plugin_repo = args.plugin_repo.resolve()
|
||||||
if not plugin_repo.is_dir():
|
if not plugin_repo.is_dir():
|
||||||
raise SystemExit(f"插件仓不存在:{plugin_repo}")
|
raise SystemExit(f"插件仓不存在:{plugin_repo}")
|
||||||
baselines.append(
|
baselines = [
|
||||||
(PLUGIN_BASELINE_PATH, collect_official_plugin_baseline(plugin_repo))
|
(PLUGIN_BASELINE_PATH, collect_official_plugin_baseline(plugin_repo))
|
||||||
|
]
|
||||||
|
write_hint = f"--write-plugins --plugin-repo {plugin_repo}"
|
||||||
|
if args.write_host or args.write_plugins:
|
||||||
|
display_paths = ", ".join(
|
||||||
|
str(_display_path(path)) for path, _baseline in baselines
|
||||||
)
|
)
|
||||||
if args.write:
|
print(f"即将写入:{display_paths}")
|
||||||
for path, baseline in baselines:
|
for path, baseline in baselines:
|
||||||
write_json(path, baseline)
|
write_json(path, baseline)
|
||||||
print(f"已写入 {path.relative_to(PROJECT_ROOT)}")
|
print(f"已写入 {_display_path(path)}")
|
||||||
return 0
|
return 0
|
||||||
checks = [check_json(path, baseline) for path, baseline in baselines]
|
checks = [
|
||||||
|
check_json(path, baseline, write_hint=write_hint)
|
||||||
|
for path, baseline in baselines
|
||||||
|
]
|
||||||
return 0 if all(checks) else 1
|
return 0 if all(checks) else 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+114
-14
@@ -10,7 +10,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -28,6 +28,8 @@ IMPORT_TARGETS = (
|
|||||||
)
|
)
|
||||||
RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE="
|
RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE="
|
||||||
LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE="
|
LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE="
|
||||||
|
PERFORMANCE_FACTOR = 2.0
|
||||||
|
PERFORMANCE_SLACK_MS = 500.0
|
||||||
|
|
||||||
|
|
||||||
def measure_import(target: str) -> dict[str, Any]:
|
def measure_import(target: str) -> dict[str, Any]:
|
||||||
@@ -231,31 +233,129 @@ def collect_baseline(repeat: int) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def check_baseline(
|
||||||
"""解析输出路径和采样次数。"""
|
expected: dict[str, Any],
|
||||||
|
actual: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""比较稳定资源契约与宽松耗时预算,返回所有不符合项。"""
|
||||||
|
errors: list[str] = []
|
||||||
|
expected_targets = expected.get("targets", {})
|
||||||
|
actual_targets = actual.get("targets", {})
|
||||||
|
if set(expected_targets) != set(actual_targets):
|
||||||
|
errors.append("冷导入目标集合已变化")
|
||||||
|
for target in sorted(set(expected_targets) & set(actual_targets)):
|
||||||
|
expected_target = expected_targets[target]
|
||||||
|
actual_target = actual_targets[target]
|
||||||
|
if (
|
||||||
|
actual_target["loaded_module_count"]
|
||||||
|
!= expected_target["loaded_module_count"]
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{target} 加载模块数变化:"
|
||||||
|
f"{expected_target['loaded_module_count']} -> "
|
||||||
|
f"{actual_target['loaded_module_count']}"
|
||||||
|
)
|
||||||
|
budget_ms = max(
|
||||||
|
expected_target["max_ms"] * PERFORMANCE_FACTOR,
|
||||||
|
expected_target["max_ms"] + PERFORMANCE_SLACK_MS,
|
||||||
|
)
|
||||||
|
if actual_target["median_ms"] > budget_ms:
|
||||||
|
errors.append(
|
||||||
|
f"{target} 冷导入中位数 {actual_target['median_ms']}ms "
|
||||||
|
f"超过预算 {round(budget_ms, 3)}ms"
|
||||||
|
)
|
||||||
|
expected_modes = expected.get("lifecycle", {}).get("modes", {})
|
||||||
|
actual_modes = actual.get("lifecycle", {}).get("modes", {})
|
||||||
|
if set(expected_modes) != set(actual_modes):
|
||||||
|
errors.append("生命周期模式集合已变化")
|
||||||
|
for mode_name in sorted(set(expected_modes) & set(actual_modes)):
|
||||||
|
expected_mode = expected_modes[mode_name]
|
||||||
|
actual_mode = actual_modes[mode_name]
|
||||||
|
if (
|
||||||
|
actual_mode["enabled_component_count"]
|
||||||
|
!= expected_mode["enabled_component_count"]
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{mode_name} 模式组件数变化:"
|
||||||
|
f"{expected_mode['enabled_component_count']} -> "
|
||||||
|
f"{actual_mode['enabled_component_count']}"
|
||||||
|
)
|
||||||
|
for sample in actual_mode.get("samples", []):
|
||||||
|
if sample["threads_after"] != sample["threads_before"]:
|
||||||
|
errors.append(f"{mode_name} 模式存在未释放线程")
|
||||||
|
if sample["tasks_after"] != sample["tasks_before"]:
|
||||||
|
errors.append(f"{mode_name} 模式存在未释放异步任务")
|
||||||
|
if sample["database_connections_started"] != 0:
|
||||||
|
errors.append(f"{mode_name} 模式隔离采样建立了数据库连接")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
||||||
|
"""解析只读打印、检查或显式写入操作。"""
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
action = parser.add_mutually_exclusive_group()
|
||||||
|
action.add_argument(
|
||||||
|
"--print",
|
||||||
|
dest="operation",
|
||||||
|
action="store_const",
|
||||||
|
const="print",
|
||||||
|
help="打印本次采样且不写文件(默认)",
|
||||||
|
)
|
||||||
|
action.add_argument(
|
||||||
|
"--check",
|
||||||
|
dest="operation",
|
||||||
|
action="store_const",
|
||||||
|
const="check",
|
||||||
|
help="按已提交基线检查资源契约和宽松性能预算",
|
||||||
|
)
|
||||||
|
action.add_argument(
|
||||||
|
"--write",
|
||||||
|
dest="operation",
|
||||||
|
action="store_const",
|
||||||
|
const="write",
|
||||||
|
help="显式写入采样基线",
|
||||||
|
)
|
||||||
parser.add_argument("--repeat", type=int, default=3)
|
parser.add_argument("--repeat", type=int, default=3)
|
||||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
return parser.parse_args()
|
parser.set_defaults(operation="print")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def _display_path(path: Path) -> Path:
|
||||||
"""执行冷导入采样并写入 JSON 基线。"""
|
try:
|
||||||
args = parse_args()
|
return path.relative_to(PROJECT_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
"""采样关键入口,并按显式操作打印、检查或写入结果。"""
|
||||||
|
args = parse_args(argv)
|
||||||
if args.repeat < 1:
|
if args.repeat < 1:
|
||||||
raise SystemExit("--repeat 必须大于等于 1")
|
raise SystemExit("--repeat 必须大于等于 1")
|
||||||
|
baseline = collect_baseline(args.repeat)
|
||||||
output = args.output.resolve()
|
output = args.output.resolve()
|
||||||
|
if args.operation == "print":
|
||||||
|
print(json.dumps(baseline, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
if args.operation == "check":
|
||||||
|
if not output.is_file():
|
||||||
|
raise SystemExit(f"性能基线不存在:{output}")
|
||||||
|
expected = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
errors = check_baseline(expected, baseline)
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
print(f"性能基线检查失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"性能基线检查通过:{_display_path(output)}")
|
||||||
|
return 0
|
||||||
|
print(f"即将写入:{_display_path(output)}")
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output.write_text(
|
output.write_text(
|
||||||
json.dumps(collect_baseline(args.repeat), ensure_ascii=False, indent=2)
|
json.dumps(baseline, ensure_ascii=False, indent=2) + "\n",
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
try:
|
print(f"已写入:{_display_path(output)}")
|
||||||
display_path = output.relative_to(PROJECT_ROOT)
|
|
||||||
except ValueError:
|
|
||||||
display_path = output
|
|
||||||
print(f"已写入 {display_path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""架构与启动性能基线脚本的 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_module_count: int = 10) -> dict:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_at": "2026-08-21T00:00:00+00:00",
|
||||||
|
"platform": "test",
|
||||||
|
"python": "3.12",
|
||||||
|
"repeat": 1,
|
||||||
|
"targets": {
|
||||||
|
"app.factory": {
|
||||||
|
"loaded_module_count": loaded_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,
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"threads_before": 1,
|
||||||
|
"threads_after": 1,
|
||||||
|
"tasks_before": 1,
|
||||||
|
"tasks_after": 1,
|
||||||
|
"database_connections_started": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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_write_host_only_updates_host_files(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
capsys,
|
||||||
|
):
|
||||||
|
"""宿主写操作不得连带覆盖官方插件 fixture。"""
|
||||||
|
dependency_path = tmp_path / "dependency.json"
|
||||||
|
runtime_path = tmp_path / "runtime.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, "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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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 not plugin_path.exists()
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "即将写入" in output
|
||||||
|
assert "dependency.json" in output
|
||||||
|
assert "runtime.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"
|
||||||
|
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, "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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
assert "检查通过" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
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_module_drift():
|
||||||
|
"""稳定的冷导入模块数量变化必须产生可诊断失败。"""
|
||||||
|
expected = _performance_sample(loaded_module_count=10)
|
||||||
|
actual = _performance_sample(loaded_module_count=11)
|
||||||
|
|
||||||
|
errors = startup_performance.check_baseline(expected, actual)
|
||||||
|
|
||||||
|
assert errors == ["app.factory 加载模块数变化:10 -> 11"]
|
||||||
@@ -13,8 +13,16 @@ BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
|
|||||||
|
|
||||||
def test_architecture_contract_baselines_match_current_source():
|
def test_architecture_contract_baselines_match_current_source():
|
||||||
"""宿主依赖图和公开运行契约变化必须显式刷新基线。"""
|
"""宿主依赖图和公开运行契约变化必须显式刷新基线。"""
|
||||||
|
baseline_paths = (
|
||||||
|
BASELINE_ROOT / "dependency-baseline.json",
|
||||||
|
BASELINE_ROOT / "runtime-contract-baseline.json",
|
||||||
|
)
|
||||||
|
contents_before = {
|
||||||
|
path: path.read_bytes()
|
||||||
|
for path in baseline_paths
|
||||||
|
}
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "scripts/architecture/baseline.py", "--check"],
|
[sys.executable, "scripts/architecture/baseline.py", "--check-host"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -22,6 +30,10 @@ def test_architecture_contract_baselines_match_current_source():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert {
|
||||||
|
path: path.read_bytes()
|
||||||
|
for path in baseline_paths
|
||||||
|
} == contents_before
|
||||||
|
|
||||||
|
|
||||||
def test_official_plugin_baseline_records_external_source():
|
def test_official_plugin_baseline_records_external_source():
|
||||||
|
|||||||
Reference in New Issue
Block a user