from __future__ import annotations import shutil from pathlib import Path from unittest.mock import patch import pytest from imagepipeline.core.context import ModuleContext from imagepipeline.modules.registry import get_module, list_modules from imagepipeline.modules.xcf_stack import XcfStackModule from imagepipeline.utils.files import find_image_by_stem from tests.conftest import make_png has_gimp = bool(shutil.which("gimp")) has_magick = bool(shutil.which("magick") or shutil.which("convert")) def _gimp_headless_works() -> bool: gimp = shutil.which("gimp") if not gimp: return False try: from imagepipeline.utils.subprocess import run_command run_command([gimp, "-idf", "-b", "(gimp-quit 0)"], timeout=10) return True except RuntimeError: return False has_working_gimp = _gimp_headless_works() class TestFindImageByStem: def test_finds_matching_stem_case_insensitively(self, tmp_path: Path) -> None: make_png(tmp_path / "Photo.PNG") found = find_image_by_stem(tmp_path, "photo") assert found == tmp_path / "Photo.PNG" def test_returns_none_when_missing(self, tmp_path: Path) -> None: make_png(tmp_path / "other.png") assert find_image_by_stem(tmp_path, "photo") is None def test_handles_extension_mismatch(self, tmp_path: Path) -> None: make_png(tmp_path / "photo.png") # Input may be photo.jpg while step output is photo.png (stem-only match). found = find_image_by_stem(tmp_path, Path("photo.jpg").stem) assert found == tmp_path / "photo.png" class TestModuleRegistration: def test_xcf_stack_registered(self) -> None: assert "xcf_stack" in list_modules() def test_get_module_returns_xcf_stack_class(self) -> None: assert get_module("xcf_stack") is XcfStackModule class TestExpectedOutputFilenames: def test_returns_xcf_for_jpg_input(self) -> None: names = XcfStackModule.expected_output_filenames( matched_groups=[], input_paths=[Path("photo.jpg")], params={}, ) assert names == ["photo.xcf"] def _make_stack_fixture(tmp_path: Path) -> dict[str, Path]: root = tmp_path input_dir = root / "input" step_a = root / "step_a" step_b = root / "step_b" output_dir = root / "xcf_out" for directory in (input_dir, step_a, step_b, output_dir): directory.mkdir(parents=True, exist_ok=True) make_png(input_dir / "photo.png") make_png(step_a / "photo.png", rgb=(10, 20, 30)) make_png(step_b / "photo.png", rgb=(30, 20, 10)) return { "root": root, "input_dir": input_dir, "step_a": step_a, "step_b": step_b, "output_dir": output_dir, } class TestXcfStackRun: @patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf") def test_collects_layers_from_input_and_prior_steps( self, mock_stack: object, tmp_path: Path ) -> None: paths = _make_stack_fixture(tmp_path) input_path = paths["root"] / "refs" / "photo.jpg" input_path.parent.mkdir() input_path.touch() ctx = ModuleContext( input_paths=[input_path], output_dir=paths["output_dir"], params=XcfStackModule.validate_module_params({}), pipeline_output_root=paths["root"], step_id="xcf_stack_01", prior_steps=[ ("step_a", paths["step_a"]), ("step_b", paths["step_b"]), ], logger=None, ) XcfStackModule().run(ctx) mock_stack.assert_called_once() layers, outfile = mock_stack.call_args[0] assert outfile == paths["output_dir"] / "photo.xcf" assert layers == [ ("input", paths["input_dir"] / "photo.png"), ("step_a", paths["step_a"] / "photo.png"), ("step_b", paths["step_b"] / "photo.png"), ] @patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf") def test_skip_missing_true_skips_missing_step( self, mock_stack: object, tmp_path: Path ) -> None: paths = _make_stack_fixture(tmp_path) (paths["step_b"] / "photo.png").unlink() input_path = paths["root"] / "photo.jpg" ctx = ModuleContext( input_paths=[input_path], output_dir=paths["output_dir"], params=XcfStackModule.validate_module_params({"skip_missing": True}), pipeline_output_root=paths["root"], step_id="xcf_stack_01", prior_steps=[ ("step_a", paths["step_a"]), ("step_b", paths["step_b"]), ], logger=None, ) XcfStackModule().run(ctx) layers, _outfile = mock_stack.call_args[0] step_ids = [step_id for step_id, _ in layers] assert step_ids == ["input", "step_a"] @patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf") def test_skip_missing_false_raises_on_missing_step( self, mock_stack: object, tmp_path: Path ) -> None: paths = _make_stack_fixture(tmp_path) (paths["step_b"] / "photo.png").unlink() input_path = paths["root"] / "photo.jpg" ctx = ModuleContext( input_paths=[input_path], output_dir=paths["output_dir"], params=XcfStackModule.validate_module_params({"skip_missing": False}), pipeline_output_root=paths["root"], step_id="xcf_stack_01", prior_steps=[ ("step_a", paths["step_a"]), ("step_b", paths["step_b"]), ], logger=None, ) with pytest.raises(ValueError, match="no image with stem 'photo' in step 'step_b'"): XcfStackModule().run(ctx) mock_stack.assert_not_called() @pytest.mark.skipif(not has_working_gimp, reason="GIMP headless not available") @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") class TestStackImagesToXcfIntegration: def test_stacks_pngs_into_xcf(self, tmp_path: Path) -> None: from imagepipeline.utils.gimp import stack_images_to_xcf bottom = tmp_path / "bottom.png" top = tmp_path / "top.png" magick = shutil.which("magick") or shutil.which("convert") assert magick is not None from imagepipeline.utils.subprocess import run_command run_command([magick, "-size", "8x8", "xc:#ff0000", str(bottom)]) run_command([magick, "-size", "8x8", "xc:#0000ff", str(top)]) outfile = tmp_path / "stack.xcf" stack_images_to_xcf( [("bottom", bottom), ("top", top)], outfile, timeout=120.0, ) assert outfile.is_file() assert outfile.stat().st_size > 0