177 lines
6.2 KiB
Python
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)
|