回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 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:
@@ -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"))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user