149 lines
7.8 KiB
Python
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()
|