#!/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 test_custom_output(binary, workdir): img = os.path.join(workdir, "solid.png") make_png(img, 40, 40, (100, 150, 200)) custom_json = os.path.join(workdir, "custom_palette.json") rc, out = run(binary, img, "-o", custom_json, cwd=workdir) assert_(rc == 0, "custom-output: expected exit 0, got %d" % rc) assert_(os.path.exists(custom_json), "custom JSON file was not written at: %s" % custom_json) # Default palette.json must NOT be written when -o is given with a different path. default_json = os.path.join(workdir, "palette.json") assert_(not os.path.exists(default_json), "default palette.json should not be written when -o is used") with open(custom_json) as f: data = json.load(f) assert_(data["image"] == img, "custom palette.json image field mismatch") palette = data["palette"] assert_(isinstance(palette, list) and 1 <= len(palette) <= 5, "palette must be a non-empty list of at most 5 entries") def test_custom_output_bad_path(binary, workdir): img = os.path.join(workdir, "solid.png") make_png(img, 40, 40, (100, 150, 200)) bad_path = os.path.join(workdir, "nonexistent_dir", "out.json") rc, out = run(binary, img, "-o", bad_path, cwd=workdir) assert_(rc != 0, "bad-output-path: expected non-zero exit, got 0") assert_(b"Cannot open output file" in out, "missing 'Cannot open output file' error 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)), ("custom_output", lambda d: test_custom_output(binary, d)), ("custom_output_bad_path", lambda d: test_custom_output_bad_path(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())