Read the analysis cache before refusing a pass, and own the cache root

Opening a recording while the session that recorded it was still live never reached
``stored_result()``, so the panel reported no result over a recording whose result was on
disk. The guard now follows the cache read, results move to the app's derived-data root
(swept at startup, the old in-recording root ignored), and the panel states which of the
four no-result states it is in, with the pass's own reason.
Verified: apps/studio 462/0/18, shared 431/0/1, autocal 358, ui_check 628/0, both gates
clean, real-GL snapshots per state.
This commit is contained in:
ykcheungab
2026-10-04 16:53:50 +08:00
parent 1aa454311f
commit a92a2ba237
14 changed files with 1138 additions and 170 deletions
+178 -1
View File
@@ -35,6 +35,10 @@ from orisys_app_shared.acquisition.image_backend import (
DEPTH,
DERIVED_MODES,
RAW_NOTE_KEY,
RESULT_FAILED,
RESULT_IMPOSSIBLE,
RESULT_NONE,
RESULT_RUNNING,
SOURCE_ANALYSIS,
SOURCE_REPLAY,
ImageFrame,
@@ -498,7 +502,9 @@ class _Engine:
return self._reader
def directory(self):
return self._reader.directory
# ``None`` for a bare video, exactly as ``ReplayEngine.directory`` answers: the
# controller's "is there anything here a pass could read?" is this call.
return None if self._reader is None else self._reader.directory
def video_path(self):
return None
@@ -743,6 +749,177 @@ def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root):
# ------------------------------------------------------------ analysis on open
def test_a_running_session_does_not_hide_a_stored_result(pass_root):
"""A result on disk is served while a session is open: reading it opens no chain.
The defect this pins: ``on_analysis_requested`` asked "is a session or a preview
open?" *before* it asked "is a result already stored?". Serving a result is a file
read — no SDK chain, no device, no working directory — so the one-chain-at-a-time
rule never applied to it, but the guard refused the whole call anyway. The operator
who recorded something and opened it while the acquisition session was still live
(a paused session keeps the device, so it counts) was shown "this recording has no
analysis result" over a recording whose arrows were sitting in the cache, and had to
stop the session and reopen the recording to see them.
"""
_require_inputs()
calls: list[str] = []
def factory(recording: Path, cancelled, on_progress):
calls.append(str(recording))
return _service(pass_root)
for live in ("running", "preview", "winding_down"):
calls.clear()
host = _Host(service_factory=factory)
if live == "running":
host._running = True
elif live == "preview":
host._preview_open = True
else:
host._session_active = True
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert controller.load_recording(RECORDING) is True
assert controller.available_result() is not None, (
f"a cached result was not served while a session was {live}"
)
assert not controller.analysis_running(), "a pass ran over a stored result"
assert calls == [str(RECORDING)], "the stored result was not even looked for"
assert parts[4].states[-1] == "reused", "the surface did not report the reuse"
assert controller.analysis_error() == ""
# What reaches the 2D sink is the cached measurement, not "there is nothing".
host.frames.clear()
panel.set_view_mode(DEPTH)
controller.render_frame()
drawn = host.frames[-1]
assert drawn.result_available is True
assert drawn.depth is not None, "the depth view was not served from the cache"
assert plan_image(drawn).note_key == "", "the panel still says it has no result"
def test_a_pass_is_refused_while_a_session_is_open_and_says_so(tmp_path, qt_app):
"""With nothing cached, the refusal is *state*: the panel states it, with the reason.
The pass really cannot run next to a session (one SDK chain and one working directory
at a time), so the fix is not to run it anyway — it is to stop the refusal from being
a silent no-op whose only trace is a status line that expires. Before this, the four
states below were one line, and three of them were wrong.
"""
_require_inputs()
empty = tmp_path / "cache"
empty.mkdir()
def factory(recording: Path, cancelled, on_progress):
return _service(empty, cancelled=cancelled, on_progress=on_progress)
host = _Host(service_factory=factory)
host._running = True
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert controller.load_recording(RECORDING) is True
assert not controller.analysis_running(), "a pass was started next to a session"
assert parts[4].states == [], "the progress surface claimed a pass"
state, reason = controller.result_state()
assert state == RESULT_FAILED, f"the refusal was reported as {state!r}"
assert reason == t("analysis.blocked") and reason, "the refusal lost its reason"
assert controller.analysis_error() == reason
host.frames.clear()
controller.render_frame()
drawn = host.frames[-1]
assert drawn.result_available is False
plan = plan_image(drawn)
assert plan.note_key == "panels.replay_analysis_failed"
assert plan.note_reason == reason, "the line the panel draws lost the reason"
assert plan.note_key != RAW_NOTE_KEY, (
"the panel promised a pass that was refused before it started"
)
assert plan.available_modes == ("recorded",)
def test_the_four_no_result_states_are_four_different_lines(tmp_path, qt_app):
"""No result yet, running, failed (with its reason), impossible — all four reachable.
Each is a different fact and a different next action, so each is a different line:
a pass is coming, a pass is going, a pass is not coming and here is why, or no pass
can ever serve this frame. ``none`` is what the panel draws for a recording whose
analysis has not been asked for yet — the redraw the engine's own ``recording_changed``
makes inside ``load_recording``, before ``on_analysis_requested`` runs. ``failed`` is
reached by a pass that really fails (a chain whose configuration file is absent),
because the reason on the line has to be the pass's own, not one a test invented.
"""
_require_inputs()
empty = tmp_path / "cache"
empty.mkdir()
def working(recording: Path, cancelled, on_progress):
return _service(empty, cancelled=cancelled, on_progress=on_progress)
def broken(recording: Path, cancelled, on_progress):
# A chain the SDK cannot open: the configuration file does not exist. This is the
# shape of every "the pass ran and failed" the operator can meet — a moved
# install, a config edited away — and the reason it produces is the pass's own.
return AnalysisService(
RECORDING,
chain=ChainSpec(sensor_type="box", config_ref=tmp_path / "absent-sensor.json"),
cache=ResultCache(empty),
cancelled=cancelled,
on_progress=on_progress,
)
# none: the recording is open, and nothing has asked for a pass yet.
host = _Host(service_factory=working)
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert engine.open_recording(RECORDING) is True
assert controller.result_state() == (RESULT_NONE, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == RAW_NOTE_KEY
# running: the pass is in flight and the panel says so instead of promising one.
controller.on_analysis_requested()
assert controller.analysis_running(), "no pass was started"
assert controller.result_state() == (RESULT_RUNNING, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == "panels.replay_analysis_running"
assert controller.wait_for_analysis(120)
_flush()
# failed: a pass that ended without a result carries its own reason to the panel.
failing = _Host(service_factory=broken)
failing_panel = _ImagePanel()
failed_ctl, failing, _, failed_parts = _controller(
host=failing, image_panel=failing_panel
)
assert failed_ctl.load_recording(RECORDING) is True
assert failed_ctl.wait_for_analysis(120), "the failing pass never finished"
_flush()
state, reason = failed_ctl.result_state()
assert reason, "a failed pass left no reason to show"
assert failed_parts[4].states[-1] == "failed", failed_parts[4].states
if state != RESULT_FAILED: # a box chain that somehow opened: then it has a result
pytest.fail(f"the failed pass is reported as {state!r}")
failing.frames.clear()
failed_ctl.render_frame()
plan = plan_image(failing.frames[-1])
assert plan.note_key == "panels.replay_analysis_failed"
assert plan.note_reason == reason, "the line the panel draws lost the reason"
# impossible: a bare video has no session metadata, so no pass can serve it at all.
bare = tmp_path / "clip.mp4"
bare.write_bytes(b"\0" * 8)
engine._reader = None
controller._toolbar.set_video_path(str(bare))
assert controller.recording_directory() is None
assert controller.result_state() == (RESULT_IMPOSSIBLE, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == "panels.replay_analysis_unavailable"
def test_opening_a_recording_with_a_stored_result_runs_no_pass(pass_root):
"""Analysis on open reuses a compatible result: no progress, no pass."""
_require_inputs()