回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。

This commit is contained in:
ykcheungab
2026-09-13 20:46:27 +08:00
parent 35e86ec1f7
commit 824544fc7a
20 changed files with 3129 additions and 834 deletions
+532 -41
View File
@@ -856,11 +856,12 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.record.depth import DepthResult
from orisys_studio.ui.playback_dialog import PlaybackDialog
from orisys_studio.replay import ReplayController
window = _show(make_window(), (1200, 700))
dialog = PlaybackDialog(theme="dark", directory=Path(record_root))
first_serial = dialog._record_serial
replay = ReplayController()
replay.open_recording(Path(record_root))
first_serial = replay._record_serial
started = _threading.Event()
release = _threading.Event()
@@ -881,14 +882,14 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
closed.append("closed")
blocking = _Blocking()
dialog._recomputer = blocking
dialog._on_recompute_depth()
replay._recomputer = blocking
replay.request_depth()
assert started.wait(5.0), "the depth request never started"
# Another recording is opened while the request is in flight.
dialog.open_recording(Path(record_root))
assert dialog._record_serial == first_serial + 1
assert dialog._recomputer is not blocking, "the processor was not replaced"
replay.open_recording(Path(record_root))
assert replay._record_serial == first_serial + 1
assert replay._recomputer is not blocking, "the processor was not replaced"
assert closed == [], "the UI thread closed a processor a worker was reading"
release.set()
@@ -897,15 +898,14 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
QApplication.processEvents()
_time.sleep(0.01)
assert closed == ["closed"], "the retired processor was never closed"
assert dialog._depth_thread is not None and not dialog._depth_thread.is_alive()
assert replay._depth_thread is not None and not replay._depth_thread.is_alive()
# The answer belongs to the previous recording, so it is dropped, not drawn.
dialog._poll_depth()
replay._poll_depth()
QApplication.processEvents()
assert dialog._depth_ok is False
pixmap = dialog.depth_label.pixmap()
assert pixmap is None or pixmap.isNull(), "an answer for another recording was drawn"
dialog.close()
assert replay._depth_ok is False
assert replay.depth_image() is None, "an answer for another recording was drawn"
replay.close()
window.close()
@@ -916,10 +916,11 @@ def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, reco
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.record.depth import DepthResult
from orisys_studio.ui.playback_dialog import PlaybackDialog
from orisys_studio.replay import ReplayController
window = _show(make_window(), (1200, 700))
dialog = PlaybackDialog(theme="dark", directory=Path(record_root))
replay = ReplayController()
replay.open_recording(Path(record_root))
started = _threading.Event()
release = _threading.Event()
closed: list[str] = []
@@ -936,12 +937,12 @@ def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, reco
def close(self) -> None:
closed.append("closed")
dialog._recomputer = _Blocking()
dialog._on_recompute_depth()
replay._recomputer = _Blocking()
replay.request_depth()
assert started.wait(5.0)
dialog.close() # closing the window must not free a processor in use
replay.close() # closing must not free a processor in use
QApplication.processEvents()
assert closed == [], "the window closed a processor that was still reading"
assert closed == [], "the controller closed a processor that was still reading"
release.set()
deadline = _time.monotonic() + 10.0
while not closed and _time.monotonic() < deadline:
@@ -1084,6 +1085,31 @@ def record_root():
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def replay_recording(record_root):
"""One short finished recording (video + forces + frames + session.json)."""
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
recording = recorder.start(
root=record_root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(record_root / "sensor.json"),
"config_sha256": "recorded",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
for index in range(60):
recorder.on_packet(ui_check._fake_packet(index))
recorder.stop()
yield Path(recording)
def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
import csv
import json
@@ -1171,8 +1197,8 @@ def test_top_area_stays_compact(make_window):
QApplication.processEvents()
assert window.toolbar.settings_expanded()
assert window.toolbar.source_combo.isVisible()
assert window.sensor_picker.isVisible()
assert window.toolbar.units_combo.isVisible()
assert window.toolbar.btn_preview.isVisible()
expanded = window.brand_bar.height() + window.toolbar.height()
assert expanded > collapsed, "opening the settings area changed nothing"
assert window.act_settings.isChecked(), "the View menu does not follow the toggle"
@@ -1353,7 +1379,79 @@ def test_overview_region_is_the_timeline_control(make_window):
_finish(window, stuck)
def test_arrow_legend_follows_the_view_mode(make_window):
def test_the_charts_start_in_follow_mode(make_window):
"""Every live entry point resumes following; only browsing is a gesture."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
panel = window.force_plots
assert panel.mode() == "follow"
assert panel.mode_label.text() == t("chart.mode_follow")
for index in range(40):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
QApplication.processEvents()
assert panel.mode() == "follow", "live samples pushed the charts into browsing"
panel._unit = "raw"
window._on_units_changed("newton")
assert panel.mode() == "follow", "a unit switch left the charts browsing"
window._on_units_changed("raw")
assert panel.mode() == "follow"
# A new session is a live session again.
panel.show_all()
assert panel.mode() == "browse"
window._start_run()
window._run_timer.stop()
assert panel.mode() == "follow", "a new run did not resume following"
_finish(window, stuck)
def test_the_charts_zoom_and_pan_in_every_view(make_window):
"""Zoom/pan must be reachable from the live view, not only while browsing."""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
panel = window.force_plots
for index in range(60):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
QApplication.processEvents()
view_box = panel.plots[0].getViewBox()
assert panel.mode() == "follow"
assert view_box.state["mouseEnabled"][0], "the live chart ignores the mouse"
# The gesture signal is what pyqtgraph emits for a wheel or drag.
view_box.setXRange(0.5, 1.5, padding=0.0)
view_box.sigRangeChangedManually.emit([True, False])
QApplication.processEvents()
assert panel.mode() == "browse", "a wheel/drag gesture did not start browsing"
assert panel.window() == (0.5, 1.5), f"the gesture was not applied: {panel.window()}"
assert all(cursor.movable for cursor in panel.cursors)
# A programmatic change is not a gesture and must not switch the view.
panel.follow_latest()
view_box.setXRange(1.0, 1.5, padding=0.0)
QApplication.processEvents()
assert panel.mode() == "follow", "a programmatic range change started browsing"
# While browsing, dragging the shared cursor moves the playhead.
panel.show_all()
moved: list[float] = []
panel.cursor_moved.connect(moved.append)
panel.cursors[0].setValue(1.25)
QApplication.processEvents()
assert [round(cursor.value(), 2) for cursor in panel.cursors] == [1.25] * 3
assert moved and moved[-1] == 1.25, "the cursor drag did not notify the replay"
_finish(window, stuck)
def test_the_arrow_legend_follows_the_view_mode(make_window):
from orisys_app_shared.acquisition.arrows import arrow_display_scale
assert arrow_display_scale("box") == 2.0
@@ -1381,22 +1479,72 @@ def test_arrow_legend_follows_the_view_mode(make_window):
# ------------------------------------------------- top rail: sensor and views
def test_the_sensor_picker_leads_the_rail(make_window):
"""Which product/config is selected must be readable, not buried."""
def test_the_sensor_summary_opens_the_dialog(make_window):
"""The rail states the sensor; the pickers live in the dialog it opens."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
assert window.toolbar.btn_sensor.text() == t(
"sensor.summary", type="BOX", config="sensor.json"
)
assert window.toolbar.btn_sensor.toolTip() == t("sensor.dialog_tip")
assert not window.sensor_picker.isVisible(), "the pickers are still on the rail"
window.toolbar.btn_sensor.click()
QApplication.processEvents()
dialog = window.sensor_dialog
assert dialog is not None and dialog.isVisible()
picker = window.sensor_picker
assert picker.isVisible(), "the sensor picker is not on the status rail"
assert picker.isVisible(), "the sensor pickers are not in the dialog"
assert picker.type_label.text() == t("sensor.type")
assert picker.config_label.text() == t("sensor.config")
assert picker.thumb_label.text() == "BOX"
assert picker.type_label.toolTip() and picker.config_label.toolTip()
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"
# The two combos stay wired: switching the product reloads its configs.
# Changing the product in the dialog reaches the window and the rail.
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
assert picker.sensor_type() == "finger"
assert picker.config_name()
QApplication.processEvents()
assert window.prefs["sensor_type"] == "finger"
assert "FINGER" in window.toolbar.btn_sensor.text()
assert picker.sensor_type() == "finger" and picker.config_name()
picker.type_combo.setCurrentIndex(picker.type_combo.findData("box"))
QApplication.processEvents()
dialog.close()
def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, a video-file run offers the way back, replay neither."""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible()
assert not window.toolbar.video_group.isVisible()
assert not hasattr(window.toolbar, "source_combo"), "the source is still a combobox"
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert not window.toolbar.camera_group.isVisible(), "the replay view shows a camera picker"
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.camera_group.isVisible()
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
QApplication.processEvents()
assert window.toolbar.video_group.isVisible()
assert "demo.mp4" in window.toolbar.source_summary.text()
assert not window.toolbar.btn_preview.isEnabled(), "preview is offered for a video file"
window.toolbar.use_camera_clicked.emit()
QApplication.processEvents()
assert window.toolbar.source_mode() == "camera"
assert window.toolbar.video_path() == ""
_finish(window, stuck)
def test_the_nav_items_switch_the_main_window_layout(make_window):
@@ -1417,7 +1565,10 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
window.brand_bar.btn_playback.click()
assert window.view_preset() == "replay"
assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible()
assert window.force_plots.mode() == "browse", "the replay view did not open the session"
# No recording is open yet: the bar says so, and the charts keep following
# the live session instead of pretending to replay something.
assert window.replay_bar.isVisible() and not window.replay.is_loaded()
assert window.force_plots.mode() == "follow", "browsing starts with no recording"
window.brand_bar.btn_live.click()
assert window.view_preset() == "live"
@@ -1461,21 +1612,361 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
)
def test_the_playback_dialog_labels_its_buttons(make_window):
"""The dialog's three controls had no text at all: it never retranslated."""
def test_the_replay_bar_labels_its_controls(make_window):
"""The old replay window's controls had no text: it never retranslated."""
from orisys_app_shared.i18n import t
from orisys_studio.ui.playback_dialog import PlaybackDialog
window = _show(make_window(), (1440, 880))
bar = window.replay_bar
assert bar.btn_open.text() == t("playback.open")
assert bar.btn_open.toolTip() == t("playback.open_tip")
assert bar.btn_depth.text() == t("playback.recompute_depth")
assert bar.btn_depth.toolTip() == t("playback.recompute_depth_tip")
assert bar.btn_unit.text(), "the unit switch is unlabelled"
assert bar.slider.toolTip() == t("playback.scrub_tip")
assert bar.info_label.text() == t("playback.empty")
assert not bar.slider.isEnabled(), "the progress bar is live with no recording"
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
"""The replay view reads the recording: charts, 2D panel and progress bar."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
dialog = PlaybackDialog(theme="dark", parent=window)
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window.replay_bar.isVisible(), "the replay view has no progress bar"
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.is_recording_source(), "the charts did not switch to the recording"
assert panel.mode() == "browse", "opening a recording did not browse it"
assert panel.mode_label.text() == t("chart.mode_replay")
assert not panel.btn_latest.isEnabled(), "a recording offers 'back to latest'"
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert window.arrow_panel.view_modes() == ("recorded", "depth")
assert window.replay.video_image() is not None, "no frame at the cursor"
assert window.replay_bar.slider.isEnabled()
# The progress bar is the playhead.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum() // 2)
QApplication.processEvents()
assert 0.0 < window.replay.position() < window.replay.duration_s()
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05
# Dragging the chart cursor scrubs the recording too.
panel.cursors[0].setValue(0.25)
QApplication.processEvents()
assert abs(window.replay.position() - 0.25) < 0.05
# Leaving the view returns the charts to the live session.
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert not panel.is_recording_source()
assert panel.mode() == "follow"
assert not window.replay_bar.isVisible()
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
# ------------------------------------------------- correctness regressions
def test_a_late_calibration_does_not_plot_raw_counts_on_the_newton_axis(make_window):
"""A device confirms its calibration a frame or two after the first packets.
Those frames carry raw counts. ``push`` used to record the new unit and keep
the old samples, so the first thing drawn on the newton axis was a spike of
raw counts — the transient at the start of every session. Switching units is
a switch, never a reinterpretation: the raw samples are dropped from the
buffer (the store still holds them) and the sample that triggered the switch
is read out but not appended twice.
"""
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
plots = window.force_plots
# The preference is newton; an uncalibrated device falls back to raw.
assert window.toolbar.units() == "newton"
plots.reset()
for index in range(3):
packet = ui_check._fake_packet(index, calibrated_axes=())
packet.force_calibrated = False
stuck.emit(packet)
window._on_run_tick()
assert plots.unit() == "raw", f"an uncalibrated device is not shown raw: {plots.unit()}"
for index in range(3, 40):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
state = plots.history.state()
assert plots.unit() == "N", f"the unit did not follow the calibration: {plots.unit()}"
assert state is not None
assert plots.history.sample_count == 37, (
f"the raw frames stayed in the newton buffer: {plots.history.sample_count} samples"
)
peak = float(np.nanmax(np.abs(state.values[:, 0])))
assert peak < 5.0, f"a raw count is still drawn on the newton axis (peak {peak})"
# The sample that caused the switch still reaches the readouts; the early
# return used to leave the cards showing the previous sample.
assert abs(plots._last_values[0] - (2.0 + 39 * 0.01)) < 1e-9
assert plots.value_labels[0].text() == "2.390", plots.value_labels[0].text()
assert plots.value_labels[0].text() != t("chart.value_unavailable")
_finish(window, stuck)
def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
"""One click on the unit chip changes the unit everywhere, not just locally."""
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display
from PyQt5.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880))
plots = window.force_plots
assert isinstance(plots.unit_badge, QPushButton), "the unit chip is not clickable"
assert plots.unit_badge.toolTip() == t("chart.unit_toggle_tip")
assert plots.unit_badge.accessibleName() == t("chart.unit_toggle")
before = plots.unit()
plots.unit_badge.click()
assert plots.unit() != before, "the unit chip did not switch the charts"
assert plots.unit_badge.text() == unit_display(plots.unit())
assert ("newton" if plots.unit() == "N" else "raw") == window.toolbar.units(), (
"the unit chip and the toolbar disagree about the unit"
)
plots.unit_badge.click()
assert plots.unit() == before, "the unit chip does not switch back"
def test_the_chart_actions_are_icons_that_still_say_what_they_do(make_window):
"""Symbols instead of words — but every one keeps its meaning in words.
The header actions (zoom to fit, box zoom, back to latest) are drawn at
runtime in the theme's text colour; a symbol with no tooltip or accessible
name would be unreadable, so both are part of the contract.
"""
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
plots = window.force_plots
for button, key in (
(plots.btn_overview, "chart.overview"),
(plots.btn_box_zoom, "chart.box_zoom"),
(plots.btn_latest, "chart.return_latest"),
):
assert button.text() == "", f"{key} still shows text: {button.text()!r}"
assert not button.icon().isNull(), f"{key} has no icon"
assert button.toolTip() == t(f"{key}_tip")
assert button.accessibleName() == t(key)
# The overview strip keeps its word: it labels a region, not an action.
assert plots.window_caption.text() == t("chart.overview")
# The arrow footer says what the factor multiplies.
footer = window.arrow_panel.scale_label.text()
assert "×" in footer, footer
assert "位移" in footer or "displacement" in footer, footer
# A theme switch re-tints the icons instead of leaving stale pixels.
window._set_theme("light")
for button in (plots.btn_overview, plots.btn_box_zoom, plots.btn_latest):
assert not button.icon().isNull(), "an icon vanished across a theme switch"
def test_a_recording_counts_up_while_it_is_running(make_window, record_root):
"""The elapsed time is the span captured so far, not just the final number."""
import orisys_studio.app_window as app_module
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
window.toolbar.btn_record.setChecked(True)
for index in range(96):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
window._on_run_tick()
stats = window.recorder.stats()
assert stats.duration_s > 2.5, f"the live duration stayed at {stats.duration_s}"
assert window.toolbar.record_chip.text() != t(
"record.state_recording", duration="00:00.0"
), "the record strip does not count up while recording"
window.toolbar.btn_record.setChecked(False)
_finish(window, stuck)
def test_the_video_follows_the_sensor_rate_and_keeps_every_row(make_window, record_root):
"""The container rate is the sensor's, and pacing never thins the data.
The packet's ``fps`` is how fast the pipeline *finished* frames, not how fast
the sensor produces them: a burst at open (or a file source) reads 48 on a
30 fps sensor, and writing every delivered frame into a 30 fps container made
playback run at the wrong speed.
"""
import csv
import cv2
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
assert abs(window._video_fps() - 30.0) < 0.01, window._video_fps()
window.toolbar.btn_record.setChecked(True)
for index in range(96):
# 48 frames per second of acquisition time, above the configured 30.
packet = ui_check._fake_packet(index, mono_t=index / 48.0)
packet.fps = 48.0
stuck.emit(packet)
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
assert report is not None and report.completed
assert report.stats.resampled_video > 0, "the extra frames were not reported"
assert any("faster than" in problem for problem in report.problems), report.problems
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 96, f"the video pacing thinned the data rows: {len(rows)}"
segments = sorted(report.directory.glob("raw_*.mp4"))
assert segments, "the recording wrote no video segment"
capture = cv2.VideoCapture(str(segments[0]))
try:
assert dialog.open_button.text() == t("playback.open")
assert dialog.unit_button.text()
assert dialog.depth_button.text() == t("playback.recompute_depth")
assert dialog.depth_button.toolTip() == t("playback.recompute_depth_tip")
written = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
container_fps = float(capture.get(cv2.CAP_PROP_FPS))
finally:
dialog.close()
capture.release()
assert abs(container_fps - 30.0) < 1.0, f"the video declares {container_fps} fps"
assert 55 <= written <= 65, f"the video holds {written} frames for 2 s at 30 fps"
_finish(window, stuck)
def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
make_window, replay_recording
):
"""The pane belongs to the source the view is showing.
While the replay view is up, its frames own the 2D panel; a live packet used
to paint over the recording. Leaving the view hands the pane back.
"""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording), "the replay could not open it"
QApplication.processEvents()
assert window._replay_owns_image(), "the replay does not own the 2D pane"
label = window.arrow_panel.image_label
# Park the playhead at the end of the recording and remember what the pane and
# the cards show there, so a live packet that overwrote either would show up.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum())
QApplication.processEvents()
before_key = label.pixmap().cacheKey()
before_text = label.text()
before_card = window.force_plots.value_labels[0].text()
live = np.full((24, 32, 3), 200, np.uint8)
for index in range(3):
packet = ui_check._fake_packet(index, mono_t=index / 30.0, frame=live)
packet.normal_force = 111.0
packet.normal_force_raw = 999.0
stuck.emit(packet)
window._on_run_tick()
QApplication.processEvents()
assert label.pixmap().cacheKey() == before_key and label.text() == before_text, (
"a live frame overwrote the recording in the 2D pane"
)
assert window.force_plots.value_labels[0].text() == before_card, (
"a live sample overwrote the recording's readout"
)
# ...and the live view takes it back, which is what makes the check above
# meaningful rather than a dead pane.
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert not window._replay_owns_image()
stuck.emit(ui_check._fake_packet(9, mono_t=9 / 30.0, frame=live))
window._on_run_tick()
QApplication.processEvents()
assert label.pixmap().cacheKey() != before_key, "the live view stopped drawing"
_finish(window, stuck)
def test_the_readout_describes_the_point_the_cursor_is_on(
make_window, replay_recording
):
"""The three numbers are the cursor's sample, not the last live packet.
A replay used to state the live acquisition in cards sitting next to a chart
of 7.5e5 raw counts, which reads as "the replay never opened".
"""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
plots = window.force_plots
assert plots.mode() == "browse", "opening a recording did not browse it"
slider = window.replay_bar.slider
slider.setValue(slider.minimum())
QApplication.processEvents()
first = plots.value_labels[0].text()
assert first not in ("", t("chart.value_unavailable")), first
slider.setValue(slider.maximum())
QApplication.processEvents()
last = plots.value_labels[0].text()
# The fixture's forces rise monotonically, so the readout must rise too: the
# cards are following the playhead rather than a constant.
assert float(last) > float(first), f"the readout ignores the cursor: {first} -> {last}"
assert float(first) != 0.0, "a recording is read out as a live zero"
assert abs(plots.cursor_time() - window.replay.position()) < 0.05
def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recording):
"""A newton preference must not empty a recording that has no calibration.
The recording's raw counts are always there; its newton triplet only exists
for the axes it calibrated. Asking for newton anyway drew three blank charts
and a dash in every card, which looks exactly like "the replay did not load".
"""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.toolbar.set_units("newton")
QApplication.processEvents()
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.unit() == "raw", "a raw-only recording was shown in newton"
assert window.toolbar.units() == "raw", "the toolbar disagrees with the charts"
assert window.replay_bar.btn_unit.text() == t("playback.show_newton"), (
"the replay bar's unit switch disagrees with the charts"
)
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable"))