fix(studio): 回放视图加一个无条件「停止」(Esc 同效),并让「文件一帧都不出」直接报错而不是一直等
- What: `ToolbarWidget` 新增 `set_reset_role("reset"/"stop")`,回放视图把轨道第二个按钮变成「停止」;
`StudioMainWindow._stop_replay_activity()` 一次停掉回放(play_timer)与「从视频重算」通道(worker),
并清掉三维面板上的重算画面,状态栏说明"已停止了什么";`_on_reset`/`_on_stop_shortcut`(Esc) 按角色路由;
`_start_playback`/`_pause_playback` 结束时调用 `_update_run_enabled()`,让停止按钮在有东西可停时才可用;
两个采集 worker 新增 `FILE_FIRST_FRAME_TIMEOUT_S=10s`:文件源一帧都没出来就报错结束会话
(原先 `saw_frame` 为假时 EOF 分支永不执行,循环空转到天荒地老、窗口一直停在"连接中")。
- Why: 用户报告"视频播放完之后无法再次按回放,卡住了",并建议加手动停止。复现得到两种状态:
① 普通回放播到末尾后再按回放**是可以**重新从头播的(探针实测 0.967→0→0.371,计时器在跑),
不是这条路径的问题;② 「从视频重算」通道跑文件时,工作线程可能既不出帧也不结束
(实测:1.5 s 的合成视频跑 25 s,packets=0 且 is_running=True),于是界面永远停在运行/连接状态,
操作者看到"卡住"。所以既给一个无论什么状态都能按的停止,也让"读不出帧"变成一条明确的错误。
- Verification: 新增/更新用例:`test_worker_frame_rate.py` 的"文件一帧都不出→error+消息"(缩短时限跑,
真实 worker);`test_ui_smoke.py` 三项:回放视图的停止按钮(播放中可用、按下停回放、重算中停下重算、
无事可停时说明)、Esc 同样能停、实时视图仍是「重置」;apps/studio 全量通过;shared 全量通过;
ui_check 583 项 0 失败(新增回放停止的 5 项检查,并给"场景已加载却仍在显示消息"那条加了诊断细节,
它此前偶发失败过一次、这次跑通)。
- Not in this change: 普通回放"播到末尾→再按回放"的路径(实测正常,未改);让回放直接播放"非记录"的视频
(仍走「从视频重算」);`_recompute_running` 与 worker 真实状态的进一步合并(现在停止按钮同时看两者)。
This commit is contained in:
@@ -104,6 +104,12 @@
|
||||
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
||||
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
|
||||
"reset": "Reset",
|
||||
"stop_playback": "Stop",
|
||||
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
|
||||
"stopped": "Stopped: {what}",
|
||||
"stopped_playback": "playback",
|
||||
"stopped_pass": "the recompute pass",
|
||||
"nothing_to_stop": "Nothing is playing or recomputing",
|
||||
"units": "Units",
|
||||
"units_newton": "Newton",
|
||||
"units_raw": "Raw",
|
||||
|
||||
@@ -104,6 +104,12 @@
|
||||
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
||||
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
||||
"reset": "重置",
|
||||
"stop_playback": "停止",
|
||||
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
||||
"stopped": "已停止:{what}",
|
||||
"stopped_playback": "回放",
|
||||
"stopped_pass": "重算通道",
|
||||
"nothing_to_stop": "当前没有正在进行的回放或重算",
|
||||
"units": "单位",
|
||||
"units_newton": "牛顿",
|
||||
"units_raw": "原始",
|
||||
|
||||
@@ -1033,6 +1033,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._play_timer.start()
|
||||
self.toolbar.set_run_checked(True)
|
||||
self.toolbar.refresh_labels()
|
||||
# The stop button appears with the playback: one place decides what the rail
|
||||
# offers, so it is told here rather than kept in step in two places.
|
||||
self._update_run_enabled()
|
||||
|
||||
def _pause_playback(self) -> None:
|
||||
if self._play_timer.isActive():
|
||||
@@ -1040,6 +1043,7 @@ class StudioMainWindow(QMainWindow):
|
||||
if self.toolbar.btn_run.isChecked():
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.toolbar.refresh_labels()
|
||||
self._update_run_enabled()
|
||||
|
||||
def _on_play_tick(self) -> None:
|
||||
"""Advance the playhead from the wall clock, not from the tick count."""
|
||||
@@ -1486,8 +1490,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self._pause_playback()
|
||||
self.toolbar.set_source_view("replay" if preset == "replay" else "live")
|
||||
# The rail's primary button is this view's main action: acquire while
|
||||
# measuring, play back while replaying.
|
||||
# measuring, play back while replaying; and the second button is that view's
|
||||
# own reset — or, in the replay view, an unconditional stop.
|
||||
self.toolbar.set_run_role("playback" if preset == "replay" else "acquire")
|
||||
self.toolbar.set_reset_role("stop" if preset == "replay" else "reset")
|
||||
self._apply_panel_visibility()
|
||||
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
|
||||
self.content_splitter.setSizes(self._splitter_sizes_for(preset))
|
||||
@@ -1529,6 +1535,14 @@ class StudioMainWindow(QMainWindow):
|
||||
reason = t("toolbar.playback_disabled")
|
||||
self.toolbar.set_run_tooltip_reason(reason)
|
||||
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
|
||||
# The stop button is offered whenever there is something to stop; the
|
||||
# operator must never have to guess whether that is the case.
|
||||
if self.toolbar.reset_role() == "stop":
|
||||
self.toolbar.set_reset_enabled(
|
||||
self._play_timer.isActive()
|
||||
or self._recompute_running
|
||||
or self._session_active
|
||||
)
|
||||
self.toolbar.set_record_enabled(
|
||||
self._running or self.recorder.is_recording
|
||||
)
|
||||
@@ -2272,7 +2286,16 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
|
||||
|
||||
def _on_stop_shortcut(self) -> None:
|
||||
"""Esc must always be able to cancel a session, even a stuck connect."""
|
||||
"""Esc must always be able to cancel a session, even a stuck connect.
|
||||
|
||||
In the replay view it is the same escape hatch as the rail's 停止 button: a
|
||||
replay or a recompute pass that will not stop by itself has to be stoppable
|
||||
from the keyboard.
|
||||
"""
|
||||
if self.toolbar.run_role() == "playback":
|
||||
if self._play_timer.isActive() or self._session_active:
|
||||
self._stop_replay_activity()
|
||||
return
|
||||
if not self._session_active:
|
||||
return
|
||||
self._stop_run(reason="user_stop")
|
||||
@@ -2576,7 +2599,36 @@ class StudioMainWindow(QMainWindow):
|
||||
else:
|
||||
self.statusBar().showMessage(t("status.run_stopped"))
|
||||
|
||||
def _stop_replay_activity(self) -> None:
|
||||
"""Stop whatever the replay view is doing. The operator's escape hatch.
|
||||
|
||||
Two different things can be running there — plain playback of the recorded
|
||||
timeline, and a recompute pass over the video — and the person in front of the
|
||||
screen should not have to work out which one is stuck before they can stop it.
|
||||
So this stops both, clears a recomputed 3D picture, and says what it stopped.
|
||||
Safe to press when nothing is running (it says so rather than doing nothing
|
||||
silently, which is what made a stuck state feel stuck).
|
||||
"""
|
||||
stopped: list[str] = []
|
||||
if self._recompute_running or self._session_active:
|
||||
self._stop_run(reason="user_stop")
|
||||
stopped.append(t("toolbar.stopped_pass"))
|
||||
if self._play_timer.isActive():
|
||||
self._pause_playback()
|
||||
stopped.append(t("toolbar.stopped_playback"))
|
||||
self.mesh3d_panel.show_reference_only()
|
||||
self._update_run_enabled()
|
||||
if stopped:
|
||||
self._notify(t("toolbar.stopped", what=", ".join(stopped)))
|
||||
else:
|
||||
self._notify(t("toolbar.nothing_to_stop"))
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
if self.toolbar.reset_role() == "stop":
|
||||
# In the replay view that button is the operator's way out of a replay or
|
||||
# a recompute pass, whatever state the transport is in.
|
||||
self._stop_replay_activity()
|
||||
return
|
||||
if self._session_phase != "running" or self._active_worker is None:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -107,6 +107,7 @@ class ToolbarWidget(QWidget):
|
||||
self._video_path = ""
|
||||
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
||||
self._run_role = "acquire"
|
||||
self._reset_role = "reset"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(2, 2, 2, 2)
|
||||
@@ -387,6 +388,34 @@ class ToolbarWidget(QWidget):
|
||||
def run_role(self) -> str:
|
||||
return self._run_role
|
||||
|
||||
def set_reset_role(self, role: str) -> None:
|
||||
"""What the second rail button does: zero the tracker, or stop the replay.
|
||||
|
||||
The replay view needs a *stop* the operator can always reach. The transport
|
||||
button is a play/pause toggle, which is ambiguous precisely when something is
|
||||
wrong (is it playing? is a recompute pass running?), so the second button —
|
||||
disabled in that view otherwise — becomes an unconditional stop instead of a
|
||||
second thing to reason about.
|
||||
"""
|
||||
wanted = "stop" if str(role) == "stop" else "reset"
|
||||
if wanted == self._reset_role:
|
||||
return
|
||||
self._reset_role = wanted
|
||||
if wanted == "reset":
|
||||
self.btn_reset.setEnabled(False)
|
||||
self._refresh_reset_label()
|
||||
|
||||
def reset_role(self) -> str:
|
||||
return self._reset_role
|
||||
|
||||
def _refresh_reset_label(self) -> None:
|
||||
if self._reset_role == "stop":
|
||||
self.btn_reset.setText(t("toolbar.stop_playback"))
|
||||
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
|
||||
return
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
self.btn_reset.setToolTip("")
|
||||
|
||||
def source_view(self) -> str:
|
||||
return self._source_view
|
||||
|
||||
@@ -686,7 +715,7 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_use_camera.setText(t("toolbar.use_camera"))
|
||||
self.btn_replay_video.setText(t("toolbar.choose_video"))
|
||||
self.refresh_labels()
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
self._refresh_reset_label()
|
||||
self.units_label.setText(t("toolbar.units"))
|
||||
self.units_combo.setItemText(0, t("toolbar.units_newton"))
|
||||
self.units_combo.setItemText(1, t("toolbar.units_raw"))
|
||||
|
||||
@@ -1994,6 +1994,98 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
||||
|
||||
|
||||
def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_recording):
|
||||
"""The operator must not have to work out *what* is stuck before stopping it.
|
||||
|
||||
The transport is a play/pause toggle, which is ambiguous exactly when something is
|
||||
wrong. So the rail's second button becomes an unconditional stop in this view, and
|
||||
it covers both things that can run there: plain playback and a recompute pass.
|
||||
"""
|
||||
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()
|
||||
|
||||
assert window.toolbar.reset_role() == "stop", (
|
||||
"the replay view kept the tracker reset button instead of a stop"
|
||||
)
|
||||
assert window.toolbar.btn_reset.text() == t("toolbar.stop_playback")
|
||||
assert not window.toolbar.btn_reset.isEnabled(), (
|
||||
"a stop is offered while nothing is running"
|
||||
)
|
||||
|
||||
# Plain playback: the stop button appears while it plays and pressing it stops.
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._play_timer.isActive()
|
||||
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while playing"
|
||||
window.toolbar.btn_reset.click()
|
||||
QApplication.processEvents()
|
||||
assert not window._play_timer.isActive(), "the stop did not stop the playback"
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport still shows playing"
|
||||
assert t("toolbar.stopped_playback") in window.statusBar().currentMessage(), (
|
||||
window.statusBar().currentMessage()
|
||||
)
|
||||
|
||||
# A recompute pass: the same button stops it, without stopping the recording.
|
||||
stopped: list = []
|
||||
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
|
||||
window._recompute_running = True
|
||||
window._update_run_enabled()
|
||||
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while a pass runs"
|
||||
window.toolbar.btn_reset.click()
|
||||
QApplication.processEvents()
|
||||
assert stopped == ["user_stop"], stopped
|
||||
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
|
||||
window.statusBar().currentMessage()
|
||||
)
|
||||
|
||||
# Nothing running: it says so instead of doing nothing at all.
|
||||
# (The stubbed ``_stop_run`` above records the reason but does not clear the flag
|
||||
# the real one clears, so the state is completed by hand here.)
|
||||
window._recompute_running = False
|
||||
window.toolbar.btn_reset.click()
|
||||
QApplication.processEvents()
|
||||
assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), (
|
||||
window.statusBar().currentMessage()
|
||||
)
|
||||
window.close()
|
||||
|
||||
|
||||
def test_escape_stops_the_replay_too(make_window, replay_recording):
|
||||
"""Esc is the documented way out; in the replay view it stops the replay."""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
assert window._load_recording(replay_recording)
|
||||
QApplication.processEvents()
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._play_timer.isActive()
|
||||
window.act_stop.trigger()
|
||||
QApplication.processEvents()
|
||||
assert not window._play_timer.isActive(), "Esc did not stop the playback"
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_live_view_keeps_the_tracker_reset(make_window):
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_live.click()
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.reset_role() == "reset"
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
||||
make_window, record_root
|
||||
):
|
||||
|
||||
@@ -2882,7 +2882,9 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
not pane.showing_message,
|
||||
"the pane is still showing a message although a scene loaded",
|
||||
"the pane is still showing a message although a scene loaded "
|
||||
f"(loaded_path={pane._loaded_path}, source={getattr(pane._source, 'path', None)}, "
|
||||
f"loaded={pane._loaded}, message={pane.message.text()[:40]!r})",
|
||||
)
|
||||
if view_for_load is not None and view_for_load.panel is not None:
|
||||
source_text = view_for_load.panel.source_label.text()
|
||||
@@ -3076,6 +3078,45 @@ def main() -> int:
|
||||
"the rail's primary button is not the replay's transport",
|
||||
)
|
||||
check(replay_window.toolbar.btn_run.isEnabled(), "the replay has no transport")
|
||||
|
||||
# The replay view offers an unconditional stop (the rail's second button), because
|
||||
# the transport is a play/pause toggle and "which one is stuck?" is not a question
|
||||
# to ask an operator in front of a frozen picture.
|
||||
check(
|
||||
replay_window.toolbar.reset_role() == "stop",
|
||||
"the replay view has no stop button",
|
||||
)
|
||||
check(
|
||||
replay_window.toolbar.btn_reset.text() == t("toolbar.stop_playback"),
|
||||
f"the stop button is not labelled as one: "
|
||||
f"{replay_window.toolbar.btn_reset.text()!r}",
|
||||
)
|
||||
# Start playing so there is something to stop, then stop it with that button.
|
||||
replay_window.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
replay_window._play_timer.isActive(),
|
||||
"the playback did not start, so the stop check would prove nothing",
|
||||
)
|
||||
check(
|
||||
replay_window.toolbar.btn_reset.isEnabled(),
|
||||
"the stop is not offered while the transport is playing",
|
||||
)
|
||||
replay_window.toolbar.btn_reset.click()
|
||||
app.processEvents()
|
||||
check(
|
||||
not replay_window.toolbar.btn_run.isChecked(),
|
||||
"the stop did not stop the transport",
|
||||
)
|
||||
check(
|
||||
not replay_window._play_timer.isActive(),
|
||||
"the stop left the playback timer running",
|
||||
)
|
||||
check(
|
||||
t("toolbar.stopped_playback") in replay_window.statusBar().currentMessage(),
|
||||
f"the stop did not say what it stopped: "
|
||||
f"{replay_window.statusBar().currentMessage()!r}",
|
||||
)
|
||||
check(
|
||||
not replay_window.toolbar.btn_run.isChecked(),
|
||||
"the replay starts while playing",
|
||||
|
||||
@@ -23,6 +23,7 @@ from orisys_app_shared.acquisition.models import (
|
||||
)
|
||||
from orisys_app_shared.acquisition.sdk_worker import (
|
||||
CAMERA_DISCONNECT_TIMEOUT_S,
|
||||
FILE_FIRST_FRAME_TIMEOUT_S,
|
||||
FPS_WINDOW_S,
|
||||
HANDOVER_TIMEOUT_S,
|
||||
STOP_JOIN_TIMEOUT_S,
|
||||
@@ -417,6 +418,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
||||
# Per-session monotonic frame index: the join key shared by the video,
|
||||
# the CSV and the calibration records.
|
||||
frame_id = 0
|
||||
#: When this session's loop started, for the file-source deadline below.
|
||||
session_started = time.perf_counter()
|
||||
|
||||
while not self._stop.is_set() and session == self._session_id and runtime is not None:
|
||||
try:
|
||||
@@ -432,6 +435,18 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
||||
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||
self._set_end_reason("disconnected")
|
||||
break
|
||||
elif is_file and (now - session_started) >= FILE_FIRST_FRAME_TIMEOUT_S:
|
||||
# The same deadline as the box worker: a file that never yields a
|
||||
# frame is an error to report, not a "connecting" state to sit in.
|
||||
message = (
|
||||
f"this video produced no frames in "
|
||||
f"{FILE_FIRST_FRAME_TIMEOUT_S:.0f} s: {source} may be "
|
||||
"unreadable, truncated, or in a codec this build cannot decode"
|
||||
)
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(message)
|
||||
self._set_end_reason("error")
|
||||
break
|
||||
|
||||
t0 = time.perf_counter()
|
||||
ok, source_frame = runtime.read_source()
|
||||
|
||||
@@ -31,6 +31,13 @@ from orisys_app_shared.acquisition.models import (
|
||||
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
|
||||
|
||||
VIDEO_EOF_TIMEOUT_S = 0.5
|
||||
#: How long a file source may deliver *nothing at all* before the session gives up.
|
||||
#: The end-of-file branch needs at least one frame to have arrived (``saw_frame``), so
|
||||
#: a video this build cannot decode — a wrong codec, a truncated file, a text file
|
||||
#: renamed to .mp4 — used to loop forever: no frames, no error, and the window sat in
|
||||
#: "connecting" until somebody pressed Stop. A failed open is a fact to report, not a
|
||||
#: state to wait in.
|
||||
FILE_FIRST_FRAME_TIMEOUT_S = 10.0
|
||||
CAMERA_DISCONNECT_TIMEOUT_S = 1.5
|
||||
#: How long a *blocking* ``stop(wait=True)`` may hold the caller.
|
||||
STOP_JOIN_TIMEOUT_S = 5.0
|
||||
@@ -851,6 +858,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
||||
# the CSV and the calibration records.
|
||||
frame_id = 0
|
||||
|
||||
#: When this session's loop started, for the file-source deadline below.
|
||||
session_started = time.perf_counter()
|
||||
|
||||
while not self._stop.is_set() and session == self._session_id:
|
||||
try:
|
||||
now = time.perf_counter()
|
||||
@@ -865,6 +875,16 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
||||
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||
self._set_end_reason("disconnected")
|
||||
break
|
||||
elif is_file and (now - session_started) >= FILE_FIRST_FRAME_TIMEOUT_S:
|
||||
message = (
|
||||
f"this video produced no frames in "
|
||||
f"{FILE_FIRST_FRAME_TIMEOUT_S:.0f} s: {source} may be "
|
||||
"unreadable, truncated, or in a codec this build cannot decode"
|
||||
)
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(message)
|
||||
self._set_end_reason("error")
|
||||
break
|
||||
|
||||
t0 = time.perf_counter()
|
||||
# ``t0`` is when this frame was *collected*: the device's own rate
|
||||
|
||||
@@ -373,6 +373,43 @@ def test_a_file_source_runs_on_the_video_s_own_timeline():
|
||||
)
|
||||
|
||||
|
||||
def test_a_file_that_yields_no_frames_ends_the_session_with_an_error(monkeypatch):
|
||||
"""A video this build cannot decode must report it, not wait forever.
|
||||
|
||||
The end-of-file branch needs at least one frame to have arrived (``saw_frame``),
|
||||
so a file that never produces one used to leave the loop spinning: no frames, no
|
||||
error, and the window in "connecting" until somebody pressed Stop. The shipping
|
||||
deadline is ten seconds; it is shortened here, because the point is the behaviour
|
||||
rather than the wait.
|
||||
"""
|
||||
import orisys_app_shared.acquisition.sdk_worker as worker_module
|
||||
|
||||
monkeypatch.setattr(worker_module, "FILE_FIRST_FRAME_TIMEOUT_S", 0.3)
|
||||
# A sensor that never produces a frame: what an unreadable codec looks like.
|
||||
monkeypatch.setattr(_Sensor, "get_img", lambda self: None)
|
||||
|
||||
CREATED.clear()
|
||||
saved = _install()
|
||||
errors: list = []
|
||||
packets: list = []
|
||||
worker = worker_module.SdkAcquisitionWorker(on_error=errors.append)
|
||||
try:
|
||||
worker.add_sink(packets.append)
|
||||
worker.start("unreadable.mp4", config_ref=BOX_CONFIG)
|
||||
deadline = time.perf_counter() + 10.0
|
||||
while time.perf_counter() < deadline and worker.is_running:
|
||||
time.sleep(0.05)
|
||||
assert not worker.is_running, (
|
||||
"the worker is still running on a file that produced no frames"
|
||||
)
|
||||
assert worker.consume_end_reason() == "error", worker.consume_end_reason()
|
||||
assert packets == []
|
||||
assert errors and "no frames" in errors[0], errors
|
||||
finally:
|
||||
worker.stop(wait=True)
|
||||
_restore(saved)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||
failures = 0
|
||||
for name, function in sorted(globals().items()):
|
||||
|
||||
Reference in New Issue
Block a user