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()
+248 -13
View File
@@ -36,6 +36,7 @@ from orisys_app_shared.acquisition.image_backend import (
DEPTH,
DERIVED_MODES,
RECORDED,
plan_image,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import DisplaySettings
@@ -87,8 +88,14 @@ def _copy_recording(destination: Path) -> Path:
return destination
def _run_the_pass(recording: Path):
"""One real pass, with the chain the *application* derives from the recording."""
def _run_the_pass(recording: Path, root: Path | None = None):
"""One real pass, with the chain the *application* derives from the recording.
``root`` names the cache explicitly because the app's own root is the user's
derived-data directory: a test must never write there, and the window it then opens
must find the result, so the root is patched to a temporary one for the whole test.
"""
from orisys_app_shared.analysis.cache import ResultCache
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader
@@ -97,23 +104,76 @@ def _run_the_pass(recording: Path):
chain = chain_spec_for(reader.session())
finally:
reader.close()
return AnalysisService(recording, chain=chain).run()
cache = None if root is None else ResultCache(root)
return AnalysisService(recording, chain=chain, cache=cache).run()
@pytest.fixture(scope="module")
def stored_recording(tmp_path_factory) -> Path:
"""A copy of the evidence recording whose own cache already holds a result."""
def cache_root(tmp_path_factory):
"""The analysis cache for this module's tests, in place of the user's own.
The app resolves its cache root at construction from
``paths.analysis_cache_dir()``, so pointing that at a temporary directory is enough
for the *window* to write and read there — which is what keeps a test from sweeping
or filling the real ``%LOCALAPPDATA%`` cache, and what makes "the window found the
stored result" a real statement rather than a coincidence of two roots.
"""
from orisys_app_shared import paths as paths_module
root = tmp_path_factory.mktemp("replay-wiring-cache") / "analysis-cache"
root.mkdir(parents=True)
patch = pytest.MonkeyPatch()
patch.setattr(paths_module, "analysis_cache_dir", lambda: root)
yield root
patch.undo()
@pytest.fixture(scope="module")
def stored_recording(tmp_path_factory, cache_root) -> Path:
"""A copy of the evidence recording whose cache already holds a result."""
_require_inputs()
recording = _copy_recording(tmp_path_factory.mktemp("replay-wiring") / "recording")
outcome = _run_the_pass(recording)
outcome = _run_the_pass(recording, cache_root)
assert outcome.complete, outcome.reason
assert outcome.frames_written > 0
return recording
@pytest.fixture
def window(make_window, qt_app):
"""The application, in the replay view, as the operator meets it."""
def window(make_window, qt_app, cache_root):
"""The application, in the replay view, as the operator meets it.
It shares the module's cache root, so a test may pre-place a result (or reuse the one
``stored_recording`` wrote) and have the window find it — which is the whole point of
a root that is not inside the recording: the same recording is the same result.
"""
return _replay_window(make_window)
@pytest.fixture
def fresh_cache(tmp_path, monkeypatch):
"""A cache root of this test's own.
A copy of the recording *is* the same recording to the cache (the key carries the
identity, not the path), so a test that wants a pass to run needs a root nothing has
written to — otherwise it would be served another test's result, which is correct
behaviour and the wrong fixture.
"""
from orisys_app_shared import paths as paths_module
root = tmp_path / "analysis-cache"
root.mkdir(exist_ok=True)
monkeypatch.setattr(paths_module, "analysis_cache_dir", lambda: root)
return root
@pytest.fixture
def own_window(make_window, qt_app, fresh_cache):
"""A window whose analysis cache belongs to this test alone."""
return _replay_window(make_window)
def _replay_window(make_window):
window = make_window(sensor_type="box", source_mode="camera")
window.resize(1440, 880)
window.show()
@@ -194,6 +254,20 @@ def _cells(rows, cols) -> set:
return {(int(row), int(col)) for row, col in zip(np.asarray(rows), np.asarray(cols))}
def _entry_set(directory: Path) -> set:
"""Every entry under ``directory``, as (relative path, size).
The owner's constraint is about the *recording*, so it is asserted on the recording's
own file set: a result written beside the footage changes it, and a size is included
so an in-place rewrite cannot pass as "the same entries".
"""
found = set()
for path in Path(directory).rglob("*"):
relative = str(path.relative_to(directory))
found.add((relative, -1 if path.is_dir() else path.stat().st_size))
return found
def _active_cells(samples) -> set:
return {
(int(row), int(col))
@@ -388,14 +462,16 @@ def test_playback_draws_the_cached_frame_not_the_video(
)
def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, window, monkeypatch):
def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, own_window, monkeypatch, fresh_cache):
"""The other trigger: the pass completes and the panel serves what it stored.
Nothing is pre-placed here, so the window runs the pass over the copy (a box chain:
a couple of seconds for this recording) and the *completion* is what has to leave the
derived view on screen — with the cursor still on the recording.
"""
window = own_window
recording = _copy_recording(tmp_path / "recording")
before = _entry_set(recording)
_sink_frames(window, monkeypatch)
_open(window, recording, monkeypatch)
@@ -424,6 +500,65 @@ def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, window, monkeypatch)
"the finished result is not what the panel draws"
)
# The owner's constraint, asserted where it can actually be broken: the recording
# directory came back exactly as it was, and the result went to the app's own cache.
assert _entry_set(recording) == before, (
"opening and analysing a recording wrote inside the recording folder"
)
assert not (recording / "analysis").exists()
assert Path(session.directory).parent == Path(fresh_cache), (
f"the result was not written to the app's cache root: {session.directory}"
)
def test_a_running_session_does_not_hide_a_stored_result(
stored_recording, window, monkeypatch
):
"""A live session must not cost the operator the cached views.
The operator's own sequence: record something, and open the recording while the
acquisition session is still live (paused sessions keep the device, so they count).
Reading a stored result opens no SDK chain, so the session is no reason to refuse it —
and refusing it left the panel saying "this recording has no analysis result" over a
recording whose arrows were in the cache, which is the report this pins. Before the
fix: the note was shown and the mode selector offered ``recorded`` alone.
"""
import ui_check
window._ensure_workers = lambda: None
stuck = ui_check._StuckWorker()
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
QApplication.processEvents()
assert window._running and window._session_active, "no session was started"
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None, (
"a running session hid a stored result the app did not have to compute"
)
assert window.analysis_progress.state() == "reused", "a pass ran beside a session"
assert window.replay.mode() == MODE_RECORDING
assert window.arrow_panel.note_label.text() == "", (
"the panel says it has no result while it is drawing one"
)
_set_mode(window, DEPTH)
frame_id = session.frame_id_at(window.replay.position())
expected = _expected_depth(window, session.frame(frame_id))
assert expected is not None
assert np.array_equal(_panel_bgr(window), expected), (
"the cache was not what the panel drew"
)
assert captured[-1].result_available is True
window.replay_ctl.stop_activity()
stuck.release()
def test_the_mode_selector_is_the_trigger_for_a_derived_view(
stored_recording, window, monkeypatch
@@ -455,7 +590,7 @@ def test_the_mode_selector_is_the_trigger_for_a_derived_view(
def test_the_display_settings_still_redraw_from_the_cache(
stored_recording, window, monkeypatch
stored_recording, window, monkeypatch, cache_root
):
"""A threshold change is a read: it redraws the stored frame and writes nothing."""
from orisys_app_shared.analysis.cache import scan_results
@@ -465,7 +600,6 @@ def test_the_display_settings_still_redraw_from_the_cache(
assert session is not None
_set_mode(window, ARROW_PURE)
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
window.replay_ctl.on_display_settings(DisplaySettings(flow_on_px=0.9))
@@ -482,7 +616,7 @@ def test_the_display_settings_still_redraw_from_the_cache(
def test_a_setting_that_moves_the_grid_is_still_refused(
stored_recording, window, monkeypatch
stored_recording, window, monkeypatch, cache_root
):
"""The stored candidate locations cannot be re-gridded; the frame is drawn anyway."""
from orisys_app_shared.analysis.cache import scan_results
@@ -492,7 +626,6 @@ def test_a_setting_that_moves_the_grid_is_still_refused(
assert session is not None
_set_mode(window, ARROW_PURE)
before_threshold = session.threshold()
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
@@ -505,3 +638,105 @@ def test_a_setting_that_moves_the_grid_is_still_refused(
assert captured, "the frame was not redrawn from the result's own grid"
assert captured[-1].frame_id == session.frame_id_at(window.replay.position())
assert {entry.directory for entry in scan_results(cache_root)} == before
# --------------------------------------------------- what the panel says without one
def test_the_panel_says_the_pass_is_running(tmp_path, own_window, monkeypatch):
"""While the pass runs, the line must not tell the operator to start one.
The old line promised "opening a recording runs a pass" *during* the pass, which is
worse than useless on a ten-minute recording: it reads as "nothing is happening".
"""
from orisys_app_shared.acquisition.image_backend import RESULT_RUNNING
from orisys_app_shared.i18n import t
window = own_window
recording = _copy_recording(tmp_path / "recording")
captured = _sink_frames(window, monkeypatch)
_open(window, recording, monkeypatch)
assert window.replay_ctl.analysis_running(), "the pass finished before it was observed"
assert window.replay_ctl.result_state() == (RESULT_RUNNING, "")
assert captured[-1].result_state == RESULT_RUNNING
assert window.arrow_panel.note_label.text() == t("panels.replay_analysis_running"), (
"the panel did not say the pass is running"
)
assert window.replay_ctl.wait_for_analysis(180.0)
QApplication.processEvents()
assert window.arrow_panel.note_label.text() == "", (
"the completed pass left a note behind"
)
def test_the_panel_states_a_failed_pass_with_its_reason(
tmp_path, own_window, monkeypatch, fresh_cache
):
"""A pass that ends without a result says so, with the pass's own reason.
Reached through the window's own open action: only the chain is made unopenable (a
configuration file that is not there — the shape of a moved install), so the worker
thread, the completion slot and the panel are all the real ones. The check that
mattered in the report is the last one: the panel and the progress surface agree.
"""
from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY,
RESULT_FAILED,
)
from orisys_app_shared.analysis.cache import ResultCache
from orisys_app_shared.analysis.service import AnalysisService, ChainSpec
from orisys_app_shared.i18n import t
window = own_window
def broken_service(recording, cancelled=None, *, on_progress=None):
return AnalysisService(
recording,
chain=ChainSpec(sensor_type="box", config_ref=tmp_path / "absent-sensor.json"),
cache=ResultCache(fresh_cache),
cancelled=cancelled,
on_progress=on_progress,
)
monkeypatch.setattr(window, "_analysis_service", broken_service)
recording = _copy_recording(tmp_path / "recording")
_open(window, recording, monkeypatch)
assert window.replay_ctl.wait_for_analysis(180.0), "the failing pass never finished"
QApplication.processEvents()
state, reason = window.replay_ctl.result_state()
assert reason, "a failed pass left no reason to show"
assert state == RESULT_FAILED, f"the failed pass is reported as {state!r}"
assert window.analysis_progress.state() == "failed"
assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_failed", reason=reason
), "the panel's line did not carry the pass's reason"
assert reason in window.arrow_panel.note_label.text()
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text(), (
"the panel promised the automatic pass that had just failed"
)
def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, monkeypatch):
"""No session metadata: no pass exists to offer, and the line must not name one."""
from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY,
RESULT_IMPOSSIBLE,
)
from orisys_app_shared.i18n import t
window = own_window
recording = _copy_recording(tmp_path / "recording")
clip = tmp_path / "clip.mp4"
shutil.copy2(recording / "raw_0001.mp4", clip)
captured = _sink_frames(window, monkeypatch)
assert window.replay_ctl.load_video(clip) is True
QApplication.processEvents()
assert window.replay.is_video_only(), "the clip did not open as a bare video"
assert window.replay_ctl.result_state() == (RESULT_IMPOSSIBLE, "")
assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_unavailable"
)
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_unavailable"