change: Implement 4: Filter candidates by real format and minimum size from downloaded bytes
Repository Validate / repository-checks (pull_request) Successful in 3s

Added tools/images.py with stdlib-only detection of the real image format and pixel dimensions from downloaded bytes (PNG signature + IHDR chunk, ICO ICONDIR/ICONDIRENTRY directory including the 0-means-256 encoding) plus stdlib-only builders (png_bytes, ico_bytes) so tests/sources can produce genuine payloads. Wired it into the CLI search path in tools/example.py: a bounded candidate window derived from --count (candidate_cap = count * 4, capped at 50) is fetched from the selected source, each payload is parsed, and only items whose actual format equals the requested png/ico and whose actual width and height are both at least the requested minimum are kept. Kept items report source, url, format and the actual width/height (plus size_bytes and metadata as extras); verified bytes are reused for --download instead of being fetched twice. Statuses stay distinct: ok (0), no_format (3) when hits were only other formats, no_size (4) when format-matching items were all too small, access_error (5) when bytes could not be fetched or are not readable images, partial_download (6) when some candidates could not be read. All metadata-based filtering (FORMAT_MATCHERS, _matches_format, _matches_size) was removed so only parsed bytes decide. Added tests/test_images.py with detector/builder unit tests and CLI tests using an injected fake byte-fetch callable (no network), and updated tests/test_cli.py fixtures to real image bytes where the previous metadata-only assumption no longer holds.

Tests:
Facade commands run with .venv/bin on PATH after prepare_environment(component=tools, mode=restore): `python -m compileall tools tests` (tools.validate) succeeded; `pytest` (tools.test) => 114 passed, including the new tests/test_images.py (31 tests); `python -m tools.example examples/input.json` (tools.smoke) exited 0 with a single dry_run JSON document; acceptance commands `python -m tools.example --help` exited 0 and `import tools` resolved. Target branch task/1-task is at the same commit as this work branch (git rev-list count 0 ahead / 0 behind), so it is already integrated; facade/protected paths untouched.

Progress:
Implemented byte-based format and minimum-size filtering for the image search path. New tools/images.py: detect_format (png/ico plus jpeg/gif/bmp/webp so "other format" is distinguishable from "unreadable"), read_png_dimensions (IHDR), read_ico_dimensions (ICONDIR entries, 0 => 256, largest entry), inspect_image/ImageInfo, ImageFormatError (code image_unreadable), and png_bytes/ico_bytes builders. tools/example.py: candidate_cap(count) bounded window (count*4, max 50) requested from the source, _select() fetches and parses every candidate, KeptImage.to_item() reports source/url/format/actual width+height/size_bytes, _download() writes the already verified bytes, and _execute() distinguishes ok/no_format/no_size/access_error/partial_download with the existing exit codes; metadata-based filtering removed. tests/test_images.py added (31 tests) and tests/test_cli.py fixtures aligned to real image bytes. All facade commands pass: compileall, pytest (114 passed), smoke, help, package import.

Published by developer-agent through Tool Backend.

