From 81e39ca86851e09bed17694909d565dbb34934da Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 15 Sep 2026 09:58:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=9A=84"=E6=B8=85=E9=9B=B6=E5=B7=B2=E7=94=9F=E6=95=88"?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=BB=8E=E6=9C=AA=E8=A2=AB=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=88worker=20=E7=9A=84=20on=5Freset=5Fapplied=20=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=8E=A5=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What: `SessionController.ensure_workers` 现在把 `on_reset_applied=self.note_reset_applied` 交给两个 worker,并在控制器上补回 `note_reset_applied()`(转发给记录通道的 `recording.note_reset`)。窗口侧保留 `_on_reset_applied` 作为一层转发——tests 通过它 模拟设备确认。 - Fix: 这是一个真实缺陷,不是重构副作用。请求路径一直是通的(`_on_reset` → worker.reset()),确认路径却从来没有人接:worker 的 `on_reset_applied` 默认 None, 于是设备回报的 frame_id / mono_t 被丢掉,`recording.note_reset` 不会执行, `_drain_reset_events()` 也永远没有事件可发。后果是真机上按「清零」只会看到「指令已发出」, 录制里不会留下 zeroing 事件(`session.json` 的 events 少一条),而 test_ui_smoke 里那条 `test_a_zeroing_is_recorded_only_after_the_worker_confirms_it` 之所以一直是绿的,是因为 它自己把 `stuck.on_reset_applied = window._on_reset_applied` 手工接上了——测试补上了 产品缺的那根线。 - What: 新增 apps/studio/tests/test_session_controller.py:8 个用例直接驱动控制器 (假 worker、假记录通道)——清零确认与标定确认都会到达记录、两个 worker 都挂上记录 sink 与三维 sink、detach_sinks 会摘掉、一次运行从 connecting 到 running 到 idle 的 相位与遥测(结束后旧遥测不可读、session id 递增)、connecting 时停止会被如实告知、 慢连接提示只说一次、丢帧批次只报一次、采集率短少必须有实测值才判定且一会话只说一次。 - Why: 记录要说真话。清零在实验里是一个事件("这一刻起力被归零"),设备既然回报了 它就应当进入记录;把接线补在控制器(A2 搬进来的那个所有者)而不是窗口里,也让 "谁在跑、报了什么"这条链路只有一个所有者。 - Verification: pytest shared/tests apps/studio/tests(offscreen)644 passed / 0 failed / 15 skipped(原 636 + 新增 8);apps/studio/tools/ui_check.py 587 项检查 0 失败; .repro/locale_check.py 0 缺失。真机上的验收仍未做(本机没有设备), 已知边界:SDK worker 的 `_on_reset_applied` 只在 `sensor.reset()` 返回后调用, 异常路径本来就不会报告确认——这一点没有改动。 - Not in this change: 不改变 `_on_reset` 的请求语义与状态栏文案;`_drain_reset_events` 仍在窗口(它只是把事件变成一行状态)。 --- apps/studio/src/orisys_studio/app_window.py | 9 +- .../src/orisys_studio/session_controller.py | 14 ++ apps/studio/tests/test_session_controller.py | 222 ++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/studio/tests/test_session_controller.py diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 828b3fd..c4e4103 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -1590,8 +1590,13 @@ class StudioMainWindow(QMainWindow): self.recording.note_calibration_applied(path, version, frame_id, mono_t, session) def _on_reset_applied(self, frame_id: int, mono_t: float, session: int) -> None: - """Called from the acquisition thread once a zeroing took effect.""" - self.recording.note_reset(frame_id, mono_t, session) + """The device confirmed a zeroing. + + The workers hand this to the session controller (which is what actually records + it); the window keeps the entry point because the tests drive the confirmation + through it. + """ + self.session.note_reset_applied(frame_id, mono_t, session) def _drain_calibration_events(self) -> None: """Publish confirmed calibration changes on the UI thread.""" diff --git a/apps/studio/src/orisys_studio/session_controller.py b/apps/studio/src/orisys_studio/session_controller.py index 218785f..2d16b5c 100644 --- a/apps/studio/src/orisys_studio/session_controller.py +++ b/apps/studio/src/orisys_studio/session_controller.py @@ -226,12 +226,18 @@ class SessionController(QObject): on_error=self.note_error, on_status=self.note_status, on_calibration_applied=self.note_calibration_applied, + # A confirmed zeroing has to reach the record as well: without this the + # device's own "reset took effect" never became an event, so a session + # that was zeroed mid-run recorded no evidence of it (the status line + # that says so is published from the same event). + on_reset_applied=self.note_reset_applied, ) if self._finger_worker is None: self._finger_worker = FingerAcquisitionWorker( on_error=self.note_error, on_status=self.note_status, on_calibration_applied=self.note_calibration_applied, + on_reset_applied=self.note_reset_applied, ) self.wire_sinks() @@ -347,6 +353,14 @@ class SessionController(QObject): """Called from the acquisition thread once new coefficients are loaded.""" self._recording.note_calibration_applied(path, version, frame_id, mono_t, session) + def note_reset_applied(self, frame_id: int, mono_t: float, session: int) -> None: + """Called from the acquisition thread once a zeroing took effect. + + This is the *confirmation*, not the request: the device reports the frame and + the time it zeroed at, and the record channel keeps them as an event. + """ + self._recording.note_reset(frame_id, mono_t, session) + def note_sample_telemetry(self, packet: Any) -> None: """Adopt one frame's own numbers. Called on the UI thread, once per new frame. diff --git a/apps/studio/tests/test_session_controller.py b/apps/studio/tests/test_session_controller.py new file mode 100644 index 0000000..4c95a13 --- /dev/null +++ b/apps/studio/tests/test_session_controller.py @@ -0,0 +1,222 @@ +"""Acceptance for the acquisition session's state and its UI-free mechanics. + +``SessionController`` owns which worker runs, in which phase, and what the session +reports (frame rate, cost, capture rate and drops, how many readouts failed). It touches +no widget, so it can be driven directly — which is what these tests do, with a fake +recording coordinator and fake workers. +""" + +from __future__ import annotations + +import pytest + +from orisys_studio import session_controller +from orisys_studio.session_controller import SessionController + + +class _Recording: + """The record channel as the controller uses it (no files, no Qt).""" + + def __init__(self) -> None: + self.packets: list = [] + self.calibrations: list = [] + self.resets: list = [] + self.invalid = 0 + + def on_packet(self, packet): + self.packets.append(packet) + return type("Report", (), {"valid": packet != "invalid"})() + + def note_calibration_applied(self, path, version, frame_id, mono_t, session) -> None: + self.calibrations.append((path, version, frame_id, mono_t, session)) + + def note_reset(self, frame_id, mono_t, session) -> None: + self.resets.append((frame_id, mono_t, session)) + + +class _Worker: + def __init__(self, **kwargs) -> None: + self.kwargs = dict(kwargs) + self.sinks: list = [] + self.running = False + + def add_sink(self, sink) -> None: + if sink not in self.sinks: + self.sinks.append(sink) + + def remove_sink(self, sink) -> None: + self.sinks.remove(sink) + + +@pytest.fixture +def workers(monkeypatch): + """Replace both worker classes with recording fakes.""" + import orisys_app_shared.acquisition.finger_worker as finger_module + import orisys_app_shared.acquisition.sdk_worker as sdk_module + + created: list[_Worker] = [] + + def factory(**kwargs): + worker = _Worker(**kwargs) + created.append(worker) + return worker + + monkeypatch.setattr(sdk_module, "SdkAcquisitionWorker", factory) + monkeypatch.setattr(finger_module, "FingerAcquisitionWorker", factory) + return created + + +def _controller(recording=None, **kwargs): + recording = recording or _Recording() + controller = SessionController( + recording=recording, packet_for_3d=lambda packet: None, **kwargs + ) + return controller, recording + + +# --------------------------------------------------------------------- tests + + +def test_a_confirmed_zeroing_reaches_the_record(qt_app, workers): + """The device's own "the zeroing took effect" must not be dropped on the floor. + + Nothing was wired to it before: the reset request went out, the confirmation came + back, and the session recorded no event for it. + """ + controller, recording = _controller() + controller.ensure_workers() + assert len(workers) == 2, "both workers are created (the sensor can change)" + + for worker in workers: + callback = worker.kwargs.get("on_reset_applied") + assert callable(callback), "the worker was not told where to report a zeroing" + callback(7, 1.5, 3) + + assert recording.resets == [(7, 1.5, 3), (7, 1.5, 3)] + + +def test_a_confirmed_calibration_reaches_the_record(qt_app, workers): + controller, recording = _controller() + controller.ensure_workers() + + for worker in workers: + worker.kwargs["on_calibration_applied"]("/tmp/cal.json", "abc123", 4, 0.2, 1) + + assert recording.calibrations == [ + ("/tmp/cal.json", "abc123", 4, 0.2, 1), + ("/tmp/cal.json", "abc123", 4, 0.2, 1), + ] + + +def test_the_record_channel_is_a_sink_on_both_workers(qt_app, workers): + controller, recording = _controller() + controller.ensure_workers() + for worker in workers: + # Without a window-provided sink, the controller's own method is registered. + assert controller.note_packet_recorded in worker.sinks + assert len(worker.sinks) == 2, "the 3D pane is a second sink on the same thread" + + # A failed readout is counted; a good one is not. + controller.note_packet_recorded("invalid") + controller.note_packet_recorded("fine") + assert controller.record_error_count == 1 + assert recording.packets == ["invalid", "fine"] + + controller.detach_sinks() + for worker in workers: + assert worker.sinks == [] + + +def test_a_run_connects_and_then_reports_its_own_telemetry(qt_app, workers): + controller, recording = _controller() + controller.ensure_workers() + controller.reset_for_run(source="/dev/video0", sensor_type="box") + first = controller.session_counter + assert controller.phase == "idle" and not controller.is_running + + controller.mark_running() + assert controller.is_running and controller.phase == "connecting" + assert controller.run_timer.isActive(), "the run timer is the session's cadence" + + packet = type( + "Packet", + (), + {"fps": 16.7, "process_ms": 58.0, "capture_fps": 30.0, "capture_dropped": 2}, + )() + controller.note_sample_telemetry(packet) + assert (controller.latest_fps, controller.latest_process_ms) == (16.7, 58.0) + assert controller.latest_capture_fps == 30.0 + assert controller.latest_capture_dropped == 2 + + assert controller.note_first_frame() is True + assert controller.phase == "running" + assert controller.note_first_frame() is False, "the phase change happens once" + + assert controller.mark_stopped("user_stop") is False + assert not controller.is_running and controller.phase == "idle" + assert not controller.run_timer.isActive() + # The telemetry of a session that is over must not be readable as this one's. + assert controller.latest_fps is None and controller.latest_capture_fps == 0.0 + assert controller.last_sample_valid is None + + controller.reset_for_run(source="/dev/video0", sensor_type="box") + assert controller.session_counter == first + 1, "a new run is a new session id" + + +def test_a_stop_while_connecting_says_so(qt_app, workers): + """The caller uses this to tell "still releasing the device" from "already idle".""" + controller, recording = _controller() + controller.ensure_workers() + controller.mark_running() + assert controller.mark_stopped("user_stop") is True + + controller.mark_running() + assert controller.note_first_frame() is True + assert controller.mark_stopped("failed") is False + assert controller.phase == "error" + + +def test_the_slow_connect_hint_is_said_once(qt_app, monkeypatch, workers): + controller, recording = _controller() + controller.ensure_workers() + clock = {"now": 100.0} + monkeypatch.setattr(session_controller.time, "perf_counter", lambda: clock["now"]) + controller.mark_running() + + clock["now"] = 100.0 + session_controller.CONNECT_SLOW_HINT_S - 0.1 + assert controller.note_connect_slow() is False + clock["now"] = 100.0 + session_controller.CONNECT_SLOW_HINT_S + assert controller.note_connect_slow() is True + assert controller.note_connect_slow() is False, "the hint was repeated" + + +def test_a_dropped_batch_is_reported_once(qt_app, workers): + controller, recording = _controller() + assert controller.take_dropped_message(0) is None + first = controller.take_dropped_message(3) + assert first is not None and "3" in first + assert controller.take_dropped_message(3) is None, "the same batch was reported twice" + assert controller.take_dropped_message(5) is not None + + +def test_a_capture_shortfall_is_judged_only_with_a_measured_rate( + qt_app, monkeypatch, workers +): + """The measured rate is the evidence; the driver's own report is an echo.""" + monkeypatch.setattr( + session_controller.session_metadata, + "capture_metadata", + lambda worker: {"requested_fps": 30.0, "negotiated_fps": 30.0}, + ) + controller, recording = _controller() + controller.ensure_workers() + + assert controller.check_capture_negotiation() is None, "nothing measured yet" + assert controller.capture_checked is False, "it decided without evidence" + + controller.note_sample_telemetry( + type("Packet", (), {"capture_fps": 15.0, "capture_dropped": 0})() + ) + controller.check_capture_negotiation() + assert controller.capture_checked is True, "the check never latched with evidence" + assert controller.check_capture_negotiation() is None, "it is said once per session"