"""Unit tests: the pictorial PDF gate (phase 140, task 01, LOCKED A1/A3). Covers the two new settings (``pdf_pictorial`` — the house kill-switch, default ON — and ``pdf_pictorial_min_chars`` — the sparseness bar with its fail-loud validator) and the pure, deterministic sparse-page gate :func:`app.rag.pdf.pictorial_pages`: the ``<``/``=``/``>`` boundary, whitespace-only pages, the empty-dict no-op, ascending order, and a large dict. The IKEA docstring example is pinned against the named threshold. """ from __future__ import annotations from typing import Any import pytest from pydantic import ValidationError from app.config import Settings from app.rag.pdf import pictorial_pages def _settings(**kwargs: Any) -> Settings: """Build Settings without reading a .env file (deterministic tests).""" kwargs.setdefault("_env_file", None) return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) # --------------------------------------------------------------------------- # The settings — defaults + env overrides # --------------------------------------------------------------------------- def test_pdf_pictorial_defaults_on_and_env_override( monkeypatch: pytest.MonkeyPatch, ) -> None: """LOCKED A3: the pass is ON by default (the owner-confirmed 2026-09-30 default); ``BOR_PDF_PICTORIAL`` is the house kill-switch (``0``/``false`` disables the pass entirely — the ``BOR_IMAGES`` naming pattern).""" monkeypatch.delenv("BOR_PDF_PICTORIAL", raising=False) assert _settings().pdf_pictorial is True monkeypatch.setenv("BOR_PDF_PICTORIAL", "0") assert _settings().pdf_pictorial is False monkeypatch.setenv("BOR_PDF_PICTORIAL", "false") assert _settings().pdf_pictorial is False monkeypatch.setenv("BOR_PDF_PICTORIAL", "1") assert _settings().pdf_pictorial is True def test_pdf_pictorial_min_chars_defaults_to_100_and_env_override( monkeypatch: pytest.MonkeyPatch, ) -> None: """LOCKED A1: the default sparseness bar is 100 stripped chars (the IKEA fixture's sparse pages run 4–73, its text-heavy pages 3,091+ — 100 sits between the classes); ``BOR_PDF_PICTORIAL_MIN_CHARS`` overrides it.""" monkeypatch.delenv("BOR_PDF_PICTORIAL_MIN_CHARS", raising=False) assert _settings().pdf_pictorial_min_chars == 100 monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "50") assert _settings().pdf_pictorial_min_chars == 50 monkeypatch.setenv("BOR_PDF_PICTORIAL_MIN_CHARS", "0") assert _settings().pdf_pictorial_min_chars == 0 # --------------------------------------------------------------------------- # The validators — the fail-loud pattern # --------------------------------------------------------------------------- def test_pdf_pictorial_min_chars_zero_is_legal() -> None: """PLAN A21: ``0`` is the every-page-is-a-candidate mode (the LLM calls are free) — it must NOT raise.""" assert _settings(pdf_pictorial_min_chars=0).pdf_pictorial_min_chars == 0 def test_pdf_pictorial_min_chars_default_is_legal() -> None: """The default (100) is trivially legal — the constructor must accept it (guards a validator regression that would block startup).""" assert _settings().pdf_pictorial_min_chars == 100 def test_pdf_pictorial_min_chars_negative_fails_loud_naming_the_field() -> None: """A negative threshold is a typo — the validator fails startup loudly (the ``agent_max_rounds`` pattern), naming the field.""" with pytest.raises(ValidationError, match="pdf_pictorial_min_chars"): _settings(pdf_pictorial_min_chars=-1) # --------------------------------------------------------------------------- # pictorial_pages — the pure, deterministic gate # --------------------------------------------------------------------------- def test_pictorial_pages_strict_less_than_boundary() -> None: """The ASSUMPTION's boundary, pinned from all three sides at ``min_chars=5``: 4 stripped chars IS a candidate (``<``), EXACTLY 5 is NOT (``=`` — a page with exactly the bar's stripped chars is not sparse), and 6 is NOT (``>``).""" page_texts = { 1: "abcd", # 4 < 5 → candidate 2: "abcde", # 5 == 5 → NOT a candidate (the pinned boundary) 3: "abcdef", # 6 > 5 → NOT a candidate } assert pictorial_pages(page_texts, min_chars=5) == [1] def test_pictorial_pages_min_chars_zero_marks_every_page() -> None: """PLAN A21's special case: ``min_chars=0`` is the "describe everything" mode — EVERY page in the map is a candidate (the strict ``<`` test cannot express this: no length is below 0, so 0 is not a stricter bar); an empty map still yields ``[]``.""" assert pictorial_pages({1: "a", 2: "x" * 7000}, min_chars=0) == [1, 2] # A whitespace-only entry (defensive — the extractor's map never # holds one) is still a candidate: the 0 branch is ALL KEYS. assert pictorial_pages({1: " "}, min_chars=0) == [1] assert pictorial_pages({}, min_chars=0) == [] def test_pictorial_pages_whitespace_only_page_is_a_candidate() -> None: """A whitespace-only page (strips to nothing — 0 < any positive bar) is a candidate: the gate counts STRIPPED chars, so padding cannot mask sparseness. (A truly text-LESS page has no key in the map at all — the importer's pass owns that case.)""" assert pictorial_pages({3: " \n\t \r\n "}, min_chars=100) == [3] def test_pictorial_pages_empty_dict_yields_empty_list() -> None: """An empty map (no readable pages at all) yields ``[]`` — the clean-text PDF's zero-candidates no-op (no render, no vision call).""" assert pictorial_pages({}, min_chars=100) == [] def test_pictorial_pages_result_is_ascending_even_when_input_isnt() -> None: """The result is ASCENDING (the render/describe order) regardless of the dict's insertion order — the unsorted candidate set {9, 2, 50, 5} comes back as [2, 5, 9, 50], and the one non-candidate (the 210-char page 12) stays out.""" page_texts = { 9: "tail marker", 2: "head marker", 12: "not sparse " + "x" * 200, # 12+? — long enough, excluded 50: "Illustration 50", 5: "mid marker", } assert pictorial_pages(page_texts, min_chars=100) == [2, 5, 9, 50] def test_pictorial_pages_large_dict() -> None: """A 500-page map with every 10th page sparse (an IKEA-class shape at scale): exactly the sparse pages come back, ascending, and the 450 text-heavy pages are excluded — the pure function handles a whole book in one call (no cap, LOCKED A3/A21).""" page_texts: dict[int, str] = {} expected: list[int] = [] for page in range(1, 501): if page % 10 == 0: page_texts[page] = "Illustration " + str(page) # 11-13 chars expected.append(page) else: page_texts[page] = "w" * 3000 # text-heavy, never a candidate assert len(page_texts) == 500 and len(expected) == 50 assert pictorial_pages(page_texts, min_chars=100) == expected def test_pictorial_pages_ikea_example_from_the_docstring() -> None: """The docstring's IKEA example, pinned against the named threshold (LOCKED A1, default 100): the title page ("BESTÅ", 5 chars) and the "Illustration N" marker pages ARE candidates; the 7 KB of multilingual warnings is NOT. A title page is a candidate — describing it is useful (the logo + product name) and never harmful.""" page_texts = { 1: "BESTÅ", # 5 chars → candidate 2: "w" * 7000, # 7 KB warnings → NOT 6: "Illustration 6", # 14 chars → candidate } assert pictorial_pages(page_texts, min_chars=100) == [1, 6] def test_pictorial_pages_is_pure_and_does_not_mutate_input() -> None: """Pure + deterministic: repeated calls agree, and the input map survives untouched (the gate reads, never rewrites).""" page_texts = {3: "marker", 1: "x" * 500, 2: " "} original = dict(page_texts) first = pictorial_pages(page_texts, min_chars=100) assert pictorial_pages(page_texts, min_chars=100) == first assert page_texts == original def test_pictorial_pages_uses_stripped_length_defensively() -> None: """Defensive strip: an UNSTRIPPED map entry (a caller that did not pre-strip) is judged on its stripped length — padding on either side cannot push a sparse page past the bar.""" assert pictorial_pages({1: " " + "y" * 98 + " "}, min_chars=100) == [1] assert pictorial_pages({1: " " + "y" * 100 + " "}, min_chars=100) == []