修复 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:
ykcheungab
2026-10-18 19:24:31 +08:00
parent dcabd547fa
commit 433b76e43c
38 changed files with 1036 additions and 171 deletions
+16
View File
@@ -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
+32 -1
View File
@@ -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 **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 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 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`. `apps/autocal/tests/test_binding_purity.py`.
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a 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, why recording and calibration sampling no longer hang off the UI refresh timer,
and for the data-reliability fixes that came with it. 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 ## Branding
Window / EXE icon: `shared/resources/branding/logo.png` Window / EXE icon: `shared/resources/branding/logo.png`
+1 -1
View File
@@ -143,7 +143,7 @@
"reset_display": "Reset display", "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).", "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": "⏸ 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_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume", "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.", "resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
+1 -1
View File
@@ -143,7 +143,7 @@
"reset_display": "复位显示", "reset_display": "复位显示",
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。", "reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
"pause": "⏸ 暂停", "pause": "⏸ 暂停",
"pause_tip": "暂停计算,画面停住,设备保持连接(F7)。", "pause_tip": "暂停计算,画面停住,设备保持连接(F7);跨过暂停的录制会留空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。", "resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续", "resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。", "resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
+11 -1
View File
@@ -264,6 +264,16 @@
"condition": "sensor_dialog_open", "condition": "sensor_dialog_open",
"purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use", "purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use",
"elements": [ "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", { "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
"purpose": "heading of the calibration half" }, "purpose": "heading of the calibration half" },
{ "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", { "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", { "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton",
"purpose": "write the calibration panel's coefficients (the panel's own action)" }, "purpose": "write the calibration panel's coefficients (the panel's own action)" },
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton", { "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", { "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" } "purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
] ]
+1 -1
View File
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
dependencies = [ dependencies = [
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not # 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 # 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", "PySide6>=6.6",
"orisys-app-shared>=0.1.0", "orisys-app-shared>=0.1.0",
"orisys>=0.5", "orisys>=0.5",
@@ -403,7 +403,31 @@ class AnalysisSession:
if self._overlay is None: if self._overlay is None:
self._overlay_failed = True self._overlay_failed = True
return None 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: def close(self) -> None:
"""Release the overlay projector; the reader is closed by :class:`ResultLibrary`.""" """Release the overlay projector; the reader is closed by :class:`ResultLibrary`."""
@@ -29,6 +29,7 @@ from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED, ARROW_STITCHED,
DERIVED_MODES, DERIVED_MODES,
RESULT_FAILED, RESULT_FAILED,
@@ -1032,14 +1033,29 @@ class ReplayController(QObject):
The first processed view is arrows over the unrolled / stitched base: the same 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 texture live uses. A derived selection the operator already chose is left
alone — that mode is not this method's to override. 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 "") current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES: if current in DERIVED_MODES:
return False return False
session = self.available_result()
# The plan for the frame just drawn offers the derived modes (the frame carries # 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. # ``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() 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 return True
def _display_policy(self) -> Any: def _display_policy(self) -> Any:
+57
View File
@@ -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()
+8 -43
View File
@@ -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: if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR)) 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") @pytest.fixture(scope="session")
def qt_app(): 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 = ( _RECORD_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record" Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
) )
+34
View File
@@ -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,
1 at t_rel_s frame_id kind detail
2 2026-09-30T01:50:28.848+00:00 -1 record_started mode=video_data
3 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
1 session_id frame_id t_rel_s FN_raw FX_raw FY_raw FN_N FX_N FY_N valid calibration_id
2 2 20 0.0 13567.7392578125 1180.1070556640625 9125.2607421875 0.555386962890625 0.006123639892578124 0.276258865234375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
3 2 21 0.064133 11343.3232421875 2028.0238037109375 7816.4375 0.444166162109375 0.034952809326171876 0.23175887499999998 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
4 2 22 0.112021 16884.728515625 745.5536499023438 7191.43896484375 0.72123642578125 0.0 0.2105089248046875 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
5 2 23 0.17623 14818.259765625 2178.03466796875 9171.318359375 0.61791298828125 0.0400531787109375 0.27782482421875 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
6 2 24 0.24043 14969.4208984375 -1287.072509765625 11873.1181640625 0.625471044921875 -0.00976046533203125 0.36968601757812497 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
7 2 25 0.304304 14659.830078125 -141.17251586914062 2469.820556640625 0.60999150390625 -0.0 0.049973898925781246 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
8 2 26 0.352255 15476.34375 -3281.966552734375 15066.1357421875 0.6508171875 -0.07758686279296875 0.478248615234375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
9 2 27 0.416172 11016.5068359375 -3127.916748046875 13734.294921875 0.427825341796875 -0.07234916943359375 0.43296602734375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
10 2 28 0.480077 14330.064453125 273.25970458984375 5539.169921875 0.59350322265625 0.0 0.15433177734375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
11 2 29 0.543786 14844.3466796875 2567.879150390625 5841.8525390625 0.619217333984375 0.05330789111328125 0.164622986328125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
12 2 30 0.591816 16986.365234375 -1623.703125 5377.94091796875 0.72631826171875 -0.02120590625 0.1488499912109375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
13 2 31 0.656365 13733.6103515625 -1030.4215087890625 9520.9345703125 0.563680517578125 -0.0010343312988281249 0.289711775390625 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
14 2 32 0.720031 17051.982421875 -2590.688720703125 10953.798828125 0.72959912109375 -0.05408341650390625 0.33842916015625 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
15 2 33 0.784173 16303.94921875 2727.433837890625 6891.0400390625 0.6921974609375 0.05873275048828125 0.20029536132812498 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
16 2 34 0.831679 17633.34375 -208.40782165527344 5174.18798828125 0.7586671875 -0.0 0.14192239160156248 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
17 2 35 0.896141 14191.8671875 -971.9476928710938 11894.728515625 0.586593359375 -0.0 0.37042076953124997 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
18 2 36 0.960091 15828.78125 -1548.9967041015625 9286.84765625 0.6684390625000001 -0.018665887939453125 0.2817528203125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
19 2 37 1.024524 18615.650390625 -3690.62451171875 10522.7822265625 0.80778251953125 -0.0914812333984375 0.323774595703125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
20 2 38 1.071701 15312.724609375 1154.6263427734375 11247.951171875 0.6426362304687501 0.005257295654296875 0.34843033984375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
21 2 39 1.135903 12159.138671875 -82.20235443115234 10962.3251953125 0.48495693359375003 -0.0 0.33871905664062496 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
22 2 40 1.199965 15671.615234375 -540.1160888671875 17301.103515625 0.66058076171875 -0.0 0.55423751953125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
23 2 41 1.264015 18193.279296875 -280.51025390625 7286.34765625 0.78666396484375 -0.0 0.2137358203125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
24 2 42 1.311786 14463.1240234375 23.800241470336914 14572.9287109375 0.6001562011718751 0.0 0.461479576171875 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
25 2 43 1.376278 14139.1728515625 549.9257202148438 14745.4931640625 0.583958642578125 0.0 0.467346767578125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
26 2 44 1.439864 14622.09375 535.4642944335938 11325.8857421875 0.6081046875 0.0 0.351080115234375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
27 2 45 1.503909 16870.396484375 -5440.37158203125 13050.87890625 0.72051982421875 -0.15097263378906248 0.4097298828125 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
28 2 46 1.551771 17459.4765625 -322.2771911621094 9053.3154296875 0.749973828125 -0.0 0.273812724609375 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
29 2 47 1.616042 21696.408203125 -1986.04833984375 11181.533203125 0.96182041015625 -0.0335256435546875 0.34617212890625 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
30 2 48 1.680262 15386.84765625 -4424.57763671875 1783.9827880859375 0.6463423828125 -0.11643563964843749 0.026655414794921874 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
31 2 49 1.744307 15400.05859375 -2761.5791015625 15228.455078125 0.6470029296875 -0.059893689453124996 0.48376747265625 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
32 2 50 1.792308 14120.4208984375 116.24051666259766 9754.70703125 0.583021044921875 0.0 0.2976600390625 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
33 2 51 1.856446 15246.4052734375 -2575.3466796875 12753.658203125 0.639320263671875 -0.053561787109375 0.39962437890624997 1 171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
34 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
1 frame_id t_rel_s acquired_at segment segment_frame in_video dropped
2 20 0.0 2026-09-30T01:50:28.870+00:00 1 1 1 0
3 21 0.064133 2026-09-30T01:50:28.935+00:00 1 2 1 0
4 22 0.112021 2026-09-30T01:50:28.983+00:00 1 3 1 0
5 23 0.17623 2026-09-30T01:50:29.048+00:00 1 4 1 0
6 24 0.24043 2026-09-30T01:50:29.111+00:00 1 5 1 0
7 25 0.304304 2026-09-30T01:50:29.175+00:00 1 6 1 0
8 26 0.352255 2026-09-30T01:50:29.231+00:00 1 7 1 0
9 27 0.416172 2026-09-30T01:50:29.287+00:00 1 8 1 0
10 28 0.480077 2026-09-30T01:50:29.351+00:00 1 9 1 0
11 29 0.543786 2026-09-30T01:50:29.414+00:00 1 10 1 0
12 30 0.591816 2026-09-30T01:50:29.462+00:00 1 11 1 0
13 31 0.656365 2026-09-30T01:50:29.526+00:00 1 12 1 0
14 32 0.720031 2026-09-30T01:50:29.590+00:00 1 13 1 0
15 33 0.784173 2026-09-30T01:50:29.654+00:00 1 14 1 0
16 34 0.831679 2026-09-30T01:50:29.702+00:00 1 15 1 0
17 35 0.896141 2026-09-30T01:50:29.766+00:00 1 16 1 0
18 36 0.960091 2026-09-30T01:50:29.830+00:00 1 17 1 0
19 37 1.024524 2026-09-30T01:50:29.895+00:00 1 18 1 0
20 38 1.071701 2026-09-30T01:50:29.942+00:00 1 19 1 0
21 39 1.135903 2026-09-30T01:50:30.006+00:00 1 20 1 0
22 40 1.199965 2026-09-30T01:50:30.070+00:00 1 21 1 0
23 41 1.264015 2026-09-30T01:50:30.134+00:00 1 22 1 0
24 42 1.311786 2026-09-30T01:50:30.182+00:00 1 23 1 0
25 43 1.376278 2026-09-30T01:50:30.247+00:00 1 24 1 0
26 44 1.439864 2026-09-30T01:50:30.310+00:00 1 25 1 0
27 45 1.503909 2026-09-30T01:50:30.373+00:00 1 26 1 0
28 46 1.551771 2026-09-30T01:50:30.422+00:00 1 27 1 0
29 47 1.616042 2026-09-30T01:50:30.486+00:00 1 28 1 0
30 48 1.680262 2026-09-30T01:50:30.552+00:00 1 29 1 0
31 49 1.744307 2026-09-30T01:50:30.615+00:00 1 30 1 0
32 50 1.792308 2026-09-30T01:50:30.664+00:00 1 31 1 0
33 51 1.856446 2026-09-30T01:50:30.728+00:00 1 32 1 0
34 52 1.920255 2026-09-30T01:50:30.792+00:00 1 33 1 0
@@ -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": {}
}
}
+28 -22
View File
@@ -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 * a box result is recorded on the box sample grid, so the canvas is compared with
``draw_arrow_display`` on the recovered field. ``draw_arrow_display`` on the recovered field.
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's The fixture clip is the project's recorded box footage, tracked in the repository beside
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a this test (``tests/data/box_short_20260930``, see its README). If it is absent the test
silently skipped end-to-end test is how a broken workflow ships. **fails** (``_require_clip``) rather than skipping: a silently skipped end-to-end test is
how a broken workflow ships.
""" """
from __future__ import annotations from __future__ import annotations
@@ -65,17 +66,12 @@ from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
APP = Path(__file__).resolve().parents[1] #: The one real input, tracked in the repository beside this test: a 33-frame box
REPO = APP.parents[1] #: recording (2026-09-30, session 2, 1.9 s) copied out of ``dist/`` before that build
EVIDENCE = ( #: output was next repackaged. See ``data/README.md`` for what it is and why it is here
REPO #: rather than generated.
/ "dist" DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
/ "OrisysStudio" SOURCE_CLIP = DATA / "raw_0001.mp4"
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
SOURCE_CLIP = EVIDENCE / "raw_0001.mp4"
def _require_clip() -> Path: def _require_clip() -> Path:
@@ -90,7 +86,8 @@ def _require_clip() -> Path:
""" """
assert SOURCE_CLIP.is_file(), ( assert SOURCE_CLIP.is_file(), (
"the bare-video test needs recorded box footage at " "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") pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
return SOURCE_CLIP 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" "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) set_mode(window, ARROW_STITCHED)
QApplication.processEvents() QApplication.processEvents()
overlay = panel_bgr(window) assert window.arrow_panel._source_image is None, (
assert painted(overlay, canvas) > 0, "the overlay painted no arrows" "the overlay drew something without the captured frame under it"
assert float(np.mean(overlay)) > 8.0, (
"the overlay is still a near-black canvas; the captured frame was not under the arrows"
) )
assert not np.array_equal(overlay, middle_shot), ( assert window.arrow_panel.image_label.text() == t("panels.arrow_capture_unavailable"), (
"the overlay view is the pure-colour canvas" window.arrow_panel.image_label.text()
) )
# ---- the depth view, from the same stored frame ------------------------- # ---- the depth view, from the same stored frame -------------------------
+1 -1
View File
@@ -14,7 +14,7 @@ button, is what tells stopped from paused.
from __future__ import annotations from __future__ import annotations
from orisys_app_shared.i18n import t 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): def test_the_transport_is_idle_with_no_session(make_window):
+45 -19
View File
@@ -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 5. **frames are addressed by id.** An id the recording does not hold is refused, never
coerced into an index. coerced into an index.
The fixtures use real footage — the 33-frame box recording under ``dist/`` and the The fixtures use real footage — the 33-frame box recording tracked beside this test
shipped finger configuration — driven through the **finger** chain, which is what gives (``data/box_short_20260930``) and the shipped finger configuration — driven through the
the result a validity mask and therefore a static candidate grid. The pass is O(5 s) **finger** chain, which is what gives the result a validity mask and therefore a static
and is shared per module. Where the SDK or the footage is absent the tests skip rather candidate grid. The pass is O(5 s) and is shared per module. Where the SDK is absent the
than fake a result: a synthetic result would not defend the id span (20…52), the tests skip rather than fake a result: a synthetic result would not defend the id span
keyframe identity or the sample round trip. (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 from __future__ import annotations
@@ -73,16 +74,13 @@ from orisys_studio.replay_analysis import (
ResultPolicyError, ResultPolicyError,
) )
REPO = Path(__file__).resolve().parents[3] #: The tracked test input (``apps/studio/tests/data/README.md`` says where it came from).
STUDIO = REPO / "dist" / "OrisysStudio" RECORDING = Path(__file__).resolve().parent / "data" / "box_short_20260930"
RECORDING = ( #: The repository's own sensor configuration. These used to be read from the packaged copy
STUDIO #: under ``dist/``, which a build can delete — the same reason the footage moved here.
/ "outputs" CONFIG = Path(__file__).resolve().parents[1] / "config"
/ "recordings" FINGER_CONFIG = CONFIG / "sensors" / "finger" / "sensor.json"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011" FINGER_EXPORT = CONFIG / "sensors" / "finger" / "assets" / "30fps-0703"
)
FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json"
FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703"
#: The evidence recording's own frame ids. Deliberately not 0…32: an implementation #: 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. #: 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: def _require_inputs() -> None:
"""The footage is committed, so its absence is a failure rather than a skip."""
missing = [ missing = [
str(path) 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 not path.exists()
] ]
if missing: assert not missing, (
pytest.skip("analysis inputs are not present: " + ", ".join(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") pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass")
@@ -479,6 +486,11 @@ class _Host:
if paint_2d: if paint_2d:
from orisys_app_shared.acquisition.image_backend import ImageFrame 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( self.frames.append(
ImageFrame( ImageFrame(
getattr(packet, "processed_frame", None), getattr(packet, "processed_frame", None),
@@ -487,6 +499,10 @@ class _Host:
valid_mask=getattr(packet, "valid_mask", None), valid_mask=getattr(packet, "valid_mask", None),
source=image_source or "replay", source=image_source or "replay",
frame_id=int(getattr(packet, "frame_id", 0) or 0), 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, arrow_policy=arrow_policy,
result_available=True, result_available=True,
) )
@@ -1652,6 +1668,8 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
""" """
from types import SimpleNamespace from types import SimpleNamespace
from orisys_app_shared.analysis.model import Provenance
class _Reader: class _Reader:
complete = True complete = True
key = "stitch" 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) frame_ids = np.array([1], dtype=np.int64)
times = np.array([0.0]) times = np.array([0.0])
keyframes: dict = {} 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): def mask(self):
return None return None
+48 -29
View File
@@ -18,7 +18,7 @@ chain — and assert on the picture the panel actually shows, not on a state a h
built. built.
The recording is copied before use: a pass writes its result into the recording's own 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 from __future__ import annotations
@@ -34,11 +34,11 @@ from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE, ARROW_PURE,
ARROW_STITCHED, ARROW_STITCHED,
DEPTH, DEPTH,
DEPTH_ARROWS,
DERIVED_MODES, DERIVED_MODES,
RECORDED, RECORDED,
plan_image, plan_image,
) )
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.visual_policy import ( from orisys_app_shared.scene3d.visual_policy import (
select_flow_samples, select_flow_samples,
@@ -46,14 +46,8 @@ from orisys_app_shared.scene3d.visual_policy import (
from orisys_studio.replay import MODE_RECORDING from orisys_studio.replay import MODE_RECORDING
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
REPO = Path(__file__).resolve().parents[3] #: The tracking-visible test input (``data/README.md`` explains where it came from).
STUDIO = REPO / "dist" / "OrisysStudio" DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
SOURCE_RECORDING = (
STUDIO
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
#: What makes a directory a recording for the application, and all a replay reads. #: What makes a directory a recording for the application, and all a replay reads.
RECORDING_FILES = ( RECORDING_FILES = (
"frames.csv", "frames.csv",
@@ -61,29 +55,41 @@ RECORDING_FILES = (
"events.csv", "events.csv",
"session.json", "session.json",
"raw_0001.mp4", "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: def _require_inputs() -> None:
missing = [ _require_recording()
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)
)
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain") pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
def _copy_recording(destination: Path) -> Path: 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) destination.mkdir(parents=True, exist_ok=True)
for name in RECORDING_FILES: for name in RECORDING_FILES + OPTIONAL_FILES:
source = SOURCE_RECORDING / name source = DATA / name
if source.exists(): if source.is_file():
shutil.copy2(source, destination / name) shutil.copy2(source, destination / name)
return destination 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" "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() == ( assert window.arrow_panel.view_modes() == (
RECORDED, RECORDED,
ARROW_PURE, ARROW_PURE,
ARROW_STITCHED, ARROW_STITCHED,
DEPTH, DEPTH,
DEPTH_ARROWS,
), "the recording's own picture must stay one step away from the derived views" ), "the recording's own picture must stay one step away from the derived views"
assert window.arrow_panel.view_mode() in DERIVED_MODES, ( assert window.arrow_panel.view_mode() in DERIVED_MODES, (
"the operator was left on the camera picture although a result is served" "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 arrow view is the recording's raw picture"
) )
# The depth view is the stored map, drawn through the window's own backend. This # The depth view is the stored map, drawn through the window's own backend. The
# result's map is exactly zero on every frame it holds — the box chain's depth is # map is this result's own array of the result's own shape, so the pixels are checked
# flat on this footage — and it is still a stored array of the result's own shape, # against it rather than against a placeholder.
# so the pixels are checked against it rather than against a flat 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. # The colour-scale caption names the range only.
_set_mode(window, DEPTH) _set_mode(window, DEPTH)
target = active_frame 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() QApplication.processEvents()
drawn = captured[-1] drawn = captured[-1]
assert drawn.frame_id == target.frame_id assert drawn.frame_id == target.frame_id
+9 -5
View File
@@ -49,13 +49,13 @@ from orisys_studio.scene3d.asset_source import (
) )
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from tests.conftest import ( from _studio_helpers import (
finish_run as _finish, finish_run as _finish,
) )
from tests.conftest import ( from _studio_helpers import (
running_window as _running_window, running_window as _running_window,
) )
from tests.conftest import ( from _studio_helpers import (
show_window as _show, 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 assert "回放" in label or "Replay" in label, label
# A running pass means the pixels are recomputed, and the label must say so. # 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._recompute_running = True
window._apply_panel_visibility() window._apply_panel_visibility()
label = view.panel.source_label.text() assert view.panel.source_label.text() == t("scene3d.source_recompute"), (
assert "重算" in label or "Recomputed" in label, label view.panel.source_label.text()
)
# Stopping the pass must clear the picture, not leave the last frame up. # Stopping the pass must clear the picture, not leave the last frame up.
window._recompute_running = False window._recompute_running = False
+1 -1
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import ui_check import ui_check
from orisys_app_shared.i18n import t 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): def test_the_group_is_exactly_play_pause_stop_reset(make_window):
+1 -1
View File
@@ -30,7 +30,7 @@ import ui_check
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui import elements from orisys_studio.ui import elements
from PySide6.QtWidgets import QApplication 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") VIEWS = ("live", "replay", "calibration")
SIZE = (1440, 880) SIZE = (1440, 880)
+61 -12
View File
@@ -7,16 +7,16 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
import ui_check import ui_check
from tests.conftest import ( from _studio_helpers import (
finish_run as _finish, finish_run as _finish,
) )
from tests.conftest import ( from _studio_helpers import (
running_window as _running_window, running_window as _running_window,
) )
from tests.conftest import ( from _studio_helpers import (
show_window as _show, show_window as _show,
) )
from tests.conftest import ( from _studio_helpers import (
visible_descendants, visible_descendants,
) )
@@ -193,13 +193,27 @@ def test_status_chip_tracks_phase(make_window):
def test_run_tooltip_explains_disabled_reason(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)) window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
assert not window.toolbar.btn_run.isEnabled() assert not window.toolbar.btn_run.isEnabled()
assert window.toolbar.btn_run.toolTip() assert window.toolbar.btn_run.toolTip()
window.toolbar.set_video_path("C:/tmp/demo.mp4") window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._update_run_enabled() 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): 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.calib_title.text() == t("sensor.calib_section")
assert dialog.version_label.text() and dialog.axes_label.text() assert dialog.version_label.text() and dialog.axes_label.text()
assert dialog.export_label.text() == t("sensor.export_none") 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. # Changing the product in the dialog reaches the window and the rail.
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger")) 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): 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 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 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. 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 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() QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes()) 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() window.brand_bar.btn_playback.click()
QApplication.processEvents() QApplication.processEvents()
assert not hasattr(window, "replay_bar"), "the replay still has a separate bar" assert not hasattr(window, "replay_bar"), "the replay still has a separate bar"
assert tuple(window.content_splitter.sizes()) == live_sizes, ( replay_sizes = tuple(window.content_splitter.sizes())
"the replay view is not laid out like the live view" 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.isVisible(), "no video picker while replaying"
assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video") assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video")
assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip") 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): 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 from types import SimpleNamespace
window = make_window(sensor_type="finger") 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, "_connect_analysis_display", lambda _panel: True)
monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None) monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None)
try: try:
window._show_analysis_frame("old box packet") window.show_analysis_packet("old box packet")
assert submitted == [] assert submitted == [], "an old sensor's depth reached the new sensor's scene"
session.reader.provenance.sensor_type = "finger" session.reader.provenance.sensor_type = "finger"
window._show_analysis_frame("finger packet") window.show_analysis_packet("finger packet")
assert submitted == ["finger packet"] assert submitted == ["finger packet"]
finally: finally:
panel._view = real_view panel._view = real_view
+22 -6
View File
@@ -1711,8 +1711,9 @@ def main() -> int:
window.force_plots.mode() == "follow", window.force_plots.mode() == "follow",
"the replay view browses before a recording is open", "the replay view browses before a recording is open",
) )
# The replay view is the live arrangement, measured rather than assumed: the # The replay view shows the live view's panes, and its preset gives the *charts* the
# splitter hands out its handles' width, so compare like with like. # 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() window.brand_bar.btn_live.click()
for _ in range(3): for _ in range(3):
app.processEvents() app.processEvents()
@@ -1722,8 +1723,13 @@ def main() -> int:
app.processEvents() app.processEvents()
replay_sizes = tuple(window.content_splitter.sizes()) replay_sizes = tuple(window.content_splitter.sizes())
check( check(
replay_sizes == live_sizes, live_sizes[1] > 0 and live_sizes[2] > 0,
f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}", 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( check(
not hasattr(window, "replay_bar"), 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}", 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 _app_module.recordings_dir = lambda: scratch_fail
fail_window.toolbar.btn_record.setChecked(True) fail_window.toolbar.btn_record.setChecked(True)
app.processEvents() 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( check(
fail_window.recorder.is_recording, 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 # A writer that keeps failing must fail the recording, and the window must
+1 -1
View File
@@ -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 - **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 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 `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 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 PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes
+1 -1
View File
@@ -367,7 +367,7 @@ Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形
### 11.1 依赖 ### 11.1 依赖
- 继续使用项目验证过的 Python、**PySide6**、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。 - 继续使用项目验证过的 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) - PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。 - 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。 - PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
+2 -2
View File
@@ -191,7 +191,7 @@ graph TB
| 规则 | 是否成立 | 证据 | | 规则 | 是否成立 | 证据 |
|---|---|---| |---|---|---|
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** | | 模块级无循环依赖 | ✅ | 扫描 **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` 同理 | | OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK | | 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK |
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" | | 采集侧可在无 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` 只应有移动与转发 | | 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` | | 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` | | 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 检查 | | 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 失败) | | 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
+4
View File
@@ -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 链接执行指南,说明默认范围及停止条件;登记文档并运行文档门禁。不改工作区通用测试标准。 | | 3 | 接入代理执行规范,约 1 小时 | 仓库 AGENTS 链接执行指南,说明默认范围及停止条件;登记文档并运行文档门禁。不改工作区通用测试标准。 |
| 4 | 验证实际选择,1–2 小时 | 分别选择 Autocal 记录修复、Studio 局部界面、shared 录制契约三个场景;核对收集清单包含必要消费者,至少实际执行一个受影响组。 | | 4 | 验证实际选择,1–2 小时 | 分别选择 Autocal 记录修复、Studio 局部界面、shared 录制契约三个场景;核对收集清单包含必要消费者,至少实际执行一个受影响组。 |
门禁平台:**Windows**。上表的定向验证可以在任何平台开发,但“提交前通过 `tools/gate.ps1`”这一条以 Windows 结果为准。
任务 2 依赖任务 1,任务 3 依赖入口可用,任务 4 最后验收。建议一名工程师顺序完成,总计约 5–8 小时;无需多个代理并行修改公共入口。 任务 2 依赖任务 1,任务 3 依赖入口可用,任务 4 最后验收。建议一名工程师顺序完成,总计约 5–8 小时;无需多个代理并行修改公共入口。
任务 1 后核对依赖覆盖,任务 2–3 后核对入口与规则一致。验收只运行调度工具测试、必要功能组和文档检查;不为证明调度工具可用而运行三个产品的全部测试。 任务 1 后核对依赖覆盖,任务 2–3 后核对入口与规则一致。验收只运行调度工具测试、必要功能组和文档检查;不为证明调度工具可用而运行三个产品的全部测试。
+24 -19
View File
@@ -77,36 +77,41 @@
按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。 按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。
- task_id:test-repair;round:1;updated_at:2026-10-18 17:45 +08:00 - task_id:test-repair;round:2;updated_at:2026-10-18 18:45 +08:00
- status:ready;owner:Claude(定义);next_agent:DeepSeek(执行) - status:ready(待验收);owner:Claude(定义);executed_by:DeepSeek
- 目标与完成条件:本轮只做第 4 节阶段 0 和 1,验收以该两行为准;阶段 2 起留待下一轮 - 目标与完成条件:用户 18:36 把范围扩到全计划,阶段 0–6 全部执行,验收以第 4 节各行与本记录为准
- 范围:`pyproject.toml`、`apps/studio/tests/**`、`shared/tests/**` 的标记与收集修复,新增 - 范围:`pyproject.toml`、`apps/studio/**`(tests、tools、locales、docs)、`shared/tests/**`、新增
`apps/studio/tests/_studio_helpers.py`;排除 `apps/autocal/**`、产品源码、locale 与 `docs/` 以外的文档 `tools/gate.ps1` 等门禁文件;`apps/autocal/**` 一律未改
- 停止条件:交接约定的默认条件;收集问题必须改 autocal 文件时停止 - 停止条件:交接约定的默认条件;收集问题必须改 autocal 文件时停止(未触发)
- 时间预算:按阶段分别计算,取第 4 节估时区间的上限(阶段 0 为 0.5 h,阶段 1 为 3 h), - 时间预算:按第 4 节估时逐阶段累计;等待用户决定与数据的时间不计
不含等待用户或数据的时间;超出 50% 即触发停止条件 - 影响面:根 `pyproject.toml` 的 `--strict-markers` 同时作用于 autocal 测试树;收集已核对(0 未知标记、
- 影响面:根 `pyproject.toml` 同时影响 autocal 测试树;记录受影响的 autocal 测试并在汇报中提醒用户, 0 错误)
不直接联系 autocal 任务(`.scratch/autocal-*` 中带 `.active` 的目录) - 授权:用户 18:36 授权执行全计划并按阶段汇报;本轮未提交、未推送、未安装依赖
- 授权:用户 2026-10-18 17:17 要求制定计划交 DeepSeek 执行、17:40 确认启动;仅授权本地修改与测试,提交、推送、安装依赖仍需用户另行授权
### 基准与输入 ### 基准与输入
| 仓库/checkout | 分支、HEAD | 起始暂存/未暂存/未跟踪变化 | 本轮修改 | | 仓库/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` - 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`(版本见 phase0 汇总)
- 输入:不适用(阶段 2 的测试录像尚待用户提供) - 输入:录像从 `.scratch/result-visible/` 找回(帧号 20…52,与测试期望一致),复制进
`apps/studio/tests/data/box_short_20260930/`(3.79 MB,不含 preroll);用户无需补录
### 执行结果 ### 执行结果
| 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 | | 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 |
|---|---|---|---| |---|---|---|---|
| 阶段 0:失败清单与第 3 节对上 | 待填 | 待填 | 待填 | | 阶段 0:失败清单与第 3 节对上 | 两次全量 pytest + `ui_check.py` | 23 项逐项对上;另有 ui_check 重试竞态 | `.scratch/test-repair/phase0/` |
| 阶段 1:根目录收集错误为 0;`timing` 排除 2 项 | 待填 | 待填 | 待填 | | 阶段 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` |
### 验收与下一步 ### 验收与下一步
- 轮次摘要:(执行后填写) - 轮次摘要:阶段 0–6 完成;偏差(`timing` 三项、box 叠加画布、四处计划外过时断言)见
- 验收者:Claude 或 GPT;决定:待验收 `.scratch/test-repair/final-report.md`
- 下一步:DeepSeek 执行阶段 0–1,交付 review - 验收者:用户(或评审代理);决定:待验收
- 下一步:box 画布重建与计时确定性各开任务;等用户授权后再提交
+9 -1
View File
@@ -24,4 +24,12 @@ packages = []
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["shared/tests", "apps/studio/tests", "apps/autocal/tests"] testpaths = ["shared/tests", "apps/studio/tests", "apps/autocal/tests"]
pythonpath = ["shared/src", "apps/studio/src", "apps/autocal/src"] 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",
]
+1 -1
View File
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
dependencies = [ dependencies = [
# Shared by both apps, which are PySide6-only (LGPLv3). `theme/qss.py` paints the chevron # 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 # 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", "PySide6>=6.6",
"opencv-python-headless>=4.10.0.84,<5", "opencv-python-headless>=4.10.0.84,<5",
"numpy>=1.24.4", "numpy>=1.24.4",
+18
View File
@@ -15,6 +15,8 @@ import time
import types import types
from pathlib import Path from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[2] APP_ROOT = Path(__file__).resolve().parents[2]
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json" 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(): def test_a_30_fps_device_is_consumed_at_30_fps():
"""The loop must not add a timer tick of latency per frame. """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 ``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 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. 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) packets, delivered = _collect(duration_s=1.5)
assert packets, "the loop emitted nothing at all" 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) 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): def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
"""A low frame rate must be attributable, not just visible. """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 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 counts the frames nobody took — which is the whole difference between "the
camera is slow" and "we are slow". 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) packets, _ = _collect(duration_s=1.5)
assert packets, "the loop emitted nothing at all" 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) 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): 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. """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 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 record what was asked for and what the driver gave, and to have a one-line
description ready for the operator. 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 ( from orisys_app_shared.acquisition.sdk_worker import (
SdkAcquisitionWorker, SdkAcquisitionWorker,
+297
View File
@@ -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
View File
+22
View File
@@ -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