Files
brain-of-reese/tests/e2e/test_prose_word_wrap.py
T
ducoterra b0c7aed613
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 165_prose_word_wrap
All verification complete. Final report:

**Phase 165 — Prose word wrap (final verification pass)**
- Verified: `PROSE_FORMATS={txt,text}` + `isProseDoc` (single-site, image arm wins); `renderDocument` three-way branch (md → `.doc-md`, prose → `.doc-prose` text node, else `pre.doc-raw` byte-identical); `.doc-prose` CSS = 72rem column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals; `[data-view="pages"]` hide list includes `.doc-prose` as one grouped declaration; phase-116 scrollbar rules intact and modal-scoped; no `innerHTML` in the prose path.
- Tests/lint (all 100% green):
  - `uv run pytest` → **3854 passed** (unit + integration)
  - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
  - `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
  - `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → **5 passed** (isolation)
  - Isolation regressions: document_viewer 8, image_documents 4, pdf_pages_in_sources 4, pdf_strip_additive_selfheal 5, edit_summaries 4, summary_in_viewer 4, chat_answer_clusters 4, garbage_pdf 3, pdf_support 6, sticky_navbar 3, smoke 3 — all passed
- Completion criteria: image description wraps, full text, both surfaces ✔; `.txt` wraps same way ✔; `py`/`json`/code keep `pre.doc-raw` + horizontal overflow + themed modal scrollbar (E2E negative control + pins) ✔; no innerHTML, AA `--ink`/`--surface` pair ✔; full gates green ✔; commit + phase-dir move are harness-owned (not executed per pipeline rules).
- Documentation: README already updated by task 03 (viewer section + image-docs section); full README/AGENTS.md review found no further inconsistencies — **verified consistent, no changes needed**.
- Notable deviations: `.doc-prose` omits `font-family: inherit` from 00_phase D2's snippet — task 01's authoritative "exact property set" excludes it and `div` inherits fonts anyway (behavior identical). PDF deferral (A4) intact: `format == "pdf"` stays in `.doc-raw`, owner call recorded.
- Next pending phase: `166_hide_empty_answer_bubble`.
2026-10-07 22:57:40 -04:00

