"""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) are exact only against the mock's deterministic ``PICTORIAL_PAGE_MODE`` canned reply. (Phase 163 re-cut: the viewer assertions moved from the phase-148 suppression contract to the ADDITIVE contract — the pictorial class shows its text INCLUDING the page descriptions, with the strip behind the Text/Pages toggle.) 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 opens the ADDITIVE view (phase 163 — a usable text layer: TEXT first, the strip one toggle away): the Text/Pages group with ``Text`` pressed, the Summary panel + content visible (the mock's page descriptions RIDE the shown text — LOCKED A1/A6), the ``.doc-pages`` strip mounted but hidden; pressing ``Pages`` shows 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 and the ``Page N`` caption while the text view is hidden (mounted for the flip- back), 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 additive view + strip on the dedicated ``/document.html`` page (admin-logged-in, the phase-79 gate satisfied): one ``renderDocument`` core — no per-surface drift — same toggle, same figures, same hidden-while-Text shape. 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 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``. Phase 163 (task 04) re-cut: this fixture is the PICTORIAL class — an ADDITIVE doc (``page_strip_mode == "additive"``) that opens on its TEXT view, so the strip is revealed by pressing the toggle's ``Pages`` button first (a primary/absent doc has no toggle — the strip already IS the content). The text content block and the Summary panel are HIDDEN while the strip shows (they stay mounted for the flip back — LOCKED A1); a primary-class doc has no such nodes at all, which the same ``not_to_be_visible`` assertion covers.""" toggle = content.locator(".doc-view-toggle") if toggle.count() > 0: toggle.get_by_role("button", name="Pages").click() 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}") # While the strip shows, the text view is not (phase 163: hidden, # not unmounted — the additive flip-back; a primary doc has no # such nodes at all). The meta badge row is asserted by the caller. expect(content.locator("pre.doc-raw")).not_to_be_visible() expect(content.locator(".doc-md")).not_to_be_visible() expect(content.locator(".doc-summary")).not_to_be_visible() # --------------------------------------------------------------------------- # 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 an ADDITIVE doc (phase 163 — a usable text layer: the pictorial class): the TEXT view first (Summary panel + content visible, the toggle's ``Text`` pressed — LOCKED A6, the mock's page descriptions ride the shown text), with the strip of its REAL pages (the owner's L5 ask) one ``Pages`` click away: ``.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 — while the text view is hidden (mounted for the flip-back); 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") # Phase 163 (task 04, LOCKED A1/A6): the pictorial class is an # ADDITIVE doc — it opens on its TEXT view: Summary panel + # content visible, the toggle's Text pressed, the strip mounted # but hidden (its lazy imgs never fetched while hidden). 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() # The text view carries the indexed content — the phase-140 page # descriptions ride the SHOWN text for an additive doc (the text # is never hidden: A1). assert PICTORIAL_PAGE_ANSWER in content.inner_text(), ( "the additive text view shows the indexed content including " "the described sparse pages" ) # …and the strip is one Pages click away (the helper flips). _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") # --------------------------------------------------------------------------- # 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 additive view through the ONE shared ``renderDocument`` core (no per-surface drift): the text view first, the strip one ``Pages`` click away with the same three loaded figures + captions/alt texts and the text view hidden while it shows, 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"