implement an integration test suite
This commit is contained in:
@@ -5,3 +5,14 @@ set(CMAKE_CXX_STANDARD 17)
|
||||
|
||||
add_executable(ccolors main.cpp)
|
||||
|
||||
# Phase 1: black-box integration test suite.
|
||||
enable_testing()
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_Interpreter_FOUND)
|
||||
add_test(
|
||||
NAME ccolors_integration
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_runner.py
|
||||
$<TARGET_FILE:ccolors>
|
||||
)
|
||||
endif()
|
||||
|
||||
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end integration test suite for ccolors.
|
||||
|
||||
Phase 1: black-box tests against the built `ccolors` binary.
|
||||
|
||||
Generates minimal valid (PNG/JPEG) and invalid test images, runs the binary,
|
||||
and asserts against terminal stdout and the generated palette.json.
|
||||
|
||||
Run directly (requires a built binary) or via ctest:
|
||||
python3 test_runner.py path/to/ccolors
|
||||
# or from a CMake build dir
|
||||
ctest --output-on-failure
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
|
||||
# A swatch line ends with an ANSI reset escape followed by " #RRGGBB".
|
||||
HEX_LINE_RE = re.compile(rb"\x1b\[0m #([0-9A-F]{6})\n")
|
||||
|
||||
|
||||
def make_png(path, width, height, rgb):
|
||||
"""Write a minimal solid-color PNG using only the stdlib (zlib)."""
|
||||
def chunk(tag, data):
|
||||
c = tag + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(
|
||||
">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
|
||||
raw = b""
|
||||
row = b"\x00" + bytes(rgb) * width
|
||||
for _ in range(height):
|
||||
raw += row
|
||||
png = b"\x89PNG\r\n\x1a\n"
|
||||
png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
||||
png += chunk(b"IDAT", zlib.compress(raw))
|
||||
png += chunk(b"IEND", b"")
|
||||
with open(path, "wb") as f:
|
||||
f.write(png)
|
||||
|
||||
|
||||
def run(binary, *args, cwd=None):
|
||||
"""Run ccolors and return (returncode, stdout_bytes)."""
|
||||
proc = subprocess.run([binary] + list(args), capture_output=True, cwd=cwd)
|
||||
return proc.returncode, proc.stdout
|
||||
|
||||
|
||||
def assert_(cond, msg):
|
||||
if not cond:
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
def test_happy_path_png(binary, workdir):
|
||||
img = os.path.join(workdir, "solid.png")
|
||||
make_png(img, 40, 40, (200, 100, 50))
|
||||
rc, out = run(binary, img, cwd=workdir)
|
||||
assert_(rc == 0, "happy path PNG: expected exit 0, got %d" % rc)
|
||||
assert_(b"PNG file detected!" in out, "missing PNG detection banner")
|
||||
assert_(b"Width: 40" in out, "missing width in output")
|
||||
assert_(b"Height: 40" in out, "missing height in output")
|
||||
assert_(b"Sampled pixels:" in out, "missing sampled-pixels line")
|
||||
matches = HEX_LINE_RE.findall(out)
|
||||
assert_(len(matches) >= 1, "expected at least one hex swatch line")
|
||||
for hexstr in matches:
|
||||
assert_(re.fullmatch(r"[0-9A-F]{6}", hexstr.decode()), "malformed hex output")
|
||||
|
||||
jpath = os.path.join(workdir, "palette.json")
|
||||
assert_(os.path.exists(jpath), "palette.json was not written")
|
||||
with open(jpath) as f:
|
||||
data = json.load(f)
|
||||
assert_(data["image"] == img, "palette.json image field mismatch")
|
||||
palette = data["palette"]
|
||||
assert_(isinstance(palette, list) and len(palette) <= 5,
|
||||
"palette must be a list of at most 5 entries")
|
||||
assert_(len(palette) >= 1, "palette should contain extracted colors")
|
||||
for entry in palette:
|
||||
for k in ("r", "g", "b", "hex"):
|
||||
assert_(k in entry, "palette entry missing key %r" % k)
|
||||
assert_(re.fullmatch(r"#[0-9A-F]{6}", entry["hex"]),
|
||||
"palette hex malformed: %r" % entry["hex"])
|
||||
return len(matches)
|
||||
|
||||
|
||||
def test_happy_path_jpeg(binary, workdir):
|
||||
convert = shutil.which("convert")
|
||||
if convert is None:
|
||||
print(" [skip] JPEG happy-path: ImageMagick 'convert' not available",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
img = os.path.join(workdir, "solid.jpg")
|
||||
subprocess.run([convert, "-size", "40x40", "xc:#C86432", img],
|
||||
check=True)
|
||||
rc, out = run(binary, img, cwd=workdir)
|
||||
assert_(rc == 0, "happy path JPEG: expected exit 0, got %d" % rc)
|
||||
assert_(b"JPEG file detected!" in out, "missing JPEG detection banner")
|
||||
jpath = os.path.join(workdir, "palette.json")
|
||||
with open(jpath) as f:
|
||||
data = json.load(f)
|
||||
assert_(isinstance(data["palette"], list), "JPEG palette not a list")
|
||||
return len(HEX_LINE_RE.findall(out))
|
||||
|
||||
|
||||
def test_file_not_found(binary, workdir):
|
||||
missing = os.path.join(workdir, "does_not_exist.jpg")
|
||||
# Ensure it truly does not exist (isolated temp dir already guarantees it).
|
||||
if os.path.exists(missing):
|
||||
os.remove(missing)
|
||||
rc, out = run(binary, missing, cwd=workdir)
|
||||
assert_(rc != 0, "file-not-found: expected non-zero exit")
|
||||
assert_(b"File not found" in out, "missing 'File not found' message")
|
||||
|
||||
|
||||
def test_invalid_format(binary, workdir):
|
||||
bogus = os.path.join(workdir, "bogus.png")
|
||||
with open(bogus, "wb") as f:
|
||||
f.write(b"this is definitely not an image file \x00\x01\x02")
|
||||
rc, out = run(binary, bogus, cwd=workdir)
|
||||
assert_(rc != 0, "invalid-format: expected non-zero exit")
|
||||
assert_(b"Unknown image file format" in out or b"Unkown image file format" in out,
|
||||
"missing unknown-image-format message")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: %s <path-to-ccolors-binary>" % sys.argv[0], file=sys.stderr)
|
||||
return 1
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print("binary not found: %s" % binary, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Each test runs in its own temp dir so palette.json never collides.
|
||||
tests = [
|
||||
("happy_path_png", lambda d: test_happy_path_png(binary, d)),
|
||||
("happy_path_jpeg", lambda d: test_happy_path_jpeg(binary, d)),
|
||||
("file_not_found", lambda d: test_file_not_found(binary, d)),
|
||||
("invalid_format", lambda d: test_invalid_format(binary, d)),
|
||||
]
|
||||
failures = []
|
||||
for name, fn in tests:
|
||||
workdir = tempfile.mkdtemp(prefix="ccolors_test_")
|
||||
try:
|
||||
info = fn(workdir)
|
||||
tail = " (%d color line(s))" % info if isinstance(info, int) else ""
|
||||
print("PASS %s%s" % (name, tail))
|
||||
except AssertionError as exc:
|
||||
print("FAIL %s: %s" % (name, exc))
|
||||
failures.append(name)
|
||||
except Exception as exc: # noqa: BLE001 - report any unexpected failure
|
||||
print("ERROR %s: %r" % (name, exc))
|
||||
failures.append(name)
|
||||
finally:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
if failures:
|
||||
print("FAILED: %s" % ", ".join(failures))
|
||||
return 1
|
||||
print("All tests passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user