Files
brain-of-reese/tests/e2e/test_pdf_pages_in_sources.py
T
ducoterra b233c8d714
Build and Push Containers / build-and-push-app (push) Successful in 2m28s
Build and Push Containers / build-and-push-db (push) Successful in 16s
phase: 148_pdf_pages_in_sources
**Phase 148 — final verification pass: complete, 100% green.**

- **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry
- **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
- **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed**
- **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched
- **Next pending phase:** `149_sync_progress_box`
2026-10-01 16:58:01 -04:00

776 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
``<id>-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 ``<img>`` (``complete && naturalWidth > 0`` —
the route served a real PNG, not a 404) with the
``Page N of <title>`` 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"