Files
bec_widgets/tests/unit_tests/test_ui_kit.py
T

314 lines
10 KiB
Python

"""Tests for the shared BEC UI kit: tokens, QML host and the QWidget controls."""
# pylint: disable=redefined-outer-name
from pathlib import Path
import pytest
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QColor
from qtpy.QtQuickWidgets import QQuickWidget
from qtpy.QtWidgets import QLineEdit, QWidget
from bec_widgets.examples.ui_kit_gallery.ui_kit_gallery import QML_GALLERY, QWidgetGallery
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.quick import (
QML_IMPORT_PATH,
MaterialIconProvider,
QmlTheme,
ThemeTokens,
create_quick_widget,
quick_theme,
release_quick_widget,
)
from bec_widgets.utils.quick.tokens import FALLBACK_PALETTES
from bec_widgets.utils.ux_kit import (
Badge,
Banner,
Card,
FormField,
IconButton,
LinearProgress,
SearchField,
SegmentedControl,
Spinner,
StatusPill,
TextButton,
refresh_kit_theme,
)
BEC_UI = QML_IMPORT_PATH / "BecUi"
@pytest.fixture
def dark_theme():
apply_theme("dark")
yield
apply_theme("light")
def test_fallback_palettes_match_bec_qthemes():
from bec_qthemes._main import read_theme_xml
from bec_qthemes.qss_editor.qss_editor import THEMES_PATH
for name, palette in FALLBACK_PALETTES.items():
_, mapping = read_theme_xml(THEMES_PATH / f"{name}.xml")
for key, value in palette.items():
assert QColor(mapping[key]) == QColor(value), (name, key)
def test_tokens_follow_application_theme(dark_theme):
dark = ThemeTokens()
assert dark.name == "dark" and dark.dark
apply_theme("light")
light = ThemeTokens()
assert light.name == "light" and not light.dark
assert light.card != dark.card
assert ThemeTokens.current().card == light.card
@pytest.mark.parametrize(
"alias, canonical",
[
("ok", "success"),
("warn", "warning"),
("err", "danger"),
("busy", "info"),
("stale", "subtle"),
],
)
def test_tone_aliases(alias, canonical):
tokens = ThemeTokens()
assert tokens.tone(alias) == tokens.tone(canonical)
assert tokens.tone_text(alias) == tokens.tone_text(canonical)
assert tokens.tone_tint(alias) == tokens.tone_tint(canonical)
def test_light_warning_text_is_darker_than_accent():
tokens = ThemeTokens()
assert tokens.warning_text.lightnessF() < tokens.warning.lightnessF()
def test_qml_theme_aliases_match_canonical_names(dark_theme):
theme = QmlTheme()
pairs = [
("muted", "fgMuted"),
("faint", "fgSubtle"),
("text", "fg"),
("foreground", "fg"),
("background", "bg"),
("busy", "info"),
("ok", "success"),
("warn", "warning"),
("err", "danger"),
("emergency", "danger"),
("errText", "dangerText"),
("okTint", "successTint"),
]
for alias, canonical in pairs:
assert theme.property(alias) == theme.property(canonical), alias
assert theme.property("isDark") is True
assert theme.property("radiusLarge") == 10
assert theme.toneText("warn") == theme.property("warningText")
def test_qml_theme_emits_changed_on_theme_switch(qtbot):
theme = quick_theme()
with qtbot.waitSignal(theme.changed):
apply_theme("dark")
assert theme.property("dark") is True
apply_theme("light")
assert theme.property("dark") is False
@pytest.mark.parametrize("color", ["%23ff0000", "ff0000", "red"])
def test_icon_provider_accepts_colour_forms(color):
provider = MaterialIconProvider()
size = QSize()
pixmap = provider.requestPixmap(f"check?color={color}&filled=1", size, QSize(20, 20))
assert not pixmap.isNull()
assert size.width() == pixmap.width()
def test_icon_provider_unknown_icon_returns_empty_pixmap():
provider = MaterialIconProvider()
assert provider.requestPixmap("no_such_icon_xyz", QSize(), QSize(20, 20)).isNull()
def test_qmldir_lists_every_control():
listed = {
line.split()[0]
for line in (BEC_UI / "qmldir").read_text().splitlines()
if line and not line.startswith("module")
}
files = {path.stem for path in BEC_UI.glob("*.qml")}
assert listed == files
def test_gallery_loads_every_control_without_errors(qtbot):
host = QWidget()
qtbot.addWidget(host)
view = create_quick_widget(host, QML_GALLERY, raise_on_error=True)
assert view.status() == QQuickWidget.Status.Ready
source = QML_GALLERY.read_text()
for path in BEC_UI.glob("*.qml"):
if path.stem in ("FieldFrame", "Icon"): # used inside every other control
continue
assert f"{path.stem} {{" in source, f"{path.stem} missing from the gallery"
release_quick_widget(view)
def test_create_quick_widget_with_context_uses_own_engine(qtbot, tmp_path: Path):
qml = tmp_path / "View.qml"
qml.write_text(
"import QtQuick\nimport BecUi\nRectangle { color: theme.card; "
"property string label: backend.objectName; StatusPill { text: 'x'; tone: 'ok' } }\n"
)
host = QWidget()
qtbot.addWidget(host)
backend = QWidget()
backend.setObjectName("demo")
view = create_quick_widget(host, qml, context={"backend": backend}, raise_on_error=True)
assert view.rootObject().property("label") == "demo"
shared = create_quick_widget(host, qml.with_name("Plain.qml"), raise_on_error=False)
assert view.engine() is not shared.engine()
release_quick_widget(view)
release_quick_widget(shared)
backend.deleteLater()
def test_create_quick_widget_raises_on_error(qtbot, tmp_path: Path):
qml = tmp_path / "Broken.qml"
qml.write_text("import QtQuick\nRectangle { nonsense: }\n")
host = QWidget()
qtbot.addWidget(host)
with pytest.raises(RuntimeError):
create_quick_widget(host, qml, raise_on_error=True)
def test_qwidget_gallery_builds(qtbot):
gallery = create_widget(qtbot, QWidgetGallery)
assert gallery.findChildren(Card)
for spinner in gallery.findChildren(Spinner):
spinner.cleanup()
for button in gallery.findChildren(TextButton):
button.set_busy(False)
for bar in gallery.findChildren(LinearProgress):
bar.cleanup()
def test_controls_follow_theme_switch(qtbot):
card = create_widget(qtbot, Card, "Title")
light_qss = card.styleSheet()
apply_theme("dark")
try:
assert card.styleSheet() != light_qss
assert ThemeTokens.current().card.name() in card.styleSheet()
finally:
apply_theme("light")
def test_refresh_kit_theme_reaches_children(qtbot):
card = create_widget(qtbot, Card, "Title")
button = TextButton("Go", "primary", parent=card)
card.body.addWidget(button)
button.setStyleSheet("")
refresh_kit_theme(card)
assert "QPushButton" in button.styleSheet()
def test_text_button_busy_and_variants(qtbot):
button = create_widget(qtbot, TextButton, "Submit", "primary")
button.set_busy(True)
assert button.busy
button.set_busy(False)
for variant in ("danger", "success", "neutral", "dangerOutline", "ghost"):
button.set_variant(variant)
assert "QPushButton" in button.styleSheet()
def test_icon_button_danger_and_checked(qtbot):
button = create_widget(qtbot, IconButton, "delete", "Remove", danger=True)
assert button.accessibleName() == "Remove"
button.setCheckable(True)
button.setChecked(True)
assert not button.icon().isNull()
def test_status_pill_named_and_colour_tones(qtbot):
pill = create_widget(qtbot, StatusPill, text="Running", tone="busy", pulse=True)
assert pill.pulsing
pill.set_status("Failed", QColor("red"), icon_name="error")
assert not pill.pulsing
assert pill.text == "Failed"
assert pill.sizeHint().width() > 30
pill.cleanup()
def test_badge_label_and_visibility(qtbot):
badge = create_widget(qtbot, Badge, count=150)
assert badge.label == "99+"
badge.set_count(0)
assert not badge.isVisible()
def test_linear_progress_clamps(qtbot):
bar = create_widget(qtbot, LinearProgress)
bar.set_value(1.5, animate=False)
assert bar.value == 1.0
bar.set_indeterminate(True)
bar.set_indeterminate(False)
bar.cleanup()
def test_banner_close_and_action(qtbot):
banner = create_widget(
qtbot, Banner, "err", "Title", "Text", action_text="Retry", closable=True
)
with qtbot.waitSignal(banner.action_triggered):
qtbot.mouseClick(banner.action_button, Qt.MouseButton.LeftButton)
with qtbot.waitSignal(banner.closed):
qtbot.mouseClick(banner.close_button, Qt.MouseButton.LeftButton)
assert not banner.isVisible()
def test_form_field_error_marks_control_invalid(qtbot):
edit = QLineEdit()
field = create_widget(qtbot, FormField, "Steps", edit, required=True, helper="At least 2")
assert field.message.text() == "At least 2"
field.set_error("Must be at least 2")
assert edit.property("invalid") is True
assert field.message.text() == "Must be at least 2"
field.set_error("")
assert edit.property("invalid") is False
assert field.message.text() == "At least 2"
def test_search_field_escape_clears(qtbot):
field = create_widget(qtbot, SearchField, shortcut_hint="Ctrl K")
field.setText("samx")
with qtbot.waitSignal(field.cleared):
qtbot.keyClick(field, Qt.Key.Key_Escape)
assert field.text() == ""
def test_segmented_control_emits_on_click_only(qtbot):
control = create_widget(qtbot, SegmentedControl, ["All", {"text": "Errors", "count": 2}])
control.set_current_index(1)
assert control.current_index == 1
with qtbot.waitSignal(control.activated) as blocker:
qtbot.mouseClick(control._buttons[0], Qt.MouseButton.LeftButton)
assert blocker.args == [0]
control.set_count(1, 5)
assert "5" in control._buttons[1].text()
def test_collapsible_card_toggles_body(qtbot):
card = create_widget(qtbot, Card, "Section", collapsible=True, title_style="caption")
assert card.title_label.text() == "SECTION"
card.set_expanded(False)
assert card.body_widget.isHidden()
card.set_expanded(True)
assert not card.body_widget.isHidden()