Final acceptance: Консольная утилита поиска и скачивания изображений для вызова ИИ-агентом (#18)
Repository Validate / repository-checks (push) Successful in 21s
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:
@@ -0,0 +1,654 @@
|
||||
"""Tests for byte-based format and size filtering.
|
||||
|
||||
Two layers are covered:
|
||||
|
||||
* :mod:`tools.images` -- the stdlib-only detector and the stdlib-only builders
|
||||
for the two accepted formats, so fixtures are real images rather than magic
|
||||
strings;
|
||||
* the CLI search path -- candidates are filtered on the *downloaded bytes*, so
|
||||
a source that mislabels a file, claims the wrong size or serves an unreadable
|
||||
body produces the correct ``ok`` / ``no_format`` / ``no_size`` /
|
||||
``access_error`` answer. Every CLI test injects a fake byte-fetch callable,
|
||||
so nothing here touches the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, example, images
|
||||
from tools.errors import SourceAccessError
|
||||
from tools.images import ImageFormatError, ImageInfo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-format builders used as fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def png_payload(width: int, height: int) -> bytes:
|
||||
"""A real PNG built with :func:`tools.images.png_bytes`."""
|
||||
|
||||
return images.png_bytes(width, height)
|
||||
|
||||
|
||||
def ico_payload(width: int, height: int) -> bytes:
|
||||
"""A real ICO (PNG-compressed entry) built with :func:`tools.images.ico_bytes`."""
|
||||
|
||||
return images.ico_bytes(width, height)
|
||||
|
||||
|
||||
def gif_payload(width: int = 100, height: int = 100) -> bytes:
|
||||
"""A payload whose real format is GIF, i.e. one we must never accept."""
|
||||
|
||||
return b"GIF89a" + struct.pack("<HH", width, height) + b"\x00" * 8
|
||||
|
||||
|
||||
def jpeg_payload() -> bytes:
|
||||
"""A payload whose real format is JPEG, i.e. another format we must not accept."""
|
||||
|
||||
return b"\xff\xd8\xff\xe0" + b"\x00" * 12
|
||||
|
||||
|
||||
def svg_payload(width: int = 100, height: int = 100) -> bytes:
|
||||
"""A real SVG document, the most common non-bitmap hit on a live source."""
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"></svg>\n'
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def tiff_payload() -> bytes:
|
||||
"""A payload whose real format is TIFF."""
|
||||
|
||||
return b"II*\x00" + b"\x00" * 16
|
||||
|
||||
|
||||
def pdf_payload() -> bytes:
|
||||
"""A payload whose real format is PDF, served by document-friendly sources."""
|
||||
|
||||
return b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tools.images: detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_png_bytes_builds_a_png_with_the_requested_dimensions() -> None:
|
||||
payload = png_payload(13, 7)
|
||||
|
||||
assert payload.startswith(images.PNG_SIGNATURE)
|
||||
assert images.detect_format(payload) == images.PNG
|
||||
assert images.inspect_image(payload) == ImageInfo(format="png", width=13, height=7)
|
||||
# The IHDR chunk really carries the dimensions, so the bytes are the source
|
||||
# of truth rather than a helper function agreeing with itself.
|
||||
assert payload[12:16] == b"IHDR"
|
||||
assert struct.unpack(">II", payload[16:24]) == (13, 7)
|
||||
assert images.read_png_dimensions(payload) == (13, 7)
|
||||
|
||||
|
||||
def test_ico_bytes_builds_a_directory_that_reports_the_requested_sizes() -> None:
|
||||
payload = ico_payload(48, 37)
|
||||
|
||||
assert images.detect_format(payload) == images.ICO
|
||||
assert images.inspect_image(payload) == ImageInfo(format="ico", width=48, height=37)
|
||||
# One directory entry, and its data range lies inside the payload.
|
||||
reserved, kind, count = struct.unpack("<HHH", payload[:6])
|
||||
assert (reserved, kind, count) == (0, 1, 1)
|
||||
_w, _h, _colours, _reserved, _planes, _bits, size, offset = struct.unpack("<BBBBHHII", payload[6:22])
|
||||
assert offset + size == len(payload)
|
||||
|
||||
|
||||
def test_ico_maximum_dimension_uses_the_zero_encoding() -> None:
|
||||
payload = ico_payload(256, 256)
|
||||
|
||||
assert payload[6] == 0 and payload[7] == 0
|
||||
assert images.read_ico_dimensions(payload) == (256, 256)
|
||||
|
||||
|
||||
def test_ico_dimensions_use_the_largest_directory_entry() -> None:
|
||||
payload = images.ico_bytes(16, 16, frames=[(16, 16), (32, 32), (256, 256)])
|
||||
|
||||
assert len(images.ico_entries(payload)) == 3
|
||||
assert images.read_ico_dimensions(payload) == (256, 256)
|
||||
assert images.inspect_image(payload).format == "ico"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
(png_payload(2, 2), images.PNG),
|
||||
(ico_payload(2, 2), images.ICO),
|
||||
(gif_payload(), images.GIF),
|
||||
(jpeg_payload(), images.JPEG),
|
||||
(b"BM" + b"\x00" * 20, images.BMP),
|
||||
(b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + b"\x00" * 8, images.WEBP),
|
||||
(svg_payload(), images.SVG),
|
||||
(svg_payload().replace(b"<?xml", b"\xef\xbb\xbf<?xml", 1), images.SVG),
|
||||
(tiff_payload(), images.TIFF),
|
||||
(b"MM\x00*" + b"\x00" * 16, images.TIFF),
|
||||
(pdf_payload(), images.PDF),
|
||||
],
|
||||
)
|
||||
def test_detect_format_names_the_real_format(payload: bytes, expected: str) -> None:
|
||||
assert images.detect_format(payload) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b"", b"payload", b"not-an-image", b"<html></html>"])
|
||||
def test_detect_format_returns_none_for_non_images(payload: bytes) -> None:
|
||||
assert images.detect_format(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [gif_payload(), jpeg_payload(), svg_payload(), tiff_payload(), pdf_payload()])
|
||||
def test_inspect_image_rejects_formats_whose_size_is_not_readable(payload: bytes) -> None:
|
||||
with pytest.raises(ImageFormatError) as excinfo:
|
||||
images.inspect_image(payload)
|
||||
|
||||
assert excinfo.value.code == "image_unreadable"
|
||||
# The format is named, so the CLI can report it as another format rather
|
||||
# than as an access problem.
|
||||
assert excinfo.value.details["format"] == images.detect_format(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"not-an-image",
|
||||
images.PNG_SIGNATURE,
|
||||
images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IDAT" + b"\x00" * 13,
|
||||
b"\x00\x00\x01\x00" + struct.pack("<HHH", 0, 1, 2),
|
||||
b"\x00\x00\x01\x00" + struct.pack("<HHH", 0, 1, 1) + struct.pack("<BBBBHHII", 16, 16, 0, 0, 1, 32, 999, 22),
|
||||
],
|
||||
)
|
||||
def test_inspect_image_rejects_unreadable_payloads(payload: bytes) -> None:
|
||||
with pytest.raises(ImageFormatError):
|
||||
images.inspect_image(payload)
|
||||
|
||||
|
||||
def test_png_dimensions_reject_a_zero_sized_image() -> None:
|
||||
payload = images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IHDR" + struct.pack(">IIBBBBB", 0, 0, 8, 6, 0, 0, 0)
|
||||
|
||||
with pytest.raises(ImageFormatError):
|
||||
images.read_png_dimensions(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI search path: filtering on downloaded bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubCandidate:
|
||||
"""Candidate whose bytes are supplied by a fake fetch callable."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
url: str,
|
||||
*,
|
||||
payload: bytes | None = None,
|
||||
error: Exception | None = None,
|
||||
mime_type: str | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> None:
|
||||
self.source_id = "fake"
|
||||
self.title = title
|
||||
self.file_url = url
|
||||
self.page_url = url + ".html"
|
||||
# Deliberately misleading metadata: filtering must ignore all of this.
|
||||
self.mime_type = mime_type
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._payload = payload
|
||||
self._error = error
|
||||
self.fetch_calls = 0
|
||||
|
||||
def fetch_bytes(self) -> bytes:
|
||||
self.fetch_calls += 1
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._payload is not None
|
||||
return self._payload
|
||||
|
||||
|
||||
class StubRegistry:
|
||||
"""Registry stand-in that returns a fixed candidate list."""
|
||||
|
||||
def __init__(self, candidates: Sequence[StubCandidate]) -> None:
|
||||
self.candidates = list(candidates)
|
||||
self.calls: list[tuple[str, str, int]] = []
|
||||
|
||||
def available_sources(self) -> Sequence[str]:
|
||||
return ["fake"]
|
||||
|
||||
def search(self, source_id: str, topic: str, limit: int = 10) -> list[StubCandidate]:
|
||||
self.calls.append((source_id, topic, limit))
|
||||
return self.candidates[:limit]
|
||||
|
||||
|
||||
def run_search(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
candidates: Sequence[StubCandidate],
|
||||
*args: str,
|
||||
) -> tuple[int, dict[str, Any], StubRegistry]:
|
||||
registry = StubRegistry(candidates)
|
||||
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
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, Mapping)
|
||||
return code, document, registry
|
||||
|
||||
|
||||
BASE_ARGS = ("--topic", "red fox", "--format", "png", "--source", "fake")
|
||||
|
||||
|
||||
def test_png_candidate_is_kept_with_its_actual_dimensions(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
payload = png_payload(800, 600)
|
||||
candidate = StubCandidate(
|
||||
"fox",
|
||||
"https://example.test/fox.png",
|
||||
payload=payload,
|
||||
# The source claims something else entirely; the bytes win.
|
||||
mime_type="image/jpeg",
|
||||
width=12,
|
||||
height=12,
|
||||
)
|
||||
|
||||
code, document, registry = run_search(
|
||||
monkeypatch, capsys, [candidate], *BASE_ARGS, "--width", "640", "--height", "480"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert document["error"] is None
|
||||
item = document["items"][0]
|
||||
assert item["source"] == "fake"
|
||||
assert item["url"] == "https://example.test/fox.png"
|
||||
assert item["format"] == "png"
|
||||
assert (item["width"], item["height"]) == (800, 600)
|
||||
assert item["size_bytes"] == len(payload)
|
||||
assert candidate.fetch_calls == 1
|
||||
# The bounded candidate window is requested from the source, not --count.
|
||||
assert registry.calls == [("fake", "red fox", example.candidate_cap(10))]
|
||||
|
||||
|
||||
def test_ico_request_with_only_png_payloads_yields_no_format(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("fox", "https://example.test/a.png", payload=png_payload(400, 400), mime_type="image/x-icon"),
|
||||
StubCandidate("fox2", "https://example.test/b.png", payload=png_payload(500, 500), mime_type="image/ico"),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
candidates,
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"64",
|
||||
"--height",
|
||||
"64",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_format_match"
|
||||
assert document["error"]["details"]["requested_format"] == "ico"
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
assert document["error"]["details"]["format_matches"] == 0
|
||||
|
||||
|
||||
def test_ico_payload_is_kept_for_an_ico_request(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidate = StubCandidate("fox", "https://example.test/fox.ico", payload=ico_payload(64, 48))
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[candidate],
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"32",
|
||||
"--height",
|
||||
"32",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
item = document["items"][0]
|
||||
assert item["format"] == "ico"
|
||||
assert (item["width"], item["height"]) == (64, 48)
|
||||
|
||||
|
||||
def test_all_too_small_payloads_yield_no_size(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("small", "https://example.test/a.png", payload=png_payload(100, 100), width=4000, height=4000),
|
||||
StubCandidate("tall", "https://example.test/b.png", payload=png_payload(900, 100)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert document["status"] == envelope.STATUS_NO_SIZE
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_size_match"
|
||||
assert document["error"]["details"]["format_matches"] == 2
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_exactly_minimum_size_is_accepted(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidate = StubCandidate("edge", "https://example.test/edge.png", payload=png_payload(200, 200))
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, [candidate], *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert (document["items"][0]["width"], document["items"][0]["height"]) == (200, 200)
|
||||
|
||||
|
||||
def test_no_format_and_no_size_and_access_error_stay_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
format_code, format_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("gif", "https://example.test/a.png", payload=gif_payload())],
|
||||
*BASE_ARGS,
|
||||
)
|
||||
size_code, size_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("small", "https://example.test/a.png", payload=png_payload(10, 10))],
|
||||
*BASE_ARGS,
|
||||
"--width",
|
||||
"100",
|
||||
"--height",
|
||||
"100",
|
||||
)
|
||||
access_code, access_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("broken", "https://example.test/a.png", error=SourceAccessError("refused"))],
|
||||
*BASE_ARGS,
|
||||
)
|
||||
|
||||
assert format_document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert size_document["status"] == envelope.STATUS_NO_SIZE
|
||||
assert access_document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert len({format_code, size_code, access_code}) == 3
|
||||
assert (format_code, size_code, access_code) == (
|
||||
envelope.EXIT_NO_FORMAT,
|
||||
envelope.EXIT_NO_SIZE,
|
||||
envelope.EXIT_ACCESS_ERROR,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_failure_yields_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate(
|
||||
"broken",
|
||||
"https://example.test/a.png",
|
||||
error=SourceAccessError("image source returned HTTP status 503", url="https://example.test/a.png"),
|
||||
)
|
||||
]
|
||||
|
||||
code, document, _ = run_search(monkeypatch, capsys, candidates, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "candidate_unreadable"
|
||||
assert document["error"]["details"]["failures"][0]["url"] == "https://example.test/a.png"
|
||||
|
||||
|
||||
def test_non_image_payload_is_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Bytes that are no image at all are still "not the requested format".
|
||||
|
||||
A live source answers with HTML error pages, licence documents and vector
|
||||
files; none of them is a png, so the topic verdict is ``no_format`` and the
|
||||
caller must not see an access error (or a partial download) instead.
|
||||
"""
|
||||
|
||||
candidate = StubCandidate(
|
||||
"junk", "https://example.test/a.png", payload=b"not-an-image", mime_type="image/png"
|
||||
)
|
||||
|
||||
code, document, _ = run_search(monkeypatch, capsys, [candidate], *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_format_match"
|
||||
details = document["error"]["details"]
|
||||
assert details["other_format_candidates"] == 1
|
||||
assert details["observed_formats"] == {example.UNKNOWN_FORMAT: 1}
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_search_keeps_png_when_the_window_also_serves_other_formats(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The real source mixes formats; a working search stays ``ok``.
|
||||
|
||||
Every non-png payload in the bounded window used to be classified as an
|
||||
unreadable candidate, which demoted a successful search to
|
||||
``partial_download`` (exit 6). They are format mismatches now.
|
||||
"""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload(), mime_type="image/png"),
|
||||
StubCandidate("jpeg", "https://example.test/a.jpg", payload=jpeg_payload(), mime_type="image/png"),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload(), mime_type="image/png"),
|
||||
StubCandidate("tiff", "https://example.test/a.tiff", payload=tiff_payload(), mime_type="image/png"),
|
||||
StubCandidate("pdf", "https://example.test/a.pdf", payload=pdf_payload(), mime_type="image/png"),
|
||||
StubCandidate("page", "https://example.test/a.html", payload=b"<html>rate limited</html>"),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(800, 600)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "640", "--height", "480"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert document["error"] is None
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
assert (document["items"][0]["width"], document["items"][0]["height"]) == (800, 600)
|
||||
|
||||
|
||||
def test_unfetchable_candidate_does_not_demote_a_usable_search(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A working search stays ``ok`` even when one candidate cannot be fetched."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate(
|
||||
"missing",
|
||||
"https://example.test/missing.png",
|
||||
error=SourceAccessError("image source returned HTTP status 503"),
|
||||
),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(300, 200)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
|
||||
|
||||
def test_ico_request_with_only_foreign_formats_yields_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""REQ-007: ico may be unavailable, and that is ``no_format`` (exit 3)."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload()),
|
||||
StubCandidate("jpeg", "https://example.test/a.jpg", payload=jpeg_payload()),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload()),
|
||||
StubCandidate("tiff", "https://example.test/a.tiff", payload=tiff_payload()),
|
||||
StubCandidate("page", "https://example.test/a.html", payload=b"<html></html>"),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
candidates,
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"64",
|
||||
"--height",
|
||||
"64",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
details = document["error"]["details"]
|
||||
assert details["requested_format"] == "ico"
|
||||
assert details["other_format_candidates"] == 5
|
||||
assert details["observed_formats"] == {
|
||||
"gif": 1,
|
||||
"jpeg": 1,
|
||||
"svg": 1,
|
||||
"tiff": 1,
|
||||
example.UNKNOWN_FORMAT: 1,
|
||||
}
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_no_size_survives_other_formats_in_the_same_window(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""REQ-008: format-matching but too-small images keep their own status."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload()),
|
||||
StubCandidate("small", "https://example.test/a.png", payload=png_payload(100, 100)),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload()),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert document["status"] == envelope.STATUS_NO_SIZE
|
||||
details = document["error"]["details"]
|
||||
assert details["format_matches"] == 1
|
||||
assert details["other_format_candidates"] == 2
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_corrupt_payload_of_the_requested_format_does_not_hide_a_valid_match(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
corrupt = images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IDAT" + b"\x00" * 13
|
||||
candidates = [
|
||||
StubCandidate("broken", "https://example.test/broken.png", payload=corrupt),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(200, 200)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
|
||||
|
||||
def test_empty_search_window_is_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
code, document, _ = run_search(monkeypatch, capsys, [], *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["error"]["details"]["candidate_count"] == 0
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_candidate_window_is_bounded_by_the_requested_count(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate(f"fox{n}", f"https://example.test/{n}.png", payload=png_payload(100, 100))
|
||||
for n in range(40)
|
||||
]
|
||||
|
||||
code, document, registry = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--count", "3"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert len(document["items"]) == 3
|
||||
assert registry.calls == [("fake", "red fox", example.candidate_cap(3))]
|
||||
assert example.candidate_cap(3) == 12
|
||||
# Fetching stops as soon as enough usable images were found.
|
||||
assert sum(candidate.fetch_calls for candidate in candidates) == 3
|
||||
|
||||
|
||||
def test_filtering_walks_past_rejected_candidates_until_the_count_is_met(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/0.png", payload=gif_payload()),
|
||||
StubCandidate("tiny", "https://example.test/1.png", payload=png_payload(10, 10)),
|
||||
StubCandidate("ok1", "https://example.test/2.png", payload=png_payload(120, 120)),
|
||||
StubCandidate("ok2", "https://example.test/3.png", payload=png_payload(130, 130)),
|
||||
StubCandidate("never", "https://example.test/4.png", payload=png_payload(140, 140)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--count", "2", "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert [item["url"] for item in document["items"]] == [
|
||||
"https://example.test/2.png",
|
||||
"https://example.test/3.png",
|
||||
]
|
||||
assert [(item["width"], item["height"]) for item in document["items"]] == [(120, 120), (130, 130)]
|
||||
assert candidates[-1].fetch_calls == 0
|
||||
Reference in New Issue
Block a user