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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user