"""Part B: playback served from an analysis result. What is being defended, in the order the plan asked for it: 1. **one timeline.** The charts, the 2D canvas and the 3D pane read one cursor, and a direct seek to frame *N* produces the same frame as playing forward to *N*; 2. **analysis on open.** A recording opens on a compatible stored result, and runs the pass (with a progress and cancel surface) only when there is none; 3. **a display change writes nothing.** The flow threshold, the colour range and the idle dots are reads: the result directory set is unchanged; 4. **an incomplete result is never served.** A cancelled or failed pass is reported as cancelled or failed, and playback is not offered from it; 5. **frames are addressed by id.** An id the recording does not hold is refused, never coerced into an index. The fixtures use real footage — the 33-frame box recording under ``dist/`` and the shipped finger configuration — driven through the **finger** chain, which is what gives the result a validity mask and therefore a static candidate grid. The pass is O(5 s) and is shared per module. Where the SDK or the footage is absent the tests skip rather than fake a result: a synthetic result would not defend the id span (20…52), the keyframe identity or the sample round trip. """ from __future__ import annotations import threading from pathlib import Path import numpy as np import pytest from orisys_app_shared.analysis.cache import ResultCache, scan_results from orisys_app_shared.analysis.reader import ( AnalysisIncompleteError, AnalysisNotFoundError, AnalysisReader, ) from orisys_app_shared.analysis.service import AnalysisService, ChainSpec from orisys_app_shared.i18n import t from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_app_shared.scene3d.visual_policy import ( policy_for_settings, select_flow_samples, ) from orisys_studio.analysis_worker import AnalysisWorker, report_from from orisys_studio.replay import MODE_ANALYSIS, MODE_RECORDING from orisys_studio.replay_analysis import ( AnalysisSession, ResultLibrary, ResultPolicyError, ) REPO = Path(__file__).resolve().parents[3] STUDIO = REPO / "dist" / "OrisysStudio" RECORDING = ( STUDIO / "outputs" / "recordings" / "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011" ) FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json" FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703" #: The evidence recording's own frame ids. Deliberately not 0…32: an implementation #: that used an index would pass every test written against a 0-based recording. FIRST_FRAME_ID = 20 LAST_FRAME_ID = 52 def _require_inputs() -> None: missing = [ str(path) for path in (RECORDING / "frames.csv", RECORDING / "raw_0001.mp4", FINGER_CONFIG) if not path.exists() ] if missing: pytest.skip("analysis inputs are not present: " + ", ".join(missing)) pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass") def _finger_chain() -> ChainSpec: return ChainSpec( sensor_type="finger", config_ref=FINGER_CONFIG, export_dir=FINGER_EXPORT ) def _service(root: Path, **kwargs) -> AnalysisService: return AnalysisService(RECORDING, chain=_finger_chain(), cache=ResultCache(root), **kwargs) # ------------------------------------------------------------------------ fixtures @pytest.fixture(scope="module") def pass_root(tmp_path_factory) -> Path: """One real analysis pass over the evidence recording, in its own cache root.""" _require_inputs() root = tmp_path_factory.mktemp("replay-analysis-b") outcome = _service(root).run() assert outcome.complete, outcome.reason return root @pytest.fixture(scope="module") def reader(pass_root: Path): reader = AnalysisReader(_result_directory(pass_root)) yield reader reader.close() def _result_directory(root: Path) -> Path: directories = [entry.directory for entry in scan_results(root) if entry.complete] assert directories, f"no complete result under {root}" return directories[0] @pytest.fixture(scope="module") def session(reader) -> AnalysisSession: service = _service(Path(reader.directory).parent) return AnalysisSession( reader, policy=service.policy, display_policy=policy_for_settings(DisplaySettings()), session_id=2, recording=RECORDING, ) def _same_samples(left, right) -> dict[str, bool]: """Compare two ``FlowSamples`` (or two frames that carry one) array by array.""" left = getattr(left, "samples", left) right = getattr(right, "samples", right) return { name: bool( np.array_equal( np.asarray(getattr(left, name)), np.asarray(getattr(right, name)) ) ) for name in ("rows", "cols", "dx", "dy", "active") } # ------------------------------------------------------------------- fakes (Qt) class _Signal: def __init__(self) -> None: self.slots: list = [] def connect(self, slot) -> None: self.slots.append(slot) class _Action: def __init__(self, checked: bool = False) -> None: self._checked = bool(checked) self.enabled: bool | None = None self.tooltips: list[str] = [] self.triggered = _Signal() self.toggled = _Signal() def isChecked(self) -> bool: # noqa: N802 - Qt's spelling return self._checked def setChecked(self, value: bool) -> None: # noqa: N802 self._checked = bool(value) def setEnabled(self, value: bool) -> None: # noqa: N802 self.enabled = bool(value) def setToolTip(self, text: str) -> None: # noqa: N802 self.tooltips.append(str(text)) def blockSignals(self, flag: bool) -> None: # noqa: N802 pass class _Button: def __init__(self) -> None: self.checked = False self.enabled = True def isChecked(self) -> bool: # noqa: N802 return self.checked def isEnabled(self) -> bool: # noqa: N802 return self.enabled def setChecked(self, value: bool) -> None: # noqa: N802 self.checked = bool(value) class _Toolbar: def __init__(self, *, video_path: str = "") -> None: self.btn_run = _Button() self._video_path = video_path self.run_checked: list[bool] = [] self.source_modes: list[str] = [] self.recompute_enabled: list[bool] = [] self.recompute_checked: list[bool] = [] def run_role(self) -> str: return "playback" def video_path(self) -> str: return self._video_path def set_video_path(self, path: str) -> None: self._video_path = str(path) def set_run_checked(self, value: bool) -> None: self.run_checked.append(bool(value)) def refresh_labels(self) -> None: pass def set_source_mode(self, mode: str) -> None: self.source_modes.append(str(mode)) def set_recompute_enabled(self, value: bool) -> None: self.recompute_enabled.append(bool(value)) def set_recompute_checked(self, value: bool) -> None: self.recompute_checked.append(bool(value)) class _Charts: def __init__(self) -> None: self.cursors: list[float] = [] self._recording = False self.cleared = 0 self.unit_value = "raw" def unit(self) -> str: return self.unit_value def set_cursor(self, position: float) -> None: self.cursors.append(float(position)) def set_recording_source(self, reader) -> None: self._recording = reader is not None def clear_recording_source(self) -> None: self.cleared += 1 self._recording = False def is_recording_source(self) -> bool: return self._recording def follow_latest(self) -> None: pass def show_all(self) -> None: pass class _ImagePanel: def __init__(self) -> None: self.kinds: list[str] = [] self.cleared: list[str] = [] def set_source_kind(self, kind: str) -> None: self.kinds.append(str(kind)) def clear_preview(self, message: str) -> None: self.cleared.append(str(message)) class _Mesh: def __init__(self) -> None: self.kinds: list[str] = [] self.reference_only = 0 self._settings = DisplaySettings() def set_source_kind(self, kind: str) -> None: self.kinds.append(str(kind)) def show_reference_only(self) -> None: self.reference_only += 1 def display_settings(self) -> DisplaySettings: return self._settings def set_display_settings(self, settings: DisplaySettings) -> None: self._settings = settings class _Progress: """The progress surface as the controller uses it, recording every transition.""" def __init__(self) -> None: self.cancel_requested = _Signal() self.states: list[str] = [] self.updates: list[dict] = [] self.cancels = 0 def start(self) -> None: self.states.append("running") def progress(self, **kwargs) -> None: self.updates.append(dict(kwargs)) def reused(self, **kwargs) -> None: self.states.append("reused") def complete(self, **kwargs) -> None: self.states.append("complete") def cancelled(self, **kwargs) -> None: self.states.append("cancelled") def failed(self, **kwargs) -> None: self.states.append("failed") def clear(self) -> None: self.states.append("idle") class _Host: def __init__(self, service_factory=None) -> None: self.prefs: dict = {} self.view = "replay" self._running = False self._preview_open = False self._session_active = False self.notes: list[str] = [] self.frames: list = [] self.analysis_packets: list = [] self.units: list[str] = [] self.presets: list[str] = [] self.service_factory = service_factory self.services_built = 0 def _in_replay_view(self) -> bool: return self.view == "replay" def _current_source_kind(self) -> str: return "replay" def _apply_view_preset(self, preset: str) -> None: self.presets.append(preset) self.view = preset def _set_display_unit(self, unit: str) -> None: self.units.append(unit) def _set_source_mode(self, mode: str) -> None: pass def _show_image_frame(self, frame) -> None: self.frames.append(frame) def _show_analysis_frame(self, packet) -> None: self.analysis_packets.append(packet) def _drop_render_marker(self) -> None: pass def _notify(self, text: str, ms: int = 0) -> None: self.notes.append(str(text)) def _start_run(self) -> None: pass def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None: pass def _update_run_enabled(self) -> None: pass def _refresh_status_rail(self) -> None: pass def _analysis_service(self, recording: Path, cancelled=None, *, on_progress=None): self.services_built += 1 if self.service_factory is None: raise AssertionError("the test did not provide an analysis service") return self.service_factory(recording, cancelled, on_progress) class _RecordingReader: """The recording reader as the engine and the controller use it.""" def __init__(self, directory: Path) -> None: self.directory = Path(directory) self.closed = False def session(self) -> dict: return {"session_id": 2, "device": {"sensor_type": "finger"}} def segments(self) -> list[Path]: return [self.directory / "raw_0001.mp4"] def info(self): return type("Info", (), {"duration_s": 1.92, "newtons": None, "decimated": False})() def video_frame_at(self, t_rel_s: float): from orisys_app_shared.record import RecordingFrame index = int(t_rel_s * 30) frame = RecordingFrame( frame_id=FIRST_FRAME_ID + index, t_rel_s=float(t_rel_s), in_video=True, dropped=False, segment=1, ) return np.zeros((4, 4, 3), dtype=np.uint8), frame def close(self) -> None: self.closed = True def _controller(engine=None, host=None, **kwargs): from orisys_studio.replay_controller import ReplayController engine = engine if engine is not None else _Engine() host = host if host is not None else _Host() toolbar = kwargs.pop("toolbar", None) or _Toolbar() charts = kwargs.pop("charts", None) or _Charts() panel = kwargs.pop("image_panel", None) or _ImagePanel() mesh = kwargs.pop("mesh_panel", None) or _Mesh() progress = kwargs.pop("progress", None) or _Progress() controller = ReplayController( engine=engine, host=host, toolbar=toolbar, charts=charts, image_panel=panel, mesh_panel=mesh, analysis_progress=progress, ) actions = { "open_video": _Action(), "playback": _Action(), "latest": _Action(), "play": _Action(), "recompute": _Action(), } controller.bind_actions(**actions) return controller, host, engine, (toolbar, charts, panel, mesh, progress, actions) class _Engine: """The engine as the controller uses it, with the timeline's mode explicit.""" def __init__(self, *, duration: float = 1.92, position: float = 0.0) -> None: self._duration = duration self._position = position self._mode = MODE_RECORDING self._session: AnalysisSession | None = None self._reader = _RecordingReader(RECORDING) self._has_video = True self.positions: list[float] = [] self.redraws = 0 def has_video(self) -> bool: return self._has_video def is_loaded(self) -> bool: return True def is_video_only(self) -> bool: return False def duration_s(self) -> float: return self._duration def position(self) -> float: return self._position def set_position(self, position: float) -> None: self._position = float(position) self.positions.append(float(position)) def reader(self): return self._reader def directory(self): return self._reader.directory def video_path(self): return None def video_frame_bgr(self): return np.zeros((4, 4, 3), dtype=np.uint8) def video_message(self) -> str: return "" def set_unit(self, unit: str) -> None: pass def mode(self) -> str: return self._mode def analysis(self): return self._session def use_analysis(self, session) -> None: self._session = session self._mode = MODE_ANALYSIS def use_recording(self) -> None: self._session = None self._mode = MODE_RECORDING def analysed_frame(self): if self._session is None: return None return self._session.frame_at(self._position) def redraw(self) -> None: self.redraws += 1 def open_recording(self, directory) -> bool: self._reader = _RecordingReader(Path(directory)) self._session = None self._mode = MODE_RECORDING return True def open_video(self, path) -> bool: return False # ------------------------------------------------------------------ the timeline def test_a_stored_result_is_served_with_the_recording_s_own_frame_ids(session): """Frame identity is the recording's counter (20…52), never an index (0…32).""" assert session.frame_count == 33 assert int(session.frame_ids[0]) == FIRST_FRAME_ID assert int(session.frame_ids[-1]) == LAST_FRAME_ID assert not session.has(0), "an index was accepted as a frame id" assert session.has(FIRST_FRAME_ID) def test_a_frame_id_the_recording_does_not_hold_is_refused(session): """A *position* passed where an *id* belongs gets an error, not a picture. This is the trap the plan names: the result's ids run 20…52, so a caller that passes an index reads a frame that exists and looks right. The reader refuses an id it does not hold, and the session does not repair the request into one it can serve. """ with pytest.raises(AnalysisNotFoundError): session.frame(int(session.frame_ids[0]) + 1000) with pytest.raises(AnalysisNotFoundError): session.frame(0) def test_position_is_signal_time_and_resolves_to_a_stored_frame_id(session): """The cursor's time base is ``t_rel_s`` from the recording's own ``frames.csv``.""" span = session.duration_s() assert span == pytest.approx(1.920, abs=1e-3), ( "the result's span must be signal time; 1.100 s would be the container's" ) middle = int(session.frame_ids[len(session.frame_ids) // 2]) moment = session.time_of(middle) assert moment is not None assert session.frame_id_at(moment) == middle assert session.frame_id_at(0.0) == FIRST_FRAME_ID assert session.time_of(0) is None, "an absent id answered with a time" def test_the_recovered_field_reselects_to_the_stored_samples(session): """The 2D canvas and the 3D pane draw the same samples, frame for frame. The canvas takes a flow field and re-runs the shared selector; the pane takes the stored selection. This asserts they agree on every stored frame — the locations, the drawn offsets and the activity verdict — so "one timeline, two figures" is a measurement rather than a hope. Magnitude is compared as float32-derived truth: it differs from the stored value by at most one ulp, which does not move a sample. """ policy = session.display_policy checked = 0 for frame_id in session.frame_ids: frame = session.frame(int(frame_id)) reselected = select_flow_samples(frame.flow, frame.valid_mask, policy) agreement = _same_samples(frame.samples, reselected) assert all(agreement.values()), f"frame {frame_id}: {agreement}" assert np.array_equal( np.asarray(frame.samples.active), np.asarray(reselected.active) ) checked += 1 assert checked == 33 def test_a_seek_equals_playing_forward_to_the_same_frame(session): """Seek to *N* and play to *N* must produce the same picture. Tolerance: none on the frame's identity (the frame id and every drawn array are compared exactly). The cursor is a float in seconds, so the *time* handed to a seek is the stored ``t_rel_s`` read back from the result: the comparison is exact because the seek is given the frame's own time, and the frame id it resolves to is then the frame that was played to. Positions between stored frames resolve to the frame at or before them, which is what playing forward passes through as well. """ ids = [int(value) for value in session.frame_ids] # Play forward from the first frame, remembering what each frame looked like. played: dict[int, object] = {} for frame_id in ids: played[frame_id] = session.frame(frame_id) # Now seek directly to a sample of positions, in a fresh session so nothing is # answered from the sequential walk's own cache. fresh = AnalysisSession( AnalysisReader(_result_directory(Path(session.directory).parent)), policy=session.policy, display_policy=session.display_policy, ) try: for frame_id in (ids[0], ids[1], ids[len(ids) // 2], ids[-2], ids[-1]): moment = fresh.time_of(frame_id) assert moment is not None seeked = fresh.frame_at(moment) assert seeked is not None assert seeked.frame_id == frame_id assert all(_same_samples(played[frame_id], seeked).values()) assert np.array_equal( np.asarray(played[frame_id].flow), np.asarray(seeked.flow), equal_nan=True, ) finally: fresh.reader.close() def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session): """One cursor, two figures: the packet's id, flow and mask are the frame's own.""" frame = session.frame_at(session.time_of(LAST_FRAME_ID - 2)) assert frame is not None packet = session.packet(frame) assert int(packet.frame_id) == int(frame.frame_id) # ``equal_nan``: every cell that is not a recorded candidate is NaN by construction, # in both the frame and the packet, and that is the contract rather than a defect. assert np.array_equal( np.asarray(packet.flow), np.asarray(frame.flow), equal_nan=True ) assert np.array_equal(np.asarray(packet.valid_mask), np.asarray(frame.valid_mask)) assert packet.flow_valid is True and packet.flow_frame_id == int(frame.frame_id) assert packet.forces_valid is False, "a result stores no force, so none may be claimed" assert float(packet.mono_t) == pytest.approx(frame.t_rel_s) # --------------------------------------------------------------- display settings def test_a_display_change_is_a_read_and_writes_no_result(pass_root, session): """The result key is blind to display settings, so a change must not write. The assertion is the strongest one available: the set of result directories is compared before and after the threshold, the colour range and the idle dots are changed, and a fresh service (the object that would write) is asked whether a compatible result exists. Nothing may appear. """ before = {entry.directory.name for entry in scan_results(pass_root)} recorded = int(session.reader.frame(int(session.frame_ids[0])).active_count) for settings in ( DisplaySettings(flow_on_px=0.2), DisplaySettings(flow_on_px=1e9), # The colour range and the exaggeration are the same class of setting: display # only, so they must not reach the cache either. ``color_scale`` needs a # ``FieldDescriptor`` and a LUT id, so the field that is constructible on its own # stands in for it here; the threshold above is the one that also changes what is # drawn. DisplaySettings(display_gain=4.0), DisplaySettings(flow_grid_spacing=10), ): assert session.serve_policy(policy_for_settings(settings)) is True after = {entry.directory.name for entry in scan_results(pass_root)} assert after == before, "a display change wrote a new analysis result" # The same cache, asked the same question, still answers with the stored result: # nothing about the display entered the key. service = _service(pass_root) assert service.stored_result() is not None # And the change really did change the picture: a threshold of 1e9 activates nothing. session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=1e9))) assert session.frame(int(session.frame_ids[0])).active_count == 0 session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=0.4))) assert session.frame(int(session.frame_ids[0])).active_count == recorded def test_a_display_change_that_moves_the_grid_is_refused_not_served(session): """The stored candidate locations are the pass's; a new grid cannot be served.""" assert session.serve_policy(policy_for_settings(DisplaySettings(max_arrows=50))) is False assert session.threshold() == pytest.approx(0.4), "a refused policy changed the session" # The threshold is the one setting that is served from the stored magnitude. assert session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=0.3))) is True def _flush(qt_app=None) -> None: """Deliver the worker's queued signals: they arrive through the GUI event loop.""" from PySide6.QtWidgets import QApplication QApplication.processEvents() def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root): """Through the controller: the surface shows the redraw and no service is built. Its own reader and session, so neither the threshold this test sets nor the frame cache is shared with another test. """ reader = AnalysisReader(_result_directory(pass_root)) session = AnalysisSession( reader, display_policy=policy_for_settings(DisplaySettings()) ) try: controller, host, engine, parts = _controller() controller.results.open_session(session) engine.use_analysis(session) engine.redraws = 0 built = host.services_built controller.on_display_settings(DisplaySettings(flow_on_px=0.5)) assert engine.redraws == 1, "the display change did not redraw" assert host.services_built == built, "a display change built an analysis service" # 0.5, not the 0.5 that arrived: ``flow_off_px`` is 0.3 and ``normalised()`` keeps # on >= off, so a threshold below the off band is raised to it rather than served # as an inverted band. The point here is that the session *adopted* the value. assert session.threshold() == pytest.approx(0.5) # The frame is rebuilt from the same stored record, not from a new one. assert session.frame(int(session.frame_ids[0])).frame_id == FIRST_FRAME_ID finally: session.reader.close() # ------------------------------------------------------------ analysis on open 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() stored = _result_directory(pass_root) calls: list[str] = [] def factory(recording: Path, cancelled, on_progress): calls.append(str(recording)) return _service(pass_root) host = _Host(service_factory=factory) controller, host, engine, parts = _controller(host=host) assert controller.load_recording(RECORDING) is True assert calls == [str(RECORDING)], f"the service was built {len(calls)} times" assert controller.analysis_active(), "the timeline did not adopt the stored result" assert engine.mode() == MODE_ANALYSIS assert parts[2].kinds[-1] == "analysis", "the 2D panel was not told it is a result" assert parts[3].kinds[-1] == "analysis", "the 3D pane was not told it is a result" assert parts[4].states[-1] == "reused", "the surface did not report the reuse" assert stored.is_dir() def test_opening_a_recording_without_a_result_starts_the_pass(tmp_path, qt_app): """No compatible result: the pass runs behind a progress surface with a cancel.""" _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) controller, host, engine, parts = _controller(host=host) assert controller.load_recording(RECORDING) is True assert parts[4].states[:1] == ["running"], "the progress surface did not start" assert controller.analysis_running(), "no pass was started" assert controller.wait_for_analysis(120) _flush() assert parts[4].states[-1] == "complete", parts[4].states assert controller.analysis_active(), "a complete result was not adopted" assert engine.mode() == MODE_ANALYSIS def test_a_cancelled_pass_is_reported_and_never_served(tmp_path, qt_app): """Cancel mid-pass: the surface says so and playback is not offered from it.""" _require_inputs() empty = tmp_path / "cache" empty.mkdir() def factory(recording: Path, cancelled, on_progress): # Cancel on the first frame, which is what the operator's button does. def cancel_after_one(progress): if progress.frames_written >= 1: cancelled.set() if on_progress is not None: on_progress(progress) return _service(empty, cancelled=cancelled, on_progress=cancel_after_one) host = _Host(service_factory=factory) controller, host, engine, parts = _controller(host=host) controller.load_recording(RECORDING) assert controller.wait_for_analysis(120) _flush() assert parts[4].states[-1] == "cancelled", parts[4].states assert not controller.analysis_active(), "an incomplete result was served" assert engine.mode() == MODE_RECORDING assert controller.analysis_error() == "cancelled" assert not any( t("playback.analysis_cancelled") in note for note in host.notes ), "a terminal analysis state was written to the transient status line" # The partial result is on disk and refuses to be read as complete. directories = [entry.directory for entry in scan_results(empty)] assert directories, "the cancelled pass wrote nothing at all" assert all( not entry.complete for entry in scan_results(empty) ), "a cancelled result claims to be complete" with pytest.raises(AnalysisIncompleteError): AnalysisReader(directories[0]) assert AnalysisReader(directories[0], allow_incomplete=True).complete is False def test_an_incomplete_result_on_disk_is_not_adopted(tmp_path): """The refusal is the reader's, and the session never dresses it as playable.""" _require_inputs() empty = tmp_path / "cache" empty.mkdir() cancelled = threading.Event() def cancel_after_one(progress): if progress.frames_written >= 1: cancelled.set() service = _service(empty, cancelled=cancelled, on_progress=cancel_after_one) outcome = service.run() assert outcome.complete is False assert outcome.frames_written >= 1, "the pass wrote nothing, so nothing was refused" with pytest.raises(AnalysisIncompleteError): AnalysisReader(outcome.directory) reader = AnalysisReader(outcome.directory, allow_incomplete=True) try: with pytest.raises(ResultPolicyError): AnalysisSession(reader) finally: reader.close() # And the service's own "is one stored?" answer is not a playable one: the directory # exists, so it answers by refusing — which is what makes the controller run a pass # instead of serving a partial result. with pytest.raises(AnalysisIncompleteError): service.stored_result() def test_the_library_never_hands_out_an_incomplete_session(tmp_path): """``ResultLibrary.open`` refuses a partial result even when asked to inspect one.""" _require_inputs() empty = tmp_path / "cache" empty.mkdir() cancelled = threading.Event() def cancel_after_one(progress): if progress.frames_written >= 1: cancelled.set() outcome = _service(empty, cancelled=cancelled, on_progress=cancel_after_one).run() library = ResultLibrary() with pytest.raises(ResultPolicyError): library.open(outcome.directory, allow_incomplete=True) assert library.session is None def test_the_worker_reports_progress_and_a_cancelled_outcome(): """The thread boundary: a pass reports progress, and a cancel is a cancelled report.""" seen: list = [] def factory(cancelled, on_progress): def cancel_after(progress): on_progress(progress) if progress.frames_written >= 2: cancelled.set() from orisys_app_shared.analysis.service import Progress class _Fake: def run(self): for index in range(5): cancel_after( Progress( frames_written=index + 1, frames_expected=5, segment_index=1, segment_count=1, frame_id=FIRST_FRAME_ID + index, elapsed_s=0.1 * index, ) ) from orisys_app_shared.analysis.service import AnalysisOutcome from orisys_app_shared.analysis.model import ResultKey return AnalysisOutcome( key=ResultKey( recording="r", algorithm="a", sensor_config="s", calibration="", reference_frame=None ), directory=Path("."), complete=False, reason="cancelled", frames_written=2, frames_expected=5, elapsed_s=0.3, ) return _Fake() from PySide6.QtWidgets import QApplication worker = AnalysisWorker(factory) worker.progressed.connect(seen.append) worker.finished.connect(lambda report: seen.append(("report", report))) assert worker.start() is True assert worker.wait(30) QApplication.processEvents() reports = [item for item in seen if isinstance(item, tuple)] assert reports, "the worker never reported" report = reports[-1][1] assert report.cancelled is True and report.complete is False assert report.frames_written == 2 assert report_from(None, cancelled=True).cancelled is True