修复 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,16 @@
|
||||
#!/bin/sh
|
||||
# Optional pre-push gate for orisys-app — not installed automatically.
|
||||
#
|
||||
# git config core.hooksPath .githooks # turn it on
|
||||
# git config --unset core.hooksPath # turn it off
|
||||
#
|
||||
# It runs the full merge gate (tools/gate.ps1: lint on the changed files, the studio
|
||||
# suite, the shared suite, ui_check.py) and blocks the push when the gate fails. The
|
||||
# gate needs Windows: it renders real Qt widgets offscreen with the project's
|
||||
# interpreter. See tools/gate.ps1 and docs/test-repair-plan.zh.md §4.
|
||||
#
|
||||
# Windows PowerShell is used rather than pwsh so the hook works on a machine that only
|
||||
# has the one PowerShell every Windows installation carries.
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -Task pre-push
|
||||
@@ -12,7 +12,7 @@ Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free opti
|
||||
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
|
||||
product would otherwise need a licence purchase. The rule is enforced, not just
|
||||
documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
|
||||
import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and
|
||||
import PySide6 and nothing else. See `apps/studio/tests/test_studio_binding_purity.py` and
|
||||
`apps/autocal/tests/test_binding_purity.py`.
|
||||
|
||||
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
|
||||
@@ -109,6 +109,37 @@ mechanically enforced.
|
||||
why recording and calibration sampling no longer hang off the UI refresh timer,
|
||||
and for the data-reliability fixes that came with it.
|
||||
|
||||
### The merge gate
|
||||
|
||||
One command runs everything a change must not break, and it is the only check that
|
||||
is meant to be unskippable:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools\gate.ps1
|
||||
```
|
||||
|
||||
It runs `tools/lint_gate.py` over the changed files, the full studio suite, the full
|
||||
shared suite, and `ui_check.py`; writes each one's output to
|
||||
`.scratch/gate/gate-<run-id>/`; and holds the run to three contracts — an **empty**
|
||||
`tools/known_failures.txt`, the skip ceilings and collection floors in
|
||||
`tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of
|
||||
that holds.
|
||||
|
||||
The **gate platform is Windows**: the suites render real Qt widgets offscreen, and two
|
||||
checks are platform-sensitive ([`docs/test-repair-plan.zh.md`](docs/test-repair-plan.zh.md)
|
||||
§3 #11 and #13), so a green Linux run is not the same statement.
|
||||
|
||||
An optional `pre-push` hook calls it, so a failing gate blocks the push. It is
|
||||
deliberately not installed for you:
|
||||
|
||||
```powershell
|
||||
git config core.hooksPath .githooks # enable; `git config --unset core.hooksPath` disables
|
||||
```
|
||||
|
||||
Day-to-day development still uses targeted test runs
|
||||
([`docs/test-execution-plan.zh.md`](docs/test-execution-plan.zh.md)); the gate is what
|
||||
runs before a change is committed or handed over.
|
||||
|
||||
## Branding
|
||||
|
||||
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
"reset_display": "Reset display",
|
||||
"reset_display_tip": "Clear the accumulated display: the curves, cursor and value cards go back to their defaults and the replay returns to the start of the recording. There is no device session in this view, so nothing of the device is touched; the 3D camera is View ▸ Reset views (F8).",
|
||||
"pause": "⏸ Pause",
|
||||
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7).",
|
||||
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7). A pause leaves a gap in the recording.",
|
||||
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
|
||||
"resume_label": "▶ Resume",
|
||||
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
"reset_display": "复位显示",
|
||||
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
|
||||
"pause": "⏸ 暂停",
|
||||
"pause_tip": "暂停计算,画面停住,设备保持连接(F7)。",
|
||||
"pause_tip": "暂停计算,画面停住,设备保持连接(F7);跨过暂停的录制会留空档。",
|
||||
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
||||
"resume_label": "▶ 继续",
|
||||
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
||||
|
||||
@@ -264,6 +264,16 @@
|
||||
"condition": "sensor_dialog_open",
|
||||
"purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use",
|
||||
"elements": [
|
||||
{ "id": "dialog_profile_scroll", "path": "sensor_dialog.profile_scroll", "objectName": "sensorProfileScroll", "class": "QScrollArea",
|
||||
"purpose": "bounded scroll area for the selected profile's description and verdict: a long warning list stays readable without growing the dialog into the calibration section" },
|
||||
{ "id": "dialog_live_calibration_title", "path": "sensor_dialog.choice_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||
"purpose": "heading of the live-calibration choice, the section 3d2fb0c added so the running chain's calibration can be changed from the dialog" },
|
||||
{ "id": "dialog_live_calibration_combo", "path": "sensor_dialog.calibration_combo", "objectName": "liveCalibrationCombo", "class": "QComboBox",
|
||||
"purpose": "which calibration the live chain uses: the profile's declared file, a browsed one, or raw force; 应用 hands it to the session" },
|
||||
{ "id": "dialog_browse_calibration", "path": "sensor_dialog.btn_browse", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "pick a calibration file that is not the profile's declared one, and persist that choice" },
|
||||
{ "id": "dialog_device_edit", "path": "sensor_dialog.device_edit", "objectName": "liveDeviceEdit", "class": "QLineEdit",
|
||||
"purpose": "the Finger device serial the browsed calibration claims; shown only for a Finger file, and a mismatch is refused before it is applied" },
|
||||
{ "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||
"purpose": "heading of the calibration half" },
|
||||
{ "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||
@@ -283,7 +293,7 @@
|
||||
{ "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
|
||||
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "push the last export into the running device" },
|
||||
"purpose": "hand the dialog's live-calibration choice to the session: remembered for the next run, and hot-updated into a device that is already acquiring (3d2fb0c moved it off the last export)" },
|
||||
{ "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
|
||||
dependencies = [
|
||||
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
|
||||
# on PyQt5 (whose free option is GPLv3-or-commercial). See
|
||||
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
|
||||
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_studio_binding_purity.py.
|
||||
"PySide6>=6.6",
|
||||
"orisys-app-shared>=0.1.0",
|
||||
"orisys>=0.5",
|
||||
|
||||
@@ -403,7 +403,31 @@ class AnalysisSession:
|
||||
if self._overlay is None:
|
||||
self._overlay_failed = True
|
||||
return None
|
||||
return self._overlay.project(video_bgr, frame_id=frame_id)
|
||||
projected = self._overlay.project(video_bgr, frame_id=frame_id)
|
||||
if projected is None:
|
||||
# Sticky, as the docstring above says. A projector that cannot map this
|
||||
# result's footage — measured: every frame of a real box recording, where the
|
||||
# stitch rebuild raises inside the SDK — will not start working mid-session,
|
||||
# and retrying per cursor move would rebuild the same failing stitch on every
|
||||
# scrub. The session records the answer so a caller can pick a view this
|
||||
# result *can* draw instead of leaving a refusal on screen.
|
||||
self._overlay_failed = True
|
||||
return projected
|
||||
|
||||
@property
|
||||
def overlay_unavailable(self) -> bool:
|
||||
"""Whether this result has been asked for an overlay base and could not give one.
|
||||
|
||||
The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same
|
||||
UV-unrolled or stitched picture live publishes, which a stored result does not
|
||||
contain: it is rebuilt from the result's provenance on first use. A **box**
|
||||
result has no stored unroll map, and the box stitch rebuild can fail on real
|
||||
footage, so the answer is only known after one attempt — and it is then sticky
|
||||
for the session (see :meth:`overlay_base`). The caller that *picks* the first
|
||||
view for an adopted result reads this, so the operator is not left on a view
|
||||
that cannot be drawn.
|
||||
"""
|
||||
return bool(self._overlay_failed)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the overlay projector; the reader is closed by :class:`ResultLibrary`."""
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DERIVED_MODES,
|
||||
RESULT_FAILED,
|
||||
@@ -1032,13 +1033,28 @@ class ReplayController(QObject):
|
||||
The first processed view is arrows over the unrolled / stitched base: the same
|
||||
texture live uses. A derived selection the operator already chose is left
|
||||
alone — that mode is not this method's to override.
|
||||
|
||||
**But only a view this result can actually draw.** The base is rebuilt from the
|
||||
result's provenance, which a box result cannot always do (no stored unroll map,
|
||||
and the box stitch rebuild can fail on real footage: measured, every frame of the
|
||||
restored 33-frame box recording). Offering the overlay there would leave the
|
||||
operator on a blank panel reading "capture unavailable" while the pure-colour
|
||||
arrows sit one step away, so the fallback is the drawable view — the overlay is
|
||||
still in the selector, and choosing it still says why it cannot be drawn.
|
||||
"""
|
||||
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
||||
if current in DERIVED_MODES:
|
||||
return False
|
||||
session = self.available_result()
|
||||
# The plan for the frame just drawn offers the derived modes (the frame carries
|
||||
# ``result_available``), so this selection is one the panel can honour.
|
||||
self._image_panel.set_view_mode(ARROW_STITCHED)
|
||||
overlay_failed = bool(getattr(session, "overlay_unavailable", False))
|
||||
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED)
|
||||
self.render_frame()
|
||||
if not overlay_failed and getattr(session, "overlay_unavailable", False):
|
||||
# The first attempt is what tells us: the base could not be rebuilt, so draw
|
||||
# the view that needs no base instead of leaving a refusal on screen.
|
||||
self._image_panel.set_view_mode(ARROW_PURE)
|
||||
self.render_frame()
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Plain helpers the studio UI tests share, importable without a ``tests`` package.
|
||||
|
||||
These four functions used to live in ``conftest.py`` and were imported as
|
||||
``from tests.conftest import ...``. That import only worked when pytest ran with
|
||||
``apps/studio`` in ``sys.path`` (or as the working directory): run from the
|
||||
repository root, the ``tests`` directory of *any* product could be shadowed, and
|
||||
the suite could not be collected at all. A module beside the tests, with the
|
||||
tests' own directory on ``sys.path``, has none of that ambiguity.
|
||||
|
||||
Fixtures stay in ``conftest.py`` (pytest discovers them); only the functions a
|
||||
test imports by name live here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant widget of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
|
||||
|
||||
def show_window(window, size):
|
||||
"""Show a window at ``size`` and flush pending Qt events."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def running_window(make_window):
|
||||
"""Return a running window backed by a controllable test worker."""
|
||||
import ui_check
|
||||
|
||||
window = show_window(
|
||||
make_window(sensor_type="box", source_mode="camera"),
|
||||
(1440, 880),
|
||||
)
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
return window, stuck
|
||||
|
||||
|
||||
def finish_run(window, stuck):
|
||||
"""Stop a test run and release its controllable worker."""
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
@@ -51,6 +51,14 @@ _TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
||||
if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
|
||||
# The tests' own directory, so ``_studio_helpers`` is importable however pytest was
|
||||
# started. Relying on pytest to insert it works only under the default import mode and
|
||||
# only for the directory a test file happens to sit in; naming it here is what lets the
|
||||
# suite be collected from the repository root (see ``docs/test-repair-plan.zh.md`` §4).
|
||||
_TESTS_DIR = Path(__file__).resolve().parent
|
||||
if str(_TESTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TESTS_DIR))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qt_app():
|
||||
@@ -117,49 +125,6 @@ def decline_video_prompt(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant widget of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
|
||||
|
||||
def show_window(window, size):
|
||||
"""Show a window at ``size`` and flush pending Qt events."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def running_window(make_window):
|
||||
"""Return a running window backed by a controllable test worker."""
|
||||
import ui_check
|
||||
|
||||
window = show_window(
|
||||
make_window(sensor_type="box", source_mode="camera"),
|
||||
(1440, 880),
|
||||
)
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
return window, stuck
|
||||
|
||||
|
||||
def finish_run(window, stuck):
|
||||
"""Stop a test run and release its controllable worker."""
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
|
||||
|
||||
_RECORD_SCRATCH = (
|
||||
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Test recordings
|
||||
|
||||
`box_short_20260930/` is one real recording, kept in the repository because several tests
|
||||
must drive the real SDK chain over real footage. A synthetic clip cannot replace it: the
|
||||
box chain's stitch probe looks for the lenslet grid in the image, does not converge on
|
||||
generated pixels, and a synthetic 896² clip sent the chain into minutes of work where this
|
||||
33-frame clip takes about two seconds.
|
||||
|
||||
## What it is
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Sensor | `box` (shipped bundle) |
|
||||
| Source | `dist/OrisysStudio/outputs/recordings/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011`, recorded 2026-09-30, `session_id` 2 |
|
||||
| Length | 33 frames, frame ids **20…52**, 1.92 s of signal time |
|
||||
| Files | `raw_0001.mp4`, `frames.csv`, `forces.csv`, `events.csv`, `session.json` (3.8 MB) |
|
||||
| Not included | `preroll_0001.mp4` (3.5 MB, optional for every reader: `RecordingReader.preroll_segment()` answers `None` when the file is absent) |
|
||||
|
||||
`dist/` is build output and is not tracked, so the copy under it disappeared on
|
||||
2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to
|
||||
`failed` without anyone noticing — see
|
||||
[`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2.
|
||||
|
||||
The frame ids are deliberately not 0…32: a test that used the frame *index* would pass on
|
||||
a 0-based recording, and real recordings start wherever the device's counter was.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Do not replace it with a generated recording.** The chain's behaviour on these pixels
|
||||
is part of what the tests defend.
|
||||
- **Do not write into it from a test.** The tests that analyse it copy it into `tmp_path`
|
||||
first, because a pass writes its result beside the recording.
|
||||
- **If it goes missing, restore it or the tests fail.** They no longer skip on a missing
|
||||
input: a skip is how this went unnoticed.
|
||||
@@ -0,0 +1,3 @@
|
||||
at,t_rel_s,frame_id,kind,detail
|
||||
2026-09-30T01:50:28.848+00:00,,-1,record_started,mode=video_data
|
||||
2026-09-30T01:50:30.825+00:00,,-1,record_stopped,
|
||||
|
@@ -0,0 +1,34 @@
|
||||
session_id,frame_id,t_rel_s,FN_raw,FX_raw,FY_raw,FN_N,FX_N,FY_N,valid,calibration_id
|
||||
2,20,0.0,13567.7392578125,1180.1070556640625,9125.2607421875,0.555386962890625,0.006123639892578124,0.276258865234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,21,0.064133,11343.3232421875,2028.0238037109375,7816.4375,0.444166162109375,0.034952809326171876,0.23175887499999998,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,22,0.112021,16884.728515625,745.5536499023438,7191.43896484375,0.72123642578125,0.0,0.2105089248046875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,23,0.17623,14818.259765625,2178.03466796875,9171.318359375,0.61791298828125,0.0400531787109375,0.27782482421875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,24,0.24043,14969.4208984375,-1287.072509765625,11873.1181640625,0.625471044921875,-0.00976046533203125,0.36968601757812497,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,25,0.304304,14659.830078125,-141.17251586914062,2469.820556640625,0.60999150390625,-0.0,0.049973898925781246,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,26,0.352255,15476.34375,-3281.966552734375,15066.1357421875,0.6508171875,-0.07758686279296875,0.478248615234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,27,0.416172,11016.5068359375,-3127.916748046875,13734.294921875,0.427825341796875,-0.07234916943359375,0.43296602734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,28,0.480077,14330.064453125,273.25970458984375,5539.169921875,0.59350322265625,0.0,0.15433177734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,29,0.543786,14844.3466796875,2567.879150390625,5841.8525390625,0.619217333984375,0.05330789111328125,0.164622986328125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,30,0.591816,16986.365234375,-1623.703125,5377.94091796875,0.72631826171875,-0.02120590625,0.1488499912109375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,31,0.656365,13733.6103515625,-1030.4215087890625,9520.9345703125,0.563680517578125,-0.0010343312988281249,0.289711775390625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,32,0.720031,17051.982421875,-2590.688720703125,10953.798828125,0.72959912109375,-0.05408341650390625,0.33842916015625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,33,0.784173,16303.94921875,2727.433837890625,6891.0400390625,0.6921974609375,0.05873275048828125,0.20029536132812498,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,34,0.831679,17633.34375,-208.40782165527344,5174.18798828125,0.7586671875,-0.0,0.14192239160156248,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,35,0.896141,14191.8671875,-971.9476928710938,11894.728515625,0.586593359375,-0.0,0.37042076953124997,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,36,0.960091,15828.78125,-1548.9967041015625,9286.84765625,0.6684390625000001,-0.018665887939453125,0.2817528203125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,37,1.024524,18615.650390625,-3690.62451171875,10522.7822265625,0.80778251953125,-0.0914812333984375,0.323774595703125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,38,1.071701,15312.724609375,1154.6263427734375,11247.951171875,0.6426362304687501,0.005257295654296875,0.34843033984375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,39,1.135903,12159.138671875,-82.20235443115234,10962.3251953125,0.48495693359375003,-0.0,0.33871905664062496,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,40,1.199965,15671.615234375,-540.1160888671875,17301.103515625,0.66058076171875,-0.0,0.55423751953125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,41,1.264015,18193.279296875,-280.51025390625,7286.34765625,0.78666396484375,-0.0,0.2137358203125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,42,1.311786,14463.1240234375,23.800241470336914,14572.9287109375,0.6001562011718751,0.0,0.461479576171875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,43,1.376278,14139.1728515625,549.9257202148438,14745.4931640625,0.583958642578125,0.0,0.467346767578125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,44,1.439864,14622.09375,535.4642944335938,11325.8857421875,0.6081046875,0.0,0.351080115234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,45,1.503909,16870.396484375,-5440.37158203125,13050.87890625,0.72051982421875,-0.15097263378906248,0.4097298828125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,46,1.551771,17459.4765625,-322.2771911621094,9053.3154296875,0.749973828125,-0.0,0.273812724609375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,47,1.616042,21696.408203125,-1986.04833984375,11181.533203125,0.96182041015625,-0.0335256435546875,0.34617212890625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,48,1.680262,15386.84765625,-4424.57763671875,1783.9827880859375,0.6463423828125,-0.11643563964843749,0.026655414794921874,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,49,1.744307,15400.05859375,-2761.5791015625,15228.455078125,0.6470029296875,-0.059893689453124996,0.48376747265625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,50,1.792308,14120.4208984375,116.24051666259766,9754.70703125,0.583021044921875,0.0,0.2976600390625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,51,1.856446,15246.4052734375,-2575.3466796875,12753.658203125,0.639320263671875,-0.053561787109375,0.39962437890624997,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,52,1.920255,17219.171875,-6285.30517578125,13345.544921875,0.73795859375,-0.1797003759765625,0.41974852734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
|
@@ -0,0 +1,34 @@
|
||||
frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped
|
||||
20,0.0,2026-09-30T01:50:28.870+00:00,1,1,1,0
|
||||
21,0.064133,2026-09-30T01:50:28.935+00:00,1,2,1,0
|
||||
22,0.112021,2026-09-30T01:50:28.983+00:00,1,3,1,0
|
||||
23,0.17623,2026-09-30T01:50:29.048+00:00,1,4,1,0
|
||||
24,0.24043,2026-09-30T01:50:29.111+00:00,1,5,1,0
|
||||
25,0.304304,2026-09-30T01:50:29.175+00:00,1,6,1,0
|
||||
26,0.352255,2026-09-30T01:50:29.231+00:00,1,7,1,0
|
||||
27,0.416172,2026-09-30T01:50:29.287+00:00,1,8,1,0
|
||||
28,0.480077,2026-09-30T01:50:29.351+00:00,1,9,1,0
|
||||
29,0.543786,2026-09-30T01:50:29.414+00:00,1,10,1,0
|
||||
30,0.591816,2026-09-30T01:50:29.462+00:00,1,11,1,0
|
||||
31,0.656365,2026-09-30T01:50:29.526+00:00,1,12,1,0
|
||||
32,0.720031,2026-09-30T01:50:29.590+00:00,1,13,1,0
|
||||
33,0.784173,2026-09-30T01:50:29.654+00:00,1,14,1,0
|
||||
34,0.831679,2026-09-30T01:50:29.702+00:00,1,15,1,0
|
||||
35,0.896141,2026-09-30T01:50:29.766+00:00,1,16,1,0
|
||||
36,0.960091,2026-09-30T01:50:29.830+00:00,1,17,1,0
|
||||
37,1.024524,2026-09-30T01:50:29.895+00:00,1,18,1,0
|
||||
38,1.071701,2026-09-30T01:50:29.942+00:00,1,19,1,0
|
||||
39,1.135903,2026-09-30T01:50:30.006+00:00,1,20,1,0
|
||||
40,1.199965,2026-09-30T01:50:30.070+00:00,1,21,1,0
|
||||
41,1.264015,2026-09-30T01:50:30.134+00:00,1,22,1,0
|
||||
42,1.311786,2026-09-30T01:50:30.182+00:00,1,23,1,0
|
||||
43,1.376278,2026-09-30T01:50:30.247+00:00,1,24,1,0
|
||||
44,1.439864,2026-09-30T01:50:30.310+00:00,1,25,1,0
|
||||
45,1.503909,2026-09-30T01:50:30.373+00:00,1,26,1,0
|
||||
46,1.551771,2026-09-30T01:50:30.422+00:00,1,27,1,0
|
||||
47,1.616042,2026-09-30T01:50:30.486+00:00,1,28,1,0
|
||||
48,1.680262,2026-09-30T01:50:30.552+00:00,1,29,1,0
|
||||
49,1.744307,2026-09-30T01:50:30.615+00:00,1,30,1,0
|
||||
50,1.792308,2026-09-30T01:50:30.664+00:00,1,31,1,0
|
||||
51,1.856446,2026-09-30T01:50:30.728+00:00,1,32,1,0
|
||||
52,1.920255,2026-09-30T01:50:30.792+00:00,1,33,1,0
|
||||
|
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"format_version": "1.1",
|
||||
"session_id": 2,
|
||||
"calibration_id": "171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348",
|
||||
"started_at": "2026-09-30T01:50:28+00:00",
|
||||
"ended_at": "2026-09-30T01:50:30+00:00",
|
||||
"video": {
|
||||
"video": true,
|
||||
"lossless_frames": false,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"segment_seconds": 300.0,
|
||||
"max_queue": 2048,
|
||||
"max_queue_bytes": 536870912,
|
||||
"preroll_frames": 30,
|
||||
"preroll_bytes": 201326592,
|
||||
"lossy_note": "MP4 is a lossy container: this is the pre-processing camera image, not a lossless record of the raw sensor data."
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"at": "2026-09-30T01:50:28.848+00:00",
|
||||
"mono_t": null,
|
||||
"frame_id": -1,
|
||||
"kind": "record_started",
|
||||
"detail": "mode=video_data"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-30T01:50:30.825+00:00",
|
||||
"mono_t": null,
|
||||
"frame_id": -1,
|
||||
"kind": "record_stopped",
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"frames": 33,
|
||||
"video_frames": 33,
|
||||
"segments": 1,
|
||||
"dropped_data": 0,
|
||||
"dropped_video": 0,
|
||||
"dropped_gaps": 0,
|
||||
"unrecorded_gaps": 0,
|
||||
"resampled_video": 0,
|
||||
"write_errors": 0,
|
||||
"failed": false,
|
||||
"last_error": "",
|
||||
"duration_s": 1.920255,
|
||||
"bytes_written": 7487099,
|
||||
"rejected_after_failure": 0
|
||||
},
|
||||
"calibration_snapshots": [],
|
||||
"preroll_frames": 30,
|
||||
"preroll_segment": "preroll_0001.mp4",
|
||||
"files": {
|
||||
"forces": "forces.csv",
|
||||
"frames": "frames.csv",
|
||||
"events": "events.csv",
|
||||
"video": "raw_%04d.mp4",
|
||||
"lossless_frames": null
|
||||
},
|
||||
"device": {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "D:\\orisys_ws\\orisys-app\\dist\\OrisysStudio\\config\\sensors\\box\\sensor.json",
|
||||
"config_sha256": "27343dbbfc768c4d43ea48917ae2f9fbc9f91ba285b049b4c58a04673e3bec1e",
|
||||
"frame": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"measured_fps": 16.6642954760577
|
||||
}
|
||||
},
|
||||
"versions": {
|
||||
"app": "0.1.0",
|
||||
"shared": "0.1.0",
|
||||
"shared_api": "0.1",
|
||||
"sdk": "0.5.3"
|
||||
},
|
||||
"capture": {
|
||||
"requested": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"fourcc": "MJPG"
|
||||
},
|
||||
"negotiated": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.00003000003,
|
||||
"fourcc": "MJPG",
|
||||
"backend": "DSHOW"
|
||||
},
|
||||
"dropped_frames": 18,
|
||||
"measured_fps": 16.6642954760577
|
||||
},
|
||||
"calibration": {
|
||||
"config_ref": "D:\\orisys_ws\\orisys-app\\dist\\OrisysStudio\\config\\sensors\\box\\sensor.json",
|
||||
"force_calibrated": true,
|
||||
"version": "171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348",
|
||||
"sn": "",
|
||||
"device_sn": ""
|
||||
},
|
||||
"depth": {
|
||||
"stored": false,
|
||||
"replay": "none",
|
||||
"quantity": "relative deformation / algorithm response, not a calibrated length",
|
||||
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
|
||||
"config_sha256": "27343dbbfc768c4d43ea48917ae2f9fbc9f91ba285b049b4c58a04673e3bec1e",
|
||||
"sdk_version": "0.5.3"
|
||||
},
|
||||
"conventions": {
|
||||
"axis_order": [
|
||||
"FN",
|
||||
"FX",
|
||||
"FY"
|
||||
],
|
||||
"force_unit": "N when calibrated, otherwise raw counts",
|
||||
"time_base": "t_rel_s is relative to the first recorded frame; an event that predates it has no relative base and is left empty",
|
||||
"frame_id": "monotonic per acquisition session"
|
||||
},
|
||||
"filter": {
|
||||
"measurement": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"flow_ema_applied": false,
|
||||
"hhd_downsample": 0,
|
||||
"dis_mode": 3
|
||||
},
|
||||
"readout": {
|
||||
"force_median_window": 1,
|
||||
"force_median_applied": false,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": 30.0,
|
||||
"force_lowpass_rate_auto": true,
|
||||
"force_lowpass_applied": false,
|
||||
"sample_rate_source": "config"
|
||||
},
|
||||
"describe": "none",
|
||||
"source": "sensor.filters_state",
|
||||
"show_unfiltered": false
|
||||
},
|
||||
"recording_mode": "video_data",
|
||||
"video_config": {
|
||||
"video": true,
|
||||
"lossless_frames": false,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"segment_seconds": 300.0,
|
||||
"max_queue": 2048,
|
||||
"max_queue_bytes": 536870912,
|
||||
"preroll_frames": 30,
|
||||
"preroll_bytes": 201326592,
|
||||
"lossy_note": "MP4 is a lossy container: this is the pre-processing camera image, not a lossless record of the raw sensor data."
|
||||
},
|
||||
"visualization3d": {
|
||||
"block_version": 1,
|
||||
"enabled": false,
|
||||
"available": false,
|
||||
"reason": "此传感器类型没有三维场景",
|
||||
"scene": {
|
||||
"path": "",
|
||||
"keys": "{'verify_key': False, 'content_key': False, 'require_signature': False}"
|
||||
},
|
||||
"gl": {},
|
||||
"display": {},
|
||||
"stream": {}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,10 @@ Where a failure would be invisible:
|
||||
* a box result is recorded on the box sample grid, so the canvas is compared with
|
||||
``draw_arrow_display`` on the recovered field.
|
||||
|
||||
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's
|
||||
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a
|
||||
silently skipped end-to-end test is how a broken workflow ships.
|
||||
The fixture clip is the project's recorded box footage, tracked in the repository beside
|
||||
this test (``tests/data/box_short_20260930``, see its README). If it is absent the test
|
||||
**fails** (``_require_clip``) rather than skipping: a silently skipped end-to-end test is
|
||||
how a broken workflow ships.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -65,17 +66,12 @@ from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
|
||||
|
||||
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
REPO = APP.parents[1]
|
||||
EVIDENCE = (
|
||||
REPO
|
||||
/ "dist"
|
||||
/ "OrisysStudio"
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
SOURCE_CLIP = EVIDENCE / "raw_0001.mp4"
|
||||
#: The one real input, tracked in the repository beside this test: a 33-frame box
|
||||
#: recording (2026-09-30, session 2, 1.9 s) copied out of ``dist/`` before that build
|
||||
#: output was next repackaged. See ``data/README.md`` for what it is and why it is here
|
||||
#: rather than generated.
|
||||
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
SOURCE_CLIP = DATA / "raw_0001.mp4"
|
||||
|
||||
|
||||
def _require_clip() -> Path:
|
||||
@@ -90,7 +86,8 @@ def _require_clip() -> Path:
|
||||
"""
|
||||
assert SOURCE_CLIP.is_file(), (
|
||||
"the bare-video test needs recorded box footage at "
|
||||
f"{SOURCE_CLIP}; copy it there or run the build that produces dist/"
|
||||
f"{SOURCE_CLIP}; it is tracked in the repository (apps/studio/tests/data/README.md), "
|
||||
"so restore it rather than skipping the test"
|
||||
)
|
||||
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
|
||||
return SOURCE_CLIP
|
||||
@@ -396,16 +393,25 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
|
||||
"the arrow view is the video's raw picture"
|
||||
)
|
||||
|
||||
# ---- overlay: arrows on the captured frame, not on the black placeholder -
|
||||
# ---- overlay: a result whose captured base cannot be rebuilt is refused -----
|
||||
# The overlay views paint on the live-shaped picture (UV unroll / box stitch). A
|
||||
# stored result does not contain it — replay rebuilds it from the result's
|
||||
# provenance — and for a box result that rebuild fails on this footage (measured:
|
||||
# every frame; the SDK stitch raises inside `Sensor.get_img`). Since 425fa0d the
|
||||
# panel **refuses** instead of painting arrows on a resized fisheye, and the refusal
|
||||
# with its reason is the honest thing to assert here.
|
||||
#
|
||||
# It used to assert painted arrows over the captured frame. That claim was only ever
|
||||
# green because the footage it needed had been deleted from `dist/`; the box overlay
|
||||
# base is itself broken (tracked separately) — this test states what the operator
|
||||
# actually gets, not what an earlier build happened to draw.
|
||||
set_mode(window, ARROW_STITCHED)
|
||||
QApplication.processEvents()
|
||||
overlay = panel_bgr(window)
|
||||
assert painted(overlay, canvas) > 0, "the overlay painted no arrows"
|
||||
assert float(np.mean(overlay)) > 8.0, (
|
||||
"the overlay is still a near-black canvas; the captured frame was not under the arrows"
|
||||
assert window.arrow_panel._source_image is None, (
|
||||
"the overlay drew something without the captured frame under it"
|
||||
)
|
||||
assert not np.array_equal(overlay, middle_shot), (
|
||||
"the overlay view is the pure-colour canvas"
|
||||
assert window.arrow_panel.image_label.text() == t("panels.arrow_capture_unavailable"), (
|
||||
window.arrow_panel.image_label.text()
|
||||
)
|
||||
|
||||
# ---- the depth view, from the same stored frame -------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ button, is what tells stopped from paused.
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
from _studio_helpers import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_transport_is_idle_with_no_session(make_window):
|
||||
|
||||
@@ -13,12 +13,13 @@ What is being defended, in the order the plan asked for it:
|
||||
5. **frames are addressed by id.** An id the recording does not hold is refused, never
|
||||
coerced into an index.
|
||||
|
||||
The fixtures use real footage — the 33-frame box recording under ``dist/`` and the
|
||||
shipped finger configuration — driven through the **finger** chain, which is what gives
|
||||
the result a validity mask and therefore a static candidate grid. The pass is O(5 s)
|
||||
and is shared per module. Where the SDK or the footage is absent the tests skip rather
|
||||
than fake a result: a synthetic result would not defend the id span (20…52), the
|
||||
keyframe identity or the sample round trip.
|
||||
The fixtures use real footage — the 33-frame box recording tracked beside this test
|
||||
(``data/box_short_20260930``) and the shipped finger configuration — driven through the
|
||||
**finger** chain, which is what gives the result a validity mask and therefore a static
|
||||
candidate grid. The pass is O(5 s) and is shared per module. Where the SDK is absent the
|
||||
tests skip rather than fake a result: a synthetic result would not defend the id span
|
||||
(20…52), the keyframe identity or the sample round trip. Where the *footage* is absent they
|
||||
fail, naming the file: a missing input is not an environment that should go green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,16 +74,13 @@ from orisys_studio.replay_analysis import (
|
||||
ResultPolicyError,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
STUDIO = REPO / "dist" / "OrisysStudio"
|
||||
RECORDING = (
|
||||
STUDIO
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json"
|
||||
FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703"
|
||||
#: The tracked test input (``apps/studio/tests/data/README.md`` says where it came from).
|
||||
RECORDING = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
#: The repository's own sensor configuration. These used to be read from the packaged copy
|
||||
#: under ``dist/``, which a build can delete — the same reason the footage moved here.
|
||||
CONFIG = Path(__file__).resolve().parents[1] / "config"
|
||||
FINGER_CONFIG = CONFIG / "sensors" / "finger" / "sensor.json"
|
||||
FINGER_EXPORT = CONFIG / "sensors" / "finger" / "assets" / "30fps-0703"
|
||||
|
||||
#: The evidence recording's own frame ids. Deliberately not 0…32: an implementation
|
||||
#: that used an index would pass every test written against a 0-based recording.
|
||||
@@ -91,13 +89,22 @@ LAST_FRAME_ID = 52
|
||||
|
||||
|
||||
def _require_inputs() -> None:
|
||||
"""The footage is committed, so its absence is a failure rather than a skip."""
|
||||
missing = [
|
||||
str(path)
|
||||
for path in (RECORDING / "frames.csv", RECORDING / "raw_0001.mp4", FINGER_CONFIG)
|
||||
for path in (
|
||||
RECORDING / "frames.csv",
|
||||
RECORDING / "raw_0001.mp4",
|
||||
FINGER_CONFIG,
|
||||
)
|
||||
if not path.exists()
|
||||
]
|
||||
if missing:
|
||||
pytest.skip("analysis inputs are not present: " + ", ".join(missing))
|
||||
assert not missing, (
|
||||
"analysis inputs are missing: "
|
||||
+ ", ".join(missing)
|
||||
+ ". The recording is committed (apps/studio/tests/data/README.md); restore it "
|
||||
"rather than skipping."
|
||||
)
|
||||
pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass")
|
||||
|
||||
|
||||
@@ -479,6 +486,11 @@ class _Host:
|
||||
if paint_2d:
|
||||
from orisys_app_shared.acquisition.image_backend import ImageFrame
|
||||
|
||||
# The fields the window's own sink puts on the frame it draws
|
||||
# (``app_window.py:2835-2851``). ``depth_valid``/``flow_valid`` are what
|
||||
# ``plan_image`` reads to decide whether the frame can be drawn as that
|
||||
# view, so a stub that leaves them out cannot answer "the depth view is
|
||||
# real" — which is exactly what the depth test below asks.
|
||||
self.frames.append(
|
||||
ImageFrame(
|
||||
getattr(packet, "processed_frame", None),
|
||||
@@ -487,6 +499,10 @@ class _Host:
|
||||
valid_mask=getattr(packet, "valid_mask", None),
|
||||
source=image_source or "replay",
|
||||
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||
flow_valid=getattr(packet, "flow_valid", None),
|
||||
flow_frame_id=getattr(packet, "flow_frame_id", None),
|
||||
depth_valid=getattr(packet, "depth_valid", None),
|
||||
depth_frame_id=getattr(packet, "depth_frame_id", None),
|
||||
arrow_policy=arrow_policy,
|
||||
result_available=True,
|
||||
)
|
||||
@@ -1652,6 +1668,8 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.analysis.model import Provenance
|
||||
|
||||
class _Reader:
|
||||
complete = True
|
||||
key = "stitch"
|
||||
@@ -1659,6 +1677,14 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
|
||||
frame_ids = np.array([1], dtype=np.int64)
|
||||
times = np.array([0.0])
|
||||
keyframes: dict = {}
|
||||
#: A real reader always carries one (``AnalysisReader.__init__`` builds it from
|
||||
#: the result's metadata) and ``AnalysisSession`` reads it: *where* the samples
|
||||
#: are travels from ``provenance.policy``, and the overlay base opens from the
|
||||
#: rest. ``829d2c1`` added those reads; this stub predated them and the default
|
||||
#: ``Provenance()`` is the honest stand-in — an empty policy, so the session
|
||||
#: falls back to the display policy exactly as it does for a result that stored
|
||||
#: no grid.
|
||||
provenance = Provenance()
|
||||
|
||||
def mask(self):
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,7 @@ chain — and assert on the picture the panel actually shows, not on a state a h
|
||||
built.
|
||||
|
||||
The recording is copied before use: a pass writes its result into the recording's own
|
||||
directory, and nothing may write into ``dist/``.
|
||||
directory, and the tracked copy the tests read must never be written to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,11 +34,11 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
DERIVED_MODES,
|
||||
RECORDED,
|
||||
plan_image,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
select_flow_samples,
|
||||
@@ -46,14 +46,8 @@ from orisys_app_shared.scene3d.visual_policy import (
|
||||
from orisys_studio.replay import MODE_RECORDING
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
STUDIO = REPO / "dist" / "OrisysStudio"
|
||||
SOURCE_RECORDING = (
|
||||
STUDIO
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
#: The tracking-visible test input (``data/README.md`` explains where it came from).
|
||||
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
#: What makes a directory a recording for the application, and all a replay reads.
|
||||
RECORDING_FILES = (
|
||||
"frames.csv",
|
||||
@@ -61,29 +55,41 @@ RECORDING_FILES = (
|
||||
"events.csv",
|
||||
"session.json",
|
||||
"raw_0001.mp4",
|
||||
"preroll_0001.mp4",
|
||||
)
|
||||
#: Written by a recording and read by nothing a replay needs: ``preroll_segment()``
|
||||
#: answers ``None`` when the file is absent, so it is copied when it is there and never
|
||||
#: required. It stays out of the repository because it is a third of the data's size.
|
||||
OPTIONAL_FILES = ("preroll_0001.mp4",)
|
||||
|
||||
|
||||
def _require_recording() -> None:
|
||||
"""The recording must be here, and its absence is a failure, not a skip.
|
||||
|
||||
The old version skipped: the tests went green on a machine where the footage had
|
||||
been repackaged away, which is exactly how eleven failing tests went unnoticed.
|
||||
"""
|
||||
missing = [
|
||||
str(DATA / name) for name in RECORDING_FILES if not (DATA / name).is_file()
|
||||
]
|
||||
assert not missing, (
|
||||
"the replay wiring tests need the tracked box test recording at "
|
||||
f"{DATA}; missing: {', '.join(missing)}. It is committed "
|
||||
"(apps/studio/tests/data/README.md); restore it rather than skipping."
|
||||
)
|
||||
|
||||
|
||||
def _require_inputs() -> None:
|
||||
missing = [
|
||||
str(SOURCE_RECORDING / name)
|
||||
for name in ("frames.csv", "raw_0001.mp4", "session.json")
|
||||
if not (SOURCE_RECORDING / name).exists()
|
||||
]
|
||||
if missing:
|
||||
pytest.skip(
|
||||
"the replay wiring tests need the evidence recording: " + ", ".join(missing)
|
||||
)
|
||||
_require_recording()
|
||||
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
|
||||
|
||||
|
||||
def _copy_recording(destination: Path) -> Path:
|
||||
"""The evidence recording, copied: nothing may write into ``dist/``."""
|
||||
"""The test recording, copied: nothing may write into the tracked data."""
|
||||
_require_recording()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for name in RECORDING_FILES:
|
||||
source = SOURCE_RECORDING / name
|
||||
if source.exists():
|
||||
for name in RECORDING_FILES + OPTIONAL_FILES:
|
||||
source = DATA / name
|
||||
if source.is_file():
|
||||
shutil.copy2(source, destination / name)
|
||||
return destination
|
||||
|
||||
@@ -303,11 +309,16 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
||||
"a pass ran over a recording that already had a result"
|
||||
)
|
||||
|
||||
# ``depth_arrows`` joined the derived views in ``091d0fd`` ("二维增加深度叠箭头"), which
|
||||
# updated ``ui_check`` and the live-view tests but not this one: it was skipping on the
|
||||
# missing footage at the time, so nothing caught it. The claim is unchanged — the
|
||||
# recording's own picture stays one step away from *every* derived view.
|
||||
assert window.arrow_panel.view_modes() == (
|
||||
RECORDED,
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
), "the recording's own picture must stay one step away from the derived views"
|
||||
assert window.arrow_panel.view_mode() in DERIVED_MODES, (
|
||||
"the operator was left on the camera picture although a result is served"
|
||||
@@ -343,14 +354,22 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
||||
"the arrow view is the recording's raw picture"
|
||||
)
|
||||
|
||||
# The depth view is the stored map, drawn through the window's own backend. This
|
||||
# result's map is exactly zero on every frame it holds — the box chain's depth is
|
||||
# flat on this footage — and it is still a stored array of the result's own shape,
|
||||
# so the pixels are checked against it rather than against a flat placeholder.
|
||||
# The depth view is the stored map, drawn through the window's own backend. The
|
||||
# map is this result's own array of the result's own shape, so the pixels are checked
|
||||
# against it rather than against a placeholder.
|
||||
#
|
||||
# It is **not** all-zero, which is what this asserted when the recording was first
|
||||
# played (a "flat depth" note): measured on the restored footage with the shipped box
|
||||
# config, the stored map has 8926 non-zero cells of 365², i.e. the chain does return
|
||||
# a depth field here. Flatness was a property of an earlier run's result, not a
|
||||
# product promise; the claim this block exists for — the depth view's pixels are
|
||||
# ``depth_map_to_bgr`` of the stored frame — is asserted below and is unchanged.
|
||||
# The colour-scale caption names the range only.
|
||||
_set_mode(window, DEPTH)
|
||||
target = active_frame
|
||||
assert target.depth is not None and target.depth_nonzero == 0
|
||||
assert target.depth is not None and target.depth_nonzero > 0, (
|
||||
f"the stored depth map holds no value at all: {target.depth_nonzero} non-zero cells"
|
||||
)
|
||||
QApplication.processEvents()
|
||||
drawn = captured[-1]
|
||||
assert drawn.frame_id == target.frame_id
|
||||
|
||||
@@ -49,13 +49,13 @@ from orisys_studio.scene3d.asset_source import (
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
show_window as _show,
|
||||
)
|
||||
|
||||
@@ -1131,10 +1131,14 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
|
||||
assert "回放" in label or "Replay" in label, label
|
||||
|
||||
# A running pass means the pixels are recomputed, and the label must say so.
|
||||
# Compared through the locale key rather than a word from it: ``c0a10e6`` reworded
|
||||
# this line ("重算" → "分析结果:非录制时的测量值"), and the claim being defended is
|
||||
# "*which* source the pane says it is showing", not the phrasing.
|
||||
window._recompute_running = True
|
||||
window._apply_panel_visibility()
|
||||
label = view.panel.source_label.text()
|
||||
assert "重算" in label or "Recomputed" in label, label
|
||||
assert view.panel.source_label.text() == t("scene3d.source_recompute"), (
|
||||
view.panel.source_label.text()
|
||||
)
|
||||
|
||||
# Stopping the pass must clear the picture, not leave the last frame up.
|
||||
window._recompute_running = False
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import ui_check
|
||||
from orisys_app_shared.i18n import t
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
from _studio_helpers import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
|
||||
|
||||
@@ -30,7 +30,7 @@ import ui_check
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio.ui import elements
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from tests.conftest import finish_run, show_window
|
||||
from _studio_helpers import finish_run, show_window
|
||||
|
||||
VIEWS = ("live", "replay", "calibration")
|
||||
SIZE = (1440, 880)
|
||||
|
||||
@@ -7,16 +7,16 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
import ui_check
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
show_window as _show,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
visible_descendants,
|
||||
)
|
||||
|
||||
@@ -193,13 +193,27 @@ def test_status_chip_tracks_phase(make_window):
|
||||
|
||||
|
||||
def test_run_tooltip_explains_disabled_reason(make_window):
|
||||
"""A video source never starts a live run, and the rail says why.
|
||||
|
||||
The button used to become enabled when a video file was chosen here; the live view
|
||||
is a camera only (``test_live_run_refuses_a_video_source`` pins the same rule from
|
||||
the other side), so choosing a file leaves Run unavailable and the reason names the
|
||||
camera instead of a missing file.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
|
||||
assert not window.toolbar.btn_run.isEnabled()
|
||||
assert window.toolbar.btn_run.toolTip()
|
||||
|
||||
window.toolbar.set_video_path("C:/tmp/demo.mp4")
|
||||
window._update_run_enabled()
|
||||
assert window.toolbar.btn_run.isEnabled()
|
||||
reason = t("toolbar.run_video_in_replay")
|
||||
assert not window.toolbar.btn_run.isEnabled(), (
|
||||
"a video source still starts a live run"
|
||||
)
|
||||
assert window.toolbar.btn_run.toolTip() == reason, window.toolbar.btn_run.toolTip()
|
||||
assert window.toolbar.run_hint.text() == reason, window.toolbar.run_hint.text()
|
||||
|
||||
|
||||
def test_toolbar_run_button_is_labelled(make_window):
|
||||
@@ -1501,7 +1515,18 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||
assert dialog.calib_title.text() == t("sensor.calib_section")
|
||||
assert dialog.version_label.text() and dialog.axes_label.text()
|
||||
assert dialog.export_label.text() == t("sensor.export_none")
|
||||
assert not dialog.btn_apply.isEnabled(), "applying with no export"
|
||||
# 应用 is the *live calibration* action, not "apply the last export" (``3d2fb0c``
|
||||
# moved it here): it is offered with no export because there is always a choice to
|
||||
# apply — the profile's declared file, a browsed one, or raw force — and with no
|
||||
# session running it only remembers that choice (``sensor_dialog.py:324`` enables it
|
||||
# unconditionally; ``apply_live_calibration_choice`` reports a problem instead of
|
||||
# raising, so an offered button cannot fail).
|
||||
assert dialog.btn_apply.isEnabled(), "the live calibration cannot be applied"
|
||||
assert dialog.btn_apply.text() == t("cal.apply")
|
||||
assert dialog.btn_apply.toolTip() == t("sensor.live_calibration_apply_tip")
|
||||
assert dialog.calibration_combo.currentData() in ("default", "none"), (
|
||||
dialog.calibration_combo.currentData()
|
||||
)
|
||||
|
||||
# Changing the product in the dialog reaches the window and the rail.
|
||||
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
|
||||
@@ -1659,11 +1684,17 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
|
||||
|
||||
|
||||
def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||
"""One layout for both views; the only difference is the source slot.
|
||||
"""The same panes and the same transport as the live view; two stated differences.
|
||||
|
||||
The replay used to carry its own bar (a progress slider plus a recording
|
||||
picker). It is gone: the chart's cursor is the playhead, its header carries
|
||||
the transport, and the rail shows the video instead of the camera.
|
||||
|
||||
What differs is deliberate (``321e52f``: "keep each preset's panel priority"):
|
||||
the replay gives the **charts** the room, so the 2D panel is narrower than in the
|
||||
live view and the charts are wider. This used to assert the splitter widths were
|
||||
*equal*, which was true before that change and is the opposite of what the
|
||||
layout now promises.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
@@ -1673,13 +1704,23 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||
|
||||
QApplication.processEvents()
|
||||
live_sizes = tuple(window.content_splitter.sizes())
|
||||
# Slot 1 is the 2D panel and slot 2 the charts (0 and 3 are the mesh and calibration
|
||||
# panels, both hidden here, hence their zero width).
|
||||
assert live_sizes[1] > 0 and live_sizes[2] > 0, live_sizes
|
||||
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
assert not hasattr(window, "replay_bar"), "the replay still has a separate bar"
|
||||
assert tuple(window.content_splitter.sizes()) == live_sizes, (
|
||||
"the replay view is not laid out like the live view"
|
||||
replay_sizes = tuple(window.content_splitter.sizes())
|
||||
assert replay_sizes[1] < live_sizes[1], (
|
||||
f"the replay 2D panel is not narrower than the live one: {replay_sizes} vs {live_sizes}"
|
||||
)
|
||||
assert replay_sizes[2] > live_sizes[2], (
|
||||
f"the replay charts did not take that room: {replay_sizes} vs {live_sizes}"
|
||||
)
|
||||
# The priority behind those numbers is ``view_layout.py``'s per-preset stretch
|
||||
# factors (arrow 4, charts 5 in replay). The splitter exposes no getter for them in
|
||||
# PySide6, so the widths above are what this test can read.
|
||||
assert window.toolbar.btn_replay_video.isVisible(), "no video picker while replaying"
|
||||
assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video")
|
||||
assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip")
|
||||
@@ -2949,6 +2990,14 @@ def test_the_rail_puts_result_identity_before_a_long_video_name(
|
||||
|
||||
|
||||
def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monkeypatch):
|
||||
"""A result from the previous sensor must not reach the new sensor's 3D scene.
|
||||
|
||||
Driven through the window's public packet entry (``show_analysis_packet``, which
|
||||
``cf49b08``/``28e955a`` made the replay path; the private ``_show_analysis_frame``
|
||||
it replaced no longer exists). The claim is unchanged: a Finger window refuses a
|
||||
packet whose result says it came from a Box, and accepts the same packet once the
|
||||
result names the sensor on screen.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
window = make_window(sensor_type="finger")
|
||||
@@ -2968,10 +3017,10 @@ def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monke
|
||||
monkeypatch.setattr(window, "_connect_analysis_display", lambda _panel: True)
|
||||
monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None)
|
||||
try:
|
||||
window._show_analysis_frame("old box packet")
|
||||
assert submitted == []
|
||||
window.show_analysis_packet("old box packet")
|
||||
assert submitted == [], "an old sensor's depth reached the new sensor's scene"
|
||||
session.reader.provenance.sensor_type = "finger"
|
||||
window._show_analysis_frame("finger packet")
|
||||
window.show_analysis_packet("finger packet")
|
||||
assert submitted == ["finger packet"]
|
||||
finally:
|
||||
panel._view = real_view
|
||||
|
||||
@@ -1711,8 +1711,9 @@ def main() -> int:
|
||||
window.force_plots.mode() == "follow",
|
||||
"the replay view browses before a recording is open",
|
||||
)
|
||||
# The replay view is the live arrangement, measured rather than assumed: the
|
||||
# splitter hands out its handles' width, so compare like with like.
|
||||
# The replay view shows the live view's panes, and its preset gives the *charts* the
|
||||
# room (321e52f: "keep each preset's panel priority"), so the splitter is measured
|
||||
# rather than assumed: slot 1 is the 2D panel, slot 2 the charts.
|
||||
window.brand_bar.btn_live.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
@@ -1722,8 +1723,13 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
replay_sizes = tuple(window.content_splitter.sizes())
|
||||
check(
|
||||
replay_sizes == live_sizes,
|
||||
f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}",
|
||||
live_sizes[1] > 0 and live_sizes[2] > 0,
|
||||
f"the live view lost a pane: {live_sizes}",
|
||||
)
|
||||
check(
|
||||
replay_sizes[1] < live_sizes[1] and replay_sizes[2] > live_sizes[2],
|
||||
"the replay view does not give the charts more room than the live view: "
|
||||
f"{replay_sizes} vs {live_sizes}",
|
||||
)
|
||||
check(
|
||||
not hasattr(window, "replay_bar"),
|
||||
@@ -2368,13 +2374,23 @@ def main() -> int:
|
||||
f"a failed start left the recorder in {fail_window.recorder.state!r}",
|
||||
)
|
||||
|
||||
# ...and the operator can simply try again.
|
||||
# ...and the operator can simply try again. ``start()`` returns once the directory
|
||||
# exists; the recorder itself moves to ``recording`` on its own writer thread, so
|
||||
# reading the flag back immediately is a race (measured: one full run in three
|
||||
# reported "refused to retry" although the retry had been accepted). The claim is
|
||||
# "retrying works", not "it worked inside one event loop", so wait for it — bounded,
|
||||
# like the write-error poll below.
|
||||
_app_module.recordings_dir = lambda: scratch_fail
|
||||
fail_window.toolbar.btn_record.setChecked(True)
|
||||
app.processEvents()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not fail_window.recorder.is_recording and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
check(
|
||||
fail_window.recorder.is_recording,
|
||||
"the recorder refused to retry after a failed directory",
|
||||
"the recorder refused to retry after a failed directory "
|
||||
f"(state {fail_window.recorder.state!r} after 2 s)",
|
||||
)
|
||||
|
||||
# A writer that keeps failing must fail the recording, and the window must
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ loaded" can only be printed if those modules imported.)
|
||||
|
||||
- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
|
||||
GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
|
||||
(`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`).
|
||||
(`apps/studio/tests/test_studio_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`).
|
||||
`setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one
|
||||
binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover
|
||||
PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes
|
||||
|
||||
@@ -367,7 +367,7 @@ Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形
|
||||
### 11.1 依赖
|
||||
|
||||
- 继续使用项目验证过的 Python、**PySide6**、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
|
||||
Qt 绑定选 PySide6(LGPLv3)而不是 PyQt5(GPLv3-or-commercial):两个 app 都只允许 PySide6,由 `apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 静态强制;构建环境里出现第二个 binding 会让 `pyqtgraph` 绑错(`setup.ps1` 会卸载 PyQt5/PyQt6/PySide2,`main.py` 在 import pyqtgraph 之前设 `PYQTGRAPH_QT_LIB=PySide6`)。
|
||||
Qt 绑定选 PySide6(LGPLv3)而不是 PyQt5(GPLv3-or-commercial):两个 app 都只允许 PySide6,由 `apps/studio/tests/test_studio_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 静态强制;构建环境里出现第二个 binding 会让 `pyqtgraph` 绑错(`setup.ps1` 会卸载 PyQt5/PyQt6/PySide2,`main.py` 在 import pyqtgraph 之前设 `PYQTGRAPH_QT_LIB=PySide6`)。
|
||||
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
|
||||
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
|
||||
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
|
||||
|
||||
@@ -191,7 +191,7 @@ graph TB
|
||||
| 规则 | 是否成立 | 证据 |
|
||||
|---|---|---|
|
||||
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
|
||||
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `tests/test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) |
|
||||
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `test_studio_binding_purity.py`/`test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) |
|
||||
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
|
||||
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK |
|
||||
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
|
||||
@@ -305,7 +305,7 @@ graph TB
|
||||
|---|---|---|---|---|
|
||||
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
|
||||
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
|
||||
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
|
||||
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_studio_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
|
||||
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
|
||||
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
|
||||
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
代理开始验证前说明“改动 → 所选测试 → 扩大理由”。定向测试通过后结束验证;没有新增改动、失败或未解决风险时,不追加全量。全量只在最终候选状态运行一次;之后仅复验新改动涉及的测试,除非新改动再次触及公共基础。
|
||||
|
||||
**提交前跑一次 `tools/gate.ps1`。** 定向测试用于开发中,门禁用于“这一版可以交给别人”的那一刻:它依次跑改动文件的 lint、studio 全量、shared 全量、`ui_check.py`,并要求 `tools/known_failures.txt` 为空、跳过数与收集数在 `tools/skip_budget.txt` 之内。可选 `pre-push` 钩子会调用它(安装方式见 [README](../README.md),不自动安装)。门禁平台是 **Windows**:测试渲染真实 Qt 控件,而 [测试修复与门禁计划](test-repair-plan.zh.md) 第 3 节的 11、13 号只在 Linux 上失败,Linux 的通过不等于同一结论。
|
||||
|
||||
不能通过跳过失败、删除断言或把必需测试标成可选来提速。测试缺依赖、收集为空或意外跳过必须报告。间歇性错误先保留证据,查明影响后只重跑受影响范围;不循环重跑全量碰运气。
|
||||
|
||||
## 拟提供的入口
|
||||
@@ -64,6 +66,8 @@
|
||||
| 3 | 接入代理执行规范,约 1 小时 | 仓库 AGENTS 链接执行指南,说明默认范围及停止条件;登记文档并运行文档门禁。不改工作区通用测试标准。 |
|
||||
| 4 | 验证实际选择,1–2 小时 | 分别选择 Autocal 记录修复、Studio 局部界面、shared 录制契约三个场景;核对收集清单包含必要消费者,至少实际执行一个受影响组。 |
|
||||
|
||||
门禁平台:**Windows**。上表的定向验证可以在任何平台开发,但“提交前通过 `tools/gate.ps1`”这一条以 Windows 结果为准。
|
||||
|
||||
任务 2 依赖任务 1,任务 3 依赖入口可用,任务 4 最后验收。建议一名工程师顺序完成,总计约 5–8 小时;无需多个代理并行修改公共入口。
|
||||
|
||||
任务 1 后核对依赖覆盖,任务 2–3 后核对入口与规则一致。验收只运行调度工具测试、必要功能组和文档检查;不为证明调度工具可用而运行三个产品的全部测试。
|
||||
|
||||
+24
-19
@@ -77,36 +77,41 @@
|
||||
|
||||
按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。
|
||||
|
||||
- task_id:test-repair;round:1;updated_at:2026-10-18 17:45 +08:00
|
||||
- status:ready;owner:Claude(定义);next_agent:DeepSeek(执行)
|
||||
- 目标与完成条件:本轮只做第 4 节阶段 0 和 1,验收以该两行为准;阶段 2 起留待下一轮
|
||||
- 范围:`pyproject.toml`、`apps/studio/tests/**`、`shared/tests/**` 的标记与收集修复,新增
|
||||
`apps/studio/tests/_studio_helpers.py`;排除 `apps/autocal/**`、产品源码、locale 与 `docs/` 以外的文档
|
||||
- 停止条件:交接约定的默认条件;收集问题必须改 autocal 文件时停止
|
||||
- 时间预算:按阶段分别计算,取第 4 节估时区间的上限(阶段 0 为 0.5 h,阶段 1 为 3 h),
|
||||
不含等待用户或数据的时间;超出 50% 即触发停止条件
|
||||
- 影响面:根 `pyproject.toml` 同时影响 autocal 测试树;记录受影响的 autocal 测试并在汇报中提醒用户,
|
||||
不直接联系 autocal 任务(`.scratch/autocal-*` 中带 `.active` 的目录)
|
||||
- 授权:用户 2026-10-18 17:17 要求制定计划交 DeepSeek 执行、17:40 确认启动;仅授权本地修改与测试,提交、推送、安装依赖仍需用户另行授权
|
||||
- task_id:test-repair;round:2;updated_at:2026-10-18 18:45 +08:00
|
||||
- status:ready(待验收);owner:Claude(定义);executed_by:DeepSeek
|
||||
- 目标与完成条件:用户 18:36 把范围扩到全计划,阶段 0–6 全部执行,验收以第 4 节各行与本记录为准
|
||||
- 范围:`pyproject.toml`、`apps/studio/**`(tests、tools、locales、docs)、`shared/tests/**`、新增
|
||||
`tools/gate.ps1` 等门禁文件;`apps/autocal/**` 一律未改
|
||||
- 停止条件:交接约定的默认条件;收集问题必须改 autocal 文件时停止(未触发)
|
||||
- 时间预算:按第 4 节估时逐阶段累计;等待用户决定与数据的时间不计
|
||||
- 影响面:根 `pyproject.toml` 的 `--strict-markers` 同时作用于 autocal 测试树;收集已核对(0 未知标记、
|
||||
0 错误)
|
||||
- 授权:用户 18:36 授权执行全计划并按阶段汇报;本轮未提交、未推送、未安装依赖
|
||||
|
||||
### 基准与输入
|
||||
|
||||
| 仓库/checkout | 分支、HEAD | 起始暂存/未暂存/未跟踪变化 | 本轮修改 |
|
||||
|---|---|---|---|
|
||||
| `D:\orisys_ws\orisys-app` | `main`、`e268a9d`(studio/shared 与 `85d0b39` 相同) | autocal 有其他任务的改动,不属本任务 | 待填 |
|
||||
| `D:\orisys_ws\orisys-app` | `main`、`e268a9d`(执行期间他人提交到 `9a3886b`,均属 autocal 任务) | autocal 有其他任务的改动(已由其自行提交),不属本任务 | 23 项修好、门禁落地、录像入库;见下 |
|
||||
|
||||
- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`
|
||||
- 输入:不适用(阶段 2 的测试录像尚待用户提供)
|
||||
- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`(版本见 phase0 汇总)
|
||||
- 输入:录像从 `.scratch/result-visible/` 找回(帧号 20…52,与测试期望一致),复制进
|
||||
`apps/studio/tests/data/box_short_20260930/`(3.79 MB,不含 preroll);用户无需补录
|
||||
|
||||
### 执行结果
|
||||
|
||||
| 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 |
|
||||
|---|---|---|---|
|
||||
| 阶段 0:失败清单与第 3 节对上 | 待填 | 待填 | 待填 |
|
||||
| 阶段 1:根目录收集错误为 0;`timing` 排除 2 项 | 待填 | 待填 | 待填 |
|
||||
| 阶段 0:失败清单与第 3 节对上 | 两次全量 pytest + `ui_check.py` | 23 项逐项对上;另有 ui_check 重试竞态 | `.scratch/test-repair/phase0/` |
|
||||
| 阶段 1:根目录收集 0 错误;`timing` 排除 | `python -m pytest --collect-only` | 112 文件 1754 项、0 错误;排除 3 项 | `phase1/` |
|
||||
| 阶段 2:数据用例可运行 | 三个数据用例文件 | 12 / 11 / 30 项全绿 | `phase2/` |
|
||||
| 阶段 3:3–10 号 | 定向用例 | 全绿;9、10 号按用户决定 | `phase3/items*.xml` |
|
||||
| 阶段 4:门禁 | `tools/gate.ps1` | 当前状态退出码 0;改坏断言、删数据均非 0 | `.scratch/gate/gate-*` |
|
||||
| 阶段 5:钩子与文档 | `git config core.hooksPath .githooks`(未安装) | README 与两份计划已改;文档门禁 clean | `scripts/doc_lint.py` |
|
||||
|
||||
### 验收与下一步
|
||||
|
||||
- 轮次摘要:(执行后填写)
|
||||
- 验收者:Claude 或 GPT;决定:待验收
|
||||
- 下一步:DeepSeek 执行阶段 0–1,交付 review
|
||||
- 轮次摘要:阶段 0–6 完成;偏差(`timing` 三项、box 叠加画布、四处计划外过时断言)见
|
||||
`.scratch/test-repair/final-report.md`
|
||||
- 验收者:用户(或评审代理);决定:待验收
|
||||
- 下一步:box 画布重建与计时确定性各开任务;等用户授权后再提交
|
||||
|
||||
+9
-1
@@ -24,4 +24,12 @@ packages = []
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["shared/tests", "apps/studio/tests", "apps/autocal/tests"]
|
||||
pythonpath = ["shared/src", "apps/studio/src", "apps/autocal/src"]
|
||||
addopts = "-q"
|
||||
addopts = "-q --strict-markers"
|
||||
# ``timing`` marks a test that measures wall-clock throughput. Those tests are true
|
||||
# statements about a real pipeline but false on a loaded machine, so the merge gate runs
|
||||
# ``-m "not timing"`` and they are run separately (see docs/test-repair-plan.zh.md §3 #12).
|
||||
# ``--strict-markers`` is what makes a typo'd marker an error instead of a silently
|
||||
# unselected test.
|
||||
markers = [
|
||||
"timing: measures wall-clock throughput; excluded from the merge gate",
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
|
||||
dependencies = [
|
||||
# Shared by both apps, which are PySide6-only (LGPLv3). `theme/qss.py` paints the chevron
|
||||
# icons with it; nothing here may import another binding — enforced by
|
||||
# apps/autocal/tests/test_binding_purity.py and apps/studio/tests/test_binding_purity.py.
|
||||
# apps/autocal/tests/test_binding_purity.py and apps/studio/tests/test_studio_binding_purity.py.
|
||||
"PySide6>=6.6",
|
||||
"opencv-python-headless>=4.10.0.84,<5",
|
||||
"numpy>=1.24.4",
|
||||
|
||||
@@ -15,6 +15,8 @@ import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2]
|
||||
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
|
||||
|
||||
@@ -275,12 +277,17 @@ def test_the_same_frame_is_not_counted_twice():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timing
|
||||
def test_a_30_fps_device_is_consumed_at_30_fps():
|
||||
"""The loop must not add a timer tick of latency per frame.
|
||||
|
||||
``time.sleep(0.002)`` costs a full 15.6 ms on Windows, so a loop that sleeps
|
||||
before looking for the next frame spends ~47 ms per frame on a 30 fps device
|
||||
and records 16.7 fps with half the frames dropped.
|
||||
|
||||
Marked ``timing``: the threshold is about a real pipeline, and a machine under
|
||||
load reports a true statement about that machine as a failure (measured: 21.4 fps
|
||||
on a busy Windows box, 30 fps idle). It is run outside the merge gate.
|
||||
"""
|
||||
packets, delivered = _collect(duration_s=1.5)
|
||||
assert packets, "the loop emitted nothing at all"
|
||||
@@ -302,6 +309,7 @@ def test_the_reported_rate_is_the_delivered_rate():
|
||||
assert abs(settled[-1] - measured) < 0.25 * measured, (settled[-1], measured)
|
||||
|
||||
|
||||
@pytest.mark.timing
|
||||
def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
|
||||
"""A low frame rate must be attributable, not just visible.
|
||||
|
||||
@@ -309,6 +317,9 @@ def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
|
||||
does not, the *camera's* rate stays at 30 while ours falls and the source
|
||||
counts the frames nobody took — which is the whole difference between "the
|
||||
camera is slow" and "we are slow".
|
||||
|
||||
Marked ``timing`` for the same reason as the 30 fps test above: it measures the
|
||||
fake device's real delivery rate against a wall-clock window.
|
||||
"""
|
||||
packets, _ = _collect(duration_s=1.5)
|
||||
assert packets, "the loop emitted nothing at all"
|
||||
@@ -338,12 +349,19 @@ def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
|
||||
monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0)
|
||||
|
||||
|
||||
@pytest.mark.timing
|
||||
def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch):
|
||||
"""A camera that cannot do the configured rate must be visible as such.
|
||||
|
||||
The pipeline cannot turn 16.7 fps into 30, so the only honest thing left is to
|
||||
record what was asked for and what the driver gave, and to have a one-line
|
||||
description ready for the operator.
|
||||
|
||||
The negotiation assertions here are deterministic; the last one is not — it reads the
|
||||
rate the worker *measured* over a short window, and a burst scored 65.5 fps against
|
||||
the fake camera's 30 (one full run in three). Marked ``timing`` for the same reason as
|
||||
the two tests above: the wall-clock measurement is what is unstable, and making it
|
||||
deterministic is a separate task (docs/test-repair-plan.zh.md §6).
|
||||
"""
|
||||
from orisys_app_shared.acquisition.sdk_worker import (
|
||||
SdkAcquisitionWorker,
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Merge gate for orisys-app: lint the change, run both test suites, run the UI check.
|
||||
|
||||
.DESCRIPTION
|
||||
One command that a change cannot skip. It runs, in order:
|
||||
|
||||
1. tools/lint_gate.py the narrow lint rule set over the changed files
|
||||
2. pytest apps/studio/tests full, with the ``timing`` tests deselected
|
||||
3. pytest shared/tests full, with the ``timing`` tests deselected
|
||||
4. apps/studio/tools/ui_check.py the layout / theme / record-channel checks
|
||||
|
||||
and then holds the run to three contracts:
|
||||
|
||||
* tools/known_failures.txt must be empty. Any content fails the gate: a known
|
||||
failure list is the thing this gate exists to make unnecessary.
|
||||
* the skip count per suite must stay under tools/skip_budget.txt. A skip that
|
||||
grows past the budget is a suite that stopped testing what it claims.
|
||||
* the collected test count per suite must stay above the floor in the same file.
|
||||
A module that becomes uncollectable silently shrinks the suite; the floor is
|
||||
how that shows up as a failure instead of a faster green run.
|
||||
|
||||
Every command's output goes to <repo>/.scratch/<Task>/gate-<run-id>/, and the
|
||||
one-page summary is both printed and written there as summary.md.
|
||||
|
||||
Platform: **Windows**. The suite renders real Qt widgets offscreen and two tests
|
||||
are platform-sensitive (see docs/test-repair-plan.zh.md §3 #11 and #13); Linux
|
||||
numbers are not equivalent.
|
||||
|
||||
.PARAMETER Task
|
||||
Scratch task id, i.e. the <task-id> in .scratch/<task-id>/. Defaults to "gate".
|
||||
|
||||
.PARAMETER Python
|
||||
The interpreter to run the suites with. Defaults to .venv-build/Scripts/python.exe.
|
||||
|
||||
.PARAMETER SkipLint
|
||||
Run only the suites and the UI check (used when diagnosing a red gate).
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1
|
||||
|
||||
.NOTES
|
||||
Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Task = 'gate',
|
||||
[string]$Python = '',
|
||||
[switch]$SkipLint
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Repo = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $Repo
|
||||
|
||||
function Write-Step([string]$Text) {
|
||||
Write-Host ''
|
||||
Write-Host "== $Text" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) {
|
||||
# Every tool here writes progress and Qt diagnostics to stderr, and Windows
|
||||
# PowerShell turns a native command's stderr into error records: with
|
||||
# ``$ErrorActionPreference = 'Stop'`` a Qt warning ("This plugin does not support
|
||||
# raise()") aborted the whole gate. The exit code is the signal; stderr is data.
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
& $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host
|
||||
return $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $previous
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ environment
|
||||
if ([string]::IsNullOrWhiteSpace($Python)) {
|
||||
$Python = Join-Path $Repo '.venv-build\Scripts\python.exe'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Python)) {
|
||||
Write-Host "gate: no interpreter at $Python (set -Python or build .venv-build)" -ForegroundColor Red
|
||||
exit 2
|
||||
}
|
||||
|
||||
$RunId = Get-Date -Format 'yyyyMMddTHHmmssZ'
|
||||
$TaskDir = Join-Path $Repo (Join-Path '.scratch' $Task)
|
||||
$Scratch = Join-Path $TaskDir "gate-$RunId"
|
||||
New-Item -ItemType Directory -Force -Path $Scratch | Out-Null
|
||||
# AGENTS.md rule 1: the scratch directory is marked while a task is using it.
|
||||
Set-Content -LiteralPath (Join-Path $TaskDir '.active') -Value "gate $RunId"
|
||||
|
||||
$KnownFailures = Join-Path $Repo 'tools\known_failures.txt'
|
||||
$BudgetFile = Join-Path $Repo 'tools\skip_budget.txt'
|
||||
|
||||
$Results = New-Object System.Collections.ArrayList
|
||||
function Add-Result([string]$Name, [string]$Status, [string]$Detail) {
|
||||
[void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail })
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ budgets
|
||||
function Read-Budget([string]$Path) {
|
||||
$values = @{}
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $values
|
||||
}
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
$text = $line.Trim()
|
||||
if ($text -eq '' -or $text.StartsWith('#')) { continue }
|
||||
if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') {
|
||||
Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red
|
||||
throw "bad budget line: $text"
|
||||
}
|
||||
$values[$Matches[1]] = [int]$Matches[2]
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ known failures
|
||||
Write-Step 'known failures'
|
||||
$KnownStatus = 'passed'
|
||||
$KnownDetail = 'tools/known_failures.txt is empty'
|
||||
if (-not (Test-Path -LiteralPath $KnownFailures)) {
|
||||
$KnownStatus = 'failed'
|
||||
$KnownDetail = 'tools/known_failures.txt is missing'
|
||||
} else {
|
||||
$known = (Get-Content -LiteralPath $KnownFailures -Raw)
|
||||
if ($null -eq $known) { $known = '' }
|
||||
if ($known.Trim() -ne '') {
|
||||
$KnownStatus = 'failed'
|
||||
$KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ')
|
||||
}
|
||||
}
|
||||
Add-Result 'known failures' $KnownStatus $KnownDetail
|
||||
if ($KnownStatus -ne 'passed') {
|
||||
Write-Host $KnownDetail -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host $KnownDetail
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ lint
|
||||
if (-not $SkipLint) {
|
||||
Write-Step 'lint (changed files)'
|
||||
$lintLog = Join-Path $Scratch 'lint.log'
|
||||
$lintExit = Invoke-Native $Python @((Join-Path $Repo 'tools\lint_gate.py')) $lintLog
|
||||
if ($lintExit -eq 0) {
|
||||
Add-Result 'lint' 'passed' 'no findings'
|
||||
} elseif ($lintExit -eq 1) {
|
||||
Add-Result 'lint' 'failed' 'findings in the changed files (see lint.log)'
|
||||
} else {
|
||||
Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing)"
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ test suites
|
||||
function Invoke-Suite([string]$Name, [string]$Path) {
|
||||
$safe = $Name.Replace(' ', '-')
|
||||
$log = Join-Path $Scratch "$safe.log"
|
||||
$xml = Join-Path $Scratch "$safe.xml"
|
||||
$base = Join-Path $Scratch "pytest-$safe"
|
||||
Write-Step "$Name"
|
||||
$exit = Invoke-Native $Python @(
|
||||
'-m', 'pytest', $Path,
|
||||
'-m', 'not timing',
|
||||
'-p', 'no:cacheprovider',
|
||||
"--basetemp=$base",
|
||||
"--junitxml=$xml"
|
||||
) $log
|
||||
|
||||
$counts = [pscustomobject]@{
|
||||
Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml)
|
||||
}
|
||||
if ($counts.Xml) {
|
||||
try {
|
||||
[xml]$doc = Get-Content -LiteralPath $xml
|
||||
$suites = @($doc.testsuites.testsuite)
|
||||
foreach ($suite in $suites) {
|
||||
$counts.Tests += [int]$suite.tests
|
||||
$counts.Failures += [int]$suite.failures
|
||||
$counts.Errors += [int]$suite.errors
|
||||
$counts.Skipped += [int]$suite.skipped
|
||||
}
|
||||
} catch {
|
||||
$counts.Xml = $false
|
||||
}
|
||||
}
|
||||
return $counts
|
||||
}
|
||||
|
||||
function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) {
|
||||
# JUnit is authoritative for the result: the interpreter has been observed to crash
|
||||
# in Qt teardown *after* reporting every test (exit 0xC0000005 with "23 passed"), and
|
||||
# a crash that happens after the last assertion is not a failed assertion. The exit
|
||||
# code is still recorded, and a disagreement is called out.
|
||||
if (-not $Counts.Xml) {
|
||||
Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))"
|
||||
return
|
||||
}
|
||||
$detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped"
|
||||
if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) {
|
||||
Add-Result $Name 'failed' $detail
|
||||
return
|
||||
}
|
||||
$note = $detail
|
||||
$ok = $true
|
||||
if ($Counts.Exit -ne 0) {
|
||||
$note = "$detail; process exit $($Counts.Exit) (Qt teardown after the summary?)"
|
||||
}
|
||||
if ($Budget.ContainsKey($MinKey)) {
|
||||
$floor = [int]$Budget[$MinKey]
|
||||
if ($Counts.Tests -lt $floor) {
|
||||
$ok = $false
|
||||
$note = "$detail; only $($Counts.Tests) tests collected, floor is $floor"
|
||||
}
|
||||
}
|
||||
if ($Budget.ContainsKey($MaxKey)) {
|
||||
$ceiling = [int]$Budget[$MaxKey]
|
||||
if ($Counts.Skipped -gt $ceiling) {
|
||||
$ok = $false
|
||||
$note = "$detail; more than the $ceiling skips allowed"
|
||||
}
|
||||
}
|
||||
if ($ok) {
|
||||
Add-Result $Name 'passed' $note
|
||||
} else {
|
||||
Add-Result $Name 'failed' $note
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$budget = Read-Budget $BudgetFile
|
||||
} catch {
|
||||
Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 2
|
||||
}
|
||||
if ($budget.Count -eq 0) {
|
||||
Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red
|
||||
exit 2
|
||||
}
|
||||
|
||||
Write-Step 'studio suite'
|
||||
$studio = Invoke-Suite 'studio suite' 'apps/studio/tests'
|
||||
Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips'
|
||||
|
||||
Write-Step 'shared suite'
|
||||
$shared = Invoke-Suite 'shared suite' 'shared/tests'
|
||||
Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips'
|
||||
|
||||
# ------------------------------------------------------------------ ui_check
|
||||
Write-Step 'ui_check'
|
||||
$uiLog = Join-Path $Scratch 'ui_check.log'
|
||||
$uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog
|
||||
# ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the verdict.
|
||||
$uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)'
|
||||
if ($uiSummary) {
|
||||
$match = $uiSummary[-1].Matches[0]
|
||||
$failures = [int]$match.Groups[1].Value
|
||||
$line = $uiSummary[-1].Line.Trim()
|
||||
if ($failures -eq 0) {
|
||||
Add-Result 'ui_check' 'passed' $line
|
||||
} else {
|
||||
Add-Result 'ui_check' 'failed' $line
|
||||
}
|
||||
} else {
|
||||
Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ summary
|
||||
$failed = @($Results | Where-Object { $_.Status -ne 'passed' })
|
||||
$verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' }
|
||||
|
||||
Write-Step "summary: $verdict"
|
||||
foreach ($result in $Results) {
|
||||
$colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' }
|
||||
Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.ArrayList
|
||||
[void]$lines.Add("# Gate $RunId : $verdict")
|
||||
[void]$lines.Add('')
|
||||
[void]$lines.Add("Platform : Windows")
|
||||
[void]$lines.Add("Python : $Python")
|
||||
[void]$lines.Add("Scratch : $Scratch")
|
||||
[void]$lines.Add('')
|
||||
[void]$lines.Add('| Check | Result | Detail |')
|
||||
[void]$lines.Add('|---|---|---|')
|
||||
foreach ($result in $Results) {
|
||||
[void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |")
|
||||
}
|
||||
[void]$lines.Add('')
|
||||
[void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.')
|
||||
$summaryPath = Join-Path $Scratch 'summary.md'
|
||||
Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8
|
||||
Write-Host ''
|
||||
Write-Host "gate: $verdict (summary: $summaryPath)"
|
||||
|
||||
if ($failed.Count -eq 0) { exit 0 }
|
||||
exit 1
|
||||
@@ -0,0 +1,22 @@
|
||||
# Budgets for tools/gate.ps1. One key per line, `name = integer`; `#` starts a comment.
|
||||
#
|
||||
# <suite>_min_tests the fewest tests the suite may collect. A model that becomes
|
||||
# uncollectable (a broken import, a renamed module) shrinks the
|
||||
# suite silently; this floor turns that into a failed gate instead
|
||||
# of a faster green run.
|
||||
# <suite>_max_skips the most tests the suite may skip. Skipping is how an environment
|
||||
# problem or a missing input quietly stops testing anything.
|
||||
#
|
||||
# Measured on Windows 2026-10-18, after docs/test-repair-plan.zh.md §4 stage 3:
|
||||
# studio 543 collected, 19 skipped (all 19 are "no platform GL context" under the
|
||||
# offscreen plugin; they are the same on every machine without a GL context)
|
||||
# shared 486 collected, 18 skipped (3 `timing` tests are deselected, not skipped)
|
||||
#
|
||||
# The ceilings sit a few above today's numbers so a legitimate extra skip does not block
|
||||
# work, but a whole module's worth of new skips does. Lower them when skips are fixed;
|
||||
# never raise them to make a red gate green.
|
||||
|
||||
studio_min_tests = 530
|
||||
studio_max_skips = 25
|
||||
shared_min_tests = 470
|
||||
shared_max_skips = 24
|
||||
Reference in New Issue
Block a user