Closes #6
This commit is contained in:
Tool Backend
2026-11-03 02:11:36 +00:00
parent 6a7538c07f
commit b544968cb8
5 changed files with 1093 additions and 74 deletions
+45 -14
View File
@@ -194,7 +194,7 @@ class FakeCandidate:
width: int | None,
height: int | None,
*,
payload: bytes = b"payload",
payload: bytes | None = None,
fail: bool = False,
) -> None:
self.source_id = "fake"
@@ -223,7 +223,11 @@ class FakeCandidate:
def fetch_bytes(self) -> bytes:
if self._fail:
raise SourceAccessError("download refused", url=self.file_url)
return self._payload
if self._payload is not None:
return self._payload
# Default to bytes that really are a PNG of the advertised size, because
# the CLI filters on downloaded bytes and never on source metadata.
return png_bytes(self.width or 1, self.height or 1)
class FakeRegistry:
@@ -265,18 +269,29 @@ def test_ok_lists_items_without_download(monkeypatch: pytest.MonkeyPatch, capsys
assert code == envelope.EXIT_OK
assert payload["status"] == envelope.STATUS_OK
assert payload["source"] == "fake"
assert [item["file_url"] for item in payload["items"]] == ["https://example.test/a.png"]
assert payload["items"][0]["width"] == 100
assert registry.calls == [("fake", "fox", 10)]
item = payload["items"][0]
assert item["url"] == "https://example.test/a.png"
assert item["file_url"] == "https://example.test/a.png"
assert item["source"] == "fake"
assert item["format"] == "png"
assert item["width"] == 100
assert item["height"] == 100
assert registry.calls == [("fake", "fox", example.candidate_cap(10))]
def test_no_format_when_source_has_no_matching_mime(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.jpg", "image/jpeg", 100, 100)])
def test_no_format_when_every_payload_really_is_another_format(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.jpg", "image/jpeg", 100, 100, payload=gif_bytes())]
)
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
assert code == envelope.EXIT_NO_FORMAT
assert payload["status"] == envelope.STATUS_NO_FORMAT
assert payload["error"]["details"]["unreadable_candidates"] == 0
assert payload["error"]["details"]["requested_format"] == "png"
def test_no_size_when_only_smaller_images_exist(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
@@ -311,7 +326,7 @@ def test_exact_size_is_accepted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.
assert len(payload["items"]) == 1
def test_resize_without_download_keeps_reported_source_size(
def test_resize_without_download_keeps_actual_size(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 400, 300)])
@@ -325,7 +340,10 @@ def test_resize_without_download_keeps_reported_source_size(
def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100)])
expected = png_bytes(100, 100)
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100, payload=expected)]
)
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path))
@@ -334,7 +352,8 @@ def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys:
assert download["ok"] is True
target = Path(download["path"])
assert target.parent == tmp_path
assert target.read_bytes() == b"payload"
# The verified bytes are stored verbatim, without a second download.
assert target.read_bytes() == expected
def test_partial_download_reports_status(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
@@ -349,6 +368,9 @@ def test_partial_download_reports_status(monkeypatch: pytest.MonkeyPatch, capsys
assert code == envelope.EXIT_PARTIAL_DOWNLOAD
assert payload["status"] == envelope.STATUS_PARTIAL_DOWNLOAD
# The readable candidate is still reported and downloaded.
assert payload["items"][0]["download"]["ok"] is True
assert payload["error"]["details"]["failed"] == 1
def test_all_downloads_failing_is_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
@@ -358,6 +380,7 @@ def test_all_downloads_failing_is_access_error(monkeypatch: pytest.MonkeyPatch,
assert code == envelope.EXIT_ACCESS_ERROR
assert payload["status"] == envelope.STATUS_ACCESS_ERROR
assert payload["error"]["code"] == "candidate_unreadable"
def test_search_access_error_maps_to_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
@@ -398,6 +421,12 @@ def png_bytes(width: int, height: int, rgba: tuple[int, int, int, int] = (255, 0
)
def gif_bytes(width: int = 100, height: int = 100) -> bytes:
"""Build a payload that really is a GIF, i.e. a format we must never accept."""
return b"GIF89a" + struct.pack("<HH", width, height) + b"\x00" * 8
def png_size(payload: bytes) -> tuple[int, int]:
assert payload.startswith(PNG_SIGNATURE)
return struct.unpack(">II", payload[16:24])
@@ -528,9 +557,11 @@ def test_resize_downscales_png_stored_inside_ico(
assert png_size(inner) == (50, 50)
def test_undecodable_payload_with_resize_fails_the_item(
def test_unreadable_payload_is_an_access_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""Bytes that are not an image must never become a "no match" verdict."""
registry = FakeRegistry(
[
FakeCandidate(
@@ -545,6 +576,6 @@ def test_undecodable_payload_with_resize_fails_the_item(
assert code == envelope.EXIT_ACCESS_ERROR
assert document["status"] == envelope.STATUS_ACCESS_ERROR
download = document["items"][0]["download"]
assert download["ok"] is False
assert download["error"]["code"] == "resize_failed"
assert document["error"]["code"] == "candidate_unreadable"
assert document["items"] == []
assert list(tmp_path.iterdir()) == []
+457
View File
@@ -0,0 +1,457 @@
"""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
# ---------------------------------------------------------------------------
# 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),
(b"\xff\xd8\xff\xe0" + b"\x00" * 12, images.JPEG),
(b"BM" + b"\x00" * 20, images.BMP),
(b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + b"\x00" * 8, images.WEBP),
],
)
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
def test_inspect_image_rejects_formats_whose_size_is_not_readable() -> None:
with pytest.raises(ImageFormatError) as excinfo:
images.inspect_image(gif_payload())
assert excinfo.value.code == "image_unreadable"
assert excinfo.value.details["format"] == images.GIF
@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_unreadable_payload_yields_access_error_not_no_format(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
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_ACCESS_ERROR
assert document["status"] == envelope.STATUS_ACCESS_ERROR
assert document["error"]["details"]["failures"][0]["error"]["code"] == "image_unreadable"
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