Files
zyt/app/tests/test_icons.py
2026-09-07 12:30:42 +08:00

177 lines
6.2 KiB
Python

"""Contract for the shared icon system.
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
painters that disagreed on stroke weight, design grid and palette. These tests
lock in the properties that keep the set reading as one family, so a new glyph
cannot quietly reintroduce a one-off weight or a clipped mark.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtGui import QColor, QImage
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui import icons
from doctor_workstation.ui.theme import COLORS
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _rendered(kind: str, size: int) -> QImage:
return icons.pixmap(kind, "strong", size).toImage()
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
left, top = image.width(), image.height()
right = bottom = -1
count = 0
for y in range(image.height()):
for x in range(image.width()):
if image.pixelColor(x, y).alpha() > 24:
count += 1
left, top = min(left, x), min(top, y)
right, bottom = max(right, x), max(bottom, y)
return left, top, right, bottom, count
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
application: QApplication,
) -> None:
"""No glyph may touch the edge of its pixmap.
Clipping is what the old painters did whenever a call site asked for a size
other than the one the geometry was authored against - the shell's ``ai``
star lost its companion dot, and the prescription icons were drawn with
16 px geometry inside 14 px and 15 px boxes.
"""
for kind in icons.available_kinds():
for size in (14, 16, 18, 20, 24):
image = _rendered(kind, size)
device = image.width() # honours the device pixel ratio
left, top, right, bottom, count = _ink_bounds(image)
assert count > 0, f"{kind}@{size} painted nothing"
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
assert right < device - 1 and bottom < device - 1, (
f"{kind}@{size} is clipped at the bottom/right"
)
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
application: QApplication,
) -> None:
"""Every mark lives in the same safe area, so none reads over- or undersized.
The set this replaces mixed glyphs that spanned the full 18 px box with a
``close`` cross that spanned only 7 px, which is why the title bar controls
never looked like siblings.
"""
for kind in icons.available_kinds():
image = _rendered(kind, 24)
device = image.width()
left, top, right, bottom, _count = _ink_bounds(image)
extent = max(right - left, bottom - top) / device
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
assert icons.stroke_px(24) == pytest.approx(2.0)
assert icons.stroke_px(18) == pytest.approx(1.5)
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
# Clamped so a small icon stays visible and a large one does not turn slab.
assert icons.stroke_px(8) == pytest.approx(1.25)
assert icons.stroke_px(64) == pytest.approx(2.25)
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
application: QApplication,
) -> None:
"""A list page builds one icon per action button per row on every refresh.
Without the cache that is a fresh ``QPainter`` run per button; the pages in
this product ask for the same handful of (kind, colour, size) triples over
and over, so the cache turns a per-row cost into a per-process one.
"""
icons.clear_cache()
for _ in range(50):
icons.icon("eye", "accent", 15)
icons.icon("trash", "danger", 15)
# Two glyphs, each painted once in its requested colour and once disabled.
assert icons._cached_pixmap.cache_info().misses == 4
# The remaining 98 calls are answered from the icon cache without painting.
assert icons._cached_icon.cache_info().hits == 98
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
assert icons.resolve_color("accent") == COLORS["indigo"]
assert icons.resolve_color("danger") == COLORS["danger"]
assert icons.resolve_color("muted") == COLORS["muted"]
# A literal colour still passes through for the few bespoke tints that remain.
assert icons.resolve_color("#8268E8") == "#8268E8"
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
application: QApplication,
) -> None:
"""Navigation rows invert on selection, so the icon has to invert with them."""
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
from PySide6.QtGui import QIcon
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
assert off != on
assert _dominant_ink(on) == QColor("#FFFFFF")
def _dominant_ink(image: QImage) -> QColor:
for y in range(image.height()):
for x in range(image.width()):
colour = image.pixelColor(x, y)
if colour.alpha() > 240:
colour.setAlpha(255)
return colour
raise AssertionError("no opaque pixel found")
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
"""Six sidebar entries need six silhouettes.
The reception detail tabs previously shared three near-identical
"document with lines" marks, which made them unreadable at 16 px.
"""
renders = {
kind: _rendered(kind, 24).constBits().tobytes()
for kind in (
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
"consultations",
"report",
"meds",
"daily",
"followup",
)
}
assert len(set(renders.values())) == len(renders)