修复:设备的"清零已生效"确认从未被记录(worker 的 on_reset_applied 没有接线)

- 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`
  仍在窗口(它只是把事件变成一行状态)。
This commit is contained in:
ykcheungab
2026-09-15 09:58:45 +08:00
parent c62452ec4b
commit 81e39ca868
3 changed files with 243 additions and 2 deletions
+7 -2
View File
@@ -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."""
@@ -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.
@@ -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"