Next to macOS 26's own icons the flat plate + girl reads better than the ringed tile, so the ring is off in every output: the tiles' inward stroke, the layered icon's border layer and its targets. BORDER_ENABLED keeps the ring code one flag away. Without a ring the girl's lowest nodes are dragged past the tile edge (EDGE_OVERSHOOT) so the outline crops her instead of leaving a gap. Clear light/dark and Tinted light/dark now come from one grayscale mono layer with Liquid Glass on: the parts of the girl that are black in light mode become a near-black frosted ink (0.13 @ 90%), her white parts stay white, each pixel painted exactly once so materials never stack. The art layer hides under "tinted" and the mono layer shows only there. Mac girl at 1.06x (between main's 1.12x and the layers' 1.0x), for the icns and the layered icon alike. Chosen from a six-appearance matrix of real IconServices renders next to Finder.
157 lines
8.2 KiB
Python
157 lines
8.2 KiB
Python
"""Icon generation reports per-target failures without hiding later targets."""
|
|
import importlib.util
|
|
import io
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
|
|
@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"])
|
|
@pytest.mark.parametrize("editor_export", [False, True])
|
|
def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, bom, editor_export):
|
|
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
|
|
script = Path(__file__).resolve().parents[2] / "scripts/generate_icons.py"
|
|
spec = importlib.util.spec_from_file_location("icon_readers_under_test", script)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
path = tmp_path / "art.svg"
|
|
element = '<path d="M0 0 L1 1" aria-label="café 東京"/>'
|
|
declaration = '<?xml version="1.0" encoding="UTF-8"?>\n' if editor_export else ""
|
|
namespaces = ' xmlns="http://www.w3.org/2000/svg" xmlns:editor="urn:editor"'
|
|
metadata = '<editor:namedview editor:zoom="1"/>' if editor_export else ""
|
|
document = f'<svg{namespaces} viewBox="0 0 20 30">{metadata}{element}</svg>'
|
|
raw = bom + (declaration + document).encode("utf-8")
|
|
path.write_bytes(raw)
|
|
art = SimpleNamespace(girls={"black": path}, paths={}, backgrounds=tmp_path, colors=None)
|
|
assert module.girl_path(art, "black") == element
|
|
inner, width, height = module.background_inner(art, path.name)
|
|
composed = ET.fromstring(f'<svg xmlns="http://www.w3.org/2000/svg">{inner}</svg>')
|
|
assert (width, height) == (20, 30)
|
|
assert [ET.tostring(child) for child in composed] == [
|
|
ET.tostring(child) for child in ET.fromstring(document)
|
|
]
|
|
assert path.read_bytes() == raw
|
|
path.write_bytes(bom + b"<svg/>")
|
|
art.paths.clear()
|
|
with pytest.raises(AssertionError, match="no <path>"):
|
|
module.girl_path(art, "black")
|
|
with pytest.raises(AssertionError, match="viewBox"):
|
|
module.background_inner(art, path.name)
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [None, "render", "directory", "verify"])
|
|
def test_write_status_includes_every_target(tmp_path, monkeypatch, capsys, failure):
|
|
# The renderer is build-only. This test injects failures at its byte boundary.
|
|
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
|
|
script = Path(__file__).resolve().parents[2] / "scripts" / "generate_icons.py"
|
|
spec = importlib.util.spec_from_file_location("icon_generator_under_test", script)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
source = tmp_path / "immutable source"
|
|
source.mkdir()
|
|
monkeypatch.setattr(module, "IconArt", lambda root: root)
|
|
monkeypatch.setattr(sys, "argv", [str(script), "--source", str(source), "--out", str(tmp_path)])
|
|
|
|
image = io.BytesIO()
|
|
Image.new("RGBA", (2, 2), (0, 0, 0, 0)).save(image, "PNG")
|
|
good_bytes = image.getvalue()
|
|
first = "blocked/icon.png" if failure == "directory" else "first.png"
|
|
if failure == "directory":
|
|
(tmp_path / "blocked").write_text("not a directory", encoding="utf-8")
|
|
monkeypatch.setattr(module, "TARGETS", [(first, "png", "first"), ("last.png", "png", "last")])
|
|
|
|
def target_bytes(art, kind, target):
|
|
assert art == source
|
|
assert not list(source.iterdir())
|
|
if target == "first":
|
|
if failure == "render":
|
|
raise RuntimeError("injected render failure")
|
|
if failure == "verify":
|
|
return b"not an image"
|
|
return good_bytes
|
|
|
|
monkeypatch.setattr(module, "target_bytes", target_bytes)
|
|
code = 0
|
|
try:
|
|
module.main()
|
|
except SystemExit as stopped:
|
|
code = stopped.code
|
|
assert bool(code) is (failure is not None)
|
|
assert (tmp_path / "last.png").read_bytes() == good_bytes
|
|
output = capsys.readouterr().out
|
|
assert "last.png: PNG (2, 2)" in output
|
|
assert ("FAILED" in output) is (failure is not None)
|
|
|
|
|
|
@pytest.mark.parametrize("platform", ["", "mac-"])
|
|
@pytest.mark.parametrize("appearance,girl", [("light", "black"), ("dark", "white")])
|
|
@pytest.mark.parametrize("colors", [None, ("#f5cc32", "#443808"), ("#e34850", "#4a1117")])
|
|
def test_icon_portrait_sits_on_the_plain_tile_inside_the_outer_silhouette(monkeypatch, platform, appearance, girl, colors):
|
|
"""The tile keeps the background's own geometry and fill with no stroke
|
|
(the ring is disabled), the portrait is clipped to that outline and its
|
|
lowest nodes are dragged past the bottom edge so the clip crops her."""
|
|
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
|
|
source = Path(__file__).resolve().parents[2]
|
|
spec = importlib.util.spec_from_file_location("icon_geometry_under_test", source / "scripts/generate_icons.py")
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
assert module.BORDER_ENABLED is False
|
|
art = SimpleNamespace(
|
|
backgrounds=source / "assets/backgrounds", colors=colors, commit="0123456",
|
|
bboxes={girl: (0, 0, 100, 90)}, paths={girl: '<path d="M 0 0 L 100 0 L 100 90 L 0 90 z"/>'},
|
|
)
|
|
name = f"squircle-{platform}{appearance}.svg"
|
|
ns = {"svg": "http://www.w3.org/2000/svg"}
|
|
original = ET.parse(art.backgrounds / name).find("svg:rect", ns)
|
|
result = ET.fromstring(module.compose_svg(art, girl, name))
|
|
tile = result.find("svg:rect", ns)
|
|
assert original is not None and tile is not None
|
|
geometry = tuple(float(original.attrib[key]) for key in ("x", "y", "width", "height", "rx"))
|
|
assert tuple(float(tile.attrib[key]) for key in ("x", "y", "width", "height", "rx")) == geometry
|
|
assert tile.get("stroke") is None and tile.get("stroke-width") is None
|
|
expected_fill = (colors or ("#ffffff", module.DARK_HEX))[appearance == "dark"]
|
|
assert tile.get("fill") == expected_fill
|
|
clip = result.find("svg:defs/svg:clipPath/svg:rect", ns)
|
|
assert clip is not None
|
|
assert tuple(float(clip.attrib[key]) for key in ("x", "y", "width", "height", "rx")) == geometry
|
|
group = result[-1]
|
|
portrait = group[-1]
|
|
assert len(group) == 1, "extend the existing contour, not a duplicate strip"
|
|
assert portrait.get("preserveAspectRatio") == "xMidYMax meet"
|
|
assert tuple(float(portrait.attrib[key]) for key in ("x", "y", "width", "height")) == module.GIRL_BOXES[name]
|
|
clip_path = result.find("svg:defs/svg:clipPath", ns)
|
|
assert clip_path is not None
|
|
assert group.get("clip-path") == f"url(#{clip_path.attrib['id']})"
|
|
assert portrait.get("overflow") == "visible"
|
|
assert len(result.findall(".//svg:path", ns)) == 2 # one badge and one portrait
|
|
# The dragged bottom node lands below the tile: the clip, not a gap, ends her.
|
|
x, y, width, height, _ = geometry
|
|
_, by, bw, bh = art.bboxes[girl]
|
|
box_y, box_h = module.GIRL_BOXES[name][1], module.GIRL_BOXES[name][3]
|
|
scale = min(module.GIRL_BOXES[name][2] / bw, box_h / bh)
|
|
lowest_raw_y = max(float(v) for v in re.findall(r"[-+]?\d*\.?\d+", portrait[0].attrib["d"])[1::2])
|
|
lowest_tile_y = box_y + box_h - bh * scale + (lowest_raw_y - by) * scale # xMidYMax: bottom-aligned
|
|
assert lowest_tile_y == pytest.approx(y + height + width * module.EDGE_OVERSHOOT)
|
|
|
|
|
|
def test_bottom_node_drag_preserves_upper_geometry_and_path_transform(monkeypatch):
|
|
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
|
|
script = Path(__file__).resolve().parents[2] / "scripts/generate_icons.py"
|
|
spec = importlib.util.spec_from_file_location("icon_nodes_under_test", script)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
path = ET.fromstring('<path transform="matrix(2,0,0,2,10,-100)" d="M 0 0 C 0 90 10 98 20 100 L 0 100 z"/>')
|
|
module.drag_bottom_nodes(path, cutoff=80, band=20, distance=10)
|
|
assert path.get("transform") == "matrix(2,0,0,2,10,-100)"
|
|
assert path.get("d") == "M 0 0 C 0 90 10 102.48 20 105 L 0 105 z"
|
|
with pytest.raises(ValueError, match="absolute M/L/C"):
|
|
module.drag_bottom_nodes(ET.fromstring('<path d="m 0 0 l 1 1"/>'), cutoff=0, band=1, distance=1)
|