dsh ui improve: add brand bar
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Shared fixtures for the customer app UI tests.
|
||||
|
||||
The tests render real Qt widgets with the offscreen platform plugin, so they need
|
||||
no display and no camera hardware. A single ``QApplication`` is shared by the
|
||||
whole session (Qt forbids creating more than one).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
|
||||
# truth between the pytest suite and the dependency-free checker.
|
||||
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
||||
if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qt_app():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, tmp_path, monkeypatch):
|
||||
"""Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path."""
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_customer.app_window import CustomerMainWindow
|
||||
|
||||
prefs_file = tmp_path / "preference.json"
|
||||
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
|
||||
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
|
||||
|
||||
default_prefs = {
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
|
||||
created = []
|
||||
|
||||
def _make(**overrides):
|
||||
data = dict(default_prefs)
|
||||
data.update(overrides)
|
||||
window = CustomerMainWindow(prefs=data)
|
||||
created.append(window)
|
||||
return window
|
||||
|
||||
yield _make
|
||||
|
||||
for window in created:
|
||||
try:
|
||||
window.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant widget of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Static checks: translation keys and preference defaults stay in sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = APP_ROOT / "src"
|
||||
LOCALES_DIR = APP_ROOT / "config" / "locales"
|
||||
DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json"
|
||||
|
||||
REPO_ROOT = APP_ROOT.parents[1]
|
||||
STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py"
|
||||
|
||||
_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""")
|
||||
_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P<body>.*?)\n\}", re.DOTALL)
|
||||
_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""")
|
||||
|
||||
|
||||
def _load_locale(name: str) -> dict:
|
||||
return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _flatten(data: dict, prefix: str = "") -> set[str]:
|
||||
keys: set[str] = set()
|
||||
for key, value in data.items():
|
||||
dotted = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
keys |= _flatten(value, f"{dotted}.")
|
||||
else:
|
||||
keys.add(dotted)
|
||||
return keys
|
||||
|
||||
|
||||
def _referenced_keys() -> set[str]:
|
||||
found: set[str] = set()
|
||||
for path in sorted(SRC_DIR.rglob("*.py")):
|
||||
found |= set(_T_CALL.findall(path.read_text(encoding="utf-8")))
|
||||
return found
|
||||
|
||||
|
||||
def test_locales_parity_and_used_keys():
|
||||
zh = _flatten(_load_locale("zh_CN"))
|
||||
en = _flatten(_load_locale("en_US"))
|
||||
|
||||
assert zh == en, {
|
||||
"zh_only": sorted(zh - en),
|
||||
"en_only": sorted(en - zh),
|
||||
}
|
||||
|
||||
referenced = _referenced_keys()
|
||||
assert referenced, "no t() calls found — the source scan is broken"
|
||||
missing = sorted(key for key in referenced if key not in zh)
|
||||
assert not missing, f"t() keys missing from locale files: {missing}"
|
||||
|
||||
|
||||
def test_preference_defaults_match_store_defaults():
|
||||
json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8")))
|
||||
source = STORE_PY.read_text(encoding="utf-8")
|
||||
match = _DEFAULT_BLOCK.search(source)
|
||||
assert match, "could not locate _DEFAULTS in prefs/store.py"
|
||||
store_keys = set(_KEY.findall(match.group("body")))
|
||||
|
||||
assert json_keys == store_keys, {
|
||||
"json_only": sorted(json_keys - store_keys),
|
||||
"store_only": sorted(store_keys - json_keys),
|
||||
}
|
||||
|
||||
|
||||
def test_locale_files_are_valid_utf8_json():
|
||||
for name in ("zh_CN", "en_US"):
|
||||
data = _load_locale(name)
|
||||
assert isinstance(data, dict)
|
||||
assert data.get("app", {}).get("title")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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_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)
|
||||
Reference in New Issue
Block a user