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`
1085 lines
48 KiB
Python
1085 lines
48 KiB
Python
"""Phase 163 task 05 E2E (Playwright, mock-only): the additive PDF
|
||
strip, the byte-identical primary/plain classes, and the fast-path
|
||
self-heal — the ``Letumdei`` failure mode, reproduced and made
|
||
impossible.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov
|
||
|
||
The dedicated suite for ``163_pdf_strip_additive_selfheal`` (A16 — one
|
||
Playwright file per phase, run in isolation). Pre-fix evidence
|
||
(2026-10-07, brain.reeseapps.com): ``Letumdei_ Heirs to the Imperium.pdf``
|
||
— 36 ``page_urls`` in the payload, ALL 36 page routes 404, 114 389
|
||
chars of genuine extracted text with exactly ONE sparse map page
|
||
(the pictorial class) — the viewer showed "Page N unavailable." on
|
||
every page with the text hidden behind the strip. Two stacked defects:
|
||
the pictorial class rendered the strip INSTEAD of its text (fixed by
|
||
the additive view, LOCKED A1/A6), and the copies were deleted before
|
||
the replacements were written, outside the DB transaction, so a later
|
||
rollback (or an external wipe) left the row over zero files with the
|
||
phase-147 fast path never re-rendering (fixed by write-first persist
|
||
+ the deterministic re-gate, LOCKED A4/A5). One local-directory source
|
||
(``strip_fixture``, the API-registered ``kind=local`` pattern) carries
|
||
three synthetic PyMuPDF PDFs — the live shape in miniature:
|
||
|
||
* ``manual.pdf`` — the PICTORIAL class (the Letumdei shape): 3 dense
|
||
prose pages + ONE sparse map-style page (<100 stripped chars, a
|
||
vector-drawn figure) → usable text layer, one gate candidate →
|
||
``page_strip_mode == "additive"``, ``page_count == 4``;
|
||
* ``scanned.pdf`` — the FALLBACK class (the phase-133 shape): two
|
||
pages whose ONLY text is the owner's XML-garbage line repeated 20× →
|
||
unusable layer → ``page_strip_mode == "primary"``, ``page_count == 2``;
|
||
* ``plain.pdf`` — clean dense text, ZERO sparse pages → no strip at
|
||
all (``page_count`` NULL, both strip keys absent from the payload).
|
||
|
||
App env: the conftest pattern with the phase-140 opt-in (``BOR_PDF_
|
||
PICTORIAL`` pinned EXPLICITLY ``1``) + the phase-148 ``BOR_PDF_PAGE_DIR``
|
||
pointed at a suite-private scratch dir (the heal deletes and re-creates
|
||
files THERE — the owner's ``~/bor-sources/pages`` default is never
|
||
touched) + ``BOR_IMPORT_EXTENSIONS`` carrying the ``pdf`` token (the
|
||
phase-126 convention). The page-directory homes are module-scoped so
|
||
the heal/repair tests mutate the exact dir the module app reads.
|
||
|
||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported for the
|
||
zero-LLM-call pin (assert 5 reads the mock's ``/v1/e2e/captured``
|
||
request log — the phase-123 house pattern for "ZERO chat calls").
|
||
|
||
Test → observable mapping (Playwright Mapping Rule):
|
||
1. ``test_additive_view`` — the ``manual.pdf`` modal: the content
|
||
payload stamps the class (``page_strip_mode == "additive"`` beside
|
||
the 4 ``page_urls``), the TEXT view opens by default (Summary panel
|
||
+ content visible — the map page's canned description RIDES the
|
||
shown text, LOCKED A1; the toggle's ``Text`` pressed, LOCKED A6)
|
||
with the strip mounted but hidden; pressing ``Pages`` shows the
|
||
4-figure strip and EVERY ``<img>`` loads (naturalWidth > 0 — the
|
||
route serves; no "unavailable" note anywhere); pressing ``Text``
|
||
back shows the text, hides the strip, and the strip NODE persists
|
||
(the same DOM element — probed by a dataset marker — with the same
|
||
4 loaded imgs: no re-render, no refetch).
|
||
2. ``test_primary_and_plain_doms`` — the ``scanned.pdf`` modal keeps
|
||
the phase-148 strip-only DOM byte-identically (payload
|
||
``page_strip_mode == "primary"``; NO ``.doc-view-toggle``, NO
|
||
``.doc-summary``, NO content block; 2 loaded ``.doc-page`` figures
|
||
with the ``Page N`` captions); the ``plain.pdf`` modal is the plain
|
||
text doc (raw content + Summary panel, ZERO ``.doc-pages`` /
|
||
``.doc-view-toggle`` nodes) and its payload carries NEITHER
|
||
``page_urls`` NOR ``page_strip_mode`` (the omission rule — the
|
||
non-qualifying payload is byte-identical to pre-phase).
|
||
3. ``test_heal_restores_deleted_copies`` — the live-loss state
|
||
reproduced: delete ALL ``pdf_page_dir/<manual-id>-p*.png`` (the row
|
||
still claims 4) → a page route 404s → run a sync over UNCHANGED
|
||
bytes (the fast path) → the four copies exist again (real PNGs),
|
||
the route serves 200 with PNG bytes, the row keeps ``page_count``
|
||
4 / mode ``additive``, the payload still carries ``page_urls`` +
|
||
``additive``, and the viewer's Pages view loads every img — the
|
||
exact D5 promise (a wipe cannot outlive one sync).
|
||
4. ``test_repair_clears_stale_strip`` — the stale-eligible state NO
|
||
current index produces: direct-DB seed sets ``plain.pdf``'s row to
|
||
``page_count`` 2 / mode ``additive`` and writes two stale copies on
|
||
disk → sync (still unchanged bytes) → both columns NULL (read
|
||
through the fixture session), the stale copies GONE, and the modal
|
||
shows the plain text doc (the strip is finished).
|
||
5. ``test_zero_llm_calls_on_heal_and_repair`` — the mock's request
|
||
log: the INITIAL index burned the vision passes it owes (≥1
|
||
``PICTORIAL_PAGE_MODE`` + ≥1 ``IMAGE_DESCRIPTION_MODE`` request —
|
||
the capture works and the premise is real), while the heal sync and
|
||
the repair sync each captured ZERO content/vision chat requests —
|
||
the re-gate is deterministic (render is CPU, LOCKED A5). The one
|
||
exempted request is the sync prelude's fixed ``ping`` model-health
|
||
probe (``app.rag.llm.check_models`` — every sync ever run pays it,
|
||
unchanged by this phase); anything else in the capture is a
|
||
violation.
|
||
|
||
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 five tests SHARE the module seed
|
||
(one source, three documents, one sync) and deliberately MUTATE shared
|
||
state (the page dir, then one row) in file order, 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
|
||
|
||
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,
|
||
MOCK_PORT,
|
||
SESSION_SECRET,
|
||
USE_REAL_LLM,
|
||
_wait_http,
|
||
)
|
||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, PICTORIAL_PAGE_ANSWER
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
|
||
#: The local directory's NAME — the source name the registry stores for
|
||
#: a ``kind='local'`` source (the directory basename, the
|
||
#: test_local_directory_sources convention). The task's fixture name.
|
||
SOURCE_NAME = "strip_fixture"
|
||
MANUAL_PATH = "manual.pdf"
|
||
SCANNED_PATH = "scanned.pdf"
|
||
PLAIN_PATH = "plain.pdf"
|
||
MANUAL_TITLE = "Campaign Manual" # the metadata title (the Letumdei stand-in)
|
||
SCANNED_TITLE = "Scanned Plates" # the metadata title
|
||
PLAIN_TITLE = "Plain Notes" # the metadata title
|
||
|
||
#: The dense prose paragraph (500+ chars — over the 100-char sparseness
|
||
#: bar, so text pages are never gate candidates; the
|
||
#: test_pdf_pages_in_sources recipe).
|
||
DENSE_PARAGRAPH = (
|
||
"This manual covers the complete campaign, its muster rules, and the "
|
||
"supply checks a first-time commander should run before marching. The "
|
||
"train holds the two supply wagons, the field forge, the water cart, "
|
||
"eight cam locks for the tent pegs, fourteen spare stakes, two spare "
|
||
"axle bolts, and the signal-horn case. Work from a flat ridge with dry "
|
||
"ground, keep the squads sorted by banner, and read every dispatch "
|
||
"through before you begin so you know which standard is which. The "
|
||
"column marches roughly ten leagues a day once supplied, weighs under "
|
||
"twelve tonnes, and needs no further provision beyond the local levy."
|
||
)
|
||
assert len(DENSE_PARAGRAPH) > 500, "the dense paragraph must be 500+ chars"
|
||
|
||
#: The sparse map page's marker — ALL page 4's text layer carries (21
|
||
#: stripped chars, under the 100-char bar — the ONE gate candidate; the
|
||
#: figure is vector-drawn, exactly like the live page-36 map).
|
||
MAP_MARKER = "Map of the Imperium"
|
||
|
||
#: The phase-133 garbage line (the owner's exact shape) — 100% of the
|
||
#: scanned pages' text looks like tags, so ``is_usable_pdf_text`` fails
|
||
#: deterministically (the fallback class).
|
||
GARBAGE_LINE = "<illustrator:StartupProfile>Web</illustrator:StartupProfile>"
|
||
GARBAGE_LINES_PER_PAGE = 20
|
||
N_SCANNED_PAGES = 2
|
||
|
||
#: The stored map-page block (the phase-140 LOCKED A4 shape) — the
|
||
#: mock's canned ``PICTORIAL_PAGE_MODE`` reply, deterministic.
|
||
MAP_BLOCK = f"[Page 4 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_STRIPHEAL", "8177"))
|
||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||
MOCK_URL = f"http://127.0.0.1:{MOCK_PORT}"
|
||
|
||
#: The suite's LLM-request ledger (assert 5): every sync stores the
|
||
#: mock's chat-completions capture (``/v1/e2e/captured`` — the phase-123
|
||
#: house pattern) taken AROUND its own run under its name, so the
|
||
#: heal/repair syncs' ZERO-call pins read from the log the app itself
|
||
#: produced, not from a spy that never saw the real request.
|
||
LLM_CAPTURES: dict[str, list[dict[str, Any]]] = {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixture builders (the house PyMuPDF recipe — the three strip classes)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _draw_map_figure(page: Any) -> None:
|
||
"""The vector map figure: a rectangle (the province) + a road line
|
||
with a two-stroke head — drawn, not embedded, so the text layer
|
||
stays the ONLY text and the embedded-image pipeline sees nothing."""
|
||
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_manual(path: Path) -> None:
|
||
"""The Letumdei shape in MINATURE (the pictorial class): three dense
|
||
prose pages + ONE sparse map-style page. Usable text layer with
|
||
exactly one gate candidate (page 4) → the ADDITIVE strip class."""
|
||
doc = fitz.open()
|
||
for i in range(1, 4):
|
||
p = doc.new_page()
|
||
rc = p.insert_textbox(
|
||
fitz.Rect(72, 72, 523, 772), f"Book {i}. {DENSE_PARAGRAPH}", fontsize=11
|
||
)
|
||
assert rc >= 0, f"the page-{i} paragraph must fit its text box"
|
||
p4 = doc.new_page()
|
||
p4.insert_text((72, 72), MAP_MARKER)
|
||
_draw_map_figure(p4)
|
||
doc.set_metadata({"title": MANUAL_TITLE})
|
||
doc.save(path)
|
||
doc.close()
|
||
|
||
|
||
def _build_scanned(path: Path) -> None:
|
||
"""The phase-133 fallback class: two pages whose ONLY text is the
|
||
XML-garbage line ×20 — the gate fails, so the page SCREENSHOTS are
|
||
the content (the PRIMARY strip class)."""
|
||
doc = fitz.open()
|
||
for page_num in range(1, N_SCANNED_PAGES + 1):
|
||
page = doc.new_page()
|
||
for i in range(GARBAGE_LINES_PER_PAGE):
|
||
page.insert_text((72, 72 + i * 14), f"{GARBAGE_LINE} p{page_num} l{i}")
|
||
doc.set_metadata({"title": SCANNED_TITLE})
|
||
doc.save(path)
|
||
doc.close()
|
||
|
||
|
||
def _build_plain(path: Path) -> None:
|
||
"""The negative class: two dense text pages, ZERO sparse candidates
|
||
— no strip ever (until the repair test seeds a stale one)."""
|
||
doc = fitz.open()
|
||
for i in (1, 2):
|
||
p = doc.new_page()
|
||
rc = p.insert_textbox(
|
||
fitz.Rect(72, 72, 523, 772), f"Note {i}. {DENSE_PARAGRAPH}", fontsize=11
|
||
)
|
||
assert rc >= 0, f"the page-{i} paragraph must fit its text box"
|
||
doc.set_metadata({"title": PLAIN_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: the three fixture PDFs, built
|
||
ONCE (module-scoped). The premise pins (deterministic — computed
|
||
from the fixtures THEMSELVES, no hardcoded page lists): ``manual``
|
||
is usable-layer with candidates ``[4]`` (4 pages — the seed's
|
||
``page_count`` target, the Letumdei shape), ``scanned`` is
|
||
UNUSABLE-layer (the fallback class), ``plain`` is usable-layer with
|
||
ZERO candidates (no strip). Every page renders to a real PNG (the
|
||
pipeline's byte premise)."""
|
||
root = tmp_path_factory.mktemp("bor_p163_strip") / SOURCE_NAME
|
||
root.mkdir(parents=True)
|
||
min_chars = Settings.model_fields["pdf_pictorial_min_chars"].default
|
||
manual = root / MANUAL_PATH
|
||
scanned = root / SCANNED_PATH
|
||
plain = root / PLAIN_PATH
|
||
_build_manual(manual)
|
||
_build_scanned(scanned)
|
||
_build_plain(plain)
|
||
manual_texts = extract_pdf_page_texts(manual)
|
||
assert is_usable_pdf_text(extract_pdf_text(manual)), (
|
||
"the manual fixture must PASS the usability gate (the pictorial class)"
|
||
)
|
||
assert pictorial_pages(manual_texts, min_chars=min_chars) == [4], (
|
||
"the manual fixture's sparse-page premise broke: candidates="
|
||
f"{pictorial_pages(manual_texts, min_chars=min_chars)}"
|
||
)
|
||
assert not is_usable_pdf_text(extract_pdf_text(scanned)), (
|
||
"the scanned fixture's text layer must FAIL the usability gate "
|
||
"(the fallback class)"
|
||
)
|
||
assert is_usable_pdf_text(extract_pdf_text(plain))
|
||
assert pictorial_pages(
|
||
extract_pdf_page_texts(plain), min_chars=min_chars
|
||
) == [], "the plain fixture must carry ZERO sparse pages (the negative class)"
|
||
assert len(render_pdf_pages(manual)) == 4
|
||
assert len(render_pdf_pages(scanned)) == N_SCANNED_PAGES
|
||
assert len(render_pdf_pages(plain)) == 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_p163_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 heal test DELETES the manual doc's copies here and the re-gate
|
||
re-creates them; the owner's ``~/bor-sources/pages`` default is
|
||
never touched."""
|
||
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
|
||
pictorial class + its kill-switch-mirroring re-gate) and the
|
||
phase-148 ``BOR_PDF_PAGE_DIR`` (suite-private — the heal mutates
|
||
this dir, and the app under test reads the same one)."""
|
||
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).
|
||
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 fixtures without it).
|
||
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
|
||
# Phase 140: the pictorial toggle EXPLICITLY on (the opt-in —
|
||
# without it the whole premise is void).
|
||
env["BOR_PDF_PICTORIAL"] = "1"
|
||
# Phase 148: the rendered-page copies' home (the heal's delete/
|
||
# re-render target).
|
||
env["BOR_PDF_PAGE_DIR"] = str(page_dir)
|
||
# The embedded-image pipeline is out of scope (the figures are
|
||
# vector-drawn — no image docs 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 dir's existence is the seed's job
|
||
scratch: Path,
|
||
page_dir: Path,
|
||
) -> Iterator[str]:
|
||
"""The app under test (all five tests): one module app with the
|
||
phase-140/148/163 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 + LLM-capture helpers (the house patterns)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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."""
|
||
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): 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 five tests SHARE the module seed's one source + three documents
|
||
and MUTATE shared state (the page dir, then one row) in file order,
|
||
so there is NO per-test truncate; the shared KB is flushed ONCE,
|
||
after the last test. The pre-wait keeps a leaked sync 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`` pattern — a plain directory is an
|
||
API/DB-only operation)."""
|
||
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; 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 _capture_reset() -> None:
|
||
"""Clear the mock's chat-completions capture (the phase-123 house
|
||
endpoint) so the NEXT sync's LLM requests are exactly what the
|
||
capture holds afterwards. The MOCK-ONLY pin is skipped under
|
||
``E2E_REAL_LLM=1`` (no capture to read — assert 5 skips too)."""
|
||
if USE_REAL_LLM:
|
||
return
|
||
r = httpx.post(f"{MOCK_URL}/v1/e2e/captured/reset", timeout=5.0)
|
||
assert r.status_code == 200, r.text
|
||
|
||
|
||
def _capture_read(name: str) -> list[dict[str, Any]]:
|
||
"""Store the mock's current capture in the suite ledger under
|
||
*name* (see :data:`LLM_CAPTURES`)."""
|
||
if USE_REAL_LLM:
|
||
LLM_CAPTURES[name] = []
|
||
return []
|
||
r = httpx.get(f"{MOCK_URL}/v1/e2e/captured", timeout=5.0)
|
||
assert r.status_code == 200, r.text
|
||
captured = r.json()
|
||
LLM_CAPTURES[name] = captured
|
||
return captured
|
||
|
||
|
||
def _run_sync(
|
||
base_url: str, capture_name: str | None = None, 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_pdf_pages_in_sources``
|
||
pattern — with the stale-terminal freshness gate). When
|
||
*capture_name* is given, the mock's request capture is reset before
|
||
the POST and stored under that name once the run settles — the
|
||
sync's ENTIRE LLM footprint, bracketed (assert 5's lens)."""
|
||
_capture_reset()
|
||
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
|
||
):
|
||
if capture_name is not None:
|
||
_capture_read(capture_name)
|
||
return body
|
||
time.sleep(0.1)
|
||
if capture_name is not None:
|
||
_capture_read(capture_name)
|
||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||
|
||
|
||
def _doc_row(path: str) -> Document:
|
||
"""The fixture session's view of one source row (the DB-lens the
|
||
heal/repair asserts read through — ``expire_on_commit`` off means
|
||
the values are the row's, freshly queried per call)."""
|
||
with SessionLocal() as db:
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == path
|
||
)
|
||
)
|
||
assert doc is not None, f"the {path} row must exist"
|
||
db.expunge(doc)
|
||
return doc
|
||
|
||
|
||
def _payload(app_url: str, path: str) -> dict[str, Any]:
|
||
"""The document-content payload (the surface the viewer renders
|
||
from) as a dict — the ``page_urls`` / ``page_strip_mode`` key
|
||
presence rules are asserted on THIS JSON, not the DB."""
|
||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
r = client.get("/api/documents/content", params={"source": SOURCE_NAME, "path": path})
|
||
assert r.status_code == 200, r.text
|
||
return r.json()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The module seed: register the local dir + one sync (all three PDFs),
|
||
# then assert the three-class DB + filesystem state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@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
|
||
index pass, its capture stored as ``LLM_CAPTURES["index"]``).
|
||
Asserts the post-index three-class contract: ``manual`` is the
|
||
pictorial class (``page_count`` 4, mode ``additive``, the canned
|
||
map block IN the content — the Letumdei shape), ``scanned`` the
|
||
fallback class (``page_count`` 2, mode ``primary``, the canned
|
||
screenshot descriptions, the garbage NOT stored), ``plain``
|
||
unqualified (both columns NULL) — and the page dir holds EXACTLY
|
||
the two qualifying docs' six copies (real PNGs, no plain copies, no
|
||
orphans). Yields the 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, capture_name="index")
|
||
assert body["state"] == "success", body
|
||
detail = body["detail"]
|
||
assert detail["files"] == 3, detail
|
||
assert detail["added"] == 3, detail
|
||
assert detail["pruned"] == 0, detail
|
||
assert detail["errors"] == 0, detail
|
||
|
||
manual = _doc_row(MANUAL_PATH)
|
||
assert manual.title == MANUAL_TITLE
|
||
assert manual.page_count == 4, (
|
||
f"the pictorial-class row must carry its page count: {manual.page_count}"
|
||
)
|
||
assert manual.page_strip_mode == "additive", (
|
||
"the pictorial class (usable layer + sparse candidate) must stamp "
|
||
f"additive: {manual.page_strip_mode!r}"
|
||
)
|
||
# The phase-140 descriptions ride the stored content (Q&A parity) —
|
||
# the ONE map block is the live Letumdei shape in miniature.
|
||
assert "---PAGE-1---" in manual.content and "---PAGE-4---" in manual.content
|
||
assert MAP_BLOCK in manual.content, (
|
||
"the map page's canned description must be in the stored content"
|
||
)
|
||
assert manual.summary
|
||
|
||
scanned = _doc_row(SCANNED_PATH)
|
||
assert scanned.page_count == N_SCANNED_PAGES
|
||
assert scanned.page_strip_mode == "primary", (
|
||
"the fallback class (unusable layer) must stamp primary: "
|
||
f"{scanned.page_strip_mode!r}"
|
||
)
|
||
assert scanned.content.startswith("---PAGE-1---")
|
||
assert IMAGE_DESCRIPTION_ANSWER in scanned.content, (
|
||
"the fallback's content must be the canned page-screenshot descriptions"
|
||
)
|
||
assert GARBAGE_LINE not in scanned.content, (
|
||
"the garbage text layer must NOT be the stored content"
|
||
)
|
||
|
||
plain = _doc_row(PLAIN_PATH)
|
||
assert plain.page_count is None, "a zero-candidate PDF must not qualify"
|
||
assert plain.page_strip_mode is None
|
||
|
||
manual_id, scanned_id, plain_id = str(manual.id), str(scanned.id), str(plain.id)
|
||
|
||
# The filesystem contract: EXACTLY the two qualifying docs' copies
|
||
# (no plain copies, no orphans) — real, non-empty PNGs.
|
||
expected = sorted(
|
||
[f"{manual_id}-p{n}.png" for n in range(1, 5)]
|
||
+ [f"{scanned_id}-p{n}.png" for n in range(1, N_SCANNED_PAGES + 1)]
|
||
)
|
||
pngs = sorted(p.name for p in page_dir.glob("*.png"))
|
||
assert pngs == expected, f"the page dir holds {pngs}, expected {expected}"
|
||
for name in pngs:
|
||
data = (page_dir / name).read_bytes()
|
||
assert data[:8] == PNG_MAGIC and data, f"{name} is not a real PNG"
|
||
assert not list(page_dir.glob(f"{plain_id}-*")), (
|
||
"the non-qualifying PDF must not have page copies"
|
||
)
|
||
yield {
|
||
"manual_id": manual_id,
|
||
"scanned_id": scanned_id,
|
||
"plain_id": plain_id,
|
||
"page_dir": page_dir,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 — all three 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) -> Locator:
|
||
"""The house doc-modal idiom: the row's path link opens the
|
||
same-page modal (no new tab). Returns the content slot locator."""
|
||
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"
|
||
modal = page.locator("#doc-modal")
|
||
expect(modal).to_be_visible(timeout=30_000)
|
||
return page.locator("#doc-modal-content")
|
||
|
||
|
||
def _wait_img_loaded(img: Locator) -> None:
|
||
"""Wait until a LAZY ``<img>`` really loaded pixels: scroll it into
|
||
the viewport, 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_figures(strip: Locator, title: str, doc_id: str, page_count: int) -> None:
|
||
"""The phase-148 figure contract inside a (visible) strip: exactly
|
||
*page_count* ``.doc-page`` figures, each with a LOADED lazy
|
||
``<img>`` (the route served a real PNG — not the "Page N
|
||
unavailable" note), the ``Page N of <title>`` alt, the exact 1-based
|
||
route src, and the ``Page N`` caption."""
|
||
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")
|
||
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}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. The additive view: text first, the strip one toggle away, no
|
||
# refetch on flip-flop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_additive_view(page: Page, app_url: str, seeded: dict[str, Any]) -> None:
|
||
"""The ``manual.pdf`` modal — the ADDITIVE class (LOCKED A1/A6):
|
||
the content payload stamps the class (``page_strip_mode ==
|
||
"additive"`` beside the 4 ``page_urls``); the TEXT view opens by
|
||
default — Summary panel + content visible, the toggle's ``Text``
|
||
``aria-pressed="true"``, the strip mounted but hidden, the map
|
||
page's canned description RIDING the shown text (the text is never
|
||
hidden for the pictorial class); pressing ``Pages`` shows the
|
||
4-figure strip with EVERY img loaded (naturalWidth > 0 — the route
|
||
serves; no "unavailable" note anywhere) and hides the text view;
|
||
pressing ``Text`` back shows the text, hides the strip, and the
|
||
strip node is the SAME mounted DOM element (a dataset probe survives
|
||
the flip — no re-render) with its 4 imgs still loaded (no
|
||
refetch)."""
|
||
# The wire pin: the class is stamped next to the page_urls.
|
||
payload = _payload(app_url, MANUAL_PATH)
|
||
assert payload.get("page_strip_mode") == "additive", payload
|
||
assert payload.get("page_urls") == [
|
||
f"/api/documents/{seeded['manual_id']}/pages/{n}" for n in range(1, 5)
|
||
], payload
|
||
|
||
login(page, app_url) # lands on /sources.html
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
expect(page.locator("#docs-tbody tr")).to_have_count(3)
|
||
content = _open_doc_modal(page, MANUAL_PATH)
|
||
expect(page.locator("#doc-modal-title")).to_have_text(MANUAL_TITLE)
|
||
|
||
# Default view: TEXT (LOCKED A6) — Summary panel + content visible,
|
||
# the strip mounted but hidden (its lazy imgs never fetched yet).
|
||
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()
|
||
assert MAP_MARKER in content.inner_text(), "the text view shows the book's text"
|
||
assert PICTORIAL_PAGE_ANSWER in content.inner_text(), (
|
||
"the additive text view shows the indexed content INCLUDING the "
|
||
"map page's description (LOCKED A1 — the text is never hidden)"
|
||
)
|
||
|
||
# Pages: the strip shows, every copy loads, the text view hides.
|
||
toggle.get_by_role("button", name="Pages").click()
|
||
expect(content.locator(".doc-pages")).to_be_visible()
|
||
_assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, seeded["manual_id"], 4)
|
||
assert "unavailable" not in content.inner_text(), (
|
||
"no page may show the 'Page N unavailable' note once the strip shows"
|
||
)
|
||
expect(content.locator("pre.doc-raw")).not_to_be_visible()
|
||
expect(content.locator(".doc-summary")).not_to_be_visible()
|
||
# Tag the strip's FIRST img so the flip-back can prove the node is
|
||
# the same DOM element (a re-render would build a fresh one).
|
||
first_img = content.locator(".doc-pages .doc-page img").first
|
||
first_img.evaluate("el => { el.dataset.p163probe = 'mounted'; }")
|
||
|
||
# Text: the reversal — text visible, strip hidden, the strip node
|
||
# STILL MOUNTED with its 4 loaded imgs (zero refetch).
|
||
toggle.get_by_role("button", name="Text").click()
|
||
expect(toggle.get_by_role("button", name="Text")).to_have_attribute(
|
||
"aria-pressed", "true"
|
||
)
|
||
expect(content.locator("pre.doc-raw")).to_be_visible()
|
||
expect(content.locator(".doc-pages")).to_be_hidden()
|
||
expect(content.locator(".doc-pages .doc-page img")).to_have_count(4)
|
||
assert (
|
||
content.locator(".doc-pages .doc-page img").first.evaluate(
|
||
"el => el.dataset.p163probe"
|
||
)
|
||
== "mounted"
|
||
), "flipping back must reuse the mounted strip (no re-render, no refetch)"
|
||
assert content.locator(".doc-pages .doc-page img").first.evaluate(
|
||
"el => el.complete && el.naturalWidth > 0"
|
||
), "the mounted imgs stay decoded across the flip (no reset)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. The primary class (byte-identical strip-only DOM) + the plain doc
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_primary_and_plain_doms(page: Page, app_url: str, seeded: dict[str, Any]) -> None:
|
||
"""The two untouched shapes, pinned side by side. ``scanned.pdf``
|
||
(the fallback class, payload ``page_strip_mode == "primary"``)
|
||
keeps the phase-148 strip-only DOM byte-identically: NO
|
||
``.doc-view-toggle``, NO ``.doc-summary``, NO content block — just
|
||
the 2-figure strip, every img loaded with the ``Page N`` captions.
|
||
``plain.pdf`` (no strip) renders the plain text doc — the raw
|
||
content + the Summary panel, ZERO ``.doc-pages`` /
|
||
``.doc-view-toggle`` nodes — and its payload carries NEITHER
|
||
``page_urls`` NOR ``page_strip_mode`` (the ABSENT-never-null
|
||
omission rule: the non-qualifying payload is byte-identical to
|
||
pre-phase)."""
|
||
# The wire pins: primary stamped; both keys absent for plain.
|
||
payload = _payload(app_url, SCANNED_PATH)
|
||
assert payload.get("page_strip_mode") == "primary", payload
|
||
assert payload.get("page_urls") == [
|
||
f"/api/documents/{seeded['scanned_id']}/pages/{n}"
|
||
for n in range(1, N_SCANNED_PAGES + 1)
|
||
], payload
|
||
plain_payload = _payload(app_url, PLAIN_PATH)
|
||
assert "page_urls" not in plain_payload, plain_payload
|
||
assert "page_strip_mode" not in plain_payload, plain_payload
|
||
|
||
login(page, app_url) # lands on /sources.html
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
|
||
# The primary class: the strip IS the view — no toggle, no Summary
|
||
# panel, no content block (the phase-148 DOM, byte-for-byte).
|
||
content = _open_doc_modal(page, SCANNED_PATH)
|
||
expect(page.locator("#doc-modal-title")).to_have_text(SCANNED_TITLE)
|
||
expect(content.locator(".doc-view-toggle")).to_have_count(0)
|
||
expect(content.locator(".doc-summary")).to_have_count(0)
|
||
expect(content.locator("pre.doc-raw")).to_have_count(0)
|
||
strip = content.locator(".doc-pages")
|
||
expect(strip).to_have_count(1)
|
||
expect(strip).to_be_visible()
|
||
_assert_figures(strip, SCANNED_TITLE, seeded["scanned_id"], N_SCANNED_PAGES)
|
||
|
||
# The plain doc: the pre-phase text rendering — no strip artifacts.
|
||
page.goto(f"{app_url}/sources.html")
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
content = _open_doc_modal(page, PLAIN_PATH)
|
||
expect(page.locator("#doc-modal-title")).to_have_text(PLAIN_TITLE)
|
||
expect(content.locator(".doc-pages")).to_have_count(0)
|
||
expect(content.locator(".doc-view-toggle")).to_have_count(0)
|
||
expect(content.locator("pre.doc-raw")).to_have_count(1)
|
||
expect(content.locator("pre.doc-raw")).to_contain_text("---PAGE-1---")
|
||
expect(content.locator("pre.doc-raw")).to_contain_text("Note 2.")
|
||
expect(content.locator(".doc-summary")).to_have_count(1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. The heal: copies deleted under a stamped page_count → one
|
||
# unchanged-byte sync restores everything
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_heal_restores_deleted_copies(
|
||
page: Page, app_url: str, seeded: dict[str, Any]
|
||
) -> None:
|
||
"""The live ``Letumdei`` failure mode, reproduced and healed: delete
|
||
ALL ``pdf_page_dir/<manual-id>-p*.png`` (the row still claims 4) →
|
||
a page route 404s (the exact live evidence) → run a sync over
|
||
UNCHANGED bytes (the phase-147 fast path) → the four copies exist
|
||
again (real PNGs), the route serves 200 with PNG bytes, the row
|
||
keeps ``page_count`` 4 / mode ``additive``, the payload still
|
||
carries ``page_urls`` + ``additive``, and the viewer's Pages view
|
||
loads EVERY img — the disk-level loss the owner saw cannot outlive
|
||
one sync (LOCKED A5, the D5 promise)."""
|
||
manual_id = seeded["manual_id"]
|
||
page_dir: Path = seeded["page_dir"]
|
||
copies = sorted(page_dir.glob(f"{manual_id}-p*.png"))
|
||
assert len(copies) == 4, f"the seed must leave 4 copies: {[p.name for p in copies]}"
|
||
for p in copies:
|
||
p.unlink()
|
||
assert not list(page_dir.glob(f"{manual_id}-p*.png")), "the wipe must be complete"
|
||
# The live-loss state on the wire: the row claims 4, the route 404s.
|
||
r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1")
|
||
assert r.status == 404, f"the wiped copy must 404 before the heal: {r.status}"
|
||
|
||
# One sync over unchanged bytes — the fast path + the re-gate. The
|
||
# run's whole LLM footprint is captured (assert 5 consumes it).
|
||
body = _run_sync(app_url, capture_name="heal")
|
||
assert body["state"] == "success", body
|
||
detail = body["detail"]
|
||
assert detail["unchanged"] == 3, detail
|
||
assert detail["added"] == 0, detail
|
||
assert detail["errors"] == 0, detail
|
||
|
||
# The disk: the four copies exist again — real PNGs.
|
||
restored = sorted(page_dir.glob(f"{manual_id}-p*.png"))
|
||
assert [p.name for p in restored] == [
|
||
f"{manual_id}-p{n}.png" for n in range(1, 5)
|
||
], f"the heal left {[p.name for p in restored]}"
|
||
for p in restored:
|
||
data = p.read_bytes()
|
||
assert data[:8] == PNG_MAGIC and data, f"{p.name} is not a real PNG after heal"
|
||
|
||
# The route serves again; the payload keeps the strip + the class.
|
||
r = page.request.get(f"{app_url}/api/documents/{manual_id}/pages/1")
|
||
assert r.status == 200, f"the healed copy must serve: {r.status}"
|
||
assert r.headers["content-type"] == "image/png", r.headers
|
||
assert r.body()[:8] == PNG_MAGIC, "the healed route served non-PNG bytes"
|
||
payload = _payload(app_url, MANUAL_PATH)
|
||
assert payload.get("page_strip_mode") == "additive", payload
|
||
assert len(payload.get("page_urls", [])) == 4, payload
|
||
|
||
# The row (the fixture session's view): count + class intact.
|
||
row = _doc_row(MANUAL_PATH)
|
||
assert row.page_count == 4
|
||
assert row.page_strip_mode == "additive"
|
||
|
||
# The viewer, complete: every page image loads through the route.
|
||
login(page, app_url)
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
content = _open_doc_modal(page, MANUAL_PATH)
|
||
content.locator(".doc-view-toggle").get_by_role("button", name="Pages").click()
|
||
expect(content.locator(".doc-pages")).to_be_visible()
|
||
_assert_figures(content.locator(".doc-pages"), MANUAL_TITLE, manual_id, 4)
|
||
assert "unavailable" not in content.inner_text(), (
|
||
"after the heal the viewer must show zero 'Page N unavailable' notes"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. The repair arm: a stale page_count on a now-ineligible doc is
|
||
# cleared, with its copies
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_repair_clears_stale_strip(
|
||
page: Page, app_url: str, seeded: dict[str, Any]
|
||
) -> None:
|
||
"""The stale-eligible state NO current index produces, seeded
|
||
direct-DB: ``plain.pdf``'s row gets ``page_count`` 2 + mode
|
||
``additive`` and two stale copies land on disk (the strip-shape a
|
||
pre-phase bug or a lowered ``pdf_pictorial_min_chars`` leaves
|
||
behind). One sync (the bytes stay unchanged) → the REPAIR arm: both
|
||
columns NULL again (read through the fixture session), the stale
|
||
copies GONE, and the modal shows the plain text doc — the strip is
|
||
finished. The other docs' strips are untouched."""
|
||
plain_id = seeded["plain_id"]
|
||
page_dir: Path = seeded["page_dir"]
|
||
# The seed: the row claims a strip its current gate would never set.
|
||
with SessionLocal() as db:
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == PLAIN_PATH
|
||
)
|
||
)
|
||
assert doc is not None
|
||
doc.page_count = 2
|
||
doc.page_strip_mode = "additive"
|
||
db.commit()
|
||
stale = [page_dir / f"{plain_id}-p{n}.png" for n in (1, 2)]
|
||
for p in stale:
|
||
p.write_bytes(b"STALE-PRE-PHASE-COPY") # not even a real PNG
|
||
|
||
body = _run_sync(app_url, capture_name="repair")
|
||
assert body["state"] == "success", body
|
||
detail = body["detail"]
|
||
assert detail["unchanged"] == 3, detail
|
||
assert detail["errors"] == 0, detail
|
||
|
||
# The row (the fixture session's view): both columns cleared.
|
||
row = _doc_row(PLAIN_PATH)
|
||
assert row.page_count is None, (
|
||
f"the repair arm must clear a stale page_count: {row.page_count}"
|
||
)
|
||
assert row.page_strip_mode is None, (
|
||
f"the repair arm must clear the mode with the count: {row.page_strip_mode}"
|
||
)
|
||
# The stale copies are gone; the other docs' copies untouched.
|
||
assert not list(page_dir.glob(f"{plain_id}-*")), (
|
||
"the repair arm must delete the stale copies"
|
||
)
|
||
assert len(list(page_dir.glob(f"{seeded['manual_id']}-p*.png"))) == 4, (
|
||
"the repair of one doc must not touch another doc's copies"
|
||
)
|
||
assert len(list(page_dir.glob(f"{seeded['scanned_id']}-p*.png"))) == 2
|
||
|
||
# The wire + the viewer: the strip is gone, the plain text doc is
|
||
# back (the same payload shape as test 2 pinned).
|
||
assert "page_urls" not in _payload(app_url, PLAIN_PATH)
|
||
login(page, app_url)
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
content = _open_doc_modal(page, PLAIN_PATH)
|
||
expect(content.locator(".doc-pages")).to_have_count(0)
|
||
expect(content.locator(".doc-view-toggle")).to_have_count(0)
|
||
expect(content.locator("pre.doc-raw")).to_be_visible()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Zero LLM calls on the fast-path repair (LOCKED A5)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_zero_llm_calls_on_heal_and_repair(seeded: dict[str, Any]) -> None:
|
||
"""The mock's request log (the house capture pattern): the INITIAL
|
||
index burned the vision passes it owes — at least one
|
||
``PICTORIAL_PAGE_MODE`` request (the map page) and at least one
|
||
``IMAGE_DESCRIPTION_MODE`` request (the fallback screenshots), the
|
||
witnesses that the capture really sees the app's calls — while the
|
||
HEAL sync and the REPAIR sync each captured EXACTLY ZERO
|
||
chat-completions requests. The re-gate mirrors the gate
|
||
deterministically from the current bytes + settings: rendering is
|
||
CPU-only, so the self-heal burns zero LLM/vision calls (LOCKED A5)."""
|
||
if USE_REAL_LLM:
|
||
pytest.skip("the zero-call pin reads the mock's request capture")
|
||
index = LLM_CAPTURES.get("index")
|
||
heal = LLM_CAPTURES.get("heal")
|
||
repair = LLM_CAPTURES.get("repair")
|
||
assert index is not None and heal is not None and repair is not None, (
|
||
"the suite ledger must hold all three syncs' captures — run the tests in order"
|
||
)
|
||
dumped = json.dumps(index)
|
||
assert "PICTORIAL_PAGE_MODE" in dumped, (
|
||
"the initial index must have burned the pictorial vision pass — "
|
||
"without this witness the zero-call pin below is vacuous"
|
||
)
|
||
assert "IMAGE_DESCRIPTION_MODE" in dumped, (
|
||
"the initial index must have burned the fallback screenshot pass"
|
||
)
|
||
heal_content = [b for b in heal if not _is_health_probe(b)]
|
||
repair_content = [b for b in repair if not _is_health_probe(b)]
|
||
assert heal_content == [], f"the heal sync burned LLM calls: {_summarize(heal_content)}"
|
||
assert repair_content == [], (
|
||
f"the repair sync burned LLM calls: {_summarize(repair_content)}"
|
||
)
|
||
|
||
|
||
def _is_health_probe(body: dict[str, Any]) -> bool:
|
||
"""The sync prelude's fixed model-health ping (``app.rag.llm
|
||
.check_models`` — one non-streaming ``ping`` chat every sync ever
|
||
run pays, unchanged by phase 163). Exempted from the zero-call
|
||
pins; NOTHING else may appear in a heal/repair capture."""
|
||
msgs = body.get("messages") or []
|
||
return (
|
||
not body.get("stream")
|
||
and len(msgs) == 1
|
||
and msgs[0].get("content") == "ping"
|
||
)
|
||
|
||
|
||
def _summarize(captured: list[dict[str, Any]]) -> str:
|
||
"""A compact one-liner per captured request (its system-prompt
|
||
marker) for the failure messages — keeps a violation readable
|
||
without dumping whole prompt bodies."""
|
||
out = []
|
||
for body in captured:
|
||
msgs = body.get("messages", [])
|
||
first = str(msgs[0].get("content", ""))[:80] if msgs else "?"
|
||
out.append(f"chat(stream={bool(body.get('stream'))}): {first}")
|
||
return " | ".join(out)
|