implement an integration test suite
This commit is contained in:
@@ -5,3 +5,14 @@ set(CMAKE_CXX_STANDARD 17)
|
|||||||
|
|
||||||
add_executable(ccolors main.cpp)
|
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