diff --git a/CMakeLists.txt b/CMakeLists.txt index 46383cf..f32303d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 + $ + ) +endif() + diff --git a/test_runner.py b/test_runner.py new file mode 100755 index 0000000..0ba438e --- /dev/null +++ b/test_runner.py @@ -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 " % 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()) \ No newline at end of file