"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens.""" from __future__ import annotations from pathlib import Path import numpy as np import pytest import ui_check from tests.conftest import visible_descendants RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) def _show(window, size): window.resize(*size) window.show() from PyQt5.QtWidgets import QApplication QApplication.processEvents() return window @pytest.mark.parametrize(("theme", "sensor_type"), [ ("dark", "box"), ("light", "box"), ("dark", "finger"), ("light", "finger"), ]) def test_window_builds_in_every_theme(make_window, theme, sensor_type): window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880)) assert window.windowTitle() assert window.content_splitter.count() == 4 @pytest.mark.parametrize("size", RESOLUTIONS) def test_panels_fit_within_window(make_window, size): window = _show( make_window(sensor_type="finger", show_cal_panel=True), size, ) sizes = window.content_splitter.sizes() panels = [ window.mesh3d_panel, window.arrow_panel, window.force_plots, window.cal_panel, ] for panel, width in zip(panels, sizes): assert panel.isVisible() assert width >= panel.minimumWidth(), ( f"{panel.objectName() or type(panel).__name__} too narrow: {width}" ) assert window.force_plots.width() >= window.force_plots.minimumWidth() assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth() @pytest.mark.parametrize("size", RESOLUTIONS) def test_children_stay_inside_their_parent(make_window, size): window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) oversize = [] for child in visible_descendants(window): parent = child.parentWidget() if parent is None or not parent.isVisible(): continue geometry = child.geometry() if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: oversize.append( f"{type(child).__name__} {geometry.width()}x{geometry.height()}" f" > parent {parent.width()}x{parent.height()}" ) assert not oversize, oversize def test_theme_tokens_have_identical_keys(): from orisys_app_shared.theme import build_qss, load_colors dark = set(load_colors("dark")) light = set(load_colors("light")) assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)} assert len(build_qss("dark")) > 1000 assert len(build_qss("light")) > 1000 def test_arrow_canvas_differs_from_accent(): """The pure-color arrow canvas must not collide with the accent pen color.""" from orisys_app_shared.theme import load_colors for theme in ("dark", "light"): colors = load_colors(theme) assert colors["arrow_canvas"] != colors["accent"], theme def test_palette_rules_hold(): """WCAG contrast and colour-separation rules from ``tools/ui_check.py``.""" import ui_check assert ui_check.audit_palette() == [] def test_flow_colormap_follows_the_theme_ramp(): from orisys_app_shared.acquisition.arrows import ( _lut_from_ramp, flow_colormap, hex_to_bgr, ) from orisys_app_shared.theme import load_colors for theme in ("dark", "light"): stops = [s.strip() for s in load_colors(theme)["flow_ramp"].split(",")] lut = flow_colormap(theme) assert lut.shape == (256, 1, 3) assert tuple(int(v) for v in lut[0, 0]) == hex_to_bgr(stops[0]) assert tuple(int(v) for v in lut[-1, 0]) == hex_to_bgr(stops[-1]) # Malformed ramps must never raise; they fall back to the built-in ramp. fallback = _lut_from_ramp("not,a,ramp") assert fallback.shape == (256, 1, 3) def test_brand_bar_renders_and_localizes(make_window): from orisys_app_shared.i18n import t window = _show(make_window(language="zh_CN"), (1440, 880)) bar = window.brand_bar assert bar.height() >= 28 assert bar.name.text() assert bar.product.text() == t("app.title") zh_tagline = bar.tagline.text() window._set_language("en_US") assert bar.tagline.text() != zh_tagline assert bar.name.text() == "ORISYS" logo = bar.logo.pixmap() if logo is not None and not logo.isNull(): image = logo.toImage() alphas = [ image.pixelColor(x, y).alpha() for y in range(image.height()) for x in range(image.width()) ] assert any(a < 32 for a in alphas) assert any(a >= 200 for a in alphas) def test_brand_bar_background_comes_from_tokens(make_window): from orisys_app_shared.theme import load_colors window = _show(make_window(), (1440, 880)) for theme in ("dark", "light"): window._set_theme(theme) colors = load_colors(theme) assert colors["brand_bar_bg"] != colors["window_bg"], theme assert window.brand_bar._theme == theme def test_disabled_buttons_use_muted_text_not_hover_background(): from orisys_app_shared.theme import build_qss, load_colors for theme in ("dark", "light"): colors = load_colors(theme) qss = build_qss(theme) assert f"color: {colors['text_muted']}" in qss, theme # The old rule painted disabled buttons with the brighter hover color. assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss def test_splitter_sizes_round_trip_through_prefs(make_window): window = _show(make_window(), (1440, 880)) window.content_splitter.setSizes([300, 420, 520, 0]) window._persist_prefs() stored = window.prefs["splitter_sizes"] assert len(stored) == 4 assert stored[2] > stored[0] def test_status_chip_tracks_phase(make_window): window = _show(make_window(), (1400, 880)) assert window._session_phase == "idle" window._set_phase("running") assert window.status_chip.text() assert "status_running" not in window.status_chip.text() window._set_phase("nonexistent-phase") assert window._session_phase == "idle" def test_run_tooltip_explains_disabled_reason(make_window): window = _show(make_window(source_mode="video", video_path=""), (1400, 880)) assert not window.toolbar.btn_run.isEnabled() assert window.toolbar.btn_run.toolTip() window.toolbar.set_video_path("C:/tmp/demo.mp4") window._update_run_enabled() assert window.toolbar.btn_run.isEnabled() def test_toolbar_run_button_is_labelled(make_window): window = _show(make_window(), (1400, 880)) label = window.toolbar.btn_run.text() assert label.strip() assert label != "â–¶" window.toolbar.set_run_checked(True) assert window.toolbar.btn_run.text() != label def test_stop_cancels_a_hung_connect(make_window): """Stop must stay live and take effect instantly while connecting.""" import time import ui_check from orisys_app_shared.i18n import t window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880)) stuck = ui_check._StuckWorker() window._ensure_workers = lambda: None window._box_worker = stuck window._finger_worker = stuck window.toolbar.btn_run.setChecked(True) assert window._session_phase == "connecting" assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting" assert window.toolbar.btn_run.text() == t("toolbar.cancel_label") assert window.act_stop.isEnabled() started = time.perf_counter() window.toolbar.btn_run.setChecked(False) elapsed = time.perf_counter() - started assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s" assert not window._running assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread" def test_escape_cancels_a_hung_connect(make_window): import time import ui_check window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880)) stuck = ui_check._StuckWorker() window._ensure_workers = lambda: None window._box_worker = stuck window._finger_worker = stuck window.toolbar.btn_run.setChecked(True) assert window.act_stop.shortcut().toString() == "Esc" started = time.perf_counter() window._on_stop_shortcut() assert time.perf_counter() - started < 0.5 assert not window._running def test_theme_switch_updates_panels(make_window): window = _show(make_window(), (1400, 880)) window._set_theme("light") assert window._theme == "light" window._set_theme("dark") assert window._theme == "dark" def test_cal_panel_records_and_lists_samples(make_window): window = _show(make_window(), (1400, 880)) cal = window.cal_panel cal.set_running(True) assert cal.btn_record.isEnabled() assert cal.btn_record.text() assert cal.table.columnCount() == 6, "the diagnostic columns changed" _add_calibration_point(window, load=1.0, raw=100.2) _add_calibration_point(window, load=2.0, raw=400.0) # off-trend _add_calibration_point(window, load=3.0, raw=300.6) assert cal.table.rowCount() == 3 assert not cal.empty_hint.isVisible() headers = [ cal.table.horizontalHeaderItem(i).text() for i in range(cal.table.columnCount()) ] from orisys_app_shared.i18n import t assert headers == [ t("cal.col_weight"), t("cal.col_fn"), t("cal.col_fx"), t("cal.col_fy"), t("cal.col_stability"), t("cal.col_state"), ] def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40) -> None: """Drive one point through the recorder and into the panel's table.""" from orisys_app_shared.force.session import SessionSample window.cal_recorder.start(channel="fn", reference_load_n=load, duration_s=1.0) for index in range(samples): window.cal_recorder.feed( SessionSample( frame_id=index, t_rel_s=index / 100.0, fn_raw=raw, fx_raw=0.4, fy_raw=-0.2, valid=True, ) ) window.cal_panel.on_record_finished(window.cal_recorder.finish_now()) def test_calibration_diagnostics_drive_the_fit(make_window): """The diagnostics must describe the model that would be exported. The export builds a piecewise-linear curve, which passes through every point it is built from — so the training residual is zero by construction and an off-trend point can only show up as a *validation* error. A private least-squares residual would have described a model nobody applies. """ window = _show(make_window(), (1400, 880)) window.cal_panel.set_running(True) _add_calibration_point(window, load=1.0, raw=100.2) _add_calibration_point(window, load=2.0, raw=400.0) _add_calibration_point(window, load=3.0, raw=300.6) _add_calibration_point(window, load=5.0, raw=500.9) recorder = window.cal_recorder fit = recorder.fit("fn") assert fit.usable and fit.n_points == 4 assert fit.method == "piecewise_linear", fit.method assert fit.breakpoints == 4, fit.breakpoints assert fit.max_residual_n <= 1e-9, "the exported model misses its own points" assert window.cal_panel.fit_label.text() # Hold the off-trend point (load 2.0 at raw 400) out: the exported model is # then built without it and the misfit is measurable. window.cal_panel.table.setCurrentCell(1, 0) window.cal_panel._on_toggle_validation() held_out = recorder.fit("fn") assert held_out.validation_points == 1 assert held_out.validation_error_n > 0.05, held_out.validation_error_n assert recorder.points[1].is_validation assert np.isnan(recorder.points[1].residual_n), "validation point entered the fit" window.cal_panel._on_toggle_validation() window.cal_panel._on_toggle_excluded() assert recorder.points[1].excluded # An excluded point leaves the export store but stays on record. assert len(window.cal_panel._store.channel("fn")) == 3 # The exported model no longer passes through the excluded point. from orisys_app_shared.force.calibration_io import ( build_export_model, predict_channel, ) store = window.cal_panel._store model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")}) assert abs(predict_channel(model, "fn", 400.0) - 2.0) > 0.05 def test_calibration_point_dialog_shows_three_axes(make_window): window = _show(make_window(), (1400, 880)) _add_calibration_point(window, load=1.0, raw=100.2) point = window.cal_recorder.points[0] assert set(point.trace) == {"fn", "fx", "fy"} assert point.trace_dt_s > 0 from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog dialog = CalibrationPointDialog(point, theme="dark") assert dialog.windowTitle() assert len(dialog.plot_widget.ci.items) == 3, "not one trace per axis" dialog.close() def test_force_plots_render_demo_data(make_window): window = _show(make_window(), (1440, 880)) for i in range(60): window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") from PyQt5.QtWidgets import QApplication QApplication.processEvents() assert window.force_plots.history.sample_count > 0 assert all(label.text() for label in window.force_plots.value_labels) # --------------------------------------------------------------- record channel def _running_window(make_window): """A window in the ``running`` phase whose worker never opens a device. The Qt timer is stopped deliberately: these tests drive the display path by calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race the assertions (and outlive the window on teardown). """ window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880)) stuck = ui_check._StuckWorker() window._ensure_workers = lambda: None window._box_worker = stuck window._finger_worker = stuck window._start_run() window._run_timer.stop() window._set_phase("running") return window, stuck def _finish(window, stuck): window._stop_run(reason="user_stop") stuck.release() def test_record_channel_does_not_depend_on_the_ui_timer(make_window): window, stuck = _running_window(make_window) for index in range(90): stuck.emit(ui_check._fake_packet(index)) assert window.session_store.count == 90, "samples were lost without a UI tick" _finish(window, stuck) def test_invalid_samples_are_excluded_not_zeroed(make_window): import math window, stuck = _running_window(make_window) for index in range(9): stuck.emit(ui_check._fake_packet(index)) stuck.emit(ui_check._fake_packet(99, valid=False, reason="read failed")) slice_ = window.session_store.data() assert slice_.valid.tolist()[-1] is False assert math.isnan(slice_.raw[-1][0]), "a failed readout became a number" assert sum(1 for _ in window.session_store.iter_valid("raw")) == 9 _finish(window, stuck) def test_calibration_sampling_survives_a_hidden_panel(make_window): window, stuck = _running_window(make_window) window._show_cal = False window._apply_panel_visibility() assert not window.cal_panel.isVisible() window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0) for index in range(40): stuck.emit(ui_check._fake_packet(index)) point = window.cal_recorder.finish_now() assert point is not None assert point.n_valid == 40, "hiding the panel stopped the sampling" assert point.status == "ok" _finish(window, stuck) def test_record_button_stays_a_cancel_affordance(make_window): from orisys_app_shared.i18n import t window, stuck = _running_window(make_window) window.cal_panel.set_running(True) window.cal_panel._on_record_clicked() assert window.cal_panel._recording assert window.cal_panel.btn_record.isEnabled(), "the recording cannot be stopped" assert window.cal_panel.btn_record.text() == t("cal.recording_stop") window.cal_panel._on_record_clicked() assert not window.cal_panel._recording _finish(window, stuck) def test_unit_switch_rebuilds_from_the_session_store(make_window): import math window, stuck = _running_window(make_window) window.toolbar.units_combo.setCurrentIndex(1) # raw window.force_plots.reset() for index in range(60): stuck.emit(ui_check._fake_packet(index)) window._on_run_tick() state = window.force_plots.history.state() assert float(state.values[:, 0].max()) > 5.0, "raw values were not plotted" window.toolbar.units_combo.setCurrentIndex(0) # newton assert window.force_plots._unit == "N" newtons = window.force_plots.history.state().values[:, 0] finite = [v for v in newtons if math.isfinite(v)] assert finite and max(finite) < 5.0, "the buffer still held raw values in N mode" _finish(window, stuck) def test_history_keeps_negative_fn(make_window): window, stuck = _running_window(make_window) window.force_plots.set_fixed_newton(True) for index in range(20): window.force_plots.push(index / 30.0, -2.5, 0.0, 0.0, unit="N") window.force_plots.push(1.0, -3.0, 0.0, 0.0, unit="N") state = window.force_plots.history.state() assert float(state.values[:, 0].min()) < 0.0, ( "negative FN was clamped before entering the buffer" ) _finish(window, stuck) def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): import numpy as np window, stuck = _running_window(make_window) window.arrow_panel.set_view_mode("depth") window._last_rendered_view_mode = None stuck.emit(ui_check._fake_packet(0, depth=None)) window._on_run_tick() from orisys_app_shared.i18n import t assert window.arrow_panel.image_label.text() == t("panels.depth_unavailable"), ( "a missing depth map silently fell back to the processed frame" ) window._last_rendered_view_mode = None stuck.emit(ui_check._fake_packet(1, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8))) window._on_run_tick() latched = window.arrow_panel.depth_scale_label.text() assert latched window._last_rendered_view_mode = None stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8))) window._on_run_tick() assert window.arrow_panel.depth_scale_label.text() == latched, ( "the depth colour scale re-fitted on every frame" ) _finish(window, stuck) # ------------------------------------------------------------- experiment record #: Recording scratch goes under the app's gitignored ``outputs`` tree rather than #: the system temp directory, so the tests also run under a file sandbox that #: only permits writes inside the workspace. RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record" @pytest.fixture def record_root(): import shutil shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) RECORD_SCRATCH.mkdir(parents=True, exist_ok=True) yield RECORD_SCRATCH shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) def test_recording_writes_the_documented_experiment_layout(make_window, record_root): import csv import json import orisys_studio.app_window as app_module from orisys_app_shared.i18n import t window, stuck = _running_window(make_window) app_module.recordings_dir = lambda: record_root assert window.toolbar.btn_record.isEnabled() assert window.toolbar.btn_record.text() == t("record.start_label") window.toolbar.btn_record.setChecked(True) assert window.recorder.is_recording, "the recording did not start" assert window.toolbar.btn_record.text() == t("record.stop_label") assert not window.toolbar.record_mode_combo.isEnabled() for index in range(20): stuck.emit(ui_check._fake_packet(index)) window._on_run_tick() window.toolbar.btn_record.setChecked(False) report = window._record_last_report assert report is not None and report.ok, getattr(report, "problems", None) names = sorted(path.name for path in report.directory.iterdir()) assert names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"] with (report.directory / "forces.csv").open(encoding="utf-8") as handle: forces = list(csv.DictReader(handle)) with (report.directory / "frames.csv").open(encoding="utf-8") as handle: frames = list(csv.DictReader(handle)) assert len(forces) == len(frames) == 20 assert [f["frame_id"] for f in frames] == [r["frame_id"] for r in forces] assert [f["t_rel_s"] for f in frames] == [r["t_rel_s"] for r in forces] meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8")) assert meta["ended_at"] assert meta["format_version"] assert meta["stats"]["frames"] == 20 _finish(window, stuck) def test_recording_leaves_unavailable_values_empty(make_window, record_root): import csv import orisys_studio.app_window as app_module window, stuck = _running_window(make_window) app_module.recordings_dir = lambda: record_root window.toolbar.btn_record.setChecked(True) stuck.emit(ui_check._fake_packet(0)) window._on_run_tick() stuck.emit(ui_check._fake_packet(1, valid=False, reason="read failed")) window._on_run_tick() window.toolbar.btn_record.setChecked(False) with (window._record_last_report.directory / "forces.csv").open(encoding="utf-8") as handle: rows = list(csv.DictReader(handle)) failed = rows[-1] assert failed["valid"] == "0" assert failed["FN_raw"] == "" and failed["FN_N"] == "", ( "a failed readout was persisted as a number" ) _finish(window, stuck)