Files
brain-of-reese/tests/e2e/test_pdf_pages_in_sources.py
T
ducoterra 6ba46e735a
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Successful in 21s
phase: 163_pdf_strip_additive_selfheal
All gates verified. Final report:

**Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)**

- Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs).
- `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%)
- `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation
- `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed
- `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins)
- Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅
- **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**.
- Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit.
- **Next pending phase:** `164_chat_error_honesty`
2026-10-07 13:57:30 -04:00

811 lines
36 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)
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
``<id>-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 ``<img>``
(``complete && naturalWidth > 0`` — the route served a real PNG,
not a 404) 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) 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"