350 lines
11 KiB
Python
350 lines
11 KiB
Python
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_scene_layers.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())
|