"""Saving selected images into the caller directory and downscaling on demand. The contract under test: * ``--download`` writes every selected image into the directory named by the caller and reports paths that really exist and really open as the requested format; * ``--download`` without ``--output-dir`` is an ``invalid_request`` -- there is no hidden default directory; * one item whose save fails is reported in that item while the run ends in the distinct ``partial_download`` status (and exit code); * ``--resize`` stores exactly the requested width and height, re-encoded in the requested format, and never enlarges or otherwise rewrites an image that already fits; without the flag the original bytes and dimensions are kept. Every fixture is a real payload built with :mod:`tools.images`, so the tests assert on decoded files rather than on helper functions agreeing with themselves. Nothing here touches the network. """ from __future__ import annotations import json from pathlib import Path from typing import Any, Sequence import pytest from tools import envelope, example, images from tools.errors import RequestFieldError class FakeCandidate: """Minimal stand-in for :class:`tools.sources.SourceCandidate`.""" def __init__(self, title: str, payload: bytes, *, fail: bool = False) -> None: self.source_id = "fake" self.title = title self.file_url = f"https://example.test/{title.replace(' ', '-')}" self.page_url = self.file_url + ".html" self.mime_type = "image/png" self.license = "CC0" self.creator = "tester" self.width: int | None = None self.height: int | None = None self._payload = payload self._fail = fail def to_dict(self) -> dict[str, Any]: return {"source_id": self.source_id, "title": self.title, "file_url": self.file_url} def fetch_bytes(self) -> bytes: if self._fail: raise OSError("download refused") return self._payload class FakeRegistry: def __init__(self, candidates: Sequence[FakeCandidate] = ()) -> None: self._candidates = list(candidates) def available_sources(self) -> Sequence[str]: return ["fake"] def search(self, source_id: str, topic: str, limit: int = 10) -> list[FakeCandidate]: return self._candidates[:limit] def run_main( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], registry: FakeRegistry, *args: str, ) -> tuple[int, dict[str, Any]]: monkeypatch.setattr(example, "default_registry", lambda: registry) code = example.main(list(args)) captured = capsys.readouterr() assert captured.err == "" lines = [line for line in captured.out.splitlines() if line.strip()] assert len(lines) == 1, captured.out return code, json.loads(lines[0]) def png(width: int, height: int) -> bytes: return images.png_bytes(width, height) def ico(width: int, height: int) -> bytes: return images.ico_bytes(width, height) def saved_image(path: Path) -> images.DecodedImage: """Decode a reported path, failing the test if it cannot be opened.""" assert path.is_file(), f"reported path does not exist: {path}" return images.decode_image(path.read_bytes()) PNG_ARGS = ("--topic", "fox", "--format", "png", "--source", "fake", "--width", "100", "--height", "100") ICO_ARGS = ("--topic", "fox", "--format", "ico", "--source", "fake", "--width", "50", "--height", "50") # --------------------------------------------------------------------------- # tools.images: the PNG/ICO helpers the saver is built on # --------------------------------------------------------------------------- def test_encode_and_decode_png_round_trip_the_requested_size() -> None: rows = (bytes((10, 20, 30, 255)) * 4,) * 3 payload = images.encode_png(4, 3, rows) assert images.detect_format(payload) == images.PNG decoded = images.decode_image(payload) assert (decoded.format, decoded.width, decoded.height) == ("png", 4, 3) assert decoded.rows == rows def test_ico_wrap_and_unwrap_round_trip_the_embedded_png() -> None: inner = png(6, 5) payload = images.ico_wrap(inner, 6, 5) assert images.detect_format(payload) == images.ICO assert images.ico_unwrap(payload) == (6, 5, inner) decoded = images.decode_image(payload) assert (decoded.format, decoded.width, decoded.height) == ("ico", 6, 5) def test_resample_rows_hits_the_target_size_exactly() -> None: rows = tuple(bytes((x, x, x, 255)) * 4 for x in range(4)) resized = images.resample_rows(rows, 4, 4, 2, 3) assert len(resized) == 3 assert {len(row) for row in resized} == {2 * 4} def test_encode_image_re_encodes_both_accepted_formats() -> None: rows = (bytes((1, 2, 3, 255)) * 2,) * 2 png_payload = images.encode_image("png", 2, 2, rows) ico_payload = images.encode_image("ico", 2, 2, rows) assert images.decode_image(png_payload).format == images.PNG assert images.decode_image(ico_payload).format == images.ICO # --------------------------------------------------------------------------- # --download: caller directory, reported paths, missing directory, save failure # --------------------------------------------------------------------------- def test_download_writes_into_the_caller_directory( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: registry = FakeRegistry([FakeCandidate("fox one", png(100, 100))]) code, document = run_main(monkeypatch, capsys, registry, *PNG_ARGS, "--download", "--output-dir", str(tmp_path)) assert code == envelope.EXIT_OK assert document["status"] == envelope.STATUS_OK download = document["items"][0]["download"] assert download["ok"] is True target = Path(download["path"]) assert target.parent == tmp_path assert target.suffix == ".png" stored = saved_image(target) assert (stored.format, stored.width, stored.height) == ("png", 100, 100) assert download["format"] == "png" assert (download["width"], download["height"]) == (100, 100) assert sorted(path.name for path in tmp_path.iterdir()) == [target.name] def test_download_without_output_dir_is_invalid_request( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: registry = FakeRegistry([FakeCandidate("fox one", png(100, 100))]) code, document = run_main(monkeypatch, capsys, registry, *PNG_ARGS, "--download") assert code == envelope.EXIT_INVALID_REQUEST assert document["status"] == envelope.STATUS_INVALID_REQUEST assert document["error"]["code"] == "request_field" assert document["error"]["details"]["field"] == "output_dir" # Nothing was written anywhere, least of all into a hidden default. assert list(tmp_path.iterdir()) == [] def test_request_document_without_output_dir_is_invalid_request() -> None: with pytest.raises(RequestFieldError) as excinfo: example.normalize_request({"topic": "fox", "format": "png", "download": True}) assert excinfo.value.field == "output_dir" # Without --download the directory is not needed, so it stays optional. request = example.normalize_request({"topic": "fox", "format": "png"}) assert request["download"] is False assert request["output_dir"] is None def test_failing_save_yields_partial_download( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: registry = FakeRegistry( [FakeCandidate("fox one", png(100, 100)), FakeCandidate("fox two", png(100, 100))] ) # The second destination is a directory, so writing the file must fail while # the first item still saves successfully. blocked = tmp_path / "002-fox-two.png" blocked.mkdir() code, document = run_main( monkeypatch, capsys, registry, *PNG_ARGS, "--count", "2", "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_PARTIAL_DOWNLOAD == 6 assert document["status"] == envelope.STATUS_PARTIAL_DOWNLOAD first, second = document["items"] assert first["download"]["ok"] is True assert saved_image(Path(first["download"]["path"])).width == 100 assert second["download"]["ok"] is False assert second["download"]["error"]["code"] assert "path" not in second["download"] assert document["error"]["code"] == "download_incomplete" assert document["error"]["details"]["failed"] == 1 # The path list only names files that exist and open as the requested format. saved_paths = document["error"]["details"]["saved_paths"] assert saved_paths == [first["download"]["path"]] for path in saved_paths: assert saved_image(Path(path)).format == "png" # --------------------------------------------------------------------------- # --resize: exact requested size, requested format, never enlarge # --------------------------------------------------------------------------- def test_resize_stores_exactly_the_requested_size( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: # 200x100 is larger than the 100x100 request in one dimension only: --resize # still stores exactly 100x100 rather than preserving the aspect ratio. registry = FakeRegistry([FakeCandidate("fox one", png(200, 100))]) code, document = run_main( monkeypatch, capsys, registry, *PNG_ARGS, "--resize", "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_OK download = document["items"][0]["download"] assert download["resize"]["applied"] is True assert download["resize"]["from"] == {"width": 200, "height": 100} assert download["resize"]["to"] == {"width": 100, "height": 100} stored = saved_image(Path(download["path"])) assert (stored.format, stored.width, stored.height) == ("png", 100, 100) def test_resize_re_encodes_ico_in_the_requested_format( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: registry = FakeRegistry([FakeCandidate("fox one", ico(100, 100))]) code, document = run_main( monkeypatch, capsys, registry, *ICO_ARGS, "--resize", "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_OK download = document["items"][0]["download"] target = Path(download["path"]) assert target.suffix == ".ico" stored = saved_image(target) assert (stored.format, stored.width, stored.height) == ("ico", 50, 50) assert images.read_ico_dimensions(target.read_bytes()) == (50, 50) def test_resize_never_enlarges_or_rewrites_a_fitting_image( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: payload = png(100, 100) registry = FakeRegistry([FakeCandidate("fox one", payload)]) code, document = run_main( monkeypatch, capsys, registry, *PNG_ARGS, "--resize", "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_OK download = document["items"][0]["download"] assert download["resize"]["applied"] is False assert download["resize"]["reason"] == "already_fits" assert Path(download["path"]).read_bytes() == payload assert (download["width"], download["height"]) == (100, 100) def test_without_resize_the_original_bytes_and_dimensions_are_kept( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: payload = png(200, 100) registry = FakeRegistry([FakeCandidate("fox one", payload)]) code, document = run_main( monkeypatch, capsys, registry, *PNG_ARGS, "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_OK download = document["items"][0]["download"] assert "resize" not in download assert Path(download["path"]).read_bytes() == payload stored = saved_image(Path(download["path"])) assert (stored.format, stored.width, stored.height) == ("png", 200, 100) def test_without_resize_an_ico_keeps_its_container_and_size( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: payload = ico(100, 100) registry = FakeRegistry([FakeCandidate("fox one", payload)]) code, document = run_main( monkeypatch, capsys, registry, *ICO_ARGS, "--download", "--output-dir", str(tmp_path) ) assert code == envelope.EXIT_OK download = document["items"][0]["download"] assert Path(download["path"]).read_bytes() == payload stored = saved_image(Path(download["path"])) assert (stored.format, stored.width, stored.height) == ("ico", 100, 100)