phase: 181_svg_document_preview
**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
"""Phase 181 E2E (Playwright): SVG documents preview as graphics, not XML.
|
||||
|
||||
Source: ``TODO.md`` L3 — "Need SVG render support on the front end when
|
||||
citing and svg source. Also would be nice to click an svg and see a
|
||||
preview in the RAG tab rather than the xml".
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov
|
||||
|
||||
The suite's OWN module app (the phase-122 ``test_image_documents.py``
|
||||
pattern) runs with ``BOR_IMAGES=true`` and every suite-writable home
|
||||
(``BOR_IMAGE_DIR`` included) pinned into the suite's tmp dir — process
|
||||
env ranks above an operator's local gitignored ``.env``, so the toggle
|
||||
cannot leak in either direction. Seeding is the phase-138 pattern
|
||||
(``test_markdown_blockquotes`` / ``test_markdown_inline_links_images``):
|
||||
truncate, then run the REAL importer in-process against a COPY of the
|
||||
phase's dedicated fixture tree ``tests/fixtures/svg_docs/`` (the shared
|
||||
fixture set's counts are pinned by other suites and must not move) with
|
||||
the deterministic mock embeddings — the vector branch of task 02
|
||||
indexes both SVGs with NO vision call, the PNG half of the last test is
|
||||
described by the mock's ``IMAGE_DESCRIPTION`` branch.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_viewer_renders_svg_not_xml`` — the Sources row for
|
||||
``diagrams/topology.svg`` opens the modal showing
|
||||
``img.doc-image-img--vector`` (``naturalWidth > 0``, non-empty alt),
|
||||
NO ``pre.doc-raw`` and no ``<svg`` XML text anywhere in the modal
|
||||
body, the extracted ``<title>`` text below the graphic; the "Full
|
||||
page" escape hatch renders the same graphic at ≥ 600px width at a
|
||||
1280px viewport (the task-04 vector sizing contract — a
|
||||
``viewBox``-only SVG would otherwise sit in the 300×150 default box).
|
||||
2. ``test_chat_figure_and_modal_preview`` — the grounded scripted-read
|
||||
question cites the SVG doc as the ``.source-image`` figure (the
|
||||
``<img>`` loads, the caption is the extracted text), and clicking
|
||||
the figure opens the modal with the full graphic (the "preview in
|
||||
the RAG tab" contract); Escape closes it.
|
||||
3. ``test_blank_svg_is_indexed_and_searchable`` — ``diagrams/blank.svg``
|
||||
(pure paths) exists as an image document whose content is the
|
||||
structural LOCKED A2 line ``Vector diagram "blank" — …``, is listed
|
||||
on the Sources page with its thumbnail, and opens in the modal with
|
||||
that line as its description.
|
||||
4. ``test_markdown_image_ref_renders`` — the two-stage seeding: import
|
||||
the tree, read the SVG doc's id from the DB, write
|
||||
``notes/inline.md`` containing ````
|
||||
and re-import; the viewer for that document shows ``img.md-img``
|
||||
pointing at the bytes route and LOADING (``naturalWidth > 0``) — the
|
||||
phase-180 renderer + this route end to end.
|
||||
5. ``test_raster_surfaces_unchanged`` — the smoke guard: a PNG doc in
|
||||
the same tree still renders through ``.doc-image-img`` (no
|
||||
``--vector`` class) and its bytes route carries NO sandbox CSP (the
|
||||
app-wide policy only) — phase 122/124 behaviour byte-unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.agent import IMAGE_DOC_MARKER
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.svg_text import extract_svg_text
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
|
||||
if USE_REAL_LLM:
|
||||
pytest.skip(
|
||||
"phase 181 pins the deterministic mock's grounded turn and seeded "
|
||||
"figures — the real model answers differently",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "svg_docs"
|
||||
|
||||
#: The source name = the imported tree root's basename (the importer's
|
||||
#: ``archive_source_name``/directory rule — the phase-180 pattern).
|
||||
SOURCE = "svg_docs"
|
||||
TOPO_PATH = "diagrams/topology.svg"
|
||||
BLANK_PATH = "diagrams/blank.svg"
|
||||
|
||||
#: The fixture diagram's own words — what the vector branch indexes
|
||||
#: (LOCKED A1) and what every text assertion below pins. Pinned as a
|
||||
#: literal here (not re-derived from the extractor) so a change to the
|
||||
#: extraction contract breaks this suite LOUDLY, next to the product.
|
||||
TOPO_TEXT = (
|
||||
"Homelab network topology\n"
|
||||
"Wiring diagram of the homelab: the edge router uplinks two managed "
|
||||
"switches, the hypervisor rack and the nas storage shelf.\n"
|
||||
"rack-1 switch\n"
|
||||
"nas storage"
|
||||
)
|
||||
|
||||
#: The grounded scripted-read question (phase-118 A1: a zero-read turn
|
||||
#: chips nothing — the mock emits the scripted ``read`` then echoes the
|
||||
#: result, the phase-122 figure precedent). Token-overlap cosines
|
||||
#: against the mock embeddings: topology.svg ≈0.70, the infra.md text
|
||||
#: co-citation ≈0.72 (both over the mock-calibrated 0.30 gate); the
|
||||
#: blank.svg line sits at ≈0.04 and never pollutes the citation set.
|
||||
QUESTION = (
|
||||
"Read the suggested document: read svg_docs/diagrams/topology.svg — "
|
||||
"what does the homelab network topology diagram show about the edge "
|
||||
"router, rack-1 switch and the nas storage?"
|
||||
)
|
||||
|
||||
#: A real 1×1 PNG (the phase-122/124 suites' bytes) — the raster smoke
|
||||
#: guard of the last test, written into the suite's tree copy only there.
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app: BOR_IMAGES pinned true, the image dir in the suite's scratch
|
||||
# (the in-process importer and the app under test SHARE that directory —
|
||||
# the copies the seed writes are the bytes the route serves).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, port: int, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-181
|
||||
knobs: ``BOR_IMAGES=true`` EXPLICITLY (an operator's local ``.env``
|
||||
cannot leak the toggle off) and ``BOR_IMAGE_DIR`` pinned into the
|
||||
suite's scratch (the app must never read or write the owner's real
|
||||
image home)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = f"http://127.0.0.1:{mock_llm}/v1"
|
||||
# Mock-calibrated gate (conftest pattern) — QUESTION grounds on these.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
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).
|
||||
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 181: the toggle under test + the suite-private homes.
|
||||
env["BOR_IMAGES"] = "true"
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(_image_dir(scratch))
|
||||
return env
|
||||
|
||||
|
||||
def _image_dir(scratch: Path) -> Path:
|
||||
return scratch / "images"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scratch(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
return tmp_path_factory.mktemp("bor_svg_preview")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int, scratch: Path
|
||||
) -> Iterator[str]:
|
||||
"""The suite's own app — ``BOR_IMAGES=true`` (the conftest session app
|
||||
is never started in this isolated run, so no port clash)."""
|
||||
port = int(os.environ.get("E2E_APP_PORT_SVG_PREVIEW", "8196"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeding: the phase-138 truncate-then-import pattern against a per-test
|
||||
# COPY of the dedicated fixture tree (the two-stage test appends a file to
|
||||
# the copy, never to the tracked fixture).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fresh_tree(scratch: Path) -> Path:
|
||||
"""A pristine copy of the fixture tree at ``<scratch>/src/svg_docs``
|
||||
(the basename IS the source name). Rmtree-first: the two-stage test
|
||||
leaves an ``inline.md`` behind, and every other test must not see it."""
|
||||
tree = scratch / "src" / SOURCE
|
||||
if tree.exists():
|
||||
shutil.rmtree(tree)
|
||||
tree.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(FIXTURES, tree)
|
||||
return tree
|
||||
|
||||
|
||||
async def _import(tree: Path, mock_llm: int, scratch: Path) -> ImportSummary:
|
||||
"""The REAL importer over the fixture copy (task 02's vector branch
|
||||
runs in-process against the mock — the same LLM the app under test
|
||||
talks to). A coroutine: the caller runs it through
|
||||
:func:`_run_in_thread` (Playwright owns the test thread's loop)."""
|
||||
settings = Settings(
|
||||
# the owner's .env cannot leak the vector knobs (_env_file is a
|
||||
# runtime-only pydantic-settings kwarg — pyright cannot see it).
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
llm_base_url=f"http://127.0.0.1:{mock_llm}/v1",
|
||||
images=True, # the toggle under test (LOCKED A7's on state)
|
||||
image_dir=str(_image_dir(scratch)),
|
||||
image_extensions=Settings.model_fields["image_extensions"].default,
|
||||
)
|
||||
return await import_sources([tree], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread — Playwright's sync API keeps an
|
||||
asyncio loop on the test thread (the house helper, phase 180)."""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _truncate() -> None:
|
||||
"""Fresh KB per test (the suites share one Postgres). ``sources_meta``
|
||||
resets with the KB — the re-import's bump UPSERTS the row back
|
||||
(the phase-122 truncate list, ``bump_sources_version``)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, folder_summaries, sources_meta"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed(mock_llm: int, scratch: Path) -> ImportSummary:
|
||||
"""Truncate + import the fresh fixture copy. Both SVGs arrive through
|
||||
the VECTOR branch (``svg_indexed == 2`` — no vision call happened),
|
||||
infra.md as the text co-citation: three docs total."""
|
||||
_truncate()
|
||||
summary = _run_in_thread(_import(_fresh_tree(scratch), mock_llm, scratch))
|
||||
assert (summary.files, summary.added, summary.images_failed) == (3, 3, 0)
|
||||
assert summary.svg_indexed == 2, summary
|
||||
return summary
|
||||
|
||||
|
||||
def _doc(source: str, path: str) -> Document:
|
||||
with SessionLocal() as db:
|
||||
doc = db.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == path)
|
||||
)
|
||||
assert doc is not None, f"no documents row for ({source!r}, {path!r})"
|
||||
return doc
|
||||
|
||||
|
||||
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 _skip_without_db(app_url: str) -> None:
|
||||
if not _health_db(app_url):
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
|
||||
|
||||
def _drill(page: Page, name: str) -> None:
|
||||
"""One level of the KB tree (the phase-122/180 click, made settle-
|
||||
safe): the drill keeps rendering the CURRENT level while the node
|
||||
fetch runs (phase 167), so the level is settled exactly when the
|
||||
clicked link is gone from the replaced table (intermediate levels
|
||||
with no direct files must not wait on an empty ``#docs-tbody``)."""
|
||||
link = page.locator(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
expect(link).to_be_visible(timeout=30_000)
|
||||
link.click()
|
||||
expect(link).to_have_count(0)
|
||||
|
||||
|
||||
def _open_doc_modal(page: Page, app_url: str, folder: str, doc_path: str) -> Any:
|
||||
"""Sources → source → folder → the document's row → the same-page
|
||||
modal. Returns the modal locator (loaded)."""
|
||||
login(page, app_url) # lands on /sources.html (admin)
|
||||
_drill(page, SOURCE)
|
||||
_drill(page, folder)
|
||||
row = page.locator("#docs-tbody tr", has_text=doc_path)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
modal = page.locator("#doc-modal")
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
return modal
|
||||
|
||||
|
||||
def _seed_page(page: Page, app_url: str, mock_llm: int, scratch: Path) -> None:
|
||||
_skip_without_db(app_url)
|
||||
page.set_default_timeout(30_000)
|
||||
_seed(mock_llm, scratch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The viewer: the SVG paints as a graphic — the XML is nowhere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_renders_svg_not_xml(
|
||||
page: Page, app_url: str, mock_llm: int, scratch: Path
|
||||
) -> None:
|
||||
"""TODO.md L3's viewer half: the modal shows the RENDERED vector
|
||||
graphic (the ``--vector`` sizing class, task 04) from the bytes
|
||||
route, alt = the extracted text; NO raw-XML surface exists — no
|
||||
``pre.doc-raw``, no ``<svg`` markup text anywhere in the modal body
|
||||
— and the extracted ``<title>`` sentence reads as the description
|
||||
below the graphic. The "Full page" escape hatch (the
|
||||
``/document.html`` viewer) spans the drawing: ≥ 600px wide at a
|
||||
1280px viewport, where the 300×150 replaced-element default box
|
||||
would have sat small (the phase's sizing contract)."""
|
||||
_seed_page(page, app_url, mock_llm, scratch)
|
||||
doc = _doc(SOURCE, TOPO_PATH)
|
||||
assert doc.is_image is True # the premise: a vector IMAGE row
|
||||
|
||||
modal = _open_doc_modal(page, app_url, "diagrams", TOPO_PATH)
|
||||
expect(page.locator("#doc-modal-title")).to_have_text("topology")
|
||||
|
||||
img = modal.locator("img.doc-image-img--vector")
|
||||
expect(img).to_have_count(1)
|
||||
expect(img).to_be_visible(timeout=15_000)
|
||||
assert img.evaluate("el => el.naturalWidth") > 0, "the SVG bytes did not paint"
|
||||
assert img.evaluate("el => el.naturalHeight") > 0
|
||||
alt = img.get_attribute("alt")
|
||||
assert alt is not None and "Homelab network topology" in alt
|
||||
assert img.get_attribute("src") == f"/api/documents/{doc.id}/image"
|
||||
expect(modal.locator(".doc-image-unavailable")).to_have_count(0)
|
||||
|
||||
# The honest negative (the owner's complaint, verbatim): NO XML text
|
||||
# and no raw-code surface for an SVG row.
|
||||
expect(modal.locator("pre.doc-raw")).to_have_count(0)
|
||||
body_text = modal.inner_text()
|
||||
assert "<svg" not in body_text, "raw XML text painted into the modal"
|
||||
assert "</title>" not in body_text
|
||||
# The extracted words ARE the document's readable content, below
|
||||
# the graphic (the phase-165 prose arm — summary === content).
|
||||
prose = modal.locator(".doc-prose")
|
||||
expect(prose).to_have_count(1)
|
||||
expect(prose).to_contain_text("Homelab network topology")
|
||||
expect(prose).to_contain_text("rack-1 switch")
|
||||
|
||||
# The Full page escape hatch: the same graphic, full column width.
|
||||
href = page.locator("#doc-modal-open").get_attribute("href")
|
||||
assert href is not None and "/document.html" in href
|
||||
page.goto(app_url + href)
|
||||
viewer_img = page.locator(".doc-image-img--vector")
|
||||
expect(viewer_img).to_be_visible(timeout=15_000)
|
||||
box = viewer_img.bounding_box()
|
||||
assert box is not None and box["width"] >= 600, (
|
||||
f"the viewBox-only SVG did not span the viewer: {box!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The chat figure + the click-through preview in the RAG tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_figure_and_modal_preview(
|
||||
page: Page, app_url: str, mock_llm: int, scratch: Path
|
||||
) -> None:
|
||||
"""TODO.md L3's citation half: the grounded question whose answer
|
||||
cites the SVG doc (the scripted read echoes the read result — the
|
||||
``IMAGE_DOC_MARKER`` line + the extracted words) shows the compact
|
||||
``.source-image`` figure whose ``<img>`` LOADS from the bytes route
|
||||
with the caption settling to the extracted text; clicking the
|
||||
figure opens the SAME-page modal showing the full graphic (the
|
||||
"click an svg … see a preview in the RAG tab rather than the xml"
|
||||
contract) and Escape closes it."""
|
||||
_seed_page(page, app_url, mock_llm, scratch)
|
||||
doc = _doc(SOURCE, TOPO_PATH)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
page.fill("#message-input", QUESTION)
|
||||
page.click("#send-btn")
|
||||
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000)
|
||||
expect(bubble).to_contain_text("Homelab network topology", timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||
|
||||
# The citation figure (phase 130: the figure IS the citation).
|
||||
# Scoped by the figure's aria-label (the source/path label) — the
|
||||
# seed also carries blank.svg, and an image doc the seed surfaces
|
||||
# is auto-cited too (phase 124); the ASSERTED figure is the
|
||||
# topology doc's.
|
||||
row = page.locator(".msg.brain .msg-meta.msg-images")
|
||||
expect(row).to_have_count(1)
|
||||
fig = row.locator(f'.source-image[aria-label="{SOURCE}/{TOPO_PATH}"]')
|
||||
expect(fig).to_have_count(1)
|
||||
fig_img = fig.locator(".source-image-img")
|
||||
expect(fig_img).to_be_visible(timeout=15_000)
|
||||
assert fig_img.evaluate("el => el.naturalWidth") > 0, (
|
||||
"the chat figure did not load the SVG bytes"
|
||||
)
|
||||
assert fig_img.get_attribute("src") == f"/api/documents/{doc.id}/image"
|
||||
caption = fig.locator(".source-image-caption")
|
||||
expect(caption).to_contain_text("Homelab network topology", timeout=15_000)
|
||||
|
||||
# The click → modal preview: the full graphic, no XML, and Escape.
|
||||
fig.click()
|
||||
modal = page.locator("#doc-modal")
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
modal_img = modal.locator("img.doc-image-img--vector")
|
||||
expect(modal_img).to_have_count(1)
|
||||
expect(modal_img).to_be_visible(timeout=15_000)
|
||||
assert modal_img.evaluate("el => el.naturalWidth") > 0
|
||||
assert "<svg" not in modal.inner_text()
|
||||
page.keyboard.press("Escape")
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The textless drawing: indexed with the structural line, previewable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_blank_svg_is_indexed_and_searchable(
|
||||
page: Page, app_url: str, mock_llm: int, scratch: Path
|
||||
) -> None:
|
||||
"""LOCKED A2: ``diagrams/blank.svg`` carries NO words — pure paths —
|
||||
yet it is NOT skipped: it exists as an image document whose indexed
|
||||
text is the structural line built only from facts in the file
|
||||
(``Vector diagram "blank" — <N> shapes (…), viewBox 640×480``), the
|
||||
line is embedded (searchable), and the Sources page lists the doc
|
||||
with its rendered thumbnail; the modal shows the graphic with that
|
||||
line as the description below it."""
|
||||
_seed_page(page, app_url, mock_llm, scratch)
|
||||
doc = _doc(SOURCE, BLANK_PATH)
|
||||
assert doc.is_image is True and doc.image_path is not None
|
||||
assert doc.content.startswith('Vector diagram "blank" —')
|
||||
assert "viewBox 640×480" in doc.content
|
||||
assert doc.summary == doc.content
|
||||
# Searchable: the structural line is an EMBEDDED chunk (both the
|
||||
# content chunk and the is_summary mirror).
|
||||
with SessionLocal() as db:
|
||||
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
|
||||
assert [c.content for c in chunks] and all(
|
||||
c.content == doc.content for c in chunks
|
||||
)
|
||||
assert all(c.embedding is not None and len(c.embedding) == 768 for c in chunks)
|
||||
|
||||
login(page, app_url)
|
||||
_drill(page, SOURCE)
|
||||
_drill(page, "diagrams")
|
||||
row = page.locator("#docs-tbody tr", has_text=BLANK_PATH)
|
||||
expect(row).to_have_count(1)
|
||||
thumb = row.locator(".kb-doc-thumb-img")
|
||||
expect(thumb).to_have_count(1)
|
||||
expect(thumb).to_be_visible(timeout=15_000)
|
||||
assert thumb.evaluate("el => el.naturalWidth") > 0, "the thumbnail is not painted"
|
||||
|
||||
# The modal: the graphic + the honest structural description (the
|
||||
# file's only possible words).
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
modal = page.locator("#doc-modal")
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
expect(modal.locator("img.doc-image-img--vector")).to_have_count(1)
|
||||
expect(modal.locator(".doc-prose")).to_contain_text('Vector diagram "blank"')
|
||||
expect(modal.locator("pre.doc-raw")).to_have_count(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The phase-180 synergy:  paints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_markdown_image_ref_renders(
|
||||
page: Page, app_url: str, mock_llm: int, scratch: Path
|
||||
) -> None:
|
||||
"""The two-stage seeding the phases' hand-off needs: import the tree,
|
||||
read the SVG doc's id from the DB, THEN write ``notes/inline.md``
|
||||
containing ```` and re-import
|
||||
(one doc added, the SVG row untouched — the fast path). The viewer
|
||||
for that document renders ``img.md-img`` pointing at the bytes route
|
||||
and LOADING it (``naturalWidth > 0``) — phase 180's allowlist
|
||||
accepts the route shape, phase 181's route answers it, and the
|
||||
reference never prints as text."""
|
||||
_seed_page(page, app_url, mock_llm, scratch)
|
||||
topo = _doc(SOURCE, TOPO_PATH)
|
||||
image_url = f"/api/documents/{topo.id}/image"
|
||||
assert extract_svg_text # the extractor produced the row's text (premise)
|
||||
|
||||
tree = scratch / "src" / SOURCE
|
||||
(tree / "notes" / "inline.md").write_text(
|
||||
"# Inline reference notes\n\n"
|
||||
"The topology drawing, inline:\n\n"
|
||||
f"\n\n"
|
||||
"Same route, inside a link: [the diagram](/sources.html).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
second = _run_in_thread(_import(tree, mock_llm, scratch))
|
||||
assert second.added == 1, second
|
||||
assert second.svg_indexed == 0, "the unchanged SVG must take the fast path"
|
||||
|
||||
modal = _open_doc_modal(page, app_url, "notes", "notes/inline.md")
|
||||
md_img = modal.locator("img.md-img")
|
||||
expect(md_img).to_have_count(1)
|
||||
assert md_img.get_attribute("src") == image_url
|
||||
assert md_img.get_attribute("alt") == "topology"
|
||||
expect(md_img).to_be_visible(timeout=15_000)
|
||||
assert md_img.evaluate("el => el.naturalWidth") > 0, (
|
||||
"the bytes route did not answer the markdown reference"
|
||||
)
|
||||
# The reference renders — it never prints.
|
||||
assert "
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. The smoke guard: raster image docs are byte-unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_raster_surfaces_unchanged(
|
||||
page: Page, app_url: str, mock_llm: int, scratch: Path
|
||||
) -> None:
|
||||
"""Phase 122/124 behaviour survives the vector branch: a PNG doc in
|
||||
the SAME tree is described through the vision seam (the mock's
|
||||
fixed answer), renders through the plain ``.doc-image-img`` with NO
|
||||
``--vector`` class, and its bytes route carries NO sandbox CSP — the
|
||||
app-wide middleware policy only, the pre-phase header set."""
|
||||
_seed_page(page, app_url, mock_llm, scratch)
|
||||
tree = scratch / "src" / SOURCE
|
||||
(tree / "diagrams" / "raster.png").write_bytes(PNG_1X1)
|
||||
summary = _run_in_thread(_import(tree, mock_llm, scratch))
|
||||
assert (summary.files, summary.added) == (4, 1)
|
||||
assert summary.svg_indexed == 0
|
||||
|
||||
doc = _doc(SOURCE, "diagrams/raster.png")
|
||||
assert doc.is_image is True
|
||||
image_url = f"/api/documents/{doc.id}/image"
|
||||
|
||||
login(page, app_url)
|
||||
_drill(page, SOURCE)
|
||||
_drill(page, "diagrams")
|
||||
row = page.locator("#docs-tbody tr", has_text="diagrams/raster.png")
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
modal = page.locator("#doc-modal")
|
||||
expect(modal).to_be_visible(timeout=30_000)
|
||||
|
||||
img = modal.locator(".doc-image-img")
|
||||
expect(img).to_have_count(1)
|
||||
expect(img).to_be_visible(timeout=15_000)
|
||||
# The raster class string is UNTOUCHED by the vector modifier.
|
||||
assert (img.get_attribute("class") or "") == "doc-image-img"
|
||||
expect(modal.locator("img.doc-image-img--vector")).to_have_count(0)
|
||||
|
||||
resp = page.request.get(app_url + image_url)
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert "sandbox" not in csp, "the SVG sandbox policy leaked onto a raster route"
|
||||
Reference in New Issue
Block a user