"""T1: a bare video, analysed end to end, through the window's own action. The workflow this defends is the minimum one an operator needs today: point Studio at a video that has no recording beside it, answer one prompt, and get a **measurement** — arrows, a depth map and a force trace that can be scrubbed in both directions, because they are read from a stored result rather than from a re-run of the chain. What makes it a *bare* video is the input: the clip is copied into a folder of its own with nothing else in it (no ``forces.csv``, no ``frames.csv``, no ``session.json``). What makes it analysable is the workspace the app generates for it (``analysis.bare_video``), written under the app's own derived-data root — which this module points at a temporary directory, so the test can assert *where* it went instead of trusting it. Where a failure would be invisible: * the prompt's answers decide what is measured, so the profile, the calibration and the reference frame are read back from the dialog's own widgets; * a recomputed force trace and a recorded one are different claims, so the charts' source kind and the provenance's ``sources``/``synthetic`` are asserted, not just the pixels; * a box result is recorded on the box sample grid, so the canvas is compared with ``draw_arrow_display`` on the recovered field. The fixture clip is the project's recorded box footage, tracked in the repository beside this test (``tests/data/box_short_20260930``, see its README). If it is absent the test **fails** (``_require_clip``) rather than skipping: a silently skipped end-to-end test is how a broken workflow ships. """ from __future__ import annotations import json import shutil from pathlib import Path import numpy as np import pytest from orisys_app_shared import paths as paths_module from orisys_app_shared.acquisition.arrows import ( depth_map_to_bgr, draw_arrow_display, flow_colormap, hex_to_bgr, ) from orisys_app_shared.acquisition.image_backend import ( ARROW_PURE, ARROW_STITCHED, DEPTH, RECORDED, RESULT_FAILED, SOURCE_REPLAY, ImageFrame, render_image, ) from orisys_app_shared.analysis import bare_video from orisys_app_shared.analysis.cache import scan_results from orisys_app_shared.i18n import t from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_app_shared.sensors import ( CatalogEntry, SensorCatalog, UserRootDecision, scan_sensor_catalog, ) from orisys_app_shared.theme import color from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog #: The one real input, tracked in the repository beside this test: a 33-frame box #: recording (2026-09-30, session 2, 1.9 s) copied out of ``dist/`` before that build #: output was next repackaged. See ``data/README.md`` for what it is and why it is here #: rather than generated. DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930" SOURCE_CLIP = DATA / "raw_0001.mp4" def _require_clip() -> Path: """The one real input: recorded box footage, copied rather than faked. A synthetic clip cannot stand in for it. The box chain's stitch probe finds its lenslet grid in a real image; on generated pixels it does not converge — measured, a synthetic 896² clip sent the chain into minutes of work where the real 33-frame clip takes two seconds. So the input is the project's own recording, and its absence is a **failure**: skipping would leave "a bare video can be analysed" untested, which is the state this task exists to change. """ assert SOURCE_CLIP.is_file(), ( "the bare-video test needs recorded box footage at " f"{SOURCE_CLIP}; it is tracked in the repository (apps/studio/tests/data/README.md), " "so restore it rather than skipping the test" ) pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain") return SOURCE_CLIP # ------------------------------------------------------------------------ fixtures @pytest.fixture(scope="module") def cache_root(tmp_path_factory): """The analysis cache for this module, in place of the user's own.""" root = tmp_path_factory.mktemp("bare-video-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 def own_cache(tmp_path, monkeypatch) -> Path: """A cache root this test alone owns, so its first open really does run a pass.""" root = tmp_path / "own-cache" root.mkdir() monkeypatch.setattr(paths_module, "analysis_cache_dir", lambda: root) return root @pytest.fixture(scope="module") def workspace_root(tmp_path_factory): """The app-owned root the generated workspace goes to, in place of the user's own.""" root = tmp_path_factory.mktemp("bare-video-workspaces") / "video-workspaces" patch = pytest.MonkeyPatch() patch.setattr(paths_module, "video_workspace_dir", lambda: root) yield root patch.undo() @pytest.fixture def bare_clip(tmp_path) -> Path: """One video, alone in a folder: a bare video and nothing else.""" folder = tmp_path / "operator-footage" folder.mkdir() clip = folder / "session-clip.mp4" shutil.copy2(_require_clip(), clip) return clip @pytest.fixture def window(make_window, qt_app, cache_root, workspace_root): """The application, in the replay view, as the operator meets it.""" view = make_window(sensor_type="box", source_mode="camera") view.resize(1440, 880) view.show() QApplication.processEvents() view.brand_bar.btn_playback.click() QApplication.processEvents() assert view.layout_ctl.in_replay_view(), "the window did not switch to the replay view" return view # ------------------------------------------------------------------------- helpers def answer_prompt( monkeypatch, *, profile: str = "shipped/box", calibration: str = "default", reference: int | None = None, accept: bool = True, seen: list | None = None, ): """Answer the setup prompt through the real dialog's own widgets. The dialog class is the real one and the window builds it; only ``exec`` is replaced, so what a test answers is exactly what an operator could have answered, and the defaults can be read back from the widgets the operator sees. """ def _answer(dialog_self): if seen is not None: seen.append(dialog_self) assert isinstance(dialog_self, VideoAnalysisDialog) assert dialog_self.select_profile(profile), ( f"the prompt does not offer {profile}: " f"{[entry.ref for entry in dialog_self.entries]}" ) if reference is not None: dialog_self.reference_spin.setValue(int(reference)) if calibration == "none": index = dialog_self.calibration_combo.findData("none") assert index >= 0 dialog_self.calibration_combo.setCurrentIndex(index) elif calibration != "default": dialog_self.select_calibration(Path(calibration)) return QDialog.DialogCode.Accepted if accept else QDialog.DialogCode.Rejected monkeypatch.setattr(VideoAnalysisDialog, "exec", _answer) def panel_bgr(window) -> np.ndarray: """The 2D panel's picture, in BGR, from the end of the real display path.""" image = window.arrow_panel._source_image assert image is not None and not image.isNull(), "the 2D panel shows no picture" width, height = image.width(), image.height() rows = np.frombuffer(bytes(image.constBits()), dtype=np.uint8) rows = rows.reshape(height, image.bytesPerLine()) return rows[:, : width * 3].reshape(height, width, 3)[:, :, ::-1].copy() def sink_frames(window, monkeypatch) -> list: captured: list = [] original = window._show_image_frame def spy(frame): captured.append(frame) return original(frame) monkeypatch.setattr(window, "_show_image_frame", spy) return captured def set_mode(window, mode: str) -> None: index = window.arrow_panel.view_combo.findData(mode) assert index >= 0, f"the mode {mode} is not offered: {window.arrow_panel.view_modes()}" window.arrow_panel.view_combo.setCurrentIndex(index) QApplication.processEvents() assert window.arrow_panel.view_mode() == mode def canvas_bgr(window) -> tuple[int, int, int]: return hex_to_bgr(color(window._theme, "arrow_canvas", "#0B1830")) def painted(image, canvas) -> int: array = np.asarray(image) return int(np.count_nonzero(np.any(array != np.asarray(canvas, np.uint8), axis=-1))) def entry_set(directory: Path) -> set: """Every entry under ``directory`` as ``(relative path, size)``.""" 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 open_bare_video(window, clip: Path, monkeypatch, **answer) -> None: """The operator's sequence up to "the pass is running": open, and answer the prompt.""" answer_prompt(monkeypatch, **answer) assert window.replay_ctl.load_video(clip) is True, "the video did not open" QApplication.processEvents() def settle(window, *, timeout_s: float = 300.0) -> None: """Let a pass finish if one is running, and check that a result came out of it.""" if window.replay_ctl.analysis_running(): assert window.replay_ctl.wait_for_analysis(timeout_s), "the pass did not finish" QApplication.processEvents() assert window.replay_ctl.analysis_error() == "", ( f"the pass left no usable result: {window.replay_ctl.analysis_error()}" ) assert window.replay_ctl.available_result() is not None, "no stored result was adopted" # --------------------------------------------------------------------- the workflow def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions( bare_clip, window, monkeypatch, own_cache, workspace_root ): """Open a bare video → answer the prompt → the pass runs → scrub it backwards.""" before = entry_set(bare_clip.parent) captured = sink_frames(window, monkeypatch) seen: list = [] open_bare_video(window, bare_clip, monkeypatch, seen=seen) assert window.replay_ctl.analysis_running(), ( "no pass was started for a bare video with no stored result" ) assert window.analysis_progress.state() == "running" settle(window) # ---- the prompt's fields and their defaults ----------------------------- dialog = seen[0] assert [entry.ref for entry in dialog.entries] == [ entry.ref for entry in bare_video.profiles_for_video(scan_sensor_catalog().entries) ], "the prompt offered a different set of profiles than the catalog can analyse with" assert "flow" in dialog.capability_label.text(), ( "the row does not say what the profile can do" ) assert dialog.calibration_combo.currentData() == "default" assert dialog.calibration_combo.currentText() == t( "video_analysis.calibration_default", name="force_calibration.json" ), "the calibration did not default to the profile's declared one" assert dialog.reference_spin.value() == 1, ( "the reference frame did not default to the first" ) chosen = dialog.choice() assert chosen is not None and chosen.reference_frame is None, ( "the first frame must stay the pass's own default, not a reset it cannot apply" ) assert chosen.calibration is not None and chosen.calibration.name == "force_calibration.json" # ---- the workspace, and where it went ----------------------------------- workspace = Path(window.replay_ctl.recording_directory() or "") assert workspace.is_dir(), "the pass was not given a generated workspace" assert workspace.parent == workspace_root, f"the workspace went to {workspace.parent}" assert workspace.parent != bare_clip.parent assert sorted(path.name for path in workspace.iterdir()) == [ "frames.csv", "raw_0001.mp4", "session.json", ] assert entry_set(bare_clip.parent) == before, ( "opening and analysing a bare video wrote beside the operator's footage" ) session = window.replay_ctl.available_result() provenance = session.reader.provenance assert provenance.synthetic is True, ( "the result does not say its input was a generated workspace" ) assert provenance.synthetic_source == str(bare_clip), ( "the provenance does not name the video the numbers were computed from" ) assert provenance.sources["flow"] == "recomputed" assert provenance.sources["forces"] == "recomputed", ( "a recomputed force trace was recorded as a recorded measurement" ) # ---- the charts: the recomputed force trace ----------------------------- assert window.force_plots.curve_kind() == "analysis", ( "the charts are not reading the result the pass wrote" ) assert window.force_plots.is_recording_source() assert window.force_plots.mode_label.text() == t("chart.mode_recomputed"), ( "the charts do not say that this trace is a recomputation" ) table = session.reader.forces() assert table is not None and len(table) > 1, "the result stores no force at all" assert np.isfinite(table.newtons).any(), "the stored force trace holds no number" unit = window.force_plots.unit() xs, ys = window.force_plots.curves[0].getData() assert xs is not None and len(xs) == len(table), "the FN curve does not hold the trace" assert np.allclose( np.asarray(ys, float), table.value_for_unit(unit)[:, 0], equal_nan=True ) # ---- the 2D canvas: arrows and depth, from the cache -------------------- canvas = canvas_bgr(window) ids = [int(value) for value in session.frame_ids] last_id, middle_id = ids[-1], ids[len(ids) // 2] assert session.frame(last_id).active_count > 0 set_mode(window, ARROW_PURE) window.replay.set_position(session.time_of(middle_id)) QApplication.processEvents() assert captured[-1].frame_id == middle_id assert captured[-1].source == SOURCE_REPLAY assert captured[-1].result_available is True # ...then scrub *backwards*, from the end of the timeline to that same frame: it must # resolve to the stored frame for the position, drawn from the cache, with no pass. window.replay.set_position(window.replay.duration_s() + 1.0) QApplication.processEvents() assert captured[-1].frame_id == last_id, ( "the cursor at the end of the timeline did not resolve to the last stored frame" ) window.replay.set_position(session.time_of(middle_id)) QApplication.processEvents() assert captured[-1].frame_id == middle_id, ( "scrubbing backwards did not resolve to the stored frame at that position" ) assert not window.replay_ctl.analysis_running(), "scrubbing ran an analysis pass" middle_shot = panel_bgr(window) frame = session.frame(middle_id) policy = session.paint_policy() expected = render_image( ImageFrame( frame.base_bgr, flow=frame.flow, depth=frame.depth, valid_mask=frame.valid_mask, source=SOURCE_REPLAY, frame_id=int(frame.frame_id), flow_valid=True, depth_valid=bool(frame.has_depth), result_available=True, arrow_policy=policy, ), mode=ARROW_PURE, theme=window._theme, draw_style=window._draw_style(), canvas_bgr=canvas, domain_mask=window._domain_mask.mask, arrow_policy=policy, ) assert expected is not None assert painted(expected, canvas) > 0, "the cached arrows painted nothing" assert np.array_equal(middle_shot, np.asarray(expected)), ( "the panel's arrow canvas is not the stored frame's own drawing" ) raw = window.replay.video_frame_bgr() assert raw is not None and not np.array_equal(middle_shot, np.asarray(raw)), ( "the arrow view is the video's raw picture" ) # ---- overlay: a result whose captured base cannot be rebuilt is refused ----- # The overlay views paint on the live-shaped picture (UV unroll / box stitch). A # stored result does not contain it — replay rebuilds it from the result's # provenance — and for a box result that rebuild fails on this footage (measured: # every frame; the SDK stitch raises inside `Sensor.get_img`). Since 425fa0d the # panel **refuses** instead of painting arrows on a resized fisheye, and the refusal # with its reason is the honest thing to assert here. # # It used to assert painted arrows over the captured frame. That claim was only ever # green because the footage it needed had been deleted from `dist/`; the box overlay # base is itself broken (tracked separately) — this test states what the operator # actually gets, not what an earlier build happened to draw. set_mode(window, ARROW_STITCHED) QApplication.processEvents() assert window.arrow_panel._source_image is None, ( "the overlay drew something without the captured frame under it" ) assert window.arrow_panel.image_label.text() == t("panels.arrow_capture_unavailable"), ( window.arrow_panel.image_label.text() ) # ---- the depth view, from the same stored frame ------------------------- set_mode(window, DEPTH) QApplication.processEvents() expected_depth = depth_map_to_bgr( frame.depth, flow_colormap(window._theme), value_range=window._depth_range(), invalid_mask=window._domain_mask.mask, ) assert expected_depth is not None assert np.array_equal(panel_bgr(window), expected_depth), ( "the depth view is not depth_map_to_bgr of the stored frame" ) # ---- the recording's own picture is still one step away ----------------- set_mode(window, RECORDED) assert np.array_equal(panel_bgr(window), np.asarray(raw)) assert window.arrow_panel.note_label.text() == "", ( "the panel says there is nothing to draw while it is drawing a result" ) def test_the_box_arrow_canvas_is_painted_by_the_live_painter( bare_clip, window, monkeypatch ): """A box pass stores the box sample grid; the panel redraws with that policy. The recovered field is NaN off the recorded cells. The shared selector only samples finite cells, so the panel matches ``draw_arrow_display`` on that sparse field. """ open_bare_video(window, bare_clip, monkeypatch) settle(window) session = window.replay_ctl.available_result() canvas = canvas_bgr(window) policy = session.paint_policy() assert int(policy.flow_grid_spacing) == 20 assert abs(float(policy.flow_arrow_scale) - 0.5) < 1e-9 common = { "background": "pure_color", "canvas_bgr": canvas, "draw_style": window._draw_style(), "colormap": flow_colormap(window._theme), "policy": policy, } set_mode(window, ARROW_PURE) first = next(frame for frame in session.frames() if frame.active_count > 0) window.replay.set_position(session.time_of(first.frame_id)) QApplication.processEvents() shown = panel_bgr(window) assert painted(shown, canvas) > 0, "the operator's arrow canvas is blank" expected = draw_arrow_display( first.base_bgr, first.flow, valid_mask=first.valid_mask, **common, ) assert np.array_equal(shown, np.asarray(expected)), ( "the panel's canvas is not the shared painter's output for this frame" ) def test_declining_the_prompt_leaves_the_video_playing_with_nothing_promised( bare_clip, window, monkeypatch ): """Cancelling is a state, not a crash: the picture plays and the panel says no pass ran.""" before = entry_set(bare_clip.parent) captured = sink_frames(window, monkeypatch) open_bare_video(window, bare_clip, monkeypatch, accept=False) assert window.replay.has_video(), "declining the prompt closed the video" assert window.replay_ctl.available_result() is None assert window.replay_ctl.recording_directory() is None, ( "a dismissed prompt still claimed a workspace as this timeline's input" ) assert not window.replay_ctl.analysis_running(), "a pass ran after the prompt was dismissed" state, reason = window.replay_ctl.result_state() assert state == RESULT_FAILED assert reason == t("playback.analysis_setup_declined") assert captured and captured[-1].result_state == RESULT_FAILED assert entry_set(bare_clip.parent) == before def test_a_split_recording_is_refused_with_a_clear_message( bare_clip, window, monkeypatch ): """A split recording is a measurement error: refuse it rather than half-analyse it. The files are named the way a split recording is (``take_0001.mp4`` beside ``take_0002.mp4``), and the chosen one is opened as a video first — the refusal comes from the setup step, which is where "what exactly is this input" is asked. """ folder = bare_clip.parent bare_clip.unlink() chosen = folder / "take_0001.mp4" shutil.copy2(_require_clip(), chosen) shutil.copy2(_require_clip(), folder / "take_0002.mp4") captured = sink_frames(window, monkeypatch) open_bare_video(window, chosen, monkeypatch) assert not window.replay_ctl.analysis_running(), "a split recording was analysed anyway" _state, reason = window.replay_ctl.result_state() assert "split recording" in reason, f"the refusal does not say why: {reason!r}" assert "take_0001.mp4" in reason and "take_0002.mp4" in reason assert window.replay.has_video(), "the refusal closed the video" assert captured, "the refusal left the panel with nothing" def test_reopening_the_video_reuses_the_result_and_runs_no_second_pass( bare_clip, window, monkeypatch, cache_root ): """The cache is the point: a second open is a file read, not a second pass. ``recording_identity`` is derived from the video and the chosen profile, so a workspace generated again is the *same* input — the key does not move and no pass runs. """ open_bare_video(window, bare_clip, monkeypatch) settle(window) directories = {entry.directory for entry in scan_results(cache_root)} assert directories, "the first open stored no result" workspace = window.replay_ctl.recording_directory() window.replay.close() QApplication.processEvents() assert window.replay_ctl.recording_directory() is None started: list = [] original_start = window.analysis_progress.start def spy_start(*args, **kwargs): started.append(True) return original_start(*args, **kwargs) monkeypatch.setattr(window.analysis_progress, "start", spy_start) open_bare_video(window, bare_clip, monkeypatch) settle(window) assert not started, "a second pass ran for a video that already had a result" assert window.analysis_progress.state() == "reused" assert {entry.directory for entry in scan_results(cache_root)} == directories assert window.replay_ctl.recording_directory() == workspace, ( "the second open generated a different workspace" ) assert window.force_plots.curve_kind() == "analysis" def test_a_setting_that_moves_the_grid_redraws_and_writes_nothing( bare_clip, window, monkeypatch, cache_root ): """A display setting is a read; a grid setting cannot be served and must not blank it.""" open_bare_video(window, bare_clip, monkeypatch) settle(window) session = window.replay_ctl.available_result() set_mode(window, ARROW_PURE) 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)) QApplication.processEvents() assert captured, "the threshold change drew nothing" assert session.threshold() == pytest.approx(0.9) assert {entry.directory for entry in scan_results(cache_root)} == before, ( "a display change wrote an analysis result" ) window.replay_ctl.on_display_settings(DisplaySettings(max_arrows=50)) QApplication.processEvents() policy = captured[-1].arrow_policy assert policy is not None, ( "the frame does not say which policy its recovered field can be sampled with" ) assert policy.max_arrows == session.paint_policy().max_arrows, ( "the display's grid setting reached the recovered field's sampling policy" ) assert painted(panel_bgr(window), canvas_bgr(window)) > 0, ( "a refused grid setting blanked the canvas" ) assert {entry.directory for entry in scan_results(cache_root)} == before def test_the_workspace_carries_the_profile_the_operator_chose( bare_clip, window, monkeypatch ): """The prompt's answer is what the pass is driven with — read back from the workspace.""" open_bare_video(window, bare_clip, monkeypatch, calibration="none") settle(window) workspace = Path(window.replay_ctl.recording_directory()) document = json.loads((workspace / "session.json").read_text(encoding="utf-8")) assert document["device"]["sensor_type"] == "box" config_ref = Path(document["device"]["config_ref"]) assert config_ref.name == "sensor.json" and "sensors" in config_ref.parts assert document["synthetic"]["profile_ref"] == "shipped/box" assert document["synthetic"]["calibration"] == "" assert document["calibration_id"] == "", ( "choosing raw force must not leave a calibration in the result's key" ) assert document["stats"]["segments"] == 1 assert (workspace / "frames.csv").is_file() # The chain was told, too: no calibration means the raw triplet, not a substitute curve. assert window.replay_ctl.available_result() is not None # ------------------------------------------------------------------- the prompt itself def _catalog(entry: CatalogEntry) -> SensorCatalog: return SensorCatalog( roots=(), entries=(entry,), user_root_decision=UserRootDecision( path=Path("."), kind="explicit", writable=True, reason="test" ), ) def _blocked_entry() -> CatalogEntry: """The shipped box bundle as the catalog reports one the SDK's validation rejected. Built from the *real* entry so the profile behind it is real too: the verdict is what is replaced, and the reason is the sentence the SDK would have produced. """ import dataclasses real = next( entry for entry in scan_sensor_catalog().entries if entry.folder == "box" ) return dataclasses.replace( real, valid=False, state="failed", reason="assets.computational-missing: the required unroll map is missing", ) def test_a_profile_the_sdk_rejected_cannot_be_chosen_and_says_why( bare_clip, window, qt_app, tmp_path ): """The catalog's ``start_blocked_reason`` reaches the prompt instead of no caller.""" facts = bare_video.probe(bare_video.resolve_video(bare_clip)) dialog = VideoAnalysisDialog( video=bare_clip, facts=facts, catalog=_catalog(_blocked_entry()), window=window ) try: entry = dialog.entries[0] blocked = bare_video.setup_blocked_reason(entry) assert blocked is not None and "unroll map" in blocked item = dialog.profile_combo.model().item(0) assert not item.isEnabled(), "a profile the SDK rejected is offered as startable" assert not dialog.buttons.button(QDialogButtonBox.StandardButton.Ok).isEnabled(), ( "OK is offered for a profile that cannot be started" ) assert dialog.note_label.text() == t("video_analysis.blocked", reason=blocked), ( "the prompt does not carry the SDK's own reason" ) assert dialog.choice() is None, "the refused profile produced a setup anyway" finally: dialog.deleteLater() def test_the_first_frame_is_the_prompt_s_default_reference( bare_clip, window, qt_app ): """A reference frame that is not asked for must not become a reset the pass cannot make.""" facts = bare_video.probe(bare_video.resolve_video(bare_clip)) dialog = VideoAnalysisDialog( video=bare_clip, facts=facts, catalog=scan_sensor_catalog(), window=window ) try: assert dialog.reference_spin.minimum() == 1 assert dialog.reference_spin.maximum() == facts.frame_count assert dialog.reference_spin.value() == 1 assert dialog.choice().reference_frame is None dialog.reference_spin.setValue(4) assert dialog.choice().reference_frame == 4 assert dialog.reference_spin.value() == 4 finally: dialog.deleteLater() def test_the_prompt_restores_a_prior_reference_and_no_calibration( bare_clip, window, qt_app ): """Reopening Analysis setup must show the answers from the last pass.""" facts = bare_video.probe(bare_video.resolve_video(bare_clip)) dialog = VideoAnalysisDialog( video=bare_clip, facts=facts, catalog=scan_sensor_catalog(), window=window, initial_profile="shipped/box", prefer_no_calibration=True, initial_reference_frame=4, ) try: assert dialog.reference_spin.value() == 4 assert dialog.choice().reference_frame == 4 assert dialog.calibration_combo.currentData() == "none" assert dialog.choice().calibration is None finally: dialog.deleteLater() def test_analysis_setup_reopens_the_prompt_after_the_first_pass( bare_clip, window, monkeypatch ): """Analysis ▸ Analysis setup… asks again; a new reference frame starts a new pass.""" open_bare_video(window, bare_clip, monkeypatch, reference=None) settle(window) first = window.replay_ctl.recording_directory() assert first is not None assert bare_video.is_synthetic(first) # The box stitch probe consumes the first decoded frames; a reference that names # one of them is refused. Frame 10 is past that offset on this clip. seen: list = [] answer_prompt(monkeypatch, profile="shipped/box", reference=10, seen=seen) window.replay_ctl.on_analysis_setup() settle(window) assert seen, "Analysis setup did not open the dialog" second = window.replay_ctl.recording_directory() assert second is not None block = bare_video.read_synthetic(second) assert block is not None assert int(block["reference_frame"]) == 10 assert window.replay_ctl.available_result() is not None def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path): """A browsed Finger file is accepted only when the device id matches its SN.""" import cv2 clip = tmp_path / "clip.mp4" writer = cv2.VideoWriter(str(clip), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (32, 24)) for _ in range(4): writer.write(np.zeros((24, 32, 3), dtype=np.uint8)) writer.release() facts = bare_video.probe(bare_video.resolve_video(clip)) catalog = scan_sensor_catalog() finger = next( ( entry for entry in catalog.entries if str(entry.product_family) == "finger" and bare_video.setup_blocked_reason(entry) is None ), None, ) assert finger is not None, "the catalog has no startable Finger profile" path = tmp_path / "DD02-14.json" path.write_text( json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}), encoding="utf-8", ) dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog) try: assert dialog.select_profile(finger.ref) dialog.select_calibration(path) assert not dialog.device_edit.isHidden() assert dialog.device_edit.text() == "DD02-14" chosen = dialog.choice() assert chosen is not None assert chosen.calibration == path assert chosen.device_id == "DD02-14" dialog.device_edit.setText("OTHER") assert dialog.choice() is None assert dialog.note_label.text() == t( "video_analysis.device_mismatch", sn="DD02-14" ) finally: dialog.deleteLater()