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
+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"