Files
orisys-app/apps/studio/tests/test_ui_smoke.py
T
2026-09-12 20:27:34 +08:00

282 lines
9.3 KiB
Python

"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
from __future__ import annotations
import pytest
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()
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02))
cal._refresh_table()
assert cal.table.rowCount() == 1
assert not cal.empty_hint.isVisible()
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)