"""Phase 163 task 05 E2E (Playwright, mock-only): the additive PDF strip, the byte-identical primary/plain classes, and the fast-path self-heal — the ``Letumdei`` failure mode, reproduced and made impossible. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov The dedicated suite for ``163_pdf_strip_additive_selfheal`` (A16 — one Playwright file per phase, run in isolation). Pre-fix evidence (2026-10-07, brain.reeseapps.com): ``Letumdei_ Heirs to the Imperium.pdf`` — 36 ``page_urls`` in the payload, ALL 36 page routes 404, 114 389 chars of genuine extracted text with exactly ONE sparse map page (the pictorial class) — the viewer showed "Page N unavailable." on every page with the text hidden behind the strip. Two stacked defects: the pictorial class rendered the strip INSTEAD of its text (fixed by the additive view, LOCKED A1/A6), and the copies were deleted before the replacements were written, outside the DB transaction, so a later rollback (or an external wipe) left the row over zero files with the phase-147 fast path never re-rendering (fixed by write-first persist + the deterministic re-gate, LOCKED A4/A5). One local-directory source (``strip_fixture``, the API-registered ``kind=local`` pattern) carries three synthetic PyMuPDF PDFs — the live shape in miniature: * ``manual.pdf`` — the PICTORIAL class (the Letumdei shape): 3 dense prose pages + ONE sparse map-style page (<100 stripped chars, a vector-drawn figure) → usable text layer, one gate candidate → ``page_strip_mode == "additive"``, ``page_count == 4``; * ``scanned.pdf`` — the FALLBACK class (the phase-133 shape): two pages whose ONLY text is the owner's XML-garbage line repeated 20× → unusable layer → ``page_strip_mode == "primary"``, ``page_count == 2``; * ``plain.pdf`` — clean dense text, ZERO sparse pages → no strip at all (``page_count`` NULL, both strip keys absent from the payload). App env: the conftest pattern with the phase-140 opt-in (``BOR_PDF_ PICTORIAL`` pinned EXPLICITLY ``1``) + the phase-148 ``BOR_PDF_PAGE_DIR`` pointed at a suite-private scratch dir (the heal deletes and re-creates files THERE — the owner's ``~/bor-sources/pages`` default is never touched) + ``BOR_IMPORT_EXTENSIONS`` carrying the ``pdf`` token (the phase-126 convention). The page-directory homes are module-scoped so the heal/repair tests mutate the exact dir the module app reads. MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported for the zero-LLM-call pin (assert 5 reads the mock's ``/v1/e2e/captured`` request log — the phase-123 house pattern for "ZERO chat calls"). Test → observable mapping (Playwright Mapping Rule): 1. ``test_additive_view`` — the ``manual.pdf`` modal: the content payload stamps the class (``page_strip_mode == "additive"`` beside the 4 ``page_urls``), the TEXT view opens by default (Summary panel + content visible — the map page's canned description RIDES the shown text, LOCKED A1; the toggle's ``Text`` pressed, LOCKED A6) with the strip mounted but hidden; pressing ``Pages`` shows the 4-figure strip and EVERY ```` loads (naturalWidth > 0 — the route serves; no "unavailable" note anywhere); pressing ``Text`` back shows the text, hides the strip, and the strip NODE persists (the same DOM element — probed by a dataset marker — with the same 4 loaded imgs: no re-render, no refetch). 2. ``test_primary_and_plain_doms`` — the ``scanned.pdf`` modal keeps the phase-148 strip-only DOM byte-identically (payload ``page_strip_mode == "primary"``; NO ``.doc-view-toggle``, NO ``.doc-summary``, NO content block; 2 loaded ``.doc-page`` figures with the ``Page N`` captions); the ``plain.pdf`` modal is the plain text doc (raw content + Summary panel, ZERO ``.doc-pages`` / ``.doc-view-toggle`` nodes) and its payload carries NEITHER ``page_urls`` NOR ``page_strip_mode`` (the omission rule — the non-qualifying payload is byte-identical to pre-phase). 3. ``test_heal_restores_deleted_copies`` — the live-loss state reproduced: delete ALL ``pdf_page_dir/-p*.png`` (the row still claims 4) → a page route 404s → run a sync over UNCHANGED bytes (the fast path) → the four copies exist again (real PNGs), the route serves 200 with PNG bytes, the row keeps ``page_count`` 4 / mode ``additive``, the payload still carries ``page_urls`` + ``additive``, and the viewer's Pages view loads every img — the exact D5 promise (a wipe cannot outlive one sync). 4. ``test_repair_clears_stale_strip`` — the stale-eligible state NO current index produces: direct-DB seed sets ``plain.pdf``'s row to ``page_count`` 2 / mode ``additive`` and writes two stale copies on disk → sync (still unchanged bytes) → both columns NULL (read through the fixture session), the stale copies GONE, and the modal shows the plain text doc (the strip is finished). 5. ``test_zero_llm_calls_on_heal_and_repair`` — the mock's request log: the INITIAL index burned the vision passes it owes (≥1 ``PICTORIAL_PAGE_MODE`` + ≥1 ``IMAGE_DESCRIPTION_MODE`` request — the capture works and the premise is real), while the heal sync and the repair sync each captured ZERO content/vision chat requests — the re-gate is deterministic (render is CPU, LOCKED A5). The one exempted request is the sync prelude's fixed ``ping`` model-health probe (``app.rag.llm.check_models`` — every sync ever run pays it, unchanged by this phase); anything else in the capture is a violation. House pattern: one module app over the shared E2E Postgres (its own port, module env — the conftest session app is never started in this isolated run, so no port clash). The five tests SHARE the module seed (one source, three documents, one sync) and deliberately MUTATE shared state (the page dir, then one row) in file order, so the shared registry + KB is truncated ONCE, after the last test (a module-granularity autouse — a per-test truncate would wipe the seed from under its siblings), with a no-running-sync wait so a leaked run cannot race it. """ from __future__ import annotations import json import os import subprocess import sys import time from collections.abc import Iterator from datetime import datetime from pathlib import Path from typing import Any import httpx import pymupdf as fitz # PyMuPDF — the synthetic-fixture builder (the house recipe) import pytest from playwright.sync_api import Locator, Page, expect from sqlalchemy import select, text from app.config import Settings from app.db import SessionLocal from app.models import Document from app.rag.pdf import ( extract_pdf_page_texts, extract_pdf_text, is_usable_pdf_text, pictorial_pages, render_pdf_pages, ) from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, MOCK_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, PICTORIAL_PAGE_ANSWER REPO = Path(__file__).resolve().parents[2] #: The local directory's NAME — the source name the registry stores for #: a ``kind='local'`` source (the directory basename, the #: test_local_directory_sources convention). The task's fixture name. SOURCE_NAME = "strip_fixture" MANUAL_PATH = "manual.pdf" SCANNED_PATH = "scanned.pdf" PLAIN_PATH = "plain.pdf" MANUAL_TITLE = "Campaign Manual" # the metadata title (the Letumdei stand-in) SCANNED_TITLE = "Scanned Plates" # the metadata title PLAIN_TITLE = "Plain Notes" # the metadata title #: The dense prose paragraph (500+ chars — over the 100-char sparseness #: bar, so text pages are never gate candidates; the #: test_pdf_pages_in_sources recipe). DENSE_PARAGRAPH = ( "This manual covers the complete campaign, its muster rules, and the " "supply checks a first-time commander should run before marching. The " "train holds the two supply wagons, the field forge, the water cart, " "eight cam locks for the tent pegs, fourteen spare stakes, two spare " "axle bolts, and the signal-horn case. Work from a flat ridge with dry " "ground, keep the squads sorted by banner, and read every dispatch " "through before you begin so you know which standard is which. The " "column marches roughly ten leagues a day once supplied, weighs under " "twelve tonnes, and needs no further provision beyond the local levy." ) assert len(DENSE_PARAGRAPH) > 500, "the dense paragraph must be 500+ chars" #: The sparse map page's marker — ALL page 4's text layer carries (21 #: stripped chars, under the 100-char bar — the ONE gate candidate; the #: figure is vector-drawn, exactly like the live page-36 map). MAP_MARKER = "Map of the Imperium" #: The phase-133 garbage line (the owner's exact shape) — 100% of the #: scanned pages' text looks like tags, so ``is_usable_pdf_text`` fails #: deterministically (the fallback class). GARBAGE_LINE = "Web" GARBAGE_LINES_PER_PAGE = 20 N_SCANNED_PAGES = 2 #: The stored map-page block (the phase-140 LOCKED A4 shape) — the #: mock's canned ``PICTORIAL_PAGE_MODE`` reply, deterministic. MAP_BLOCK = f"[Page 4 illustration: {PICTORIAL_PAGE_ANSWER}]" #: The PNG signature (the route/seed byte assertions' magic number). PNG_MAGIC = b"\x89PNG\r\n\x1a\n" #: Phase 79 (task 04, full inventory): the conftest session app owns #: its port in a combined run — this module app binds its own port #: instead (a same-port second uvicorn dies on bind and would drive the #: wrong server). Env-overridable. APP_PORT = int(os.environ.get("E2E_APP_PORT_STRIPHEAL", "8177")) APP_URL = f"http://127.0.0.1:{APP_PORT}" MOCK_URL = f"http://127.0.0.1:{MOCK_PORT}" #: The suite's LLM-request ledger (assert 5): every sync stores the #: mock's chat-completions capture (``/v1/e2e/captured`` — the phase-123 #: house pattern) taken AROUND its own run under its name, so the #: heal/repair syncs' ZERO-call pins read from the log the app itself #: produced, not from a spy that never saw the real request. LLM_CAPTURES: dict[str, list[dict[str, Any]]] = {} # --------------------------------------------------------------------------- # Fixture builders (the house PyMuPDF recipe — the three strip classes) # --------------------------------------------------------------------------- def _draw_map_figure(page: Any) -> None: """The vector map figure: a rectangle (the province) + a road line with a two-stroke head — drawn, not embedded, so the text layer stays the ONLY text and the embedded-image pipeline sees nothing.""" page.draw_rect(fitz.Rect(100, 120, 300, 220)) page.draw_line(fitz.Point(320, 170), fitz.Point(470, 170)) page.draw_line(fitz.Point(470, 170), fitz.Point(450, 158)) page.draw_line(fitz.Point(470, 170), fitz.Point(450, 182)) def _build_manual(path: Path) -> None: """The Letumdei shape in MINATURE (the pictorial class): three dense prose pages + ONE sparse map-style page. Usable text layer with exactly one gate candidate (page 4) → the ADDITIVE strip class.""" doc = fitz.open() for i in range(1, 4): p = doc.new_page() rc = p.insert_textbox( fitz.Rect(72, 72, 523, 772), f"Book {i}. {DENSE_PARAGRAPH}", fontsize=11 ) assert rc >= 0, f"the page-{i} paragraph must fit its text box" p4 = doc.new_page() p4.insert_text((72, 72), MAP_MARKER) _draw_map_figure(p4) doc.set_metadata({"title": MANUAL_TITLE}) doc.save(path) doc.close() def _build_scanned(path: Path) -> None: """The phase-133 fallback class: two pages whose ONLY text is the XML-garbage line ×20 — the gate fails, so the page SCREENSHOTS are the content (the PRIMARY strip class).""" doc = fitz.open() for page_num in range(1, N_SCANNED_PAGES + 1): page = doc.new_page() for i in range(GARBAGE_LINES_PER_PAGE): page.insert_text((72, 72 + i * 14), f"{GARBAGE_LINE} p{page_num} l{i}") doc.set_metadata({"title": SCANNED_TITLE}) doc.save(path) doc.close() def _build_plain(path: Path) -> None: """The negative class: two dense text pages, ZERO sparse candidates — no strip ever (until the repair test seeds a stale one).""" doc = fitz.open() for i in (1, 2): p = doc.new_page() rc = p.insert_textbox( fitz.Rect(72, 72, 523, 772), f"Note {i}. {DENSE_PARAGRAPH}", fontsize=11 ) assert rc >= 0, f"the page-{i} paragraph must fit its text box" doc.set_metadata({"title": PLAIN_TITLE}) doc.save(path) doc.close() @pytest.fixture(scope="module") def pdfs_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: """The local directory source's root: the three fixture PDFs, built ONCE (module-scoped). The premise pins (deterministic — computed from the fixtures THEMSELVES, no hardcoded page lists): ``manual`` is usable-layer with candidates ``[4]`` (4 pages — the seed's ``page_count`` target, the Letumdei shape), ``scanned`` is UNUSABLE-layer (the fallback class), ``plain`` is usable-layer with ZERO candidates (no strip). Every page renders to a real PNG (the pipeline's byte premise).""" root = tmp_path_factory.mktemp("bor_p163_strip") / SOURCE_NAME root.mkdir(parents=True) min_chars = Settings.model_fields["pdf_pictorial_min_chars"].default manual = root / MANUAL_PATH scanned = root / SCANNED_PATH plain = root / PLAIN_PATH _build_manual(manual) _build_scanned(scanned) _build_plain(plain) manual_texts = extract_pdf_page_texts(manual) assert is_usable_pdf_text(extract_pdf_text(manual)), ( "the manual fixture must PASS the usability gate (the pictorial class)" ) assert pictorial_pages(manual_texts, min_chars=min_chars) == [4], ( "the manual fixture's sparse-page premise broke: candidates=" f"{pictorial_pages(manual_texts, min_chars=min_chars)}" ) assert not is_usable_pdf_text(extract_pdf_text(scanned)), ( "the scanned fixture's text layer must FAIL the usability gate " "(the fallback class)" ) assert is_usable_pdf_text(extract_pdf_text(plain)) assert pictorial_pages( extract_pdf_page_texts(plain), min_chars=min_chars ) == [], "the plain fixture must carry ZERO sparse pages (the negative class)" assert len(render_pdf_pages(manual)) == 4 assert len(render_pdf_pages(scanned)) == N_SCANNED_PAGES assert len(render_pdf_pages(plain)) == 2 return root @pytest.fixture(scope="module") def scratch(tmp_path_factory: pytest.TempPathFactory) -> Path: """The suite-private app homes (pages dir, upload dir, checkouts, images) — disposable with the module.""" return tmp_path_factory.mktemp("bor_p163_scratch") @pytest.fixture(scope="module") def page_dir(scratch: Path) -> Path: """The app's ``pdf_page_dir`` (``BOR_PDF_PAGE_DIR``) for this suite — the heal test DELETES the manual doc's copies here and the re-gate re-creates them; the owner's ``~/bor-sources/pages`` default is never touched.""" return scratch / "pages" # --------------------------------------------------------------------------- # Module app (the conftest env pattern + the phase-140/148 knobs) # --------------------------------------------------------------------------- def _app_env(mock_llm: int, scratch: Path, page_dir: Path) -> dict[str, str]: """The conftest app env (leak guards included) with the phase-140 opt-in (``BOR_PDF_PICTORIAL`` EXPLICITLY on — the conftest pins it off in ``os.environ`` for the pre-phase suites; this suite IS the pictorial class + its kill-switch-mirroring re-gate) and the phase-148 ``BOR_PDF_PAGE_DIR`` (suite-private — the heal mutates this dir, and the app under test reads the same one).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = ( "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_llm}/v1" ) # Mock-calibrated gates (conftest pattern). env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" # Phase 67: instant retry waits + the code-default budget. env["BOR_LLM_RETRY_DELAY"] = "0" env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) env.setdefault( "BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", ) env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET # Leak guards (conftest pattern — the owner's local gitignored # .env cannot leak the scope). env["BOR_GIT_SOURCES"] = "" env["BOR_DOCS_REPO"] = "" env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default) env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default # Phase 126: the import scope WITH the ``pdf`` token (the walk is # blind to the fixtures without it). env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf" # Phase 140: the pictorial toggle EXPLICITLY on (the opt-in — # without it the whole premise is void). env["BOR_PDF_PICTORIAL"] = "1" # Phase 148: the rendered-page copies' home (the heal's delete/ # re-render target). env["BOR_PDF_PAGE_DIR"] = str(page_dir) # The embedded-image pipeline is out of scope (the figures are # vector-drawn — no image docs either way). env["BOR_IMAGES"] = "false" env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") env["BOR_SOURCES_DIR"] = str(scratch / "checkouts") env["BOR_IMAGE_DIR"] = str(scratch / "images") return env @pytest.fixture(scope="module") def app_server( mock_llm: int, pdfs_dir: Path, # noqa: ARG001 — the dir's existence is the seed's job scratch: Path, page_dir: Path, ) -> Iterator[str]: """The app under test (all five tests): one module app with the phase-140/148/163 env (see :func:`_app_env`).""" proc = subprocess.Popen( [ sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning", ], cwd=REPO, env=_app_env(mock_llm, scratch, page_dir), ) try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server # --------------------------------------------------------------------------- # DB + sync + LLM-capture helpers (the house patterns) # --------------------------------------------------------------------------- def _truncate_all() -> None: """Fresh KB + registry (the E2E isolation pattern): the suites share one Postgres, and a leftover row would corrupt the counts. ``sources_meta`` (the KB generation counter) resets with the KB.""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, steering_notes, " "kb_overview, git_sources, folder_summaries, sources_meta" ) ) db.commit() # without the commit the TRUNCATE rolls back (the house pattern) def _health_db(app_url: str) -> bool: try: return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up" except Exception: # noqa: BLE001 — unreachable is the skip case return False def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None: """No background sync may leak across tests (the run state lives in the app's memory): wait for the app's sync status to be non-running BEFORE the truncate (the phase-96 pattern).""" with httpx.Client(base_url=base_url, timeout=5.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: r = client.get("/api/sync/status") assert r.status_code == 200, r.text if r.json()["state"] != "running": return time.sleep(0.2) raise AssertionError(f"a sync was still running at a test boundary: {base_url}") @pytest.fixture(scope="module", autouse=True) def _clean_kb(app_url: str) -> Iterator[None]: """Module-end KB cleanup (the phase-96 pattern, module granularity): the five tests SHARE the module seed's one source + three documents and MUTATE shared state (the page dir, then one row) in file order, so there is NO per-test truncate; the shared KB is flushed ONCE, after the last test. The pre-wait keeps a leaked sync from racing the truncate.""" yield _wait_sync_settled(app_url) _truncate_all() def _register_source(base_url: str, path: Path) -> None: """Register the local directory through the authenticated API (the ``test_local_directory_sources`` pattern — a plain directory is an API/DB-only operation).""" with httpx.Client(base_url=base_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text r = client.post("/api/git-sources", json={"kind": "local", "path": str(path)}) assert r.status_code == 201, r.text def _started_at(body: dict[str, Any]) -> float: """The status body's ``started_at`` as a wall-clock epoch (the server stamps it in UTC; same host, same clock).""" started = body.get("started_at") if not started: return 0.0 try: return datetime.fromisoformat(str(started)).timestamp() except ValueError: return 0.0 def _capture_reset() -> None: """Clear the mock's chat-completions capture (the phase-123 house endpoint) so the NEXT sync's LLM requests are exactly what the capture holds afterwards. The MOCK-ONLY pin is skipped under ``E2E_REAL_LLM=1`` (no capture to read — assert 5 skips too).""" if USE_REAL_LLM: return r = httpx.post(f"{MOCK_URL}/v1/e2e/captured/reset", timeout=5.0) assert r.status_code == 200, r.text def _capture_read(name: str) -> list[dict[str, Any]]: """Store the mock's current capture in the suite ledger under *name* (see :data:`LLM_CAPTURES`).""" if USE_REAL_LLM: LLM_CAPTURES[name] = [] return [] r = httpx.get(f"{MOCK_URL}/v1/e2e/captured", timeout=5.0) assert r.status_code == 200, r.text captured = r.json() LLM_CAPTURES[name] = captured return captured def _run_sync( base_url: str, capture_name: str | None = None, timeout_s: float = 120.0 ) -> dict[str, Any]: """Login + ``POST /api/sync`` + poll the status endpoint until THIS run reaches a terminal state (the ``test_pdf_pages_in_sources`` pattern — with the stale-terminal freshness gate). When *capture_name* is given, the mock's request capture is reset before the POST and stored under that name once the run settles — the sync's ENTIRE LLM footprint, bracketed (assert 5's lens).""" _capture_reset() with httpx.Client(base_url=base_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text t0 = time.time() r = client.post("/api/sync") assert r.status_code == 202, r.text saw_running = False deadline = time.monotonic() + timeout_s body: dict[str, Any] = {} while time.monotonic() < deadline: r = client.get("/api/sync/status") assert r.status_code == 200, r.text body = r.json() state = body["state"] if state == "running": saw_running = True elif state in ("success", "failed") and ( saw_running or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance ): if capture_name is not None: _capture_read(capture_name) return body time.sleep(0.1) if capture_name is not None: _capture_read(capture_name) raise AssertionError(f"sync did not reach a terminal state: {body}") def _doc_row(path: str) -> Document: """The fixture session's view of one source row (the DB-lens the heal/repair asserts read through — ``expire_on_commit`` off means the values are the row's, freshly queried per call).""" with SessionLocal() as db: doc = db.scalar( select(Document).where( Document.source == SOURCE_NAME, Document.path == path ) ) assert doc is not None, f"the {path} row must exist" db.expunge(doc) return doc def _payload(app_url: str, path: str) -> dict[str, Any]: """The document-content payload (the surface the viewer renders from) as a dict — the ``page_urls`` / ``page_strip_mode`` key presence rules are asserted on THIS JSON, not the DB.""" with httpx.Client(base_url=app_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text r = client.get("/api/documents/content", params={"source": SOURCE_NAME, "path": path}) assert r.status_code == 200, r.text return r.json() # --------------------------------------------------------------------------- # The module seed: register the local dir + one sync (all three PDFs), # then assert the three-class DB + filesystem state # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def seeded(app_url: str, pdfs_dir: Path, page_dir: Path) -> Iterator[dict[str, Any]]: """Register the local directory source and run ONE real sync (the index pass, its capture stored as ``LLM_CAPTURES["index"]``). Asserts the post-index three-class contract: ``manual`` is the pictorial class (``page_count`` 4, mode ``additive``, the canned map block IN the content — the Letumdei shape), ``scanned`` the fallback class (``page_count`` 2, mode ``primary``, the canned screenshot descriptions, the garbage NOT stored), ``plain`` unqualified (both columns NULL) — and the page dir holds EXACTLY the two qualifying docs' six copies (real PNGs, no plain copies, no orphans). Yields the ids + the page dir.""" if not _health_db(app_url): pytest.skip("Postgres not reachable — run `podman compose up -d db` first") _truncate_all() _register_source(app_url, pdfs_dir) body = _run_sync(app_url, capture_name="index") assert body["state"] == "success", body detail = body["detail"] assert detail["files"] == 3, detail assert detail["added"] == 3, detail assert detail["pruned"] == 0, detail assert detail["errors"] == 0, detail manual = _doc_row(MANUAL_PATH) assert manual.title == MANUAL_TITLE assert manual.page_count == 4, ( f"the pictorial-class row must carry its page count: {manual.page_count}" ) assert manual.page_strip_mode == "additive", ( "the pictorial class (usable layer + sparse candidate) must stamp " f"additive: {manual.page_strip_mode!r}" ) # The phase-140 descriptions ride the stored content (Q&A parity) — # the ONE map block is the live Letumdei shape in miniature. assert "---PAGE-1---" in manual.content and "---PAGE-4---" in manual.content assert MAP_BLOCK in manual.content, ( "the map page's canned description must be in the stored content" ) assert manual.summary scanned = _doc_row(SCANNED_PATH) assert scanned.page_count == N_SCANNED_PAGES assert scanned.page_strip_mode == "primary", ( "the fallback class (unusable layer) must stamp primary: " f"{scanned.page_strip_mode!r}" ) assert scanned.content.startswith("---PAGE-1---") assert IMAGE_DESCRIPTION_ANSWER in scanned.content, ( "the fallback's content must be the canned page-screenshot descriptions" ) assert GARBAGE_LINE not in scanned.content, ( "the garbage text layer must NOT be the stored content" ) plain = _doc_row(PLAIN_PATH) assert plain.page_count is None, "a zero-candidate PDF must not qualify" assert plain.page_strip_mode is None manual_id, scanned_id, plain_id = str(manual.id), str(scanned.id), str(plain.id) # The filesystem contract: EXACTLY the two qualifying docs' copies # (no plain copies, no orphans) — real, non-empty PNGs. expected = sorted( [f"{manual_id}-p{n}.png" for n in range(1, 5)] + [f"{scanned_id}-p{n}.png" for n in range(1, N_SCANNED_PAGES + 1)] ) pngs = sorted(p.name for p in page_dir.glob("*.png")) assert pngs == expected, f"the page dir holds {pngs}, expected {expected}" for name in pngs: data = (page_dir / name).read_bytes() assert data[:8] == PNG_MAGIC and data, f"{name} is not a real PNG" assert not list(page_dir.glob(f"{plain_id}-*")), ( "the non-qualifying PDF must not have page copies" ) yield { "manual_id": manual_id, "scanned_id": scanned_id, "plain_id": plain_id, "page_dir": page_dir, } # --------------------------------------------------------------------------- # Shared UI helpers # --------------------------------------------------------------------------- def _drill_to_source(page: Page, name: str) -> None: """Sources tree: top level → the source (the file table then holds its direct files — all three fixtures sit at the directory root).""" page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{name}")') expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000) def _open_doc_modal(page: Page, doc_path: str) -> Locator: """The house doc-modal idiom: the row's path link opens the same-page modal (no new tab). Returns the content slot locator.""" before = len(page.context.pages) page.locator(f'#docs-tbody a.doc-link:text-is("{doc_path}")').click() assert len(page.context.pages) == before, "the row link must not open a new tab" modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) return page.locator("#doc-modal-content") def _wait_img_loaded(img: Locator) -> None: """Wait until a LAZY ```` really loaded pixels: scroll it into the viewport, await the browser's own ``decode()`` promise (it rejects on a failed fetch — a 404 route), and assert ``naturalWidth > 0`` (a real PNG, not a zero-size error image).""" img.scroll_into_view_if_needed() img.evaluate("el => el.decode()") natural = img.evaluate("el => el.naturalWidth") assert natural > 0, f"the page image did not load (naturalWidth={natural})" def _assert_figures(strip: Locator, title: str, doc_id: str, page_count: int) -> None: """The phase-148 figure contract inside a (visible) strip: exactly *page_count* ``.doc-page`` figures, each with a LOADED lazy ```` (the route served a real PNG — not the "Page N unavailable" note), the ``Page N of `` alt, the exact 1-based route src, and the ``Page N`` caption.""" figures = strip.locator(".doc-page") expect(figures).to_have_count(page_count) for i in range(page_count): n = i + 1 fig = figures.nth(i) img = fig.locator("img") expect(img).to_have_count(1) expect(img).to_have_attribute("loading", "lazy") expect(img).to_have_attribute("alt", f"Page {n} of {title}") expect(img).to_have_attribute("src", f"/api/documents/{doc_id}/pages/{n}") _wait_img_loaded(img) expect(fig.locator("figcaption.doc-page-num")).to_have_text(f"Page {n}") # --------------------------------------------------------------------------- # 1. The additive view: text first, the strip one toggle away, no # refetch on flip-flop # --------------------------------------------------------------------------- def test_additive_view(page: Page, app_url: str, seeded: dict[str, Any]) -> None: """The ``manual.pdf`` modal — the ADDITIVE class (LOCKED A1/A6): the content payload stamps the class (``page_strip_mode == "additive"`` beside the 4 ``page_urls``); the TEXT view opens by default — Summary panel + content visible, the toggle's ``Text`` ``aria-pressed="true"``, the strip mounted but hidden, the map page's canned description RIDING the shown text (the text is never hidden for the pictorial class); pressing ``Pages`` shows the 4-figure strip with EVERY img loaded (naturalWidth > 0 — the route serves; no "unavailable" note anywhere) and hides the text view; pressing ``Text`` back shows the text, hides the strip, and the strip node is the SAME mounted DOM element (a dataset probe survives the flip — no re-render) with its 4 imgs still loaded (no refetch).""" # The wire pin: the class is stamped next to the page_urls. payload = _payload(app_url, MANUAL_PATH) assert payload.get("page_strip_mode") == "additive", payload assert payload.get("page_urls") == [ f"/api/documents/{seeded['manual_id']}/pages/{n}" for n in range(1, 5) ], payload login(page, app_url) # lands on /sources.html _drill_to_source(page, SOURCE_NAME) expect(page.locator("#docs-tbody tr")).to_have_count(3) content = _open_doc_modal(page, MANUAL_PATH) expect(page.locator("#doc-modal-title")).to_have_text(MANUAL_TITLE) # Default view: TEXT (LOCKED A6) — Summary panel + content visible, # the strip mounted but hidden (its lazy imgs never fetched yet). toggle = content.locator(".doc-view-toggle") expect(toggle).to_have_count(1) expect(toggle.get_by_role("button", name="Text")).to_have_attribute( "aria-pressed", "true" ) expect(toggle.get_by_role("button", name="Pages")).to_have_attribute( "aria-pressed", "false" ) expect(content.locator("pre.doc-raw")).to_be_visible() expect(content.locator(".doc-summary")).to_be_visible() expect(content.locator(".doc-pages")).to_be_hidden() assert MAP_MARKER in content.inner_text(), "the text view shows the book's text" assert PICTORIAL_PAGE_ANSWER in content.inner_text(), ( "the additive text view shows the indexed content INCLUDING the " "map page's description (LOCKED A1 — the text is never hidden)" ) # Pages: the strip shows, every copy loads, the text view hides. toggle.get_by_role("button", name="Pages").click() expect(content.locator(".doc-pages")).to_be_visible() _assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, seeded["manual_id"], 4) assert "unavailable" not in content.inner_text(), ( "no page may show the 'Page N unavailable' note once the strip shows" ) expect(content.locator("pre.doc-raw")).not_to_be_visible() expect(content.locator(".doc-summary")).not_to_be_visible() # Tag the strip's FIRST img so the flip-back can prove the node is # the same DOM element (a re-render would build a fresh one). first_img = content.locator(".doc-pages .doc-page img").first first_img.evaluate("el => { el.dataset.p163probe = 'mounted'; }") # Text: the reversal — text visible, strip hidden, the strip node # STILL MOUNTED with its 4 loaded imgs (zero refetch). toggle.get_by_role("button", name="Text").click() expect(toggle.get_by_role("button", name="Text")).to_have_attribute( "aria-pressed", "true" ) expect(content.locator("pre.doc-raw")).to_be_visible() expect(content.locator(".doc-pages")).to_be_hidden() expect(content.locator(".doc-pages .doc-page img")).to_have_count(4) assert ( content.locator(".doc-pages .doc-page img").first.evaluate( "el => el.dataset.p163probe" ) == "mounted" ), "flipping back must reuse the mounted strip (no re-render, no refetch)" assert content.locator(".doc-pages .doc-page img").first.evaluate( "el => el.complete && el.naturalWidth > 0" ), "the mounted imgs stay decoded across the flip (no reset)" # --------------------------------------------------------------------------- # 2. The primary class (byte-identical strip-only DOM) + the plain doc # --------------------------------------------------------------------------- def test_primary_and_plain_doms(page: Page, app_url: str, seeded: dict[str, Any]) -> None: """The two untouched shapes, pinned side by side. ``scanned.pdf`` (the fallback class, payload ``page_strip_mode == "primary"``) keeps the phase-148 strip-only DOM byte-identically: NO ``.doc-view-toggle``, NO ``.doc-summary``, NO content block — just the 2-figure strip, every img loaded with the ``Page N`` captions. ``plain.pdf`` (no strip) renders the plain text doc — the raw content + the Summary panel, ZERO ``.doc-pages`` / ``.doc-view-toggle`` nodes — and its payload carries NEITHER ``page_urls`` NOR ``page_strip_mode`` (the ABSENT-never-null omission rule: the non-qualifying payload is byte-identical to pre-phase).""" # The wire pins: primary stamped; both keys absent for plain. payload = _payload(app_url, SCANNED_PATH) assert payload.get("page_strip_mode") == "primary", payload assert payload.get("page_urls") == [ f"/api/documents/{seeded['scanned_id']}/pages/{n}" for n in range(1, N_SCANNED_PAGES + 1) ], payload plain_payload = _payload(app_url, PLAIN_PATH) assert "page_urls" not in plain_payload, plain_payload assert "page_strip_mode" not in plain_payload, plain_payload login(page, app_url) # lands on /sources.html _drill_to_source(page, SOURCE_NAME) # The primary class: the strip IS the view — no toggle, no Summary # panel, no content block (the phase-148 DOM, byte-for-byte). content = _open_doc_modal(page, SCANNED_PATH) expect(page.locator("#doc-modal-title")).to_have_text(SCANNED_TITLE) expect(content.locator(".doc-view-toggle")).to_have_count(0) expect(content.locator(".doc-summary")).to_have_count(0) expect(content.locator("pre.doc-raw")).to_have_count(0) strip = content.locator(".doc-pages") expect(strip).to_have_count(1) expect(strip).to_be_visible() _assert_figures(strip, SCANNED_TITLE, seeded["scanned_id"], N_SCANNED_PAGES) # The plain doc: the pre-phase text rendering — no strip artifacts. page.goto(f"{app_url}/sources.html") _drill_to_source(page, SOURCE_NAME) content = _open_doc_modal(page, PLAIN_PATH) expect(page.locator("#doc-modal-title")).to_have_text(PLAIN_TITLE) expect(content.locator(".doc-pages")).to_have_count(0) expect(content.locator(".doc-view-toggle")).to_have_count(0) expect(content.locator("pre.doc-raw")).to_have_count(1) expect(content.locator("pre.doc-raw")).to_contain_text("---PAGE-1---") expect(content.locator("pre.doc-raw")).to_contain_text("Note 2.") expect(content.locator(".doc-summary")).to_have_count(1) # --------------------------------------------------------------------------- # 3. The heal: copies deleted under a stamped page_count → one # unchanged-byte sync restores everything # --------------------------------------------------------------------------- def test_heal_restores_deleted_copies( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: """The live ``Letumdei`` failure mode, reproduced and healed: delete ALL ``pdf_page_dir/<manual-id>-p*.png`` (the row still claims 4) → a page route 404s (the exact live evidence) → run a sync over UNCHANGED bytes (the phase-147 fast path) → the four copies exist again (real PNGs), the route serves 200 with PNG bytes, the row keeps ``page_count`` 4 / mode ``additive``, the payload still carries ``page_urls`` + ``additive``, and the viewer's Pages view loads EVERY img — the disk-level loss the owner saw cannot outlive one sync (LOCKED A5, the D5 promise).""" manual_id = seeded["manual_id"] page_dir: Path = seeded["page_dir"] copies = sorted(page_dir.glob(f"{manual_id}-p*.png")) assert len(copies) == 4, f"the seed must leave 4 copies: {[p.name for p in copies]}" for p in copies: p.unlink() assert not list(page_dir.glob(f"{manual_id}-p*.png")), "the wipe must be complete" # The live-loss state on the wire: the row claims 4, the route 404s. r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1") assert r.status == 404, f"the wiped copy must 404 before the heal: {r.status}" # One sync over unchanged bytes — the fast path + the re-gate. The # run's whole LLM footprint is captured (assert 5 consumes it). body = _run_sync(app_url, capture_name="heal") assert body["state"] == "success", body detail = body["detail"] assert detail["unchanged"] == 3, detail assert detail["added"] == 0, detail assert detail["errors"] == 0, detail # The disk: the four copies exist again — real PNGs. restored = sorted(page_dir.glob(f"{manual_id}-p*.png")) assert [p.name for p in restored] == [ f"{manual_id}-p{n}.png" for n in range(1, 5) ], f"the heal left {[p.name for p in restored]}" for p in restored: data = p.read_bytes() assert data[:8] == PNG_MAGIC and data, f"{p.name} is not a real PNG after heal" # The route serves again; the payload keeps the strip + the class. r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1") assert r.status == 200, f"the healed copy must serve: {r.status}" assert r.headers["content-type"] == "image/png", r.headers assert r.body()[:8] == PNG_MAGIC, "the healed route served non-PNG bytes" payload = _payload(app_url, MANUAL_PATH) assert payload.get("page_strip_mode") == "additive", payload assert len(payload.get("page_urls", [])) == 4, payload # The row (the fixture session's view): count + class intact. row = _doc_row(MANUAL_PATH) assert row.page_count == 4 assert row.page_strip_mode == "additive" # The viewer, complete: every page image loads through the route. login(page, app_url) _drill_to_source(page, SOURCE_NAME) content = _open_doc_modal(page, MANUAL_PATH) content.locator(".doc-view-toggle").get_by_role("button", name="Pages").click() expect(content.locator(".doc-pages")).to_be_visible() _assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, manual_id, 4) assert "unavailable" not in content.inner_text(), ( "after the heal the viewer must show zero 'Page N unavailable' notes" ) # --------------------------------------------------------------------------- # 4. The repair arm: a stale page_count on a now-ineligible doc is # cleared, with its copies # --------------------------------------------------------------------------- def test_repair_clears_stale_strip( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: """The stale-eligible state NO current index produces, seeded direct-DB: ``plain.pdf``'s row gets ``page_count`` 2 + mode ``additive`` and two stale copies land on disk (the strip-shape a pre-phase bug or a lowered ``pdf_pictorial_min_chars`` leaves behind). One sync (the bytes stay unchanged) → the REPAIR arm: both columns NULL again (read through the fixture session), the stale copies GONE, and the modal shows the plain text doc — the strip is finished. The other docs' strips are untouched.""" plain_id = seeded["plain_id"] page_dir: Path = seeded["page_dir"] # The seed: the row claims a strip its current gate would never set. with SessionLocal() as db: doc = db.scalar( select(Document).where( Document.source == SOURCE_NAME, Document.path == PLAIN_PATH ) ) assert doc is not None doc.page_count = 2 doc.page_strip_mode = "additive" db.commit() stale = [page_dir / f"{plain_id}-p{n}.png" for n in (1, 2)] for p in stale: p.write_bytes(b"STALE-PRE-PHASE-COPY") # not even a real PNG body = _run_sync(app_url, capture_name="repair") assert body["state"] == "success", body detail = body["detail"] assert detail["unchanged"] == 3, detail assert detail["errors"] == 0, detail # The row (the fixture session's view): both columns cleared. row = _doc_row(PLAIN_PATH) assert row.page_count is None, ( f"the repair arm must clear a stale page_count: {row.page_count}" ) assert row.page_strip_mode is None, ( f"the repair arm must clear the mode with the count: {row.page_strip_mode}" ) # The stale copies are gone; the other docs' copies untouched. assert not list(page_dir.glob(f"{plain_id}-*")), ( "the repair arm must delete the stale copies" ) assert len(list(page_dir.glob(f"{seeded['manual_id']}-p*.png"))) == 4, ( "the repair of one doc must not touch another doc's copies" ) assert len(list(page_dir.glob(f"{seeded['scanned_id']}-p*.png"))) == 2 # The wire + the viewer: the strip is gone, the plain text doc is # back (the same payload shape as test 2 pinned). assert "page_urls" not in _payload(app_url, PLAIN_PATH) login(page, app_url) _drill_to_source(page, SOURCE_NAME) content = _open_doc_modal(page, PLAIN_PATH) expect(content.locator(".doc-pages")).to_have_count(0) expect(content.locator(".doc-view-toggle")).to_have_count(0) expect(content.locator("pre.doc-raw")).to_be_visible() # --------------------------------------------------------------------------- # 5. Zero LLM calls on the fast-path repair (LOCKED A5) # --------------------------------------------------------------------------- def test_zero_llm_calls_on_heal_and_repair(seeded: dict[str, Any]) -> None: """The mock's request log (the house capture pattern): the INITIAL index burned the vision passes it owes — at least one ``PICTORIAL_PAGE_MODE`` request (the map page) and at least one ``IMAGE_DESCRIPTION_MODE`` request (the fallback screenshots), the witnesses that the capture really sees the app's calls — while the HEAL sync and the REPAIR sync each captured EXACTLY ZERO chat-completions requests. The re-gate mirrors the gate deterministically from the current bytes + settings: rendering is CPU-only, so the self-heal burns zero LLM/vision calls (LOCKED A5).""" if USE_REAL_LLM: pytest.skip("the zero-call pin reads the mock's request capture") index = LLM_CAPTURES.get("index") heal = LLM_CAPTURES.get("heal") repair = LLM_CAPTURES.get("repair") assert index is not None and heal is not None and repair is not None, ( "the suite ledger must hold all three syncs' captures — run the tests in order" ) dumped = json.dumps(index) assert "PICTORIAL_PAGE_MODE" in dumped, ( "the initial index must have burned the pictorial vision pass — " "without this witness the zero-call pin below is vacuous" ) assert "IMAGE_DESCRIPTION_MODE" in dumped, ( "the initial index must have burned the fallback screenshot pass" ) heal_content = [b for b in heal if not _is_health_probe(b)] repair_content = [b for b in repair if not _is_health_probe(b)] assert heal_content == [], f"the heal sync burned LLM calls: {_summarize(heal_content)}" assert repair_content == [], ( f"the repair sync burned LLM calls: {_summarize(repair_content)}" ) def _is_health_probe(body: dict[str, Any]) -> bool: """The sync prelude's fixed model-health ping (``app.rag.llm .check_models`` — one non-streaming ``ping`` chat every sync ever run pays, unchanged by phase 163). Exempted from the zero-call pins; NOTHING else may appear in a heal/repair capture.""" msgs = body.get("messages") or [] return ( not body.get("stream") and len(msgs) == 1 and msgs[0].get("content") == "ping" ) def _summarize(captured: list[dict[str, Any]]) -> str: """A compact one-liner per captured request (its system-prompt marker) for the failure messages — keeps a violation readable without dumping whole prompt bodies.""" out = [] for body in captured: msgs = body.get("messages", []) first = str(msgs[0].get("content", ""))[:80] if msgs else "?" out.append(f"chat(stream={bool(body.get('stream'))}): {first}") return " | ".join(out)