Final acceptance: Консольная утилита поиска и скачивания изображений для вызова ИИ-агентом (#18)
Repository Validate / repository-checks (push) Successful in 21s

Co-authored-by: AI Tester <ai-tester@git.p2prod.ru>
This commit was merged in pull request #18.
This commit is contained in:
2026-11-03 02:51:14 +00:00
committed by ai-reviewer
parent 1b21e0ba82
commit 44473a1055
23 changed files with 6643 additions and 2 deletions
+326
View File
@@ -0,0 +1,326 @@
"""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)