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`.
495 lines
22 KiB
Python
495 lines
22 KiB
Python
"""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")
|