修复 Studio 失败测试并新增合并门禁
把丢失的 box 录像放回 apps/studio/tests/data/,修掉第 3 节 3–10 号与两处过时测试桩, 注册 timing 标记并让根目录收集零错误;新增 tools/gate.ps1 与可选的 pre-push 钩子。 验证:studio 524 通过 19 跳过、shared 467 通过 18 跳过、ui_check 632 项 0 失败、gate.ps1 退出码 0。 细节见 docs/test-repair-plan.zh.md。
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""Binding policy for the studio.
|
||||
|
||||
The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off
|
||||
PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in
|
||||
``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it:
|
||||
|
||||
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and
|
||||
nothing else, so such an import is a hard start-up failure;
|
||||
* every UI and 3D module must name the binding it uses rather than inheriting one;
|
||||
* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6;
|
||||
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``);
|
||||
* importing the application must not drag another binding into ``sys.modules`` — that is
|
||||
what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first.
|
||||
|
||||
Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6
|
||||
instead of using a hand-written list of enum holders: a curated list missed
|
||||
``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and
|
||||
``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh
|
||||
interpreter**, because in-process the answer depends on what some earlier test imported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import enum
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] # apps/studio
|
||||
SRC = APP / "src"
|
||||
PROJECT_ROOT = APP.parents[1] # orisys-app
|
||||
|
||||
#: Everything that ships or drives the app.
|
||||
SCAN_ROOTS = (SRC, APP / "tools")
|
||||
|
||||
#: Bindings no module may import.
|
||||
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||
|
||||
#: The one binding allowed.
|
||||
ALLOWED_BINDING = "PySide6"
|
||||
|
||||
#: Directories that must name their binding explicitly.
|
||||
#:
|
||||
#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed —
|
||||
#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no
|
||||
#: Qt binding at all so they can be tested headless, and requiring one would forbid that
|
||||
#: design. The modules there that *do* use Qt already name PySide6.
|
||||
BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",)
|
||||
|
||||
#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that
|
||||
#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling
|
||||
#: in code from one named in the policy that forbids it.
|
||||
PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty")
|
||||
|
||||
_SKIP_PARTS = ("__pycache__",)
|
||||
|
||||
|
||||
def _python_files(root: Path) -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in root.rglob("*.py")
|
||||
if not any(part in path.parts for part in _SKIP_PARTS)
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
# One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way.
|
||||
return path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
|
||||
|
||||
def _imported_packages(path: Path) -> set[str]:
|
||||
"""Every top-level package name imported by ``path``."""
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(ast.parse(_read(path))):
|
||||
if isinstance(node, ast.Import):
|
||||
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
found.add(node.module.split(".")[0])
|
||||
return found
|
||||
|
||||
|
||||
def _qt_namespace() -> dict[str, object]:
|
||||
"""The Qt classes the sources could name, resolved through the binding itself."""
|
||||
from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets
|
||||
|
||||
namespace: dict[str, object] = {"Qt": QtCore.Qt}
|
||||
return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets)
|
||||
|
||||
|
||||
def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]:
|
||||
"""Attribute reads whose value resolves to an enum instance rather than an enum class.
|
||||
|
||||
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer
|
||||
``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither
|
||||
is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft``
|
||||
resolves to an enum instance, which is the spelling this test exists to reject.
|
||||
"""
|
||||
tree = ast.parse(_read(path))
|
||||
called = {
|
||||
id(node.func)
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
}
|
||||
inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
|
||||
continue
|
||||
if id(node) in called or id(node) in inner:
|
||||
continue
|
||||
holder, member = node.value.id, node.attr
|
||||
target_holder = namespace.get(holder)
|
||||
if target_holder is None:
|
||||
for module in modules:
|
||||
if hasattr(module, holder):
|
||||
target_holder = namespace[holder] = getattr(module, holder)
|
||||
break
|
||||
else:
|
||||
continue
|
||||
value = getattr(target_holder, member, None)
|
||||
if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace:
|
||||
found.add(f"{holder}.{member}")
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def test_no_studio_module_imports_a_forbidden_binding():
|
||||
offenders = []
|
||||
for root in SCAN_ROOTS:
|
||||
for path in _python_files(root):
|
||||
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||
if bad:
|
||||
offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}")
|
||||
assert not offenders, (
|
||||
f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_ui_module_imports_the_allowed_binding():
|
||||
"""A widget module that names no binding would be using one it does not declare."""
|
||||
offenders = []
|
||||
for directory in BINDING_DECLARING_DIRS:
|
||||
for path in _python_files(directory):
|
||||
if path.name == "__init__.py":
|
||||
continue # a re-export barrel, not a widget module
|
||||
if ALLOWED_BINDING not in _imported_packages(path):
|
||||
offenders.append(str(path.relative_to(APP)))
|
||||
assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}"
|
||||
|
||||
|
||||
def test_no_pyqt_only_spelling_survives():
|
||||
offenders = []
|
||||
for root in SCAN_ROOTS:
|
||||
for path in _python_files(root):
|
||||
text = _read(path)
|
||||
for name in PYQT_ONLY_NAMES:
|
||||
if name in text:
|
||||
offenders.append(f"{path.relative_to(APP)}: {name}")
|
||||
assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}"
|
||||
|
||||
|
||||
def test_no_module_uses_an_unscoped_enum():
|
||||
try:
|
||||
import PySide6 # noqa: F401
|
||||
except ImportError: # pragma: no cover - depends on the machine
|
||||
import pytest
|
||||
|
||||
pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked")
|
||||
namespace, modules = _qt_namespace()
|
||||
offenders = {}
|
||||
for root in SCAN_ROOTS:
|
||||
for path in _python_files(root):
|
||||
found = _unscoped_enums(path, namespace, modules)
|
||||
if found:
|
||||
offenders[str(path.relative_to(APP))] = found
|
||||
assert not offenders, (
|
||||
"unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped "
|
||||
f"form: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_importing_the_application_pulls_in_no_other_binding():
|
||||
"""Fresh interpreter: PySide6 only, whatever this test session imported before."""
|
||||
import os
|
||||
|
||||
script = (
|
||||
"import sys;"
|
||||
"import orisys_studio.app_window;"
|
||||
"import orisys_studio.scene3d.view;"
|
||||
"import orisys_studio.ui.force_plots;"
|
||||
"print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))"
|
||||
)
|
||||
env = dict(os.environ)
|
||||
entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)]
|
||||
if env.get("PYTHONPATH"):
|
||||
entries.append(env["PYTHONPATH"])
|
||||
env["PYTHONPATH"] = os.pathsep.join(entries)
|
||||
# The app's own start-up contract (``main.py``) and a display-free platform: the point
|
||||
# is which *binding* comes in, not whether a window can be shown.
|
||||
env.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||
env.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
pulled = [name for name in result.stdout.strip().split(",") if name]
|
||||
assert pulled == [], (
|
||||
f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it "
|
||||
"finds first, so a second binding in the environment breaks the plots"
|
||||
)
|
||||
|
||||
|
||||
def test_the_app_declares_its_binding_dependency():
|
||||
"""The packaging must state PySide6, not leave it to chance."""
|
||||
pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8")
|
||||
assert ALLOWED_BINDING in pyproject
|
||||
for forbidden in FORBIDDEN_BINDINGS:
|
||||
assert f'"{forbidden}' not in pyproject, forbidden
|
||||
Reference in New Issue
Block a user