fix(studio): 回放视图里选了不是记录的视频时,明确说明原因并给出可行路径
- What: `_load_replay_video()` 先用与 `list_recordings()` 相同的判据确认"这是一条记录"(视频同目录 必须有 `forces.csv`);不是记录时不再把该目录当成记录打开,而是给出原因(状态栏 + 2D 面板常驻文案)、 在 rail 上保留该视频路径,并把「从视频重算力值」置为可用;`_can_recompute()` 统一"重算有没有东西可读" (记录或已选视频),`_sync_menu_checks`/`_refresh_replay_state`/`_on_recompute_toggled` 共用它; 回放按钮在"已选视频但不是记录"时给出对应 tooltip;`_start_playback()` 遇到零长度时间轴时改为明确提示, 不再静默取消勾选。 - Why: 用户报告"历史回放面板,选择视频后按回放键没有反应"。复现确认:`RecordingReader` 容忍空目录, 所以随便一段视频所在目录都会被"成功"打开成一条空记录,`duration_s()==0`,按回放→`_start_playback` 直接静默 return——按钮点了像没点。一条记录的定义(录像 + forces.csv + session.json)此前只写在 `list_recordings` 里,回放入口没有用同一个判据。 - Verification: 复现脚本三种状态(未选/裸视频/记录内视频)行为符合预期;新增三项 UI 用例 (裸视频被拒且说明原因并能重算、记录内视频可打开且能播、缺视频段的记录给出提示); apps/studio 全量通过;ui_check 576 项 0 失败(新增 6 项覆盖同一行为);locale_check 0 缺失。 - Not in this change: 让回放直接播放"非记录"的视频(那需要一条没有力值的纯视频通道;现在用「从视频重算」 覆盖这个需求);`RecordingReader` 对空目录的容忍行为(它要允许正在写入、尚未写 session.json 的记录)。
This commit is contained in:
@@ -1994,6 +1994,118 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
||||
|
||||
|
||||
def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
||||
make_window, record_root
|
||||
):
|
||||
"""A bare video has no forces.csv, so it is not something this view can replay.
|
||||
|
||||
The reader tolerates a directory with nothing in it, so opening the video's own
|
||||
folder as a recording used to *succeed* — and then the transport played a
|
||||
zero-length timeline, which is what "pressing 回放 does nothing" was.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
bare = Path(record_root) / "bare_video.mp4"
|
||||
writer = cv2.VideoWriter(
|
||||
str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
||||
)
|
||||
for index in range(30):
|
||||
writer.write(np.full((48, 64, 3), index, dtype=np.uint8))
|
||||
writer.release()
|
||||
assert bare.is_file()
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
assert not window.replay.is_loaded()
|
||||
|
||||
assert window._load_replay_video(bare) is False, (
|
||||
"a video with no recording beside it was accepted as a recording"
|
||||
)
|
||||
QApplication.processEvents()
|
||||
assert not window.replay.is_loaded(), "an empty folder was opened as a recording"
|
||||
assert not window.toolbar.btn_run.isEnabled(), (
|
||||
"the transport is offered although there is nothing to play"
|
||||
)
|
||||
reason = window.toolbar.btn_run.toolTip()
|
||||
assert "forces.csv" in reason or "recording" in reason.lower(), reason
|
||||
# And what was missing, in the panel that would have shown the frames.
|
||||
body = window.arrow_panel.image_label.text()
|
||||
assert "forces.csv" in body or "recording" in body.lower(), body
|
||||
# The way forward for a video that is not a recording: recompute from it.
|
||||
assert window.act_recompute.isEnabled(), (
|
||||
"a chosen video cannot be recomputed either, so the operator has no way to "
|
||||
"look at it"
|
||||
)
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||
make_window, replay_recording
|
||||
):
|
||||
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()
|
||||
segments = sorted(Path(replay_recording).glob("raw_*.mp4"))
|
||||
assert segments, "the fixture wrote no video segment"
|
||||
assert window._load_replay_video(segments[0]) is True
|
||||
QApplication.processEvents()
|
||||
assert window.replay.is_loaded()
|
||||
assert window.toolbar.btn_run.isEnabled(), "the transport is not offered"
|
||||
assert t("toolbar.playback_needs_recording") not in window.toolbar.btn_run.toolTip(), (
|
||||
"a loaded recording is still described as one that cannot be played"
|
||||
)
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._play_timer.isActive(), "pressing 回放 did not start playback"
|
||||
window._pause_playback()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, record_root):
|
||||
"""The defensive case: a recording whose video segments are gone."""
|
||||
import csv
|
||||
import json
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
directory = Path(record_root) / "no_video_recording"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "session.json").write_text(
|
||||
json.dumps({"format_version": 1, "device": {"sensor_type": "finger"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (directory / "forces.csv").open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(["session_id", "frame_id", "t_rel_s", "FN_raw"])
|
||||
writer.writerow([1, 1, 0.0, 1.0])
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
if not window._load_recording(directory):
|
||||
pytest.skip("this recording layout was refused before it could be played")
|
||||
QApplication.processEvents()
|
||||
assert window.replay.duration_s() <= 0.0
|
||||
window._start_playback()
|
||||
QApplication.processEvents()
|
||||
assert not window._play_timer.isActive()
|
||||
assert t("playback.empty_recording")[:8] in window.statusBar().currentMessage(), (
|
||||
window.statusBar().currentMessage()
|
||||
)
|
||||
window.close()
|
||||
|
||||
|
||||
# ------------------------------------------- selection readout and notices
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user