Files
matrix-screen-controller/测试相关资料/如何测试/本机测试环境/local_test.py
T

349 lines
11 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.
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Sequence
HARNESS_DIR = Path(__file__).resolve().parent
WORKSPACE_ROOT = HARNESS_DIR.parents[2]
SOURCE_DIR = WORKSPACE_ROOT / "核桃派软件源代码"
VENV_DIR = HARNESS_DIR / ".venv"
WORK_DIR = HARNESS_DIR / "work"
DATA_DIR = HARNESS_DIR / "data"
VENV_PYTHON = VENV_DIR / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
REQUIREMENTS_STAMP = VENV_DIR / ".requirements.sha256"
REQUIREMENTS_FILES = (
SOURCE_DIR / "requirements.txt",
SOURCE_DIR / "requirements-dev.txt",
)
PYTEST_TARGETS = {
"config": (
"tests/test_config_store.py",
"tests/test_wifi.py",
"tests/test_templates.py",
"tests/test_animations.py",
"tests/test_animation_preview_fixture.py",
"tests/test_migrate_state_root.py",
),
"display": ("tests/test_display_service.py",),
"api": ("tests/test_api.py", "tests/test_fonts.py", "tests/test_ui_copy_editor.py"),
"python": (),
}
SUITES = (*PYTEST_TARGETS, "frontend", "all")
class HarnessError(RuntimeError):
"""A user-actionable local test environment error."""
def _display_command(command: Sequence[str]) -> str:
return subprocess.list2cmdline([str(part) for part in command])
def _run(
command: Sequence[str | Path],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
) -> None:
normalized = [str(part) for part in command]
print(f"> {_display_command(normalized)}", flush=True)
subprocess.run(normalized, cwd=cwd, env=env, check=True)
def _validate_layout() -> None:
missing = [path for path in (*REQUIREMENTS_FILES, SOURCE_DIR / "app") if not path.exists()]
if missing:
joined = "、".join(str(path) for path in missing)
raise HarnessError(f"无法定位项目文件:{joined}")
def _requirements_fingerprint() -> str:
digest = hashlib.sha256()
for path in REQUIREMENTS_FILES:
digest.update(path.name.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def setup_environment() -> None:
_validate_layout()
HARNESS_DIR.mkdir(parents=True, exist_ok=True)
if VENV_DIR.exists() and not VENV_PYTHON.is_file():
raise HarnessError(
f"持久环境存在但 Python 不可用:{VENV_PYTHON}。"
"请先把损坏的 .venv 改名留作检查,再手动删除或移走它,然后重新运行 setup;"
"工具不会自动删除现有环境。"
)
if not VENV_PYTHON.is_file():
print(f"创建持久测试环境:{VENV_DIR}", flush=True)
_run((sys.executable, "-m", "venv", VENV_DIR))
fingerprint = _requirements_fingerprint()
recorded = (
REQUIREMENTS_STAMP.read_text(encoding="ascii").strip()
if REQUIREMENTS_STAMP.is_file()
else ""
)
if recorded != fingerprint:
print("依赖首次安装或 requirements 已变化,正在从官方 PyPI 同步。", flush=True)
_run(
(
VENV_PYTHON,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--upgrade",
"--index-url",
"https://pypi.org/simple",
"-r",
REQUIREMENTS_FILES[-1],
)
)
_run((VENV_PYTHON, "-m", "pip", "check"))
REQUIREMENTS_STAMP.write_text(f"{fingerprint}\n", encoding="ascii")
print("依赖同步完成,已记录 requirements 哈希。", flush=True)
else:
print("requirements 未变化,跳过依赖安装。", flush=True)
_run((VENV_PYTHON, "-m", "pip", "check"))
def _base_environment() -> dict[str, str]:
WORK_DIR.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["MATRIX_DRIVER"] = "mock"
env["PYTHONPYCACHEPREFIX"] = str(WORK_DIR / "pycache")
return env
def _test_environment() -> dict[str, str]:
env = _base_environment()
# Tests that construct an explicit project root remain isolated per case.
# The process-level paths below protect module-level application imports
# from ever reading or migrating either source-tree or manual mock data.
test_data_dir = WORK_DIR / "test-app-data"
env["MATRIX_TEST_ROOT"] = str(test_data_dir)
env["MATRIX_DATA_DIR"] = str(test_data_dir)
env["MATRIX_RUNTIME_DIR"] = str(test_data_dir / "runtime")
for name in ("STATE_DIRECTORY", "RUNTIME_DIRECTORY"):
env.pop(name, None)
return env
def _serve_environment() -> dict[str, str]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
env = _base_environment()
env["MATRIX_DATA_DIR"] = str(DATA_DIR)
env["MATRIX_RUNTIME_DIR"] = str(DATA_DIR / "runtime")
return env
def _remove_path(path: Path) -> bool:
if not path.exists() and not path.is_symlink():
return False
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
print(f"已清理:{path}", flush=True)
return True
def _assert_within(path: Path, root: Path) -> None:
resolved_path = path.resolve(strict=False)
resolved_root = root.resolve(strict=False)
try:
resolved_path.relative_to(resolved_root)
except ValueError as exc:
raise HarnessError(f"拒绝处理工作区外路径:{resolved_path}") from exc
def _prepare_pytest_temp() -> Path:
pytest_temp = WORK_DIR / "pytest"
_assert_within(pytest_temp, HARNESS_DIR)
_remove_path(pytest_temp)
pytest_temp.mkdir(parents=True, exist_ok=True)
return pytest_temp
def _run_pytest(suite: str, env: dict[str, str]) -> None:
pytest_temp = _prepare_pytest_temp()
command: list[str | Path] = [
VENV_PYTHON,
"-m",
"pytest",
"-q",
"-p",
"no:cacheprovider",
f"--basetemp={pytest_temp}",
]
command.extend(PYTEST_TARGETS[suite])
_run(command, cwd=SOURCE_DIR, env=env)
def _run_frontend_tests(env: dict[str, str]) -> None:
node = shutil.which("node")
if not node:
raise HarnessError("未找到 Node.js;frontend/all 套件需要可执行的 node。")
_run(
(
node,
"--test",
"tests/test_animation_ui.mjs",
"tests/test_canvas_tools.mjs",
"tests/test_color_math.mjs",
"tests/test_current_display_playback_ui.mjs",
"tests/test_device_ui.mjs",
"tests/test_font_picker.mjs",
"tests/test_frp_ui.mjs",
"tests/test_interaction_feedback.mjs",
"tests/test_scene_model.mjs",
"tests/test_settings_ui.mjs",
"tests/test_template_management_ui.mjs",
"tests/test_ui_copy_editor.mjs",
"tests/test_wifi_ui.mjs",
"tests/test_workspace_order_ui.mjs",
),
cwd=SOURCE_DIR,
env=env,
)
javascript_files = sorted((SOURCE_DIR / "app" / "static").rglob("*.js"))
if not javascript_files:
raise HarnessError("未找到 app/static 下的 JavaScript 文件。")
for path in javascript_files:
_run((node, "--check", path), cwd=SOURCE_DIR, env=env)
_run((node, "--check", "app/ui_copy_editor/static/editor.js"), cwd=SOURCE_DIR, env=env)
_run((VENV_PYTHON, "scripts/build_ui_copy_catalog.py", "--check"), cwd=SOURCE_DIR, env=env)
def run_tests(suite: str) -> None:
setup_environment()
env = _test_environment()
if suite == "all":
_run_pytest("python", env)
_run_frontend_tests(env)
elif suite == "frontend":
_run_frontend_tests(env)
else:
_run_pytest(suite, env)
print(f"本机测试套件通过:{suite}", flush=True)
def serve(port: int) -> None:
setup_environment()
env = _serve_environment()
print(f"本机 mock 页面:http://127.0.0.1:{port}", flush=True)
try:
_run(
(
VENV_PYTHON,
"-m",
"uvicorn",
"app.main:app",
"--host",
"127.0.0.1",
"--port",
str(port),
),
cwd=SOURCE_DIR,
env=env,
)
except KeyboardInterrupt:
print("本机 mock 服务已停止。", flush=True)
def clean() -> None:
_validate_layout()
fixed_targets = [
WORK_DIR,
DATA_DIR / "runtime",
SOURCE_DIR / ".pytest_cache",
SOURCE_DIR / ".coverage",
SOURCE_DIR / "coverage.xml",
WORKSPACE_ROOT / ".playwright-cli",
WORKSPACE_ROOT / "output" / "playwright",
HARNESS_DIR / "__pycache__",
]
for target in fixed_targets:
_assert_within(target, WORKSPACE_ROOT)
cache_dirs: list[Path] = []
for root in (SOURCE_DIR / "app", SOURCE_DIR / "scripts", SOURCE_DIR / "tests"):
if root.is_dir():
cache_dirs.extend(root.rglob("__pycache__"))
for target in cache_dirs:
_assert_within(target, SOURCE_DIR)
removed = 0
for target in sorted(cache_dirs, key=lambda item: len(item.parts), reverse=True):
removed += int(_remove_path(target))
for target in fixed_targets:
removed += int(_remove_path(target))
if not VENV_PYTHON.is_file():
raise HarnessError("清理后未找到持久 .venv Python;清理验收失败。")
if not REQUIREMENTS_STAMP.is_file():
raise HarnessError("清理后依赖哈希不存在;清理验收失败。")
if (DATA_DIR / "config.json").exists():
print("已保留:data/config.json", flush=True)
print(f"清理完成,共移除 {removed} 个可再生目标;.venv 已保留。", flush=True)
def _port(value: str) -> int:
port = int(value)
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("端口必须在 1..65535。")
return port
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="奇妙小屏幕控制器本机持久测试工具")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("setup", help="创建或更新持久 .venv")
test_parser = subparsers.add_parser("test", help="运行所需的本机测试套件")
test_parser.add_argument("--suite", choices=SUITES, default="all")
serve_parser = subparsers.add_parser("serve", help="以前台方式启动 mock 网页")
serve_parser.add_argument("--port", type=_port, default=8765)
subparsers.add_parser("clean", help="清理可再生产物,保留 .venv 和本机配置")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if args.command == "setup":
setup_environment()
elif args.command == "test":
run_tests(args.suite)
elif args.command == "serve":
serve(args.port)
elif args.command == "clean":
clean()
else:
raise HarnessError(f"未知命令:{args.command}")
except (HarnessError, OSError, subprocess.CalledProcessError) as exc:
print(f"本机测试工具失败:{exc}", file=sys.stderr, flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())