phase: 140_pictorial_pdf_pages
Everything is verified green. Final report: ## Phase 140 final verification pass — report - Verified all 4 tasks already complete (code in working tree, task files in `complete/`): `pictorial_pages` gate + 2 settings (`BOR_PDF_PICTORIAL`, `BOR_PDF_PICTORIAL_MIN_CHARS` + validator), `PICTORIAL_PAGE_MODE`/`describe_pdf_page` seam + `render_pdf_pages` page selector + mock branch, `_pictorial_pdf_pass` in `_index_pdf_file` with log-only counters, dedicated E2E. No code changes needed. - Test results (exact commands): - `uv run pytest --cov=app --cov-report=term-missing` → **3320 passed, 11 skipped, 0 failed; TOTAL 99%** (>90% gate) - `uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` → **3 passed in isolation** (Playwright, mock-only) - Pre-existing suites unedited: `test_pdf_support.py` 6 passed; `test_garbage_pdf.py` 2 passed + 1 skipped (owner fixture); `test_pdf_image_context.py` 4 passed - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors - Completion criteria: 1. IKEA tar import repro — pinned by 2 integration tests; **skipped loudly here** (`test_sources/ikea_instructions.tar` absent on this machine — verified repo-wide; same house convention as completed phase-133 HNB tests; runs real on the owner's machine). Equivalent logic fully covered by unit pins on synthetic IKEA-class (vector-drawn, zero-raster) PDFs. 2. Chat grounded on described page — **green** (E2E test b: phrase-only-in-illustration question, PDF in `done.sources`, answer echoes the block) 3. Clean-text no-op (zero vision calls) + garbage precedence pin — **green** (`test_zero_candidates_is_a_noop` unit; `test_garbage_pdf_with_pictorial_on_takes_the_fallback_not_the_pass` integration, verified alone) 4. `BOR_PDF_PICTORIAL=0` byte-identical — **green** (unit pin on synthetic PDF; IKEA variant runs on owner's machine) 5. Full gate + E2E isolation + pre-existing suites — **green** (above) 6. ruff + pyright — **clean** 7. Commit/phase-move — delegated to harness per run rules (left in working tree: 16 modified + 8 new files, uncommitted) - Deviations: none beyond the expected owner-fixture skips (11, all pre-existing fixture-missing skips, including 2 IKEA). - Next pending phase: **none** — `todo/` holds only 140; 139 is already complete.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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) == []
|
||||
Reference in New Issue
Block a user