"""Live calibration choice in the sensor dialog: pick, remember, open, hot-apply.""" from __future__ import annotations import json from pathlib import Path import pytest from orisys_app_shared.i18n import t from orisys_app_shared.sensors import scan_sensor_catalog from orisys_studio.ui import sensor_picker as picker_module from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog from orisys_studio.ui.sensor_picker import SensorPickerWidget from PySide6.QtWidgets import QWidget CONFIG = { "name": "fixture", "camera": { "resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100], }, "tracker": {"algorithm": "dis"}, "stitcher": {"n": [1, 1]}, } def _write(path: Path, payload: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2), encoding="utf-8") def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]: data: dict[str, object] = { "schema_version": 1, "profile_id": profile_id, "display_name": "Mini lab", "product_family": "mini", "processing_backend": "single_camera", "sensor_json": "sensor.json", } data.update(overrides) return data class _Host(QWidget): """Window stand-in with the live-calibration prefs API the dialog expects.""" def __init__(self) -> None: super().__init__() self.prefs: dict = {} self.applied = 0 self._choices: dict[str, tuple[str, Path | None, str]] = {} self.picker: SensorPickerWidget | None = None self._dialog: SensorDialog | None = None def _on_reveal_config(self) -> None: return def _prefs_key(self) -> str | None: if self.picker is None: return None entry = self.picker.selected_entry() return None if entry is None else entry.ref def live_calibration_choice(self) -> tuple[str, Path | None, str]: key = self._prefs_key() if not key: return ROLE_NONE, None, "" return self._choices.get(key, (ROLE_NONE, None, "")) def set_live_calibration_choice( self, *, role: str, path: Path | str | None = None, device_id: str | None = None, ) -> None: key = self._prefs_key() if not key: return file_path = Path(path) if role == ROLE_FILE and path else None self._choices[key] = (role, file_path, str(device_id or "")) store = self.prefs.setdefault("live_calibration", {}) payload: dict[str, str] = {"role": role} if file_path is not None: payload["path"] = str(file_path) if device_id: payload["device_id"] = str(device_id) store[key] = payload def apply_live_calibration_choice(self) -> None: if self._dialog is not None and self._dialog.choice_problem(): return self.applied += 1 def _make_picker(qt_app, tmp_path, monkeypatch, build): root = tmp_path / "user" build(root) catalog = scan_sensor_catalog( shipped_root=tmp_path / "no-shipped-root", user_root=root ) monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog) return SensorPickerWidget() def _dialog_for(picker) -> tuple[_Host, SensorDialog]: host = _Host() host.picker = picker dialog = SensorDialog(window=host, picker=picker) host._dialog = dialog return host, dialog def test_without_a_profile_default_the_combo_starts_on_raw_force( qt_app, tmp_path, monkeypatch ): def build(root: Path) -> None: _write(root / "mini-lab" / "sensor.json", CONFIG) _write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1")) picker = _make_picker(qt_app, tmp_path, monkeypatch, build) picker.set_sensor_type("mini") _host, dialog = _dialog_for(picker) try: assert dialog.calibration_combo.findData(ROLE_DEFAULT) < 0 assert dialog.current_role() == ROLE_NONE assert dialog.current_calibration_path() is None assert t("sensor.catalog_calibration_none") in dialog.profile_label.text() finally: dialog.deleteLater() picker.deleteLater() def test_a_box_profile_default_appears_as_a_combo_row(qt_app): """The shipped box bundle declares ``force_calibration.json`` as its default.""" picker = SensorPickerWidget() picker.set_sensor_type("box") entry = picker.selected_entry() assert entry is not None assert entry.profile is not None assert entry.profile.default_calibration is not None, ( "premise: shipped box must expose a profile default calibration" ) _host, dialog = _dialog_for(picker) try: assert dialog.calibration_combo.findData(ROLE_DEFAULT) >= 0 assert dialog.calibration_combo.findData(ROLE_NONE) >= 0 assert dialog.current_role() == ROLE_NONE dialog.calibration_combo.setCurrentIndex( dialog.calibration_combo.findData(ROLE_DEFAULT) ) path = dialog.current_calibration_path() assert path is not None and path.name == "force_calibration.json" assert t( "sensor.catalog_calibration_selected", name="force_calibration.json" ) in dialog.profile_label.text() finally: dialog.deleteLater() picker.deleteLater() def test_browsing_a_file_shows_its_name_and_persists_per_bundle( qt_app, tmp_path, monkeypatch ): def build(root: Path) -> None: for name, family, profile in ( ("mini-a", "mini", "orisys.mini.a-r1"), ("finger-a", "finger", "orisys.finger.a-r1"), ): folder = root / name _write(folder / "sensor.json", CONFIG) extras = { "display_name": name, "product_family": family, "processing_backend": ( "uv_unroll" if family == "finger" else "single_camera" ), } if family == "finger": extras["computational_assets"] = {} _write(folder / "manifest.json", _descriptor(profile, **extras)) picker = _make_picker(qt_app, tmp_path, monkeypatch, build) host, dialog = _dialog_for(picker) cal = tmp_path / "custom.json" cal.write_text( json.dumps({"meta": {"sensor_type": "mini", "sn": "X"}}), encoding="utf-8" ) try: picker.set_sensor_type("mini") dialog.select_calibration_file(cal) assert dialog.current_role() == ROLE_FILE assert dialog.calibration_combo.currentText() == t( "video_analysis.calibration_file", name="custom.json" ) mini_key = picker.selected_entry().ref assert host.prefs["live_calibration"][mini_key]["path"] == str(cal) picker.set_sensor_type("finger") dialog.refresh() assert dialog.current_role() == ROLE_NONE finger_key = picker.selected_entry().ref assert finger_key != mini_key assert ROLE_FILE != host.prefs.get("live_calibration", {}).get( finger_key, {} ).get("role") finally: dialog.deleteLater() picker.deleteLater() def test_a_finger_file_with_a_mismatched_sn_is_not_a_valid_choice( qt_app, tmp_path, monkeypatch ): def build(root: Path) -> None: folder = root / "finger-lab" _write(folder / "sensor.json", CONFIG) _write( folder / "manifest.json", _descriptor( "orisys.finger.lab-r1", display_name="Finger lab", product_family="finger", processing_backend="uv_unroll", computational_assets={}, ), ) picker = _make_picker(qt_app, tmp_path, monkeypatch, build) picker.set_sensor_type("finger") host, dialog = _dialog_for(picker) cal = tmp_path / "DD02-14.json" cal.write_text( json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}), encoding="utf-8", ) try: dialog.select_calibration_file(cal) # Offscreen dialogs are not shown; ``isHidden`` is the visibility flag. assert not dialog.device_edit.isHidden() assert dialog.device_edit.text() == "DD02-14" assert dialog.choice_problem() == "" dialog.device_edit.setText("OTHER") assert dialog.choice_problem() == t( "video_analysis.device_mismatch", sn="DD02-14" ) dialog.btn_apply.click() assert host.applied == 0 finally: dialog.deleteLater() picker.deleteLater() def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path): window = make_window() cal = tmp_path / "DD02-14.json" cal.write_text( json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}), encoding="utf-8", ) finger = next( ( entry for entry in scan_sensor_catalog().entries if str(entry.product_family) == "finger" and entry.start_blocked_reason is None ), None, ) if finger is None: pytest.skip("no startable Finger profile in the catalog") window.sensor_picker.set_sensor_type("finger") window.sensor_picker.set_config_name(finger.key) window.set_live_calibration_choice( role=ROLE_FILE, path=cal, device_id="DD02-14" ) cal_path, device_id, problem = window._resolved_live_calibration() assert problem == "" assert cal_path == cal assert device_id == "DD02-14" window.set_live_calibration_choice( role=ROLE_FILE, path=cal, device_id="OTHER" ) _path, _device, problem = window._resolved_live_calibration() assert problem == t("video_analysis.device_mismatch", sn="DD02-14") def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_path): window = make_window() cal = tmp_path / "force_calibration.json" cal.write_text('{"schema_version": 2}', encoding="utf-8") window.set_live_calibration_choice(role=ROLE_FILE, path=cal) pending: list[str] = [] class _Worker: def add_sink(self, _sink) -> None: return def set_pending_calibration(self, path) -> None: pending.append(str(path)) worker = _Worker() # Both slots filled so ``ensure_workers`` does not replace the shim. window.session.set_worker("box", worker) window.session.set_worker("finger", worker) window.session.select_worker("box") window.session.set_running(True) assert window._running assert window._active_worker is worker window.apply_live_calibration_choice() assert pending == [str(cal)] pending.clear() window.set_live_calibration_choice(role=ROLE_NONE) window.apply_live_calibration_choice() assert pending == [] assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered")