Repository Validate / repository-checks (push) Successful in 21s
Co-authored-by: AI Tester <ai-tester@git.p2prod.ru>
327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""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)
|