495 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 165 E2E (Playwright): prose word wrap — the TODO.md L3 claim
("Need word wrap for image explanations and other long text rather than
scrolling horizontally") proven end-to-end, with ZERO horizontal
overflow, on BOTH viewer surfaces: the ``/document.html`` page and the
in-page modal opened from a Sources row.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov
Seeding is the direct-DB idiom of ``tests/e2e/test_document_viewer.py``
(``_seed_long_line_doc`` — the viewer is database-only: no chunks, no
fixture import, no mock LLM turn) + the image-document shape of
``tests/e2e/test_image_documents.py``: an ``is_image`` row whose
``content`` AND ``summary`` are the vision description (the importer
invariant, phase 122 A3) and whose ``image_path`` points at a real PNG
written into the module's scratch dir — so the ``image_url`` bytes
route serves REAL pixels (probed over HTTP at seed time and again
through the rendered ``<img>.naturalWidth``).
Three seeded docs, one per rendering arm (phase 165 D1):
* ``notes/landscape.png`` — image doc (prose by construction, whatever
its extension): a ~860-char SINGLE-PARAGRAPH vision description;
* ``notes/prose-long.txt`` — the ``txt`` prose arm: one ~860-char line
PLUS a second paragraph (proves BOTH intra-line wrap and the
paragraph break — ``white-space: pre-wrap`` carries the ``\\n\\n`` as
a blank rendered line);
* ``notes/code-long.py`` — the NEGATIVE control (locked A1/A3): one
~900-char line that MUST keep overflowing horizontally in
``pre.doc-raw`` — the wrapping fix must never wrap code.
Per prose doc the suite asserts, on BOTH surfaces: the prose block
exists exactly once and carries the FULL text (nothing clamped),
``scrollWidth <= clientWidth + 1`` (the TODO's literal defect — no
horizontal scrollbar), the block is taller than 2.2 line-heights (the
text really wrapped), the computed ``white-space`` is ``pre-wrap``, and
the block rides the 72rem reading column (``max-width: 1152px``, never
wider than its content host). The image doc additionally keeps the
phase-122 ordering (the ``<img>`` ABOVE the prose) and the suppressed
Summary panel (``summary === content``).
Test → observable mapping (Playwright Mapping Rule):
1. ``test_image_doc_description_wraps_on_the_page`` — the standalone
viewer page: image above the wrapping ``.doc-prose``, full text,
zero horizontal overflow, summary suppressed, no ``pre.doc-raw``.
2. ``test_image_doc_description_wraps_in_the_modal`` — the same doc
through the Sources-row modal: identical contract on
``#doc-modal-content``.
3. ``test_txt_doc_wraps_on_the_page`` — the ``.txt`` body on the page:
full raw text, the ``\\n\\n`` preserved AND rendered as a blank
line, no horizontal overflow, 72rem column.
4. ``test_txt_doc_wraps_in_the_modal`` — the same through the modal.
5. ``test_code_doc_still_scrolls_horizontally`` — the locked code path
on both surfaces: ``pre.doc-raw``, ``white-space: pre``,
``scrollWidth > clientWidth`` (phase 116 A1 alive E2E, not just in
the CSS pins).
"""
from __future__ import annotations
import base64
from collections.abc import Iterator
from datetime import UTC, datetime
from urllib.parse import quote
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from app.models import Document
from e2e.auth_helpers import login
SOURCE = "docs"
IMAGE_PATH = "notes/landscape.png"
TXT_PATH = "notes/prose-long.txt"
CODE_PATH = "notes/code-long.py"
#: The mock-free stand-in for a vision description: ONE single-line
#: paragraph (no ``\\n`` at all — exactly the shape that used to become
#: one unbreakable line in the old ``pre.doc-raw``), long enough that
#: rendering it unwrapped would overflow any viewport.
DESCRIPTION = (
"A wide screenshot of the homelab dashboard on the wall display: the top row shows four "
"gauge panels, CPU load at eighteen percent, memory at six of thirty-two gigabytes, disk at "
"forty-one percent across the merged pool, and network throughput peaking at four hundred "
"megabits during the nightly backup window, while the middle band lists every service as a "
"green pill (proxmox, pihole, nginx proxy manager, gitea, immich, postgres, valkey, and the "
"rag indexer), and the bottom strip is a time-series graph of requests per second whose "
"legend reads ingress, api, and embeddings; the sidebar carries the clock, a weather "
"widget, and a small map pin marking the rack location beside the hall closet. Every label "
"on the capture stays legible at full width, from the uptime counter in the corner to the "
"footnote that names the exporter version and the scrape interval."
)
#: The ``.txt`` body: ONE ~860-char line (the intra-line wrap proof)
#: plus a second paragraph (the paragraph-break proof).
_FOX = "the quick brown fox jumps over the lazy dog "
TXT_LINE = "notes/prose-long.txt reads: " + _FOX * 18
TXT_PARA2 = (
"The second paragraph exists to prove the paragraph BREAK survives the prose block: "
"pre-wrap carries it."
)
TXT_CONTENT = TXT_LINE + "\n\n" + TXT_PARA2
#: The negative control: one ~900-char code line — locked A1 (phase
#: 116): code NEVER wraps, it keeps the horizontal scroller.
CODE_LINE = "result = render_report(" + '"' + _FOX * 20 + '"' + ")"
assert len(DESCRIPTION) >= 800, "the vision description must be a long single line"
assert "\n" not in DESCRIPTION, "the description must be ONE line (the un-wrapped defect shape)"
assert len(TXT_LINE) >= 800 and "\n" not in TXT_LINE
assert len(CODE_LINE) >= 800 and "\n" not in CODE_LINE
#: A real 1×1 transparent PNG (the phase-122 fixture bytes — the route
#: serves the real bytes; naturalWidth 1 is the served-proof probe).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
# ---------------------------------------------------------------------------
# DB helpers (the test_document_viewer direct-seed idiom)
# ---------------------------------------------------------------------------
def _truncate_kb() -> None:
"""Fresh KB per module run (the E2E isolation pattern of
tests/e2e/test_image_documents.py — every catalogue table the
seeded rows could touch resets, so leftovers from an earlier
suite can never corrupt this one's counts or rows)."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
))
db.commit()
@pytest.fixture(scope="module")
def seeded(app_url: str, tmp_path_factory: pytest.TempPathFactory) -> Iterator[dict[str, str]]:
"""Seed the three docs (image / txt / code) straight into the KB —
the viewer is database-only, so no chunks and no LLM are involved
(the ``_seed_long_line_doc`` pattern) — and write the image PNG
into the module's scratch dir so ``image_path`` points at bytes the
PUBLIC bytes route actually serves (probed here over HTTP with the
right Content-Type). Yields the identity map for the assertions;
truncates on teardown."""
try:
body = httpx.get(f"{app_url}/api/health", timeout=5).json()
except Exception: # noqa: BLE001 — unreachable is the skip case
body = {"db": "down"}
if body.get("db") != "up":
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
_truncate_kb()
scratch = tmp_path_factory.mktemp("bor_prose_wrap")
png = scratch / "landscape.png"
png.write_bytes(PNG_1X1)
with SessionLocal() as db:
image_doc = Document(
source=SOURCE,
path=IMAGE_PATH,
full_path=str(png),
title="Landscape Screenshot",
content=DESCRIPTION,
summary=DESCRIPTION, # the importer invariant: summary IS the description
is_image=True,
image_path=str(png),
content_hash="a1" * 32,
indexed_at=datetime.now(UTC),
)
db.add(image_doc)
db.add(
Document(
source=SOURCE,
path=TXT_PATH,
full_path="/tmp/prose-long.txt",
title="Prose Long Txt",
content=TXT_CONTENT,
content_hash="b2" * 32,
indexed_at=datetime.now(UTC),
)
)
db.add(
Document(
source=SOURCE,
path=CODE_PATH,
full_path="/tmp/code-long.py",
title="Code Long Py",
content=CODE_LINE + "\n",
content_hash="c3" * 32,
indexed_at=datetime.now(UTC),
)
)
db.commit()
image_url = f"/api/documents/{image_doc.id}/image"
# The seeded image_url serves REAL bytes (phase 122's route is
# public — phase 124 A4): the render assertions below are about a
# genuinely delivered image, never a broken one.
served = httpx.get(f"{app_url}{image_url}", timeout=10)
assert served.status_code == 200, "the seeded image bytes must serve through the route"
assert served.headers["content-type"] == "image/png"
yield {
"image_url": image_url,
"description": DESCRIPTION,
}
_truncate_kb()
# ---------------------------------------------------------------------------
# Surface helpers
# ---------------------------------------------------------------------------
def _drill(page: Page, *names: str) -> None:
"""Phase 97: drill the catalogue tree (exact name match, one per
name) to the level that holds the asserted file (the
test_document_viewer idiom)."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _open_doc_page(page: Page, app_url: str, doc_path: str) -> None:
"""The STANDALONE surface: sign in, then the direct
/document.html?source=…&path=… URL (the same percent-encoding the
chips and row links carry)."""
login(page, app_url, next="/")
page.goto(
f"{app_url}/document.html?source={SOURCE}&path={quote(doc_path, safe='')}",
timeout=30_000,
)
def _open_doc_modal(page: Page, app_url: str, path_needle: str) -> None:
"""The IN-PAGE modal surface: the Sources row's path link (phase
26 — no navigation, the modal opens over the catalogue)."""
login(page, app_url)
_drill(page, "docs", "notes")
row = page.locator("#docs-tbody tr", has_text=path_needle)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator("#doc-modal")).to_be_visible(timeout=30_000)
def _assert_wrapping_prose(page: Page, content_sel: str, full_text: str) -> dict[str, object]:
"""The phase-165 prose contract on ONE surface's content host:
* exactly ONE ``.doc-prose`` block, NO ``pre.doc-raw`` sibling;
* the FULL text — to_have_text (whitespace-normalized) AND the raw
``textContent`` byte-for-byte (nothing clamped, nothing dropped);
* ``scrollWidth <= clientWidth + 1`` — THE TODO's literal defect:
no horizontal overflow, no sideways scrollbar (an 858-char
unwrapped line would report ~6000px here — this bar alone proves
the wrap once the full raw text is present);
* ``height > 2.2 × line-height`` — the block stands several line
boxes tall (the task's proof bar; with the two bars above it
means the FULL text occupies real wrapped lines, not a clipped
or scrolled single line);
* computed ``white-space == "pre-wrap"`` and ``max-width``
resolving to the 72rem reading column (1152px at the 16px root),
never wider than the content host.
"""
prose = page.locator(f"{content_sel} .doc-prose")
expect(prose).to_have_count(1)
expect(prose).to_have_text(full_text, timeout=30_000)
expect(page.locator(f"{content_sel} pre.doc-raw")).to_have_count(0)
m = prose.evaluate(
"""(el) => {
const host = el.parentElement;
const cs = getComputedStyle(el);
return {
raw: el.textContent,
inner: el.innerText,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
height: el.getBoundingClientRect().height,
width: el.getBoundingClientRect().width,
hostWidth: host.getBoundingClientRect().width,
whiteSpace: cs.whiteSpace,
maxWidth: cs.maxWidth,
lineHeight: parseFloat(cs.lineHeight),
};
}"""
)
assert m["raw"] == full_text, "the prose block must hold the FULL content (raw equality)"
assert m["scrollWidth"] <= m["clientWidth"] + 1, (
f"horizontal overflow — the TODO defect is back: {m}"
)
assert m["lineHeight"] > 0, f"line-height did not resolve to px: {m}"
assert m["height"] > 2.2 * m["lineHeight"], (
f"the block is {m['height']}px tall, want > 2.2 line-heights "
f"({2.2 * m['lineHeight']:.0f}px) — the text did not wrap"
)
assert m["whiteSpace"] == "pre-wrap", f"white-space is {m['whiteSpace']!r}, not pre-wrap"
assert m["maxWidth"] == "1152px", (
f"max-width is {m['maxWidth']!r}, want 1152px (the 72rem --chat-column)"
)
assert m["width"] <= m["hostWidth"] + 1, (
f"the block ({m['width']}px) is wider than its content host ({m['hostWidth']}px)"
)
return m
def _assert_code_scrolls(page: Page, content_sel: str) -> None:
"""The locked code contract (phase 116 A1 / phase 165 A3) on ONE
surface: the code doc renders ``pre.doc-raw`` with
``white-space: pre`` + ``overflow-x: auto`` and REAL horizontal
overflow — no prose block anywhere."""
pre = page.locator(f"{content_sel} pre.doc-raw")
expect(pre).to_have_count(1, timeout=30_000)
expect(pre).to_have_text(CODE_LINE + "\n", timeout=30_000)
expect(page.locator(f"{content_sel} .doc-prose")).to_have_count(0)
dims = pre.evaluate(
"""(el) => {
const cs = getComputedStyle(el);
return {
sw: el.scrollWidth,
cw: el.clientWidth,
ws: cs.whiteSpace,
ox: cs.overflowX,
};
}"""
)
assert dims["sw"] > dims["cw"], f"the code line stopped overflowing: {dims}"
assert dims["ws"] == "pre", f"code white-space is {dims['ws']!r}, not pre (A3 broken)"
assert dims["ox"] == "auto", f"overflow-x is {dims['ox']!r}, not auto"
# ---------------------------------------------------------------------------
# 1–2. The image doc: the vision description wraps on both surfaces
# ---------------------------------------------------------------------------
def test_image_doc_description_wraps_on_the_page(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""The standalone /document.html page: the ``is_image`` doc renders
the ``<img>`` (from the served bytes route) ABOVE the prose block
(the phase-122 ordering), the labeled Summary panel stays
SUPPRESSED (summary === content), and the full ~860-char
description reads as wrapping prose — zero horizontal overflow,
pre-wrap, the 72rem column, no ``pre.doc-raw`` anywhere."""
page.set_default_timeout(30_000)
_open_doc_page(page, app_url, IMAGE_PATH)
expect(page.locator("#doc-title")).to_have_text("Landscape Screenshot")
_assert_wrapping_prose(page, "#doc-content", seeded["description"])
# The image is served AND rendered, and it sits ABOVE the prose
# (phase 122's ordering contract — the image first, the wrapping
# description below it).
img = page.locator("#doc-content .doc-image-img")
expect(img).to_have_count(1)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"])
expect(img).to_be_visible()
assert img.evaluate("el => el.naturalWidth") == 1, "the bytes route must deliver real PNG bytes"
assert page.evaluate(
"""() => {
const host = document.querySelector('#doc-content');
const img = host.querySelector('.doc-image-img');
const prose = host.querySelector('.doc-prose');
return (img.compareDocumentPosition(prose)
& Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
}"""
), "the <img> must render ABOVE the prose block (phase 122 ordering)"
# The verbatim-description case keeps the Summary panel suppressed
# (the panel would duplicate the text right below the image).
expect(page.locator("#doc-content .doc-summary")).to_have_count(0)
expect(page.locator("#doc-content .doc-image-unavailable")).to_have_count(0)
def test_image_doc_description_wraps_in_the_modal(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""The SAME doc through the Sources-row modal: the identical prose
contract on ``#doc-modal-content`` — one shared renderDocument core
means the modal wraps exactly like the page (no per-surface drift),
inside the narrower (1100px) panel."""
page.set_default_timeout(30_000)
_open_doc_modal(page, app_url, "landscape.png")
expect(page.locator("#doc-modal-title")).to_have_text("Landscape Screenshot")
_assert_wrapping_prose(page, "#doc-modal-content", seeded["description"])
img = page.locator("#doc-modal-content .doc-image-img")
expect(img).to_have_count(1)
expect(img).to_have_attribute("src", seeded["image_url"])
assert img.evaluate("el => el.naturalWidth") == 1
assert page.evaluate(
"""() => {
const host = document.querySelector('#doc-modal-content');
const img = host.querySelector('.doc-image-img');
const prose = host.querySelector('.doc-prose');
return (img.compareDocumentPosition(prose)
& Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
}"""
), "the modal must keep the <img> ABOVE the prose block too"
expect(page.locator("#doc-modal-content .doc-summary")).to_have_count(0)
# ---------------------------------------------------------------------------
# 3–4. The .txt doc: a 860-char line wraps, the paragraph break survives
# ---------------------------------------------------------------------------
def _assert_paragraph_break(m: dict[str, object]) -> None:
"""The SECOND paragraph proof (beyond the wrap proof in
_assert_wrapping_prose): the raw text node keeps the ``\\n\\n``
verbatim, and the presentation text (``innerText``, which in a
pre-wrap context carries the SOURCE hard breaks — soft wraps are
rendered line boxes, not innerText newlines) still shows the
three-paragraph source shape: long line, BLANK line, second
paragraph. The intra-line wrap itself is the scrollWidth/height
proof above — the 858-char source line is one innerText line that
measurably does NOT overflow horizontally."""
raw = str(m["raw"])
inner = str(m["inner"])
assert "\n\n" in raw, "the raw text node must keep the paragraph break"
assert "\n\n" in inner, (
"the pre-wrap presentation text must keep the blank paragraph line"
)
lines = inner.splitlines()
assert len(lines) == 3 and lines[1] == "", (
f"the paragraph shape is not (line, blank, paragraph): {lines}"
)
def test_txt_doc_wraps_on_the_page(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
# `seeded` is the module's seed+truncate barrier (this test reads
# the txt row it creates, not its return value).
"""The standalone page, the ``txt`` prose arm: the ~860-char line
wraps inside the reading column (zero horizontal overflow — the
TODO's literal ask), the full body is present raw, the paragraph
break renders as a blank line, ``pre-wrap`` + the 72rem column are
in effect, and no ``pre.doc-raw`` remains for a prose format."""
page.set_default_timeout(30_000)
_open_doc_page(page, app_url, TXT_PATH)
expect(page.locator("#doc-title")).to_have_text("Prose Long Txt")
expect(page.locator("#doc-meta .format-badge")).to_have_text("txt")
m = _assert_wrapping_prose(page, "#doc-content", TXT_CONTENT)
_assert_paragraph_break(m)
def test_txt_doc_wraps_in_the_modal(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
# `seeded`: the module's seed barrier (see test 3).
"""The same ``.txt`` doc through the modal: identical wrapping on
``#doc-modal-content`` (the shared core, the shared CSS)."""
page.set_default_timeout(30_000)
_open_doc_modal(page, app_url, "prose-long.txt")
expect(page.locator("#doc-modal-title")).to_have_text("Prose Long Txt")
m = _assert_wrapping_prose(page, "#doc-modal-content", TXT_CONTENT)
_assert_paragraph_break(m)
# ---------------------------------------------------------------------------
# 5. The negative control: code keeps the horizontal scroller (locked A1/A3)
# ---------------------------------------------------------------------------
def test_code_doc_still_scrolls_horizontally(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
# `seeded`: the module's seed barrier (see test 3).
"""The wrapping fix is provably SCOPED: the ~900-char code line
still lands in ``pre.doc-raw`` and STILL overflows horizontally
with ``white-space: pre`` + ``overflow-x: auto`` on BOTH surfaces
— phase 116's locked A1 ("wrapping breaks code readability") alive
end-to-end, not just in the CSS pins (phase 165 D3)."""
page.set_default_timeout(30_000)
# Surface 1: the standalone page.
_open_doc_page(page, app_url, CODE_PATH)
expect(page.locator("#doc-title")).to_have_text("Code Long Py")
expect(page.locator("#doc-meta .format-badge")).to_have_text("py")
_assert_code_scrolls(page, "#doc-content")
# Surface 2: the in-page modal (the phase-116 themed scrollbar
# owner — tests/e2e/test_document_viewer.py pins the bar itself;
# here the overflow contract is what must not have drifted).
_open_doc_modal(page, app_url, "code-long.py")
expect(page.locator("#doc-modal-title")).to_have_text("Code Long Py")
_assert_code_scrolls(page, "#doc-modal-content")