修复 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
+1 -1
View File
@@ -143,7 +143,7 @@
"reset_display": "Reset display",
"reset_display_tip": "Clear the accumulated display: the curves, cursor and value cards go back to their defaults and the replay returns to the start of the recording. There is no device session in this view, so nothing of the device is touched; the 3D camera is View ▸ Reset views (F8).",
"pause": "⏸ Pause",
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7).",
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7). A pause leaves a gap in the recording.",
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume",
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
+1 -1
View File
@@ -143,7 +143,7 @@
"reset_display": "复位显示",
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
"pause": "⏸ 暂停",
"pause_tip": "暂停计算,画面停住,设备保持连接(F7)。",
"pause_tip": "暂停计算,画面停住,设备保持连接(F7);跨过暂停的录制会留空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
+11 -1
View File
@@ -264,6 +264,16 @@
"condition": "sensor_dialog_open",
"purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use",
"elements": [
{ "id": "dialog_profile_scroll", "path": "sensor_dialog.profile_scroll", "objectName": "sensorProfileScroll", "class": "QScrollArea",
"purpose": "bounded scroll area for the selected profile's description and verdict: a long warning list stays readable without growing the dialog into the calibration section" },
{ "id": "dialog_live_calibration_title", "path": "sensor_dialog.choice_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
"purpose": "heading of the live-calibration choice, the section 3d2fb0c added so the running chain's calibration can be changed from the dialog" },
{ "id": "dialog_live_calibration_combo", "path": "sensor_dialog.calibration_combo", "objectName": "liveCalibrationCombo", "class": "QComboBox",
"purpose": "which calibration the live chain uses: the profile's declared file, a browsed one, or raw force; 应用 hands it to the session" },
{ "id": "dialog_browse_calibration", "path": "sensor_dialog.btn_browse", "objectName": null, "class": "QPushButton",
"purpose": "pick a calibration file that is not the profile's declared one, and persist that choice" },
{ "id": "dialog_device_edit", "path": "sensor_dialog.device_edit", "objectName": "liveDeviceEdit", "class": "QLineEdit",
"purpose": "the Finger device serial the browsed calibration claims; shown only for a Finger file, and a mismatch is refused before it is applied" },
{ "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
"purpose": "heading of the calibration half" },
{ "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
@@ -283,7 +293,7 @@
{ "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton",
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
"purpose": "push the last export into the running device" },
"purpose": "hand the dialog's live-calibration choice to the session: remembered for the next run, and hot-updated into a device that is already acquiring (3d2fb0c moved it off the last export)" },
{ "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
]
+1 -1
View File
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
dependencies = [
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
# on PyQt5 (whose free option is GPLv3-or-commercial). See
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_studio_binding_purity.py.
"PySide6>=6.6",
"orisys-app-shared>=0.1.0",
"orisys>=0.5",
@@ -403,7 +403,31 @@ class AnalysisSession:
if self._overlay is None:
self._overlay_failed = True
return None
return self._overlay.project(video_bgr, frame_id=frame_id)
projected = self._overlay.project(video_bgr, frame_id=frame_id)
if projected is None:
# Sticky, as the docstring above says. A projector that cannot map this
# result's footage — measured: every frame of a real box recording, where the
# stitch rebuild raises inside the SDK — will not start working mid-session,
# and retrying per cursor move would rebuild the same failing stitch on every
# scrub. The session records the answer so a caller can pick a view this
# result *can* draw instead of leaving a refusal on screen.
self._overlay_failed = True
return projected
@property
def overlay_unavailable(self) -> bool:
"""Whether this result has been asked for an overlay base and could not give one.
The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same
UV-unrolled or stitched picture live publishes, which a stored result does not
contain: it is rebuilt from the result's provenance on first use. A **box**
result has no stored unroll map, and the box stitch rebuild can fail on real
footage, so the answer is only known after one attempt — and it is then sticky
for the session (see :meth:`overlay_base`). The caller that *picks* the first
view for an adopted result reads this, so the operator is not left on a view
that cannot be drawn.
"""
return bool(self._overlay_failed)
def close(self) -> None:
"""Release the overlay projector; the reader is closed by :class:`ResultLibrary`."""
@@ -29,6 +29,7 @@ from typing import Any
import numpy as np
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DERIVED_MODES,
RESULT_FAILED,
@@ -1032,14 +1033,29 @@ class ReplayController(QObject):
The first processed view is arrows over the unrolled / stitched base: the same
texture live uses. A derived selection the operator already chose is left
alone — that mode is not this method's to override.
**But only a view this result can actually draw.** The base is rebuilt from the
result's provenance, which a box result cannot always do (no stored unroll map,
and the box stitch rebuild can fail on real footage: measured, every frame of the
restored 33-frame box recording). Offering the overlay there would leave the
operator on a blank panel reading "capture unavailable" while the pure-colour
arrows sit one step away, so the fallback is the drawable view — the overlay is
still in the selector, and choosing it still says why it cannot be drawn.
"""
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES:
return False
session = self.available_result()
# The plan for the frame just drawn offers the derived modes (the frame carries
# ``result_available``), so this selection is one the panel can honour.
self._image_panel.set_view_mode(ARROW_STITCHED)
overlay_failed = bool(getattr(session, "overlay_unavailable", False))
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED)
self.render_frame()
if not overlay_failed and getattr(session, "overlay_unavailable", False):
# The first attempt is what tells us: the base could not be rebuilt, so draw
# the view that needs no base instead of leaving a refusal on screen.
self._image_panel.set_view_mode(ARROW_PURE)
self.render_frame()
return True
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:
sys.path.insert(0, str(_TOOLS_DIR))
# The tests' own directory, so ``_studio_helpers`` is importable however pytest was
# started. Relying on pytest to insert it works only under the default import mode and
# only for the directory a test file happens to sit in; naming it here is what lets the
# suite be collected from the repository root (see ``docs/test-repair-plan.zh.md`` §4).
_TESTS_DIR = Path(__file__).resolve().parent
if str(_TESTS_DIR) not in sys.path:
sys.path.insert(0, str(_TESTS_DIR))
@pytest.fixture(scope="session")
def qt_app():
@@ -117,49 +125,6 @@ def decline_video_prompt(monkeypatch):
)
def visible_descendants(widget):
"""Yield every visible descendant widget of ``widget``."""
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events."""
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def running_window(make_window):
"""Return a running window backed by a controllable test worker."""
import ui_check
window = show_window(
make_window(sensor_type="box", source_mode="camera"),
(1440, 880),
)
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
return window, stuck
def finish_run(window, stuck):
"""Stop a test run and release its controllable worker."""
window._stop_run(reason="user_stop")
stuck.release()
_RECORD_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
)
+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
``draw_arrow_display`` on the recovered field.
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a
silently skipped end-to-end test is how a broken workflow ships.
The fixture clip is the project's recorded box footage, tracked in the repository beside
this test (``tests/data/box_short_20260930``, see its README). If it is absent the test
**fails** (``_require_clip``) rather than skipping: a silently skipped end-to-end test is
how a broken workflow ships.
"""
from __future__ import annotations
@@ -65,17 +66,12 @@ from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
APP = Path(__file__).resolve().parents[1]
REPO = APP.parents[1]
EVIDENCE = (
REPO
/ "dist"
/ "OrisysStudio"
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
SOURCE_CLIP = EVIDENCE / "raw_0001.mp4"
#: The one real input, tracked in the repository beside this test: a 33-frame box
#: recording (2026-09-30, session 2, 1.9 s) copied out of ``dist/`` before that build
#: output was next repackaged. See ``data/README.md`` for what it is and why it is here
#: rather than generated.
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
SOURCE_CLIP = DATA / "raw_0001.mp4"
def _require_clip() -> Path:
@@ -90,7 +86,8 @@ def _require_clip() -> Path:
"""
assert SOURCE_CLIP.is_file(), (
"the bare-video test needs recorded box footage at "
f"{SOURCE_CLIP}; copy it there or run the build that produces dist/"
f"{SOURCE_CLIP}; it is tracked in the repository (apps/studio/tests/data/README.md), "
"so restore it rather than skipping the test"
)
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
return SOURCE_CLIP
@@ -396,16 +393,25 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
"the arrow view is the video's raw picture"
)
# ---- overlay: arrows on the captured frame, not on the black placeholder -
# ---- overlay: a result whose captured base cannot be rebuilt is refused -----
# The overlay views paint on the live-shaped picture (UV unroll / box stitch). A
# stored result does not contain it — replay rebuilds it from the result's
# provenance — and for a box result that rebuild fails on this footage (measured:
# every frame; the SDK stitch raises inside `Sensor.get_img`). Since 425fa0d the
# panel **refuses** instead of painting arrows on a resized fisheye, and the refusal
# with its reason is the honest thing to assert here.
#
# It used to assert painted arrows over the captured frame. That claim was only ever
# green because the footage it needed had been deleted from `dist/`; the box overlay
# base is itself broken (tracked separately) — this test states what the operator
# actually gets, not what an earlier build happened to draw.
set_mode(window, ARROW_STITCHED)
QApplication.processEvents()
overlay = panel_bgr(window)
assert painted(overlay, canvas) > 0, "the overlay painted no arrows"
assert float(np.mean(overlay)) > 8.0, (
"the overlay is still a near-black canvas; the captured frame was not under the arrows"
assert window.arrow_panel._source_image is None, (
"the overlay drew something without the captured frame under it"
)
assert not np.array_equal(overlay, middle_shot), (
"the overlay view is the pure-colour canvas"
assert window.arrow_panel.image_label.text() == t("panels.arrow_capture_unavailable"), (
window.arrow_panel.image_label.text()
)
# ---- the depth view, from the same stored frame -------------------------
+1 -1
View File
@@ -14,7 +14,7 @@ button, is what tells stopped from paused.
from __future__ import annotations
from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window
from _studio_helpers import finish_run, running_window, show_window
def test_the_transport_is_idle_with_no_session(make_window):
+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
coerced into an index.
The fixtures use real footage — the 33-frame box recording under ``dist/`` and the
shipped finger configuration — driven through the **finger** chain, which is what gives
the result a validity mask and therefore a static candidate grid. The pass is O(5 s)
and is shared per module. Where the SDK or the footage is absent the tests skip rather
than fake a result: a synthetic result would not defend the id span (20…52), the
keyframe identity or the sample round trip.
The fixtures use real footage — the 33-frame box recording tracked beside this test
(``data/box_short_20260930``) and the shipped finger configuration — driven through the
**finger** chain, which is what gives the result a validity mask and therefore a static
candidate grid. The pass is O(5 s) and is shared per module. Where the SDK is absent the
tests skip rather than fake a result: a synthetic result would not defend the id span
(20…52), the keyframe identity or the sample round trip. Where the *footage* is absent they
fail, naming the file: a missing input is not an environment that should go green.
"""
from __future__ import annotations
@@ -73,16 +74,13 @@ from orisys_studio.replay_analysis import (
ResultPolicyError,
)
REPO = Path(__file__).resolve().parents[3]
STUDIO = REPO / "dist" / "OrisysStudio"
RECORDING = (
STUDIO
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json"
FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703"
#: The tracked test input (``apps/studio/tests/data/README.md`` says where it came from).
RECORDING = Path(__file__).resolve().parent / "data" / "box_short_20260930"
#: The repository's own sensor configuration. These used to be read from the packaged copy
#: under ``dist/``, which a build can delete — the same reason the footage moved here.
CONFIG = Path(__file__).resolve().parents[1] / "config"
FINGER_CONFIG = CONFIG / "sensors" / "finger" / "sensor.json"
FINGER_EXPORT = CONFIG / "sensors" / "finger" / "assets" / "30fps-0703"
#: The evidence recording's own frame ids. Deliberately not 0…32: an implementation
#: that used an index would pass every test written against a 0-based recording.
@@ -91,13 +89,22 @@ LAST_FRAME_ID = 52
def _require_inputs() -> None:
"""The footage is committed, so its absence is a failure rather than a skip."""
missing = [
str(path)
for path in (RECORDING / "frames.csv", RECORDING / "raw_0001.mp4", FINGER_CONFIG)
for path in (
RECORDING / "frames.csv",
RECORDING / "raw_0001.mp4",
FINGER_CONFIG,
)
if not path.exists()
]
if missing:
pytest.skip("analysis inputs are not present: " + ", ".join(missing))
assert not missing, (
"analysis inputs are missing: "
+ ", ".join(missing)
+ ". The recording is committed (apps/studio/tests/data/README.md); restore it "
"rather than skipping."
)
pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass")
@@ -479,6 +486,11 @@ class _Host:
if paint_2d:
from orisys_app_shared.acquisition.image_backend import ImageFrame
# The fields the window's own sink puts on the frame it draws
# (``app_window.py:2835-2851``). ``depth_valid``/``flow_valid`` are what
# ``plan_image`` reads to decide whether the frame can be drawn as that
# view, so a stub that leaves them out cannot answer "the depth view is
# real" — which is exactly what the depth test below asks.
self.frames.append(
ImageFrame(
getattr(packet, "processed_frame", None),
@@ -487,6 +499,10 @@ class _Host:
valid_mask=getattr(packet, "valid_mask", None),
source=image_source or "replay",
frame_id=int(getattr(packet, "frame_id", 0) or 0),
flow_valid=getattr(packet, "flow_valid", None),
flow_frame_id=getattr(packet, "flow_frame_id", None),
depth_valid=getattr(packet, "depth_valid", None),
depth_frame_id=getattr(packet, "depth_frame_id", None),
arrow_policy=arrow_policy,
result_available=True,
)
@@ -1652,6 +1668,8 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
"""
from types import SimpleNamespace
from orisys_app_shared.analysis.model import Provenance
class _Reader:
complete = True
key = "stitch"
@@ -1659,6 +1677,14 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
frame_ids = np.array([1], dtype=np.int64)
times = np.array([0.0])
keyframes: dict = {}
#: A real reader always carries one (``AnalysisReader.__init__`` builds it from
#: the result's metadata) and ``AnalysisSession`` reads it: *where* the samples
#: are travels from ``provenance.policy``, and the overlay base opens from the
#: rest. ``829d2c1`` added those reads; this stub predated them and the default
#: ``Provenance()`` is the honest stand-in — an empty policy, so the session
#: falls back to the display policy exactly as it does for a result that stored
#: no grid.
provenance = Provenance()
def mask(self):
return None
+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.
The recording is copied before use: a pass writes its result into the recording's own
directory, and nothing may write into ``dist/``.
directory, and the tracked copy the tests read must never be written to.
"""
from __future__ import annotations
@@ -34,11 +34,11 @@ from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
DEPTH_ARROWS,
DERIVED_MODES,
RECORDED,
plan_image,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.visual_policy import (
select_flow_samples,
@@ -46,14 +46,8 @@ from orisys_app_shared.scene3d.visual_policy import (
from orisys_studio.replay import MODE_RECORDING
from PySide6.QtWidgets import QApplication
REPO = Path(__file__).resolve().parents[3]
STUDIO = REPO / "dist" / "OrisysStudio"
SOURCE_RECORDING = (
STUDIO
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
#: The tracking-visible test input (``data/README.md`` explains where it came from).
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
#: What makes a directory a recording for the application, and all a replay reads.
RECORDING_FILES = (
"frames.csv",
@@ -61,29 +55,41 @@ RECORDING_FILES = (
"events.csv",
"session.json",
"raw_0001.mp4",
"preroll_0001.mp4",
)
#: Written by a recording and read by nothing a replay needs: ``preroll_segment()``
#: answers ``None`` when the file is absent, so it is copied when it is there and never
#: required. It stays out of the repository because it is a third of the data's size.
OPTIONAL_FILES = ("preroll_0001.mp4",)
def _require_recording() -> None:
"""The recording must be here, and its absence is a failure, not a skip.
The old version skipped: the tests went green on a machine where the footage had
been repackaged away, which is exactly how eleven failing tests went unnoticed.
"""
missing = [
str(DATA / name) for name in RECORDING_FILES if not (DATA / name).is_file()
]
assert not missing, (
"the replay wiring tests need the tracked box test recording at "
f"{DATA}; missing: {', '.join(missing)}. It is committed "
"(apps/studio/tests/data/README.md); restore it rather than skipping."
)
def _require_inputs() -> None:
missing = [
str(SOURCE_RECORDING / name)
for name in ("frames.csv", "raw_0001.mp4", "session.json")
if not (SOURCE_RECORDING / name).exists()
]
if missing:
pytest.skip(
"the replay wiring tests need the evidence recording: " + ", ".join(missing)
)
_require_recording()
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
def _copy_recording(destination: Path) -> Path:
"""The evidence recording, copied: nothing may write into ``dist/``."""
"""The test recording, copied: nothing may write into the tracked data."""
_require_recording()
destination.mkdir(parents=True, exist_ok=True)
for name in RECORDING_FILES:
source = SOURCE_RECORDING / name
if source.exists():
for name in RECORDING_FILES + OPTIONAL_FILES:
source = DATA / name
if source.is_file():
shutil.copy2(source, destination / name)
return destination
@@ -303,11 +309,16 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
"a pass ran over a recording that already had a result"
)
# ``depth_arrows`` joined the derived views in ``091d0fd`` ("二维增加深度叠箭头"), which
# updated ``ui_check`` and the live-view tests but not this one: it was skipping on the
# missing footage at the time, so nothing caught it. The claim is unchanged — the
# recording's own picture stays one step away from *every* derived view.
assert window.arrow_panel.view_modes() == (
RECORDED,
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
DEPTH_ARROWS,
), "the recording's own picture must stay one step away from the derived views"
assert window.arrow_panel.view_mode() in DERIVED_MODES, (
"the operator was left on the camera picture although a result is served"
@@ -343,14 +354,22 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
"the arrow view is the recording's raw picture"
)
# The depth view is the stored map, drawn through the window's own backend. This
# result's map is exactly zero on every frame it holds — the box chain's depth is
# flat on this footage — and it is still a stored array of the result's own shape,
# so the pixels are checked against it rather than against a flat placeholder.
# The depth view is the stored map, drawn through the window's own backend. The
# map is this result's own array of the result's own shape, so the pixels are checked
# against it rather than against a placeholder.
#
# It is **not** all-zero, which is what this asserted when the recording was first
# played (a "flat depth" note): measured on the restored footage with the shipped box
# config, the stored map has 8926 non-zero cells of 365², i.e. the chain does return
# a depth field here. Flatness was a property of an earlier run's result, not a
# product promise; the claim this block exists for — the depth view's pixels are
# ``depth_map_to_bgr`` of the stored frame — is asserted below and is unchanged.
# The colour-scale caption names the range only.
_set_mode(window, DEPTH)
target = active_frame
assert target.depth is not None and target.depth_nonzero == 0
assert target.depth is not None and target.depth_nonzero > 0, (
f"the stored depth map holds no value at all: {target.depth_nonzero} non-zero cells"
)
QApplication.processEvents()
drawn = captured[-1]
assert drawn.frame_id == target.frame_id
+9 -5
View File
@@ -49,13 +49,13 @@ from orisys_studio.scene3d.asset_source import (
)
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from tests.conftest import (
from _studio_helpers import (
finish_run as _finish,
)
from tests.conftest import (
from _studio_helpers import (
running_window as _running_window,
)
from tests.conftest import (
from _studio_helpers import (
show_window as _show,
)
@@ -1131,10 +1131,14 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
assert "回放" in label or "Replay" in label, label
# A running pass means the pixels are recomputed, and the label must say so.
# Compared through the locale key rather than a word from it: ``c0a10e6`` reworded
# this line ("重算" → "分析结果:非录制时的测量值"), and the claim being defended is
# "*which* source the pane says it is showing", not the phrasing.
window._recompute_running = True
window._apply_panel_visibility()
label = view.panel.source_label.text()
assert "重算" in label or "Recomputed" in label, label
assert view.panel.source_label.text() == t("scene3d.source_recompute"), (
view.panel.source_label.text()
)
# Stopping the pass must clear the picture, not leave the last frame up.
window._recompute_running = False
+1 -1
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import ui_check
from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window
from _studio_helpers import finish_run, running_window, show_window
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
+1 -1
View File
@@ -30,7 +30,7 @@ import ui_check
from orisys_app_shared.i18n import t
from orisys_studio.ui import elements
from PySide6.QtWidgets import QApplication
from tests.conftest import finish_run, show_window
from _studio_helpers import finish_run, show_window
VIEWS = ("live", "replay", "calibration")
SIZE = (1440, 880)
+61 -12
View File
@@ -7,16 +7,16 @@ from pathlib import Path
import numpy as np
import pytest
import ui_check
from tests.conftest import (
from _studio_helpers import (
finish_run as _finish,
)
from tests.conftest import (
from _studio_helpers import (
running_window as _running_window,
)
from tests.conftest import (
from _studio_helpers import (
show_window as _show,
)
from tests.conftest import (
from _studio_helpers import (
visible_descendants,
)
@@ -193,13 +193,27 @@ def test_status_chip_tracks_phase(make_window):
def test_run_tooltip_explains_disabled_reason(make_window):
"""A video source never starts a live run, and the rail says why.
The button used to become enabled when a video file was chosen here; the live view
is a camera only (``test_live_run_refuses_a_video_source`` pins the same rule from
the other side), so choosing a file leaves Run unavailable and the reason names the
camera instead of a missing file.
"""
from orisys_app_shared.i18n import t
window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
assert not window.toolbar.btn_run.isEnabled()
assert window.toolbar.btn_run.toolTip()
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._update_run_enabled()
assert window.toolbar.btn_run.isEnabled()
reason = t("toolbar.run_video_in_replay")
assert not window.toolbar.btn_run.isEnabled(), (
"a video source still starts a live run"
)
assert window.toolbar.btn_run.toolTip() == reason, window.toolbar.btn_run.toolTip()
assert window.toolbar.run_hint.text() == reason, window.toolbar.run_hint.text()
def test_toolbar_run_button_is_labelled(make_window):
@@ -1501,7 +1515,18 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
assert dialog.calib_title.text() == t("sensor.calib_section")
assert dialog.version_label.text() and dialog.axes_label.text()
assert dialog.export_label.text() == t("sensor.export_none")
assert not dialog.btn_apply.isEnabled(), "applying with no export"
# 应用 is the *live calibration* action, not "apply the last export" (``3d2fb0c``
# moved it here): it is offered with no export because there is always a choice to
# apply — the profile's declared file, a browsed one, or raw force — and with no
# session running it only remembers that choice (``sensor_dialog.py:324`` enables it
# unconditionally; ``apply_live_calibration_choice`` reports a problem instead of
# raising, so an offered button cannot fail).
assert dialog.btn_apply.isEnabled(), "the live calibration cannot be applied"
assert dialog.btn_apply.text() == t("cal.apply")
assert dialog.btn_apply.toolTip() == t("sensor.live_calibration_apply_tip")
assert dialog.calibration_combo.currentData() in ("default", "none"), (
dialog.calibration_combo.currentData()
)
# Changing the product in the dialog reaches the window and the rail.
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
@@ -1659,11 +1684,17 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
"""One layout for both views; the only difference is the source slot.
"""The same panes and the same transport as the live view; two stated differences.
The replay used to carry its own bar (a progress slider plus a recording
picker). It is gone: the chart's cursor is the playhead, its header carries
the transport, and the rail shows the video instead of the camera.
What differs is deliberate (``321e52f``: "keep each preset's panel priority"):
the replay gives the **charts** the room, so the 2D panel is narrower than in the
live view and the charts are wider. This used to assert the splitter widths were
*equal*, which was true before that change and is the opposite of what the
layout now promises.
"""
from orisys_app_shared.i18n import t
@@ -1673,13 +1704,23 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes())
# Slot 1 is the 2D panel and slot 2 the charts (0 and 3 are the mesh and calibration
# panels, both hidden here, hence their zero width).
assert live_sizes[1] > 0 and live_sizes[2] > 0, live_sizes
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert not hasattr(window, "replay_bar"), "the replay still has a separate bar"
assert tuple(window.content_splitter.sizes()) == live_sizes, (
"the replay view is not laid out like the live view"
replay_sizes = tuple(window.content_splitter.sizes())
assert replay_sizes[1] < live_sizes[1], (
f"the replay 2D panel is not narrower than the live one: {replay_sizes} vs {live_sizes}"
)
assert replay_sizes[2] > live_sizes[2], (
f"the replay charts did not take that room: {replay_sizes} vs {live_sizes}"
)
# The priority behind those numbers is ``view_layout.py``'s per-preset stretch
# factors (arrow 4, charts 5 in replay). The splitter exposes no getter for them in
# PySide6, so the widths above are what this test can read.
assert window.toolbar.btn_replay_video.isVisible(), "no video picker while replaying"
assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video")
assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip")
@@ -2949,6 +2990,14 @@ def test_the_rail_puts_result_identity_before_a_long_video_name(
def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monkeypatch):
"""A result from the previous sensor must not reach the new sensor's 3D scene.
Driven through the window's public packet entry (``show_analysis_packet``, which
``cf49b08``/``28e955a`` made the replay path; the private ``_show_analysis_frame``
it replaced no longer exists). The claim is unchanged: a Finger window refuses a
packet whose result says it came from a Box, and accepts the same packet once the
result names the sensor on screen.
"""
from types import SimpleNamespace
window = make_window(sensor_type="finger")
@@ -2968,10 +3017,10 @@ def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monke
monkeypatch.setattr(window, "_connect_analysis_display", lambda _panel: True)
monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None)
try:
window._show_analysis_frame("old box packet")
assert submitted == []
window.show_analysis_packet("old box packet")
assert submitted == [], "an old sensor's depth reached the new sensor's scene"
session.reader.provenance.sensor_type = "finger"
window._show_analysis_frame("finger packet")
window.show_analysis_packet("finger packet")
assert submitted == ["finger packet"]
finally:
panel._view = real_view
+22 -6
View File
@@ -1711,8 +1711,9 @@ def main() -> int:
window.force_plots.mode() == "follow",
"the replay view browses before a recording is open",
)
# The replay view is the live arrangement, measured rather than assumed: the
# splitter hands out its handles' width, so compare like with like.
# The replay view shows the live view's panes, and its preset gives the *charts* the
# room (321e52f: "keep each preset's panel priority"), so the splitter is measured
# rather than assumed: slot 1 is the 2D panel, slot 2 the charts.
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
@@ -1722,8 +1723,13 @@ def main() -> int:
app.processEvents()
replay_sizes = tuple(window.content_splitter.sizes())
check(
replay_sizes == live_sizes,
f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}",
live_sizes[1] > 0 and live_sizes[2] > 0,
f"the live view lost a pane: {live_sizes}",
)
check(
replay_sizes[1] < live_sizes[1] and replay_sizes[2] > live_sizes[2],
"the replay view does not give the charts more room than the live view: "
f"{replay_sizes} vs {live_sizes}",
)
check(
not hasattr(window, "replay_bar"),
@@ -2368,13 +2374,23 @@ def main() -> int:
f"a failed start left the recorder in {fail_window.recorder.state!r}",
)
# ...and the operator can simply try again.
# ...and the operator can simply try again. ``start()`` returns once the directory
# exists; the recorder itself moves to ``recording`` on its own writer thread, so
# reading the flag back immediately is a race (measured: one full run in three
# reported "refused to retry" although the retry had been accepted). The claim is
# "retrying works", not "it worked inside one event loop", so wait for it — bounded,
# like the write-error poll below.
_app_module.recordings_dir = lambda: scratch_fail
fail_window.toolbar.btn_record.setChecked(True)
app.processEvents()
deadline = time.monotonic() + 2.0
while not fail_window.recorder.is_recording and time.monotonic() < deadline:
app.processEvents()
time.sleep(0.01)
check(
fail_window.recorder.is_recording,
"the recorder refused to retry after a failed directory",
"the recorder refused to retry after a failed directory "
f"(state {fail_window.recorder.state!r} after 2 s)",
)
# A writer that keeps failing must fail the recording, and the window must