**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`
776 lines
34 KiB
Python
776 lines
34 KiB
Python
"""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"
|