初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
data/
|
||||
work/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,45 @@
|
||||
# 本机持久测试环境
|
||||
|
||||
本目录保存奇妙小屏幕控制器的本机测试工具、可复用虚拟环境和人工保留的 mock 数据。命令从工作区根目录执行,脚本会自行定位中文路径下的源码目录。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
python ".\测试相关资料\如何测试\本机测试环境\local_test.py" setup
|
||||
python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test --suite all
|
||||
python ".\测试相关资料\如何测试\本机测试环境\local_test.py" serve --port 8765
|
||||
python ".\测试相关资料\如何测试\本机测试环境\local_test.py" clean
|
||||
```
|
||||
|
||||
测试套件:
|
||||
|
||||
- `config`:配置/模板 schema、耐久写入、路径解析和旧数据根迁移 pytest。
|
||||
- `display`:显示服务、渲染和帧变换 pytest。
|
||||
- `api`:REST、WebSocket 和静态资源 pytest。
|
||||
- `python`:全部 pytest。
|
||||
- `frontend`:颜色/场景 Node 测试及全部静态 JavaScript 语法检查。
|
||||
- `all`:`python` 与 `frontend`。
|
||||
|
||||
`test` 和 `serve` 会自动调用 `setup`。首次运行或两个 requirements 文件变化时才从官方 PyPI 安装/更新依赖;未变化时只运行快速的 `pip check`。
|
||||
|
||||
## 保留与清理边界
|
||||
|
||||
`serve` 显式使用:
|
||||
|
||||
- 持久数据根:`data/`
|
||||
- 运行期数据根:`data/runtime/`
|
||||
|
||||
这两个目录用于本机隔离,不替代生产环境的 `/var/lib/matrix-screen-controller` 和 `/run/matrix-screen-controller`。自动化用例必须使用 `work/` 下或 pytest 提供的隔离临时目录,不得修改人工保留的数据。
|
||||
|
||||
长期保留:
|
||||
|
||||
- `.venv/` 及其中的 `.requirements.sha256`
|
||||
- `data/config.json`
|
||||
- `data/templates/` 中人工保存的模板及有效缩略图
|
||||
- `data/animations/` 中人工保存的动图、帧及有效缩略图
|
||||
- `data/` 中以后按 `CONFIG-DATA-LIFECYCLE` 登记的其他持久测试数据
|
||||
- 本目录的脚本、README 和 `.gitignore`
|
||||
|
||||
`clean` 只删除可再生产物:`work/`、整个 `data/runtime/`、源码缓存、覆盖率文件和已知浏览器测试产物。它不会清空 `data/`、扫描 `%TEMP%` 或整盘,也不会自动删除损坏的 `.venv`。测试产生的配置/模板反例必须留在隔离临时目录并由测试框架回收,不能把损坏样例或多代备份积累在本目录。
|
||||
|
||||
完整的分层测试选择、远端冒烟和实屏档位见上一级的《核桃派点阵屏控制服务测试流程.md》。
|
||||
@@ -0,0 +1,348 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user