"""Phase 148 task 04 E2E (Playwright, mock-only): the pictorial PDF opens as its PAGES — a text-heavy PDF renders byte-identical to pre-phase. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov The dedicated suite for ``148_pdf_pages_in_sources`` (A16 — one Playwright file per phase, run in isolation): the owner's TODO L5 scenario end to end on SYNTHETIC fixtures in the owner's named ``ikea_instructions.tar``'s exact CLASS (the real fixture is pinned at the integration level — the E2E keeps the deterministic canned-reply premise). Two PyMuPDF PDFs (the house recipe) land in a LOCAL DIRECTORY source (``BOR_SOURCES_DIR``-sibling temp dir, API-registered — local dirs are the owner's named case) and one real sync indexes them: * ``pictorial.pdf`` — the IKEA shape (the pictorial class): page 1 a dense prose paragraph (over the 100-char sparseness bar — NOT a candidate; the whole layer passes ``is_usable_pdf_text``), pages 2–3 SPARSE (a short marker + a drawn VECTOR figure — rectangle + arrow, <100 stripped chars — the ``pictorial_pages(texts, min_chars=100)`` candidates the mock's vision describes with the canned ``PICTORIAL_PAGE_ANSWER``); * ``textheavy.pdf`` — 2 dense text pages (ZERO candidates — the negative class: the text view, the pre-phase rendering). App env: the conftest pattern with the phase-140 opt-in (``BOR_PDF_ PICTORIAL`` pinned EXPLICITLY ``1`` — the conftest pins it off in ``os.environ`` for the pre-phase-140 suites) + the phase-148 ``BOR_PDF_PAGE_DIR`` pointed at a suite-private scratch dir (the seed asserts the copies THERE; the owner's ``~/bor-sources/pages`` default is never touched). ``BOR_IMPORT_EXTENSIONS`` carries the ``pdf`` token (the phase-126 convention — the owner's local ``.env`` may lack it, and without it the walk is blind to the fixture). MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the described blocks (and the seed's DB witness that the descriptions ARE indexed but NOT displayed) are exact only against the mock's deterministic ``PICTORIAL_PAGE_MODE`` canned reply. Test → observable mapping (Playwright Mapping Rule): 1. ``test_pictorial_pdf_shows_pages_in_modal`` — the seed (module) asserts the DB + filesystem first: the pictorial row has ``page_count == 3`` and the scratch page dir holds EXACTLY ``-p{1..3}.png`` (real PNG magic, non-empty — the textheavy doc's copies do not exist); then the Sources-tree doc modal shows the ``.doc-pages`` strip: exactly three ``.doc-page`` figures, each a LOADED lazy ```` (``complete && naturalWidth > 0`` — the route served a real PNG, not a 404) with the ``Page N of `` alt, the ``Page N`` caption, the NO ``.doc-raw`` / NO ``.doc-md`` / NO ``.doc-summary`` suppression contract (the mock's page descriptions sit in the DB content but nowhere in the rendered modal), the meta badge row (source / Created / Indexed) untouched. 2. ``test_textheavy_pdf_unchanged`` — the textheavy modal renders the PRE-PHASE shape: the content in ``pre.doc-raw`` (both pages), the Summary panel (the mock's lite digest), NO ``.doc-pages`` — and the content payload's ``page_urls`` KEY is ABSENT (never ``null`` — the omission pin the viewer's strip gate rides on). 3. ``test_full_page_surface`` — the same strip on the dedicated ``/document.html`` page (admin-logged-in, the phase-79 gate satisfied): one ``renderDocument`` core — no per-surface drift — same figures, same suppression. 4. ``test_page_route_serves_png`` — an ANONYMOUS (no-login) direct request on ``/api/documents/<id>/pages/1``: 200, ``image/png``, the ``Cache-Control: private, max-age=3600`` header, a non-empty PNG body byte-equal to the stored copy (the public uuid4-credential bytes route, from the UI's perspective). 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 four tests SHARE the module seed (one source, two documents, one sync), 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 from urllib.parse import quote 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, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.mock_llm import PICTORIAL_PAGE_ANSWER REPO = Path(__file__).resolve().parents[2] #: The local directory's NAME — the source name the registry stores #: for a local-kind source (the directory basename, the #: test_local_directory_sources convention). SOURCE_NAME = "e2e-p148-pdf" PICTORIAL_PATH = "pictorial.pdf" TEXTHEAVY_PATH = "textheavy.pdf" PICTORIAL_TITLE = "Assembly Instructions" # the metadata title TEXTHEAVY_TITLE = "Dense Notes" # the metadata title #: Page 1 — the dense prose paragraph (over the 100-char sparseness #: bar — NOT a pictorial candidate; the whole layer is USABLE). PAGE_ONE = ( "This manual covers the complete flat-pack furniture frame, its " "shipped hardware kit, and the safety checks a first-time assembler " "should run before starting. The box holds the two side panels, the " "top board, the adjustable leg set, eight cam locks, fourteen wood " "screws, two bolts, and the wall-anchoring strap. Work on a flat " "surface with soft flooring, keep the hardware sorted by size, and " "read every step through before you begin so you know which panel " "is which. The frame stands roughly a metre tall once assembled, " "weights under twelve kilograms, and needs no further tools beyond " "the included Allen key." ) assert len(PAGE_ONE) > 500, "page 1 must be a 500+ char paragraph (the task's recipe)" #: The sparse pages' markers — ALL the text layer carries on pages #: 2–3 (6 stripped chars, under the 100-char bar — the candidates); #: the figures are drawn vector (no raster image). PAGE_TWO_MARKER = "Fig. 2" PAGE_THREE_MARKER = "Fig. 3" #: The stored illustration blocks (the phase-140 LOCKED A4 shape) — #: the mock's canned ``PICTORIAL_PAGE_MODE`` reply, deterministic. BLOCK_TWO = f"[Page 2 illustration: {PICTORIAL_PAGE_ANSWER}]" BLOCK_THREE = f"[Page 3 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_PDFPAGES", "8161")) APP_URL = f"http://127.0.0.1:{APP_PORT}" # --------------------------------------------------------------------------- # Fixture builder (the house PyMuPDF recipe — the IKEA class: a dense # prose page + sparse vector-illustrated pages) # --------------------------------------------------------------------------- def _draw_illustration(page: Any) -> None: """The IKEA pictogram class: a rectangle (a part) + a line arrow with a two-stroke head (a join) — vector-drawn, so ``get_image_info()`` sees ZERO embedded raster images (the phase-122/137 pipeline is blind to it).""" 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_pictorial(path: Path) -> None: """The IKEA shape: page 1 = the dense prose paragraph (``insert_textbox`` — word-wrapped), pages 2–3 = a short marker + the drawn vector figure (sparse — the pictorial candidates).""" doc = fitz.open() page1 = doc.new_page() rc = page1.insert_textbox(fitz.Rect(72, 72, 523, 772), PAGE_ONE, fontsize=11) assert rc >= 0, "the page-1 paragraph must fit its text box" for marker in (PAGE_TWO_MARKER, PAGE_THREE_MARKER): p = doc.new_page() p.insert_text((72, 72), marker) _draw_illustration(p) doc.set_metadata({"title": PICTORIAL_TITLE}) doc.save(path) doc.close() def _build_textheavy(path: Path) -> None: """The negative class: two DENSE text pages (zero candidates — the text view, byte-identical to pre-phase).""" doc = fitz.open() for i in (1, 2): p = doc.new_page() rc = p.insert_textbox( fitz.Rect(72, 72, 523, 772), f"Paragraph {i}: {PAGE_ONE}", fontsize=11 ) assert rc >= 0, f"the page-{i} paragraph must fit its text box" doc.set_metadata({"title": TEXTHEAVY_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: both fixture PDFs, built ONCE (module-scoped). The directory's NAME is the source name (a ``kind='local'`` registry row indexes under the directory's basename — the test_local_directory_sources convention), so it is a named subdirectory, not the mktemp root (whose name carries a random suffix). The premise pins (deterministic — computed from the fixtures THEMSELVES, no hardcoded page list): the pictorial PDF is usable-layer with candidates [2, 3] (3 pages — the seed's ``page_count`` target); the textheavy PDF is usable-layer with ZERO candidates.""" root = tmp_path_factory.mktemp("bor_p148_pdf") / SOURCE_NAME root.mkdir(parents=True) min_chars = Settings.model_fields["pdf_pictorial_min_chars"].default pic = root / PICTORIAL_PATH heavy = root / TEXTHEAVY_PATH _build_pictorial(pic) _build_textheavy(heavy) pic_texts = extract_pdf_page_texts(pic) heavy_texts = extract_pdf_page_texts(heavy) assert is_usable_pdf_text(extract_pdf_text(pic)), ( "the pictorial fixture must PASS the usability gate (the IKEA " "class — the fallback class is the other qualifying class, not " "this one)" ) assert pictorial_pages(pic_texts, min_chars=min_chars) == [2, 3], ( f"the pictorial fixture's sparse-page premise broke: " f"{pictorial_pages(pic_texts, min_chars=min_chars)}" ) assert is_usable_pdf_text(extract_pdf_text(heavy)) assert pictorial_pages(heavy_texts, min_chars=min_chars) == [], ( "the textheavy fixture must carry ZERO sparse pages (the " "negative class)" ) # Every page renders to a real PNG (the pipeline's byte premise). assert len(render_pdf_pages(pic)) == 3 assert len(render_pdf_pages(heavy)) == 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_p148_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 seed asserts the ``<doc-id>-p{N}.png`` copies here.""" 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 IKEA class) and the phase-148 ``BOR_PDF_PAGE_DIR`` (suite-private — the seed's file assertions target it, the owner's default is never written).""" 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) — the app boots with the # same env shape the story suites use. 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 fixture without it). env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf" # Phase 140: the pictorial toggle EXPLICITLY on (the opt-in — see # the module docstring; without it the whole premise is void). env["BOR_PDF_PICTORIAL"] = "1" # Phase 148: the rendered-page copies' home (the seed asserts the # files here). env["BOR_PDF_PAGE_DIR"] = str(page_dir) # The embedded-image pipeline is out of scope (the fixtures' # figures are vector-drawn — no image doc 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 env only needs the homes; the dir's existence is the seed's job scratch: Path, page_dir: Path, ) -> Iterator[str]: """The app under test (all four tests): one module app with the phase-140/148 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 helpers # --------------------------------------------------------------------------- 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 — the sync re-creates the row.""" 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, and a still-running run would keep importing into the NEXT test's truncated KB): 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 four tests SHARE the module seed's single source + two documents, so there is NO per-test truncate (it would wipe the seed from under the next test — the per-test pattern belongs to the suites where every test imports its own source); instead the shared KB is flushed ONCE, after the last test — no rows leak into the next suite's run. The pre-wait keeps a leaked sync (the module app is still up here) 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`` / phase-94 pattern — a plain directory is an API/DB-only operation; the page form is gone).""" 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; this process runs on the 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 _run_sync(base_url: str, 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_garbage_pdf`` pattern over plain httpx — with the stale-terminal freshness gate: a terminal is accepted only after a running tick was observed or its ``started_at`` post-dates this POST).""" 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 ): return body time.sleep(0.1) raise AssertionError(f"sync did not reach a terminal state: {body}") # --------------------------------------------------------------------------- # The module seed: register the local dir + one sync (both PDFs), then # assert the DB rows and the rendered page copies # --------------------------------------------------------------------------- @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 mock's vision model describes the two sparse pages in-process — the ``PICTORIAL_PAGE_MODE`` canned reply). Asserts the phase-148 index-time contract: the pictorial row qualifies (``page_count == 3``, the canned blocks IN the stored content — the Q&A witness), the textheavy row does not (``page_count`` NULL, no copies), and the scratch page dir holds EXACTLY the pictorial doc's ``<id>-p{1..3}.png`` (real PNGs, non-empty — no orphans, no textheavy copies). Yields both docs' 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) assert body["state"] == "success", body detail = body["detail"] assert detail["files"] == 2, detail assert detail["added"] == 2, detail assert detail["pruned"] == 0, detail assert detail["errors"] == 0, detail with SessionLocal() as db: pic = db.scalar( select(Document).where( Document.source == SOURCE_NAME, Document.path == PICTORIAL_PATH ) ) heavy = db.scalar( select(Document).where( Document.source == SOURCE_NAME, Document.path == TEXTHEAVY_PATH ) ) assert pic is not None, "the pictorial PDF must become a document" assert heavy is not None, "the textheavy PDF must become a document" # The pictorial class (IKEA shape): qualifies. The metadata # title (LOCKED A2); a TEXT document (no image doc, no image # path). assert pic.title == PICTORIAL_TITLE assert pic.is_image is False and pic.image_path is None assert pic.page_count == 3, ( f"the qualifying PDF's page_count must be its page count: {pic.page_count}" ) # The Q&A witness: the described pages ARE in the stored # content (the embeddings/summary ride on them — the strip # just does not display them). assert "---PAGE-1---" in pic.content and "---PAGE-3---" in pic.content assert BLOCK_TWO in pic.content and BLOCK_THREE in pic.content assert pic.summary, "the qualifying doc still gets its lite summary" # The negative class: NO strip (the pre-phase-148 viewer). assert heavy.title == TEXTHEAVY_TITLE assert heavy.page_count is None, ( f"a text-heavy PDF must not qualify: page_count={heavy.page_count}" ) assert heavy.summary, "the textheavy doc still gets its lite summary" pic_id, heavy_id = str(pic.id), str(heavy.id) # The filesystem contract: EXACTLY the qualifying doc's three # copies (no textheavy copies, no orphans) — real, non-empty PNGs. pngs = sorted(page_dir.glob("*.png")) assert [p.name for p in pngs] == [ f"{pic_id}-p1.png", f"{pic_id}-p2.png", f"{pic_id}-p3.png", ], f"the page dir holds {sorted(p.name for p in pngs)}" for png in pngs: data = png.read_bytes() assert data[:8] == PNG_MAGIC and len(data) > 0, f"{png.name} is not a real PNG" assert not list(page_dir.glob(f"{heavy_id}-*")), ( "the non-qualifying PDF must not have page copies" ) yield { "pictorial_id": pic_id, "textheavy_id": heavy_id, "page_dir": page_dir, "title": PICTORIAL_TITLE, } # --------------------------------------------------------------------------- # 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 — both 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) -> None: """The house doc-modal idiom: the row's path link opens the same-page modal (no new tab).""" 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" expect(page.locator("#doc-modal")).to_be_visible(timeout=30_000) def _wait_img_loaded(img: Locator) -> None: """Wait until a LAZY ``<img>`` really loaded pixels: scroll it into the viewport (a below-the-fold lazy img is not fetched yet), then 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_page_strip( content: Locator, title: str, doc_id: str, page_count: int = 3 ) -> None: """The phase-148 strip contract inside a content container (the ONE ``renderDocument`` core — modal and full page, no per-surface drift): a ``.doc-pages`` strip with exactly *page_count* ``.doc-page`` figures; each figure's lazy ``<img>`` is LOADED (the route served a real PNG) with the ``Page N of <title>`` alt and the exact 1-based route src, and the figcaption reads ``Page N``; the text content block AND the Summary panel are SUPPRESSED (the descriptions stay in the DB, not displayed).""" strip = content.locator(".doc-pages") expect(strip).to_have_count(1) 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") # The figure still carries its <img> (a lost copy would have # been swapped for the "Page N unavailable" note instead). 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}") # The suppression contract (the owner's TODO L5): no text content # block, no Summary panel — the meta badge row is asserted by the # caller (it renders exactly as today). expect(content.locator("pre.doc-raw")).to_have_count(0) expect(content.locator(".doc-md")).to_have_count(0) expect(content.locator(".doc-summary")).to_have_count(0) # --------------------------------------------------------------------------- # 1. The pictorial PDF: the modal shows its three rendered pages # --------------------------------------------------------------------------- def test_pictorial_pdf_shows_pages_in_modal( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: """The IKEA-shaped PDF opens in the doc modal as a strip of its REAL pages (the owner's L5 ask): ``.doc-pages`` with exactly three ``.doc-page`` figures — each a loaded lazy ``<img>`` (naturalWidth > 0 proves the full pipeline: sync → render → persist → route → viewer) with the ``Page N of <title>`` alt and the ``Page N`` caption — and the suppression contract: NO text content (``.doc-raw``), NO Summary panel (the mock's page descriptions are in the DB content, nowhere in the rendered modal); the meta badge row (source / Created / Indexed) renders as today.""" login(page, app_url) # lands on /sources.html _drill_to_source(page, SOURCE_NAME) expect(page.locator("#docs-tbody tr")).to_have_count(2) _open_doc_modal(page, PICTORIAL_PATH) expect(page.locator("#doc-modal-title")).to_have_text( seeded["title"] ) content = page.locator("#doc-modal-content") _assert_page_strip(content, seeded["title"], seeded["pictorial_id"]) # The meta badge row is untouched by the strip (the pre-phase row). meta = page.locator("#doc-modal-meta") expect(meta.locator(".doc-source-badge")).to_have_text(SOURCE_NAME) expect(meta.locator(".doc-created")).to_contain_text("Created") expect(meta.locator(".doc-indexed")).to_contain_text("Indexed") # The suppression witness: the mock's canned page descriptions sit # in the DB (the seed asserted it) but are NOT displayed. assert PICTORIAL_PAGE_ANSWER not in content.inner_text(), ( "the described pages' text is in the DB for Q&A — it must not " "be shown to the user (the owner's contract)" ) # --------------------------------------------------------------------------- # 2. The text-heavy PDF: byte-identical to pre-phase # --------------------------------------------------------------------------- def test_textheavy_pdf_unchanged( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: """The zero-candidates PDF renders the PRE-PHASE shape: the content in ``pre.doc-raw`` (both pages, verbatim), the Summary panel (the mock's lite digest), NO ``.doc-pages`` strip — and the payload's ``page_urls`` KEY is ABSENT from the content endpoint's JSON (never ``null`` — the omission pin the viewer's strip gate rides on; the non-qualifying doc's payload is byte-identical to pre-phase).""" login(page, app_url) # lands on /sources.html _drill_to_source(page, SOURCE_NAME) _open_doc_modal(page, TEXTHEAVY_PATH) expect(page.locator("#doc-modal-title")).to_have_text(TEXTHEAVY_TITLE) content = page.locator("#doc-modal-content") # No strip for the negative class… expect(content.locator(".doc-pages")).to_have_count(0) # …the pre-phase raw rendering (both pages, the stored content # verbatim). pre = content.locator("pre.doc-raw") expect(pre).to_have_count(1) expect(pre).to_contain_text("---PAGE-1---") expect(pre).to_contain_text("Paragraph 1:") expect(pre).to_contain_text("---PAGE-2---") expect(pre).to_contain_text("Paragraph 2:") # …and the Summary panel (a non-empty lite summary). expect(content.locator(".doc-summary")).to_have_count(1) expect(content.locator(".doc-summary-text")).not_to_be_empty() # The payload pin: the page_urls KEY is ABSENT (the admin cookie # rides the browser context — the content endpoint is # require_user-gated). r = page.request.get( f"{app_url}/api/documents/content", params={"source": SOURCE_NAME, "path": TEXTHEAVY_PATH}, ) assert r.status == 200, r.text assert "page_urls" not in r.json(), ( f"the non-qualifying doc's payload must not carry page_urls: " f"{r.json().get('page_urls')!r}" ) # --------------------------------------------------------------------------- # 3. The full page surface: the SAME strip, no per-surface drift # --------------------------------------------------------------------------- def test_full_page_surface(page: Page, app_url: str, seeded: dict[str, Any]) -> None: """The dedicated ``/document.html`` page (admin-logged-in — the phase-79 gate satisfied) renders the SAME strip through the ONE shared ``renderDocument`` core: the same three loaded figures with captions/alt texts, the same suppression (no text content, no Summary panel), the meta row as today.""" login(page, app_url, next="/") page.goto( f"{app_url}/document.html?source={quote(SOURCE_NAME)}" f"&path={quote(PICTORIAL_PATH)}" ) expect(page.locator("#doc-title")).to_have_text(seeded["title"]) _assert_page_strip( page.locator("#doc-content"), seeded["title"], seeded["pictorial_id"] ) # The meta row on the full page is the pre-phase shape too. expect(page.locator("#doc-meta .doc-source-badge")).to_have_text(SOURCE_NAME) expect(page.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed") # --------------------------------------------------------------------------- # 4. The page bytes route: public PNG with the image route's headers # --------------------------------------------------------------------------- def test_page_route_serves_png( page: Page, app_url: str, seeded: dict[str, Any] ) -> None: """An ANONYMOUS (no-login — this test's fresh context never signs in) direct request on the first ``page_urls`` entry: 200, ``image/png``, the ``Cache-Control: private, max-age=3600`` header (the image route's header), a non-empty PNG body byte-equal to the stored copy (the public uuid4-credential bytes route — the document id IS the credential — from the UI's perspective).""" doc_id = seeded["pictorial_id"] r = page.request.get(f"{app_url}/api/documents/{doc_id}/pages/1") assert r.status == 200, r.text assert r.headers["content-type"] == "image/png", r.headers assert r.headers["cache-control"] == "private, max-age=3600", r.headers body = r.body() assert body, "the page route served an empty body" assert body[:8] == PNG_MAGIC, "the page route served non-PNG bytes" # The exact stored bytes (the route streams the persistent copy). stored = (seeded["page_dir"] / f"{doc_id}-p1.png").read_bytes() assert body == stored, "the route's bytes are not the stored copy's bytes"