mirror of
https://github.com/javl/image2cpp.git
synced 2026-09-11 09:03:04 +00:00
367 lines
15 KiB
Python
367 lines
15 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": ", ",
|
|
}
|
|
|
|
OUTPUT_FORMAT_IDS = {
|
|
"plain": "outputFormatPlain",
|
|
"arduino": "outputFormatArduino",
|
|
"arduino_single": "outputFormatArduinoSingle",
|
|
"adafruit_gfx": "outputFormatAdafruitGfx",
|
|
}
|
|
|
|
# (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),
|
|
("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),
|
|
("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 real width/height text inputs."""
|
|
page.fill("#screenWidth", str(width))
|
|
page.fill("#screenHeight", 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.check(f"#{OUTPUT_FORMAT_IDS[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"])
|
|
|
|
|
|
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."""
|
|
return (
|
|
settings["drawMode"] in ("horizontal1bit", "vertical1bit")
|
|
and settings["outputFormat"] == "plain"
|
|
and not settings["bitswap"]
|
|
and "," in settings["separator"]
|
|
)
|
|
|
|
|
|
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.check(f"#{OUTPUT_FORMAT_IDS['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 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")
|
|
|
|
browser.close()
|
|
|
|
if args.update:
|
|
print(f"\nWrote {len(SCENARIOS)} golden files to {GOLDEN_DIR}")
|
|
return
|
|
|
|
if failures:
|
|
print(f"\n{len(failures)} of {len(SCENARIOS)} scenarios failed:")
|
|
for f in failures:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
|
|
print(f"\nAll {len(SCENARIOS)} scenarios match golden output.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|