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`
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
"""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"
|
||||
@@ -24,13 +24,19 @@ format — the upload gate admits it) and arriving through the REAL admin
|
||||
upload flow → the RAG page's Sync button:
|
||||
|
||||
* (a) the document appears on the Sources page and the document viewer
|
||||
renders the stored content: page 1's prose WITHOUT an illustration
|
||||
block, page 2 WITH ``[Page 2 illustration: <the mock's canned
|
||||
PICTORIAL_PAGE_ANSWER>]`` (the task-02 ``PICTORIAL_PAGE_MODE`` branch
|
||||
— deterministic; the stored content is asserted BYTE-EXACT against
|
||||
the fixture's own extraction in the seed — the candidates are computed
|
||||
from ``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no
|
||||
hardcoded page list);
|
||||
shows the PDF's ACTUAL pages (phase 148 — a described sparse page
|
||||
makes the doc page-QUALIFYING: the page strip REPLACES the text
|
||||
content AND the Summary panel, the owner's TODO L5 contract): two
|
||||
``.doc-page`` figures with loaded lazy images + ``Page N`` captions,
|
||||
NO ``.doc-raw``, NO ``.doc-summary``; the described content itself
|
||||
(page 1's prose WITHOUT an illustration block, page 2 WITH
|
||||
``[Page 2 illustration: <the mock's canned PICTORIAL_PAGE_ANSWER>]``
|
||||
— the task-02 ``PICTORIAL_PAGE_MODE`` branch, deterministic) stays in
|
||||
the DB: the seed asserts it BYTE-EXACT against the fixture's own
|
||||
extraction (the candidates computed from
|
||||
``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no
|
||||
hardcoded page list) and the chat test below proves it still reaches
|
||||
the model;
|
||||
* (b) a chat question that ONLY the illustration answers ("how are the
|
||||
two parts joined on page two?" — the phrase lives NOWHERE in the text
|
||||
layer) grounds (the described page's embedding retrieves — the mock's
|
||||
@@ -77,11 +83,14 @@ Test → observable mapping (Playwright Mapping Rule):
|
||||
Sources page reads 1 doc / 3 chunks (2 page-anchored content chunks +
|
||||
the summary chunk — no image doc), the drill-down table lists the PDF
|
||||
as a plain text row (LOCKED A4 — a text document, no thumbnail), and
|
||||
the same-page document viewer (the row's path link) renders the
|
||||
stored content under the METADATA title: the page-1 prose (NO
|
||||
``[Page 1 illustration`` block), the page-2 marker + its
|
||||
``[Page 2 illustration: <canned>]`` block, and the Summary panel
|
||||
(a non-image doc with a non-empty summary).
|
||||
the same-page document viewer (the row's path link) shows the
|
||||
PHASE-148 PAGE STRIP under the METADATA title (the fixture is
|
||||
page-qualifying — the pictorial pass described its sparse page):
|
||||
exactly two ``.doc-page`` figures, each a LOADED lazy ``<img>``
|
||||
(naturalWidth > 0) with the ``Page N of <title>`` alt + the
|
||||
``Page N`` caption, NO ``.doc-raw`` / NO ``.doc-summary`` (the
|
||||
page descriptions stay in the DB for Q&A, not displayed — the
|
||||
owner's contract).
|
||||
2. ``test_chat_question_grounds_and_echoes_the_illustration`` — the raw
|
||||
SSE over the scripted seeded-read question (httpx — the ``done``
|
||||
event is the wire lens the task pins; the UI flow's premise is the
|
||||
@@ -554,9 +563,14 @@ def seeded(
|
||||
"described sparse page — the pictorial pass misfired"
|
||||
)
|
||||
# The non-markdown doc got its lite summary (the mock's
|
||||
# SUMMARY_MODE digest) — the summary chunk + the viewer's
|
||||
# Summary panel exist.
|
||||
# SUMMARY_MODE digest) — the summary chunk exists (the
|
||||
# viewer's Summary panel is suppressed by the phase-148
|
||||
# strip — the summary itself stays in the DB).
|
||||
assert doc.summary
|
||||
# Phase 148: the described sparse page makes this doc
|
||||
# page-QUALIFYING — the strip's payload (page_urls) rides
|
||||
# on page_count (the 2-page fixture's count).
|
||||
assert doc.page_count == 2
|
||||
finally:
|
||||
page.close()
|
||||
yield {"title": PDF_TITLE}
|
||||
@@ -574,12 +588,18 @@ def test_sources_page_and_viewer_show_the_described_page(
|
||||
content chunks + the phase-30 summary chunk — no image doc; the
|
||||
vector figure is invisible to the embedded-image pipeline). The
|
||||
drill-down table lists the PDF as a plain text row (LOCKED A4), and
|
||||
the same-page document viewer (the row's path link, phase 26)
|
||||
renders the stored content under the METADATA title: page 1's prose
|
||||
WITHOUT an illustration block (not a candidate) and page 2 WITH
|
||||
``[Page 2 illustration: <canned>]`` (the described sparse page —
|
||||
LOCKED A4's user surface), plus the Summary panel (a non-image doc
|
||||
with a non-empty summary)."""
|
||||
the same-page document viewer (the row's path link, phase 26) shows
|
||||
the PHASE-148 PAGE STRIP under the METADATA title: the fixture is
|
||||
page-qualifying (the pictorial pass described its sparse page), so
|
||||
the strip REPLACES the text content AND the Summary panel — two
|
||||
``.doc-page`` figures, each a loaded lazy ``<img>`` with the
|
||||
``Page N of <title>`` alt + the ``Page N`` caption, NO
|
||||
``.doc-raw``, NO ``.doc-summary``. The described content (page 1's
|
||||
prose WITHOUT an illustration block, page 2 WITH
|
||||
``[Page 2 illustration: <canned>]`` — LOCKED A4) stays in the DB
|
||||
for Q&A: the seed asserted it byte-exact, the chat test proves it
|
||||
reaches the model — it is simply not displayed (the owner's
|
||||
contract)."""
|
||||
login(page, app_url) # lands on /sources.html
|
||||
expect(page.locator("#stat-docs")).to_have_text("1")
|
||||
expect(page.locator("#stat-chunks")).to_have_text("3")
|
||||
@@ -596,27 +616,44 @@ def test_sources_page_and_viewer_show_the_described_page(
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#doc-modal-title")).to_have_text(PDF_TITLE)
|
||||
|
||||
raw = modal.locator("pre.doc-raw")
|
||||
# Page 1 — the prose WITHOUT any illustration block. The viewer
|
||||
# renders the content verbatim (``insert_textbox`` wraps mid-
|
||||
# sentence, so compare whitespace-normalized).
|
||||
expect(raw).to_contain_text("---PAGE-1---")
|
||||
rendered = " ".join(raw.inner_text().split())
|
||||
assert " ".join(PAGE_ONE.split()) in rendered, (
|
||||
"the page-1 prose is not what the viewer renders"
|
||||
)
|
||||
assert "[Page 1 illustration" not in rendered, (
|
||||
"page 1 (over the sparseness bar) must carry NO illustration block"
|
||||
)
|
||||
# Page 2 — the sparse page WITH the described block (a) — the mock's
|
||||
# canned reply, deterministic.
|
||||
expect(raw).to_contain_text("---PAGE-2---")
|
||||
expect(raw).to_contain_text(PAGE_TWO)
|
||||
expect(raw).to_contain_text(ILLUSTRATION_BLOCK)
|
||||
# The Summary panel renders (a non-image doc with a non-empty
|
||||
# summary); no image block (the PDF is not an image document).
|
||||
expect(modal.locator(".doc-summary")).to_have_count(1)
|
||||
# Phase 148: the page-qualifying fixture shows its ACTUAL pages —
|
||||
# a .doc-pages strip with exactly two .doc-page figures (the
|
||||
# 2-page fixture), each a LOADED lazy <img> (naturalWidth > 0 —
|
||||
# the route served a real PNG) with the "Page N of <title>" alt +
|
||||
# the "Page N" caption. The text content AND the Summary panel are
|
||||
# SUPPRESSED (the owner's contract — the descriptions stay in the
|
||||
# DB for Q&A, not displayed).
|
||||
strip = modal.locator(".doc-pages")
|
||||
expect(strip).to_have_count(1)
|
||||
figures = strip.locator(".doc-page")
|
||||
expect(figures).to_have_count(2)
|
||||
for i in range(2):
|
||||
n = i + 1
|
||||
img = figures.nth(i).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 {PDF_TITLE}")
|
||||
img.scroll_into_view_if_needed()
|
||||
img.evaluate("el => el.decode()")
|
||||
assert img.evaluate("el => el.naturalWidth") > 0, (
|
||||
f"page {n} image did not load (the route must serve a real PNG)"
|
||||
)
|
||||
expect(figures.nth(i).locator("figcaption.doc-page-num")).to_have_text(
|
||||
f"Page {n}"
|
||||
)
|
||||
# The suppression contract: no text content block, no Summary
|
||||
# panel, no image block (the PDF is not an image document).
|
||||
expect(modal.locator("pre.doc-raw")).to_have_count(0)
|
||||
expect(modal.locator(".doc-summary")).to_have_count(0)
|
||||
expect(modal.locator(".doc-image-img")).to_have_count(0)
|
||||
# The canned description is in the DB (the seed asserted it)
|
||||
# but nowhere in the rendered modal.
|
||||
assert PICTORIAL_PAGE_ANSWER not in modal.inner_text(), (
|
||||
"the described page's text is in the DB for Q&A — it must not "
|
||||
"be shown to the user (the owner's contract)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user