把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口
控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。 验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
This commit is contained in:
@@ -165,11 +165,12 @@ class _Host:
|
||||
def __init__(self, *, view: str = "replay") -> None:
|
||||
self.prefs: dict = {}
|
||||
self.view = view
|
||||
self._running = False
|
||||
self._preview_open = False
|
||||
self._session_active = False
|
||||
self._running_flag = False
|
||||
self._preview_open_flag = False
|
||||
self._session_active_flag = False
|
||||
self.notes: list[str] = []
|
||||
self.frames: list = []
|
||||
self.analysis_packets: list = []
|
||||
self.presets: list[str] = []
|
||||
self.units: list[str] = []
|
||||
self.source_modes: list[str] = []
|
||||
@@ -178,46 +179,105 @@ class _Host:
|
||||
self.run_updates = 0
|
||||
self.rail_updates = 0
|
||||
self.markers = 0
|
||||
#: What the setup prompt answers for a bare video: a workspace directory, or
|
||||
#: ``None`` for "the operator declined". The window builds a real dialog; a fake
|
||||
#: has no dialog, so the answer is the field a test sets.
|
||||
self.workspace = None
|
||||
self.workspace_requests: list = []
|
||||
|
||||
# reads
|
||||
def _in_replay_view(self) -> bool:
|
||||
return self.view == "replay"
|
||||
# AcquireGate reads (legacy private names kept as writeable fields for tests)
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running_flag
|
||||
|
||||
def _current_source_kind(self) -> str:
|
||||
@running.setter
|
||||
def running(self, value: bool) -> None:
|
||||
self._running_flag = bool(value)
|
||||
|
||||
@property
|
||||
def preview_open(self) -> bool:
|
||||
return self._preview_open_flag
|
||||
|
||||
@preview_open.setter
|
||||
def preview_open(self, value: bool) -> None:
|
||||
self._preview_open_flag = bool(value)
|
||||
|
||||
@property
|
||||
def session_active(self) -> bool:
|
||||
return self._session_active_flag
|
||||
|
||||
@session_active.setter
|
||||
def session_active(self, value: bool) -> None:
|
||||
self._session_active_flag = bool(value)
|
||||
|
||||
# compatibility for tests that still assign ``_running`` / friends
|
||||
@property
|
||||
def _running(self) -> bool:
|
||||
return self._running_flag
|
||||
|
||||
@_running.setter
|
||||
def _running(self, value: bool) -> None:
|
||||
self._running_flag = bool(value)
|
||||
|
||||
@property
|
||||
def _preview_open(self) -> bool:
|
||||
return self._preview_open_flag
|
||||
|
||||
@_preview_open.setter
|
||||
def _preview_open(self, value: bool) -> None:
|
||||
self._preview_open_flag = bool(value)
|
||||
|
||||
@property
|
||||
def _session_active(self) -> bool:
|
||||
return self._session_active_flag
|
||||
|
||||
@_session_active.setter
|
||||
def _session_active(self, value: bool) -> None:
|
||||
self._session_active_flag = bool(value)
|
||||
|
||||
def current_source_kind(self) -> str:
|
||||
return "recompute"
|
||||
|
||||
# writes
|
||||
def _apply_view_preset(self, preset: str) -> None:
|
||||
self.presets.append(preset)
|
||||
self.view = preset
|
||||
def bare_video_workspace(self, video):
|
||||
self.workspace_requests.append(video)
|
||||
return self.workspace
|
||||
|
||||
def _set_display_unit(self, unit: str) -> None:
|
||||
def analysis_service(self, recording, cancelled=None, *, on_progress=None):
|
||||
raise AssertionError("the test did not provide an analysis service")
|
||||
|
||||
def set_display_unit(self, unit: str) -> None:
|
||||
self.units.append(unit)
|
||||
|
||||
def _set_source_mode(self, mode: str) -> None:
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
self.source_modes.append(mode)
|
||||
|
||||
def _show_image_frame(self, frame) -> None:
|
||||
def show_frame(self, frame) -> None:
|
||||
self.frames.append(frame)
|
||||
|
||||
def _drop_render_marker(self) -> None:
|
||||
def show_analysis_packet(self, packet) -> None:
|
||||
self.analysis_packets.append(packet)
|
||||
|
||||
def drop_render_marker(self) -> None:
|
||||
self.markers += 1
|
||||
|
||||
def _notify(self, text: str, ms: int = 0) -> None:
|
||||
def notify(self, text: str, ms: int = 0) -> None:
|
||||
self.notes.append(str(text))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
def start_acquire(self) -> None:
|
||||
self.started += 1
|
||||
|
||||
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
||||
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
||||
self.stopped.append(reason)
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
def update_run_enabled(self) -> None:
|
||||
self.run_updates += 1
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
def refresh_status_rail(self) -> None:
|
||||
self.rail_updates += 1
|
||||
|
||||
def persist_prefs(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, *, newtons=None) -> None:
|
||||
@@ -276,6 +336,10 @@ class _Engine:
|
||||
def reader(self):
|
||||
return self._reader
|
||||
|
||||
def directory(self):
|
||||
"""No recording directory: a stub opens a video, not a recorded session."""
|
||||
return None
|
||||
|
||||
def video_path(self):
|
||||
return self._path
|
||||
|
||||
@@ -309,6 +373,23 @@ class _Engine:
|
||||
return self.accept
|
||||
|
||||
|
||||
def _wire_replay(controller, host):
|
||||
"""Signals replace private start/stop/view calls on the shell."""
|
||||
controller.view_requested.connect(
|
||||
lambda preset: (
|
||||
host.presets.append(preset),
|
||||
setattr(host, "view", preset),
|
||||
controller.set_active_view(preset),
|
||||
)
|
||||
)
|
||||
controller.acquire_requested.connect(host.start_acquire)
|
||||
controller.acquire_stop_requested.connect(
|
||||
lambda reason: host.stop_acquire(reason=reason)
|
||||
)
|
||||
controller.set_active_view(host.view)
|
||||
return controller
|
||||
|
||||
|
||||
def _controller(**kwargs):
|
||||
"""A controller wired to fakes; returns ``(controller, host, engine, parts)``."""
|
||||
engine = kwargs.pop("engine", None) or _Engine()
|
||||
@@ -317,13 +398,16 @@ def _controller(**kwargs):
|
||||
charts = kwargs.pop("charts", None) or _Charts()
|
||||
panel = kwargs.pop("image_panel", None) or _ImagePanel()
|
||||
mesh = kwargs.pop("mesh_panel", None) or _Mesh()
|
||||
controller = ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
controller = _wire_replay(
|
||||
ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
),
|
||||
host,
|
||||
)
|
||||
actions = {
|
||||
"open_video": _Action(),
|
||||
@@ -380,20 +464,19 @@ def test_the_playhead_follows_the_wall_clock_and_stops_at_the_end(qt_app, monkey
|
||||
assert not controller.is_playing(), "the transport kept running past the end"
|
||||
|
||||
|
||||
def test_the_transport_runs_a_recompute_pass_instead_of_playing(qt_app):
|
||||
"""With the recompute switch on, play starts the *pipeline*, not the cursor."""
|
||||
def test_the_transport_plays_even_when_the_recompute_switch_is_on(qt_app):
|
||||
"""Analysis is a separate pass; the rail's play button always moves the cursor."""
|
||||
engine = _Engine(duration=1.0, has_video=True)
|
||||
controller, host, engine, parts = _controller(engine=engine)
|
||||
parts[4]["recompute"].setChecked(True)
|
||||
|
||||
controller.on_transport_toggled(True)
|
||||
assert controller.recompute_running
|
||||
assert host.started == 1
|
||||
assert not controller.is_playing(), "the recompute pass also started playback"
|
||||
assert controller.is_playing(), "play must scrub the recording"
|
||||
assert host.started == 0, "play must not open an acquisition chain"
|
||||
|
||||
controller.on_transport_toggled(False)
|
||||
assert not controller.recompute_running
|
||||
assert host.stopped == ["user_stop"]
|
||||
assert not controller.is_playing()
|
||||
assert host.stopped == []
|
||||
|
||||
|
||||
def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app):
|
||||
@@ -402,12 +485,12 @@ def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app):
|
||||
)
|
||||
assert controller.owns_image()
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
assert not controller.owns_image(), "a live view does not hand the pane to a replay"
|
||||
|
||||
host.view = "replay"
|
||||
controller.recompute_running = True
|
||||
assert not controller.owns_image(), "the pass is drawing, not the replay"
|
||||
controller.set_active_view("replay")
|
||||
# An analysis pass is drawn by this controller too, so ownership stays with replay.
|
||||
assert controller.owns_image()
|
||||
|
||||
|
||||
def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app, tmp_path):
|
||||
@@ -416,16 +499,45 @@ def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app,
|
||||
engine = _Engine()
|
||||
controller, host, engine, parts = _controller(engine=engine)
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
assert controller.load_video(bare) is False
|
||||
assert host.notes[-1] == t("toolbar.replay_view_only")
|
||||
assert engine.opened == [], "a video was opened from the live view"
|
||||
|
||||
host.view = "replay"
|
||||
controller.set_active_view("replay")
|
||||
assert controller.load_video(bare) is True, "a playable clip was refused"
|
||||
assert engine.opened == [("video", bare)]
|
||||
assert host.source_modes[-1] == "video"
|
||||
assert host.prefs["video_path"] == str(bare)
|
||||
# Opening a bare video asks how to analyse it — once, after it is on screen. The answer
|
||||
# here is "declined", and that must leave today's behaviour intact: the video plays,
|
||||
# and no workspace is claimed as this timeline's input.
|
||||
assert host.workspace_requests == [bare], "the setup prompt was never asked"
|
||||
assert controller.recording_directory() is None
|
||||
assert controller.result_state()[1] == t("playback.analysis_setup_declined")
|
||||
|
||||
|
||||
def test_a_bare_video_uses_the_workspace_the_prompt_answers_with(qt_app, tmp_path):
|
||||
"""The generated workspace *is* the recording directory for everything downstream."""
|
||||
bare = tmp_path / "clip.mp4"
|
||||
bare.write_bytes(b"not really a video")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "session.json").write_text("{}", encoding="utf-8")
|
||||
host = _Host()
|
||||
host.workspace = workspace
|
||||
controller, host, engine, parts = _controller(host=host)
|
||||
|
||||
assert controller.load_video(bare) is True
|
||||
assert host.workspace_requests == [bare]
|
||||
assert controller.recording_directory() == workspace, (
|
||||
"the pass would have read the video's own folder instead of the workspace"
|
||||
)
|
||||
|
||||
# Dropping what is open must drop the claim with it: the workspace is keyed by the
|
||||
# video, so reopening finds it, but it is not this timeline's input any more.
|
||||
controller.on_recording_changed(False)
|
||||
assert controller.recording_directory() is None
|
||||
|
||||
|
||||
def test_the_charts_fall_back_to_raw_when_a_recording_has_no_newtons(qt_app):
|
||||
@@ -449,7 +561,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
|
||||
controller, host, engine, parts = _controller()
|
||||
actions = parts[4]
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
controller.sync_actions()
|
||||
assert [
|
||||
actions[name].enabled for name in ("open_video", "playback", "latest")
|
||||
@@ -459,7 +571,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
|
||||
assert parts[0].recompute_enabled[-1] is False
|
||||
assert actions["open_video"].tooltips[-1] == t("toolbar.replay_view_only")
|
||||
|
||||
host.view = "replay"
|
||||
controller.set_active_view("replay")
|
||||
engine._has_video = True
|
||||
controller.sync_actions()
|
||||
assert actions["open_video"].enabled is True
|
||||
|
||||
Reference in New Issue
Block a user