Files
image2cpp/tests/test_conversions.py
T
2026-08-18 10:37:20 +02:00

445 lines
19 KiB
Python

#!/usr/bin/env python3
"""Test suite for image2cpp's conversion output.
Controls the real index.html in a headless browser (Playwright), feeds it the
deterministic test-pattern image, exercises every conversion option through
the actual UI, and compares the generated code output against saved golden
files. Because it runs the real app (not a reimplementation of the
conversion logic), any change to js/script.js or js/dithering.js that
changes output is caught.
Usage:
python tests/test_conversions.py # run tests, compare to golden
python tests/test_conversions.py --diff # print diff on failing scenarios
python tests/test_conversions.py --update # (re)generate golden files
"""
import argparse
import difflib
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).parent.parent
INDEX_HTML = ROOT / "index.html"
TEST_IMAGE = Path(__file__).parent / "fixtures" / "test_pattern.png"
GOLDEN_DIR = Path(__file__).parent / "golden"
NATIVE_WIDTH, NATIVE_HEIGHT = 32, 24
# Full set of controllable options and their app defaults.
BASE_SETTINGS = {
"drawMode": "horizontal1bit",
"outputFormat": "plain",
"backgroundColor": "white",
"invertColors": False,
"ditheringMode": 0,
"ditheringThreshold": 128,
"scale": 1,
"centerHorizontally": False,
"centerVertically": False,
"rotation": 0,
"flipHorizontally": False,
"flipVertically": False,
"bitswap": False,
"prefix": "0x",
"separator": ", ",
"esp32Format": False,
"bytesPerRow": 16,
"showAsciiPreview": False,
"outputComments": True,
"fullExample": False,
}
# (scenario name, overrides on top of BASE_SETTINGS, optional canvas resize)
SCENARIOS = [
("default", {}, None),
("draw_mode_vertical1bit", {"drawMode": "vertical1bit"}, None),
("draw_mode_horizontal565", {"drawMode": "horizontal565"}, None),
("draw_mode_horizontal888", {"drawMode": "horizontal888"}, None),
("draw_mode_horizontal_alpha", {"drawMode": "horizontalAlpha"}, None),
("output_arduino", {"outputFormat": "arduino"}, None),
("output_arduino_single", {"outputFormat": "arduino_single"}, None),
("output_adafruit_gfx", {"outputFormat": "adafruit_gfx"}, None),
("output_arduino_esp32", {"outputFormat": "arduino", "esp32Format": True}, None),
(
"output_arduino_single_esp32",
{"outputFormat": "arduino_single", "esp32Format": True},
None,
),
("output_adafruit_gfx_esp32", {"outputFormat": "adafruit_gfx", "esp32Format": True}, None),
(
"output_arduino_esp32_565",
{"outputFormat": "arduino", "drawMode": "horizontal565", "esp32Format": True},
None,
),
("invert_colors", {"invertColors": True}, None),
("flip_horizontal", {"flipHorizontally": True}, None),
("flip_vertical", {"flipVertically": True}, None),
("flip_both", {"flipHorizontally": True, "flipVertically": True}, None),
("rotate_90", {"rotation": 90}, None),
("rotate_180", {"rotation": 180}, None),
("rotate_270", {"rotation": 270}, None),
("rotate_90_vertical", {"drawMode": "vertical1bit", "rotation": 90}, None),
("rotate_270_vertical", {"drawMode": "vertical1bit", "rotation": 270}, None),
("rotate_180_vertical", {"drawMode": "vertical1bit", "rotation": 180}, None),
("rotate_90_horizontal565", {"drawMode": "horizontal565", "rotation": 90}, None),
("rotate_90_horizontal888", {"drawMode": "horizontal888", "rotation": 90}, None),
("rotate_90_horizontal_alpha", {"drawMode": "horizontalAlpha", "rotation": 90}, None),
(
"rotate_90_flip_both",
{"rotation": 90, "flipHorizontally": True, "flipVertically": True},
None,
),
("rotate_270_flip_horizontal", {"rotation": 270, "flipHorizontally": True}, None),
("rotate_90_resized", {"rotation": 90}, (48, 24)),
("bitswap_vertical", {"drawMode": "vertical1bit", "bitswap": True}, None),
("dithering_bayer", {"ditheringMode": 1}, None),
("dithering_floyd_steinberg", {"ditheringMode": 2}, None),
("dithering_atkinson", {"ditheringMode": 3}, None),
("dithering_threshold_low", {"ditheringThreshold": 64}, None),
("dithering_threshold_high", {"ditheringThreshold": 200}, None),
("bitswap", {"bitswap": True}, None),
("ascii_preview", {"showAsciiPreview": True}, None),
(
"ascii_preview_bytes_per_row_ignored",
{"showAsciiPreview": True, "bytesPerRow": 3},
None,
),
(
"ascii_preview_horizontal565",
{"showAsciiPreview": True, "drawMode": "horizontal565"},
None,
),
(
"ascii_preview_horizontal888",
{"showAsciiPreview": True, "drawMode": "horizontal888"},
None,
),
(
"ascii_preview_arduino",
{"showAsciiPreview": True, "outputFormat": "arduino"},
None,
),
("remove_zeroes_commas", {"prefix": "", "separator": ""}, None),
# scale=1 (original) on a canvas larger than the image so the
# background actually shows through in the margins.
("background_white_larger_canvas", {"backgroundColor": "white"}, (48, 40)),
("background_black", {"backgroundColor": "black"}, (48, 40)),
# RGB channels of a transparent background are zero-initialized, same as
# an explicit black background, so the two are indistinguishable under
# the 1-bit draw modes (which ignore alpha). Use the alpha draw mode
# here so transparency is actually used.
(
"background_transparent",
{"backgroundColor": "transparent", "drawMode": "horizontalAlpha"},
(48, 40),
),
("scale_fit_larger_canvas", {"scale": 2}, (48, 40)),
("scale_stretch_larger_canvas", {"scale": 3}, (48, 40)),
("scale_stretch_h_larger_canvas", {"scale": 4}, (48, NATIVE_HEIGHT)),
("scale_stretch_v_larger_canvas", {"scale": 5}, (NATIVE_WIDTH, 40)),
(
"center_both_larger_canvas",
{"centerHorizontally": True, "centerVertically": True},
(48, 40),
),
(
"kitchen_sink",
{
"drawMode": "vertical1bit",
"invertColors": True,
"flipHorizontally": True,
"flipVertically": True,
"bitswap": True,
"removeZeroesCommas": True,
"ditheringMode": 2,
},
None,
),
]
# Bytes per output value per drawMode, mirroring CONVERSION_BYTES_PER_VALUE in
# js/script.js. Used to verify downloadBinFile()'s byte stream (via the
# computeBinData() helper) matches the hex values shown in the text output,
# instead of the multi-byte values (565/888) getting truncated to one byte.
BYTES_PER_VALUE = {
"horizontal1bit": 1,
"vertical1bit": 1,
"horizontal565": 2,
"horizontal888": 4,
"horizontalAlpha": 1,
}
# Scenario names to also verify against computeBinData(); restricted to ones
# using the default plain/comma-separated output (outputFormat and separator
# untouched), since that's the format downloadBinFile's parser expects.
BIN_CHECK_NAMES = {
"default",
"draw_mode_vertical1bit",
"draw_mode_horizontal565",
"draw_mode_horizontal888",
"draw_mode_horizontal_alpha",
"rotate_90_horizontal565",
"rotate_90_horizontal888",
"rotate_90_horizontal_alpha",
}
def expected_bin_bytes(output, draw_mode):
"""Reconstruct the byte stream a correct downloadBinFile() should produce
from the plain-format hex text output, expanding each value back to its
full byte width (MSB first) instead of assuming one byte per value."""
bytes_per_value = BYTES_PER_VALUE[draw_mode]
values = (int(tok, 16) for tok in re.findall(r"0x[0-9a-fA-F]+", output))
result = []
for value in values:
for shift in range((bytes_per_value - 1) * 8, -1, -8):
result.append((value >> shift) & 0xFF)
return result
def reset_canvas_size(page, width, height):
"""Set canvas size through the first image's per-image width/height
inputs in the "Canvas size(s)" list (each image has its own size in the
current layout; there's no single global canvas size control anymore)."""
page.fill("#image-size-settings li:nth-child(1) input[name='width']", str(width))
page.fill("#image-size-settings li:nth-child(1) input[name='height']", str(height))
def apply_settings(page, settings):
page.select_option("#drawMode", settings["drawMode"])
# Selecting an output format resets prefix/separator to their defaults
# (updateOutputFormat() in script.js), so this must run before those two
# fields are filled in below.
page.select_option("#outputFormat", settings["outputFormat"])
page.check(f"#backgroundColor{settings['backgroundColor'].capitalize()}")
set_checkbox(page, "#invertColors", settings["invertColors"])
page.select_option("#ditheringMode", str(settings["ditheringMode"]))
page.fill("#ditheringThreshold", str(settings["ditheringThreshold"]))
page.select_option("#scale", str(settings["scale"]))
set_checkbox(page, "#centerHorizontally", settings["centerHorizontally"])
set_checkbox(page, "#centerVertically", settings["centerVertically"])
page.select_option("#rotation", str(settings["rotation"]))
set_checkbox(page, "#flipHorizontally", settings["flipHorizontally"])
set_checkbox(page, "#flipVertically", settings["flipVertically"])
set_checkbox(page, "#bitswap", settings["bitswap"])
page.fill("#prefix", settings["prefix"])
page.fill("#separator", settings["separator"])
page.fill("#bytesPerRow", str(settings["bytesPerRow"]))
set_checkbox(page, "#showAsciiPreview", settings["showAsciiPreview"])
set_checkbox(page, "#outputComments", settings["outputComments"])
# esp32Format/fullExample checkboxes only exist in the DOM (visible) for
# non-plain output formats; plain output has no type/PROGMEM/example code
# for them to affect.
if settings["outputFormat"] != "plain":
set_checkbox(page, "#esp32Format", settings["esp32Format"])
set_checkbox(page, "#fullExample", settings["fullExample"])
def set_checkbox(page, selector, checked):
if checked:
page.check(selector)
else:
page.uncheck(selector)
def run_scenario(page, name, overrides, resize):
settings = {**BASE_SETTINGS, **overrides}
reset_canvas_size(page, *(resize or (NATIVE_WIDTH, NATIVE_HEIGHT)))
apply_settings(page, settings)
# Force a redraw with the final settings state regardless of which
# controls' change events actually fired (some may be no-ops if the
# value didn't change from the previous scenario).
page.evaluate("updateAllImages()")
page.click("text=Generate code")
return page.input_value("#code-output")
def roundtrip_eligible(settings):
"""Only plain-format, non-bitswapped 1-bit output is re-importable via the
"Paste byte array" box: other draw modes aren't supported by the parser,
bitswap mangles the byte stream with no importer to undo it, and the
parser splits bytes on commas, so a separator without one (e.g. the
empty string used to strip formatting) can't be read back. showAsciiPreview
and a non-default bytesPerRow are excluded too: run_roundtrip reloads a
fresh page and never restores those two, so the re-exported text would use
default formatting and not textually match the original even though the
underlying pixel data round-trips fine."""
return (
settings["drawMode"] in ("horizontal1bit", "vertical1bit")
and settings["outputFormat"] == "plain"
and not settings["bitswap"]
and "," in settings["separator"]
and not settings["showAsciiPreview"]
and settings["bytesPerRow"] == BASE_SETTINGS["bytesPerRow"]
)
def run_roundtrip(page, output, draw_mode, width, height):
"""Paste a generated byte array back in, import it, and re-export it,
same steps a user would take to sanity-check their array. Uses a fresh
page load, so the reimported canvas is the only pixel source, nothing
left over from a previous scenario."""
page.goto(INDEX_HTML.as_uri())
# window.onload defaults outputFormat to "arduino"; force it back to plain
# to match the format the scenario was exported in.
page.select_option("#outputFormat", "plain")
page.fill("#byte-input", output)
page.fill("#text-input-width", str(width))
page.fill("#text-input-height", str(height))
direction = "horizontal" if draw_mode == "horizontal1bit" else "vertical"
page.click(f"text=Read as {direction}")
# The imported canvas is only backed by a real <img> once its data-URL
# finishes loading (async); switching draw mode before that swap
# completes triggers a redraw from the still-blank placeholder image and
# wipes the canvas. Wait for the swap so this matches how a person would
# naturally pause between clicking "Read as..." and changing the dropdown.
page.wait_for_function("images.first().img.src")
if direction == "vertical":
page.select_option("#drawMode", "vertical1bit")
page.click("text=Generate code")
return page.input_value("#code-output")
def run_multi_image_independent_sizes(page):
"""Each image in a multi-image upload keeps its own canvas size; setting
one image's width/height must not resize any other image's canvas.
Regression test for a bug where every canvas was forced to the size of
the first uploaded image."""
page.goto(INDEX_HTML.as_uri())
page.set_input_files("#file-input", [str(TEST_IMAGE), str(TEST_IMAGE)])
page.wait_for_function("document.querySelectorAll('#image-size-settings li').length === 2")
page.fill("#image-size-settings li:nth-child(1) input[name='width']", "50")
page.fill("#image-size-settings li:nth-child(1) input[name='height']", "60")
page.fill("#image-size-settings li:nth-child(2) input[name='width']", "20")
page.fill("#image-size-settings li:nth-child(2) input[name='height']", "10")
sizes = page.evaluate(
"[images.getByIndex(0).canvas.width, images.getByIndex(0).canvas.height,"
" images.getByIndex(1).canvas.width, images.getByIndex(1).canvas.height]"
)
expected = [50, 60, 20, 10]
if sizes != expected:
print("FAIL multi_image_independent_sizes")
return [f"multi_image_independent_sizes: expected canvas sizes {expected}, got {sizes}"]
print("ok multi_image_independent_sizes")
return []
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--update", action="store_true", help="(re)generate golden files instead of comparing"
)
parser.add_argument(
"--diff", action="store_true", help="print a diff against golden output on failure"
)
args = parser.parse_args()
if not TEST_IMAGE.exists():
print(f"Test image missing at {TEST_IMAGE}; run generate_test_image.py first")
sys.exit(1)
GOLDEN_DIR.mkdir(parents=True, exist_ok=True)
failures = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(INDEX_HTML.as_uri())
page.set_input_files("#file-input", str(TEST_IMAGE))
# Wait for the app to finish loading the image and create its canvas.
page.wait_for_selector("#image-size-settings li")
for name, overrides, resize in SCENARIOS:
settings = {**BASE_SETTINGS, **overrides}
output = run_scenario(page, name, overrides, resize)
golden_path = GOLDEN_DIR / f"{name}.txt"
if args.update:
golden_path.write_text(output)
print(f"wrote {name}")
continue
if not golden_path.exists():
failures.append(f"{name}: no golden file (run with --update first)")
print(f"MISSING {name}")
continue
expected = golden_path.read_text()
if output != expected:
failures.append(name)
print(f"FAIL {name}")
if args.diff:
diff = difflib.unified_diff(
expected.splitlines(keepends=True),
output.splitlines(keepends=True),
fromfile=f"{name} (golden)",
tofile=f"{name} (actual)",
)
sys.stdout.writelines(diff)
continue
print(f"ok {name}")
if name in BIN_CHECK_NAMES:
expected_bytes = expected_bin_bytes(output, settings["drawMode"])
actual_bytes = page.evaluate("Array.from(computeBinData())")
if actual_bytes != expected_bytes:
failures.append(f"{name}: bin data mismatch")
print(f"FAIL {name} (bin)")
else:
print(f"ok {name} (bin)")
if roundtrip_eligible(settings):
width, height = resize or (NATIVE_WIDTH, NATIVE_HEIGHT)
roundtripped = run_roundtrip(page, output, settings["drawMode"], width, height)
# The re-imported image has no filename to derive a glyph
# comment from, so plain output drops its leading "// name,
# WxHpx" comment line; strip it before comparing byte data.
comparable_output = re.sub(r"^//[^\n]*\n", "", output)
if roundtripped != comparable_output:
failures.append(f"{name}: round-trip mismatch")
print(f"FAIL {name} (round-trip)")
if args.diff:
diff = difflib.unified_diff(
comparable_output.splitlines(keepends=True),
roundtripped.splitlines(keepends=True),
fromfile=f"{name} (exported)",
tofile=f"{name} (re-imported+exported)",
)
sys.stdout.writelines(diff)
else:
print(f"ok {name} (round-trip)")
# The page was reloaded for the round-trip check; reload the
# test image so the next scenario has its canvas back.
page.goto(INDEX_HTML.as_uri())
page.set_input_files("#file-input", str(TEST_IMAGE))
page.wait_for_selector("#image-size-settings li")
if not args.update:
failures.extend(run_multi_image_independent_sizes(page))
browser.close()
if args.update:
print(f"\nWrote {len(SCENARIOS)} golden files to {GOLDEN_DIR}")
return
total = len(SCENARIOS) + 1
if failures:
print(f"\n{len(failures)} of {total} scenarios failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print(f"\nAll {total} scenarios match golden output.")
if __name__ == "__main__":
main()