更新
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user