Files
avatar/tests/test_avatar.py
T

149 lines
7.8 KiB
Python

"""End-to-end export checks; run with uv run python -m unittest discover -s tests."""
import io
import re
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from PIL import Image
from fontTools.pens.boundsPen import BoundsPen
from fontTools.svgLib.path import parse_path
import resvg_py
from cmatrix_avatar import BLUE, Cell, CMatrixAvatar, export, find_font, make_svg
from avatar_color import sdr_color
class AvatarExportTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.font = find_font()
def test_svg_is_portable_and_reproducible(self):
svg = make_svg(CMatrixAvatar(42), self.font)
self.assertEqual(svg, make_svg(CMatrixAvatar(42), self.font))
self.assertNotEqual(svg, make_svg(CMatrixAvatar(43), self.font))
root = ET.fromstring(svg)
ns = "{http://www.w3.org/2000/svg}"
self.assertFalse(list(root.iter(ns + "text")))
self.assertFalse(list(root.iter(ns + "image")))
ids = {node.get("id") for node in root.iter() if node.get("id")}
for node in root.iter(ns + "use"):
self.assertIn(node.get("href")[1:], ids)
self.assertTrue(list(root.iter(ns + "path")))
def test_png_matches_svg_and_logo_geometry(self):
with tempfile.TemporaryDirectory() as directory:
svg_path, png_path = export(CMatrixAvatar(42), Path(directory) / "nested/gwb",
self.font, 1024, previews=True)
with Image.open(png_path) as image:
self.assertEqual(image.size, (1024, 1024))
rgba = image.convert("RGBA")
self.assertEqual(rgba.getchannel("A").getextrema(), (255, 255))
raster = resvg_py.svg_to_bytes(svg_string=svg_path.read_text(encoding="utf-8"))
with Image.open(io.BytesIO(raster)) as rendered:
self.assertEqual(rgba.tobytes(), rendered.convert("RGBA").tobytes())
blue = tuple(bytes.fromhex(sdr_color(BLUE)[1:]))
mask = Image.new("L", image.size)
pixels = rgba.tobytes()
mask.putdata([255 if pixels[i:i + 3] == bytes(blue) else 0
for i in range(0, len(pixels), 4)])
x0, y0, x1, y1 = mask.getbbox()
self.assertTrue(0.70 < (x1 - x0) / 1024 < 0.75)
self.assertAlmostEqual((x0 + x1) / 2, 512, delta=16)
self.assertAlmostEqual((y0 + y1) / 2, 512, delta=16)
for size in (128, 64):
with Image.open(png_path.with_name(f"gwb-{size}.png")) as preview:
self.assertEqual(preview.size, (size, size))
def test_cli_custom_size_and_svg_output_path(self):
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "portrait.svg"
result = subprocess.run([sys.executable, str(script), "--seed", "7", "--size", "256",
"--output", str(output)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(output.exists())
with Image.open(output.with_suffix(".png")) as image:
self.assertEqual(image.size, (256, 256))
def test_cli_default_resolution(self):
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "default.png"
result = subprocess.run([sys.executable, str(script), "--seed", "42",
"--output", str(output)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
with Image.open(output) as image:
self.assertEqual(image.size, (2048, 2048))
def test_explicit_logo_font_without_box_glyphs_is_rejected(self):
ocr = Path("C:/Windows/Fonts/OCRAEXT.TTF")
if not ocr.exists():
self.skipTest("OCR A Extended is not installed")
# Automatic fallback succeeds (covered by export tests); explicitly
# choosing an incomplete logo font must never silently drop characters.
with self.assertRaisesRegex(ValueError, "missing characters"):
make_svg(CMatrixAvatar(42), ocr, logo_font_path=ocr)
def test_cli_rejects_invalid_options(self):
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
for arguments in (("--size", "0"), ("--rows", "7"), ("--columns", "20"), ("--output", "avatar.jpg")):
with self.subTest(arguments=arguments):
result = subprocess.run([sys.executable, str(script), *arguments], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertNotIn("Traceback", result.stderr)
def test_complete_grid_and_edge_glyphs_are_inside_canvas(self):
ns = "{http://www.w3.org/2000/svg}"
for rows, columns in ((43, 77), (20, 35), (28, 67)):
with self.subTest(rows=rows, columns=columns):
avatar = CMatrixAvatar(42, rows, columns)
# Color every border cell separately: the whole border must
# survive, while only central interior cells may be reserved.
avatar.cells = [[Cell("l", "#ffff00" if r in (0, rows - 1) or c in (0, columns - 1)
else "#00ff00") for c in range(columns)] for r in range(rows)]
root = ET.fromstring(make_svg(avatar, self.font))
self.assertFalse(list(root.iter(ns + "clipPath")))
side = float(root.get("viewBox").split()[2])
group = root.find(ns + "g")
dx, dy = map(float, re.findall(r"[-\d.]+", group.get("transform")))
border = next(p for p in group if p.get("data-base") == "#ffff00")
self.assertEqual(border.get("d").count("M"), 2 * rows + 2 * columns - 4)
for path in group:
bounds = BoundsPen(None)
parse_path(path.get("d"), bounds)
x0, y0, x1, y1 = bounds.bounds
self.assertGreater(x0 + dx, 0)
self.assertGreater(y0 + dy, 0)
self.assertLess(x1 + dx, side)
self.assertLess(y1 + dy, side)
# Interior fill must leave the ENTIRE logo rectangle empty.
rendered = resvg_py.svg_to_bytes(svg_string=ET.tostring(root, encoding="unicode"))
with Image.open(io.BytesIO(rendered)) as image:
box = image.crop((round(2048 * .14) + 2, round(2048 * .37),
round(2048 * .86) - 2, round(2048 * .63))).convert("RGB")
colors = box.getdata() if not hasattr(box, "get_flattened_data") else box.get_flattened_data()
self.assertFalse(any(g > r + 20 and g > b + 20 for r, g, b in colors))
def test_logo_ratio_is_independent_of_background_grid(self):
ns = "{http://www.w3.org/2000/svg}"
for rows, columns in ((20, 35), (43, 77), (60, 107)):
root = ET.fromstring(make_svg(CMatrixAvatar(42, rows, columns), self.font))
side = float(root.get("viewBox").split()[2])
logo = root.find(f"{ns}path[@id='logo']")
bounds = BoundsPen(None)
parse_path(logo.get("d"), bounds)
x0, y0, x1, y1 = bounds.bounds
x, y, scale, ox, oy = map(float, re.findall(r"[-\d.]+", logo.get("transform")))
self.assertAlmostEqual((x1 - x0) * scale / side, .72)
self.assertAlmostEqual(x + (x1 - x0) * scale / 2, side / 2)
self.assertAlmostEqual(y + (y1 - y0) * scale / 2, side / 2)
if __name__ == "__main__":
unittest.main()