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`.
This commit is contained in:
@@ -52,8 +52,11 @@ Test → observable mapping (Playwright Mapping Rule):
|
||||
BOTH docs — the read text doc first (read order), then the
|
||||
auto-cited image ref (``image_url`` present; the text ref's key is
|
||||
ABSENT); the last brain bubble's ``.msg-body`` direct children are
|
||||
exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta
|
||||
msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the
|
||||
exactly ``[reasoning, bubble, msg-meta, msg-meta msg-images,
|
||||
msg-meta msg-actions]`` — the ONE phase-161 reasoning container
|
||||
ABOVE the answer (this turn's tool record lives inside its
|
||||
``.reasoning-body`` as a ``.tool-calls-disclosure`` block, the
|
||||
phase-130 timeline re-targeted by phase 161 D1) then the chips row (EXACTLY the
|
||||
text chip — NO chip for the image doc, phase 130 task 01) before
|
||||
the images row (the figure, ``<img>`` loaded from the bytes route,
|
||||
alt + caption settling to the canned description), no related row
|
||||
@@ -205,11 +208,14 @@ assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), (
|
||||
)
|
||||
|
||||
#: The .msg-body child sequence of a completed scripted-read bubble
|
||||
#: (phase 130's fix, DOM-asserted): the tool record (the scripted read),
|
||||
#: the answer bubble, the chips row (the bare .msg-meta), the images
|
||||
#: row, the actions row LAST. The live and restore paths share it.
|
||||
#: (phase 130's fix, DOM-asserted; re-targeted by phase 161 D1): the
|
||||
#: ONE reasoning container (the scripted read's tool record rides
|
||||
#: INSIDE it — .reasoning .tool-calls-disclosure, asserted beside the
|
||||
#: sequence), the answer bubble, the chips row (the bare .msg-meta),
|
||||
#: the images row, the actions row LAST. The live and restore paths
|
||||
#: share it.
|
||||
EXPECTED_CHILD_CLASSES = [
|
||||
"tool-calls-disclosure",
|
||||
"reasoning",
|
||||
"bubble",
|
||||
"msg-meta",
|
||||
"msg-meta msg-images",
|
||||
@@ -759,13 +765,20 @@ def test_answer_clusters_render_in_order(
|
||||
expect(bubble).to_contain_text(TEXT_TITLE)
|
||||
expect(bubble).to_contain_text(READ_BODY_QUOTE)
|
||||
|
||||
# The DOM ORDER (the fix): tool record, answer, chips row, images
|
||||
# row, actions row LAST — asserted exactly (this sequence fails
|
||||
# against the pre-phase soup div, where the buttons shared the
|
||||
# chips row and the figure interleaved with it).
|
||||
# The DOM ORDER (the fix): reasoning container (tool record
|
||||
# inside), answer, chips row, images row, actions row LAST —
|
||||
# asserted exactly (this sequence fails against the pre-phase
|
||||
# soup div, where the buttons shared the chips row and the figure
|
||||
# interleaved with it).
|
||||
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, (
|
||||
_body_child_classes(page)
|
||||
)
|
||||
# The tool record lives INSIDE the ONE reasoning container above
|
||||
# the answer (phase 161 D1/D5 re-target of the phase-130
|
||||
# timeline) — the read line carries the read document's path.
|
||||
tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
|
||||
expect(tool_record).to_have_count(1)
|
||||
expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
|
||||
|
||||
# The clusters + the actions row's contents (all three buttons —
|
||||
# admin, docs repo configured) + nothing after the actions row.
|
||||
@@ -817,7 +830,9 @@ def test_image_without_chip_keeps_its_citation_via_figure(
|
||||
img = page.locator("#doc-modal .doc-image-img")
|
||||
expect(img).to_have_count(1)
|
||||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||
expect(page.locator("#doc-modal pre.doc-raw")).to_have_text(
|
||||
# Phase 165: the description renders in the wrapping .doc-prose
|
||||
# block (an image description is prose — no scrolling pre).
|
||||
expect(page.locator("#doc-modal .doc-prose")).to_have_text(
|
||||
seeded["description"]
|
||||
)
|
||||
page.keyboard.press("Escape") # the phase-26 close affordance
|
||||
@@ -877,10 +892,15 @@ def test_restore_renders_the_same_clusters(
|
||||
expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
# The IDENTICAL structure (the restore renders through the same
|
||||
# appenders — the child sequence is the live one, exactly).
|
||||
# appenders — the child sequence is the live one, exactly) — the
|
||||
# segments path re-renders the reasoning container with the tool
|
||||
# record inside (phase 161 D4).
|
||||
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, (
|
||||
_body_child_classes(page)
|
||||
)
|
||||
tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
|
||||
expect(tool_record).to_have_count(1)
|
||||
expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
|
||||
_assert_cluster_rows(
|
||||
page, seeded,
|
||||
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
|
||||
|
||||
@@ -38,10 +38,13 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
inner content).
|
||||
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
|
||||
the Phase-08 surface colour, every asset same-origin or ``data:``.
|
||||
8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw code
|
||||
block (``pre.doc-raw``) overflows horizontally, a scroll action moves
|
||||
it, the themed scrollbar tokens are in effect via computed style, and
|
||||
a screenshot records the themed (not native light-gray) bar.
|
||||
8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw CODE
|
||||
block (``pre.doc-raw`` — a seeded ``.json`` doc since phase 165;
|
||||
prose — image descriptions and ``txt``/``text`` — now WRAPS in
|
||||
``.doc-prose`` and never lands here) overflows horizontally, a
|
||||
scroll action moves it, the themed scrollbar tokens are in effect
|
||||
via computed style, and a screenshot records the themed (not native
|
||||
light-gray) bar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -153,14 +156,20 @@ def _seed_long_line_doc() -> None:
|
||||
a non-markdown doc whose one long line is deliberately longer than
|
||||
the modal width — the shape of the TODO's quest .pl ``quest::say``
|
||||
line. Direct DB seed, the ``test_modal_xss_safe`` pattern (the
|
||||
viewer is database-only; no chunks needed)."""
|
||||
viewer is database-only; no chunks needed). Phase 165: the path is
|
||||
``.json`` — txt/text joined the PROSE set (wrapped in .doc-prose),
|
||||
so the code-scroll fixture is a CODE doc, keeping the locked A1
|
||||
proof on the branch that still scrolls."""
|
||||
long_line = "quest::say(\"" + ("the quick brown fox jumps over the lazy dog " * 20) + "\")"
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
Document(
|
||||
source="docs",
|
||||
path="notes/long-line.txt",
|
||||
full_path="/tmp/long-line.txt",
|
||||
# Phase 165: .json, NOT .txt — txt/text joined the PROSE
|
||||
# set (wrapped in .doc-prose); the phase-116 scroll
|
||||
# contract binds CODE, and json stays code-ish (D1).
|
||||
path="notes/long-line.json",
|
||||
full_path="/tmp/long-line.json",
|
||||
title="Long Line Fixture",
|
||||
content=(
|
||||
"# long-line-fixture: the line below is deliberately\n"
|
||||
@@ -551,7 +560,7 @@ def test_raw_code_block_themed_scrollbar(
|
||||
|
||||
login(page, app_url)
|
||||
_drill(page, "docs", "notes")
|
||||
row = page.locator("#docs-tbody tr", has_text="long-line.txt")
|
||||
row = page.locator("#docs-tbody tr", has_text="long-line.json")
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
|
||||
|
||||
@@ -387,7 +387,8 @@ def test_document_viewer_renders_image_and_description(
|
||||
(phase 26): the ``is_image`` content block renders the PERSISTENT
|
||||
bytes first (``.doc-image-img`` from the bytes route, ``alt`` =
|
||||
the summary), and the description follows in the normal content
|
||||
slot (``pre.doc-raw`` — the plain-content path). The labeled
|
||||
slot (``.doc-prose`` — the phase-165 wrapping prose arm: an image
|
||||
description is prose, it WRAPS, never a scrolling pre). The labeled
|
||||
Summary panel is SUPPRESSED for the verbatim-description case
|
||||
(summary === content — the importer invariant; the panel would
|
||||
duplicate the text right below the image)."""
|
||||
@@ -406,8 +407,9 @@ def test_document_viewer_renders_image_and_description(
|
||||
expect(img).to_have_attribute("alt", seeded["description"])
|
||||
|
||||
# The description below the image (the doc's readable content IS
|
||||
# the vision description).
|
||||
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
|
||||
# the vision description) — prose (phase 165), never the pre.
|
||||
expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
|
||||
expect(modal.locator("pre.doc-raw")).to_have_count(0)
|
||||
# The verbatim case: no duplicate Summary panel.
|
||||
expect(modal.locator(".doc-summary")).to_have_count(0)
|
||||
# No "Image unavailable" note — the copy is intact.
|
||||
|
||||
@@ -546,7 +546,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
|
||||
"""The embedded image's row (the synthetic ``#page=2#img=0.png``
|
||||
path) opens the modal: the PERSISTENT copy renders first (the bytes
|
||||
route, alt = the description), the description follows in the
|
||||
content slot, and the labeled Summary panel is SUPPRESSED for the
|
||||
WRAPPING prose block (phase 165: .doc-prose — an image description
|
||||
is prose, never a scrolling pre), and the labeled Summary panel is SUPPRESSED for the
|
||||
verbatim-description case (summary === content — the importer
|
||||
invariant; the panel would duplicate the text right below the
|
||||
image)."""
|
||||
@@ -563,7 +564,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
|
||||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||
expect(img).to_have_attribute("alt", seeded["description"])
|
||||
|
||||
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
|
||||
expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
|
||||
expect(modal.locator("pre.doc-raw")).to_have_count(0)
|
||||
# The verbatim case: no duplicate Summary panel.
|
||||
expect(modal.locator(".doc-summary")).to_have_count(0)
|
||||
# No "Image unavailable" note — the copy is intact.
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""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")
|
||||
@@ -27,7 +27,10 @@ the short-page stretch. This suite proves the BROWSER behavior:
|
||||
* the short-page layout is EXACTLY as it was: body stretched to the
|
||||
viewport, footer pinned to the viewport bottom, the phase-52
|
||||
pinned composer (``position: sticky; bottom: env(safe-area-inset-
|
||||
bottom)``) resting in its in-flow slot above the footer.
|
||||
bottom)``) resting in its in-flow slot above the footer. The pin
|
||||
is POST-boot (phase 88 keeps the cluster static until app.js adds
|
||||
``#view-chat.chat-booted``), so test 3 waits for that sentinel
|
||||
before reading it — the house boot-settle pattern.
|
||||
|
||||
Seeding (A3): the long-scroll surfaces are the seeded Sources table
|
||||
(``tests/fixtures/docs/`` — 13 docs, same harness as the phase-10/26
|
||||
@@ -37,14 +40,27 @@ short unique docs + one ``doc-long.md`` with a ~40,000-char body).
|
||||
mock embeddings server (the ``test_document_viewer.py`` pattern) — no
|
||||
real LLM is involved.
|
||||
|
||||
Phase 156 re-target (owner-locked A1): the catalogue card became the
|
||||
tables' OWN bounded scroller (``.table-wrap`` max-height
|
||||
min(70vh, 64rem)), so a Sources file level no longer grows the
|
||||
DOCUMENT past a full viewport — its scroll is the residual chrome
|
||||
height. Test 1 therefore proves the stick across the page's REAL
|
||||
scroll range (mid + bottom, card scroller pinned too); the DEEP-scroll
|
||||
proof (the audit's ~−1264px repro, scroll far past one viewport) is
|
||||
test 2's long-document surface, which still grows the document many
|
||||
viewports.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_app_header_stuck_at_top_on_sources``
|
||||
2. ``test_doc_header_stuck_at_top_on_long_document``
|
||||
1. ``test_app_header_stuck_at_top_on_sources`` — stuck mid-page AND at
|
||||
the page bottom; the 41-row table scrolls inside the phase-156 card
|
||||
2. ``test_doc_header_stuck_at_top_on_long_document`` — stuck at the
|
||||
bottom of a many-viewport scroll (the deep-scroll proof)
|
||||
3. ``test_short_page_stretch_and_resting_composer_intact``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
@@ -223,26 +239,49 @@ def test_app_header_stuck_at_top_on_sources(
|
||||
page.click('#folders-tbody a.folder-link:text-is("gen")')
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS - 13)
|
||||
|
||||
# The precondition the story needs: the page must actually scroll —
|
||||
# fail loudly if the table ever stops being long enough.
|
||||
# Phase 156 (owner-locked A1): the 41-row table scrolls INSIDE the
|
||||
# bounded card (max-height min(70vh, 64rem)) — the card is the rows'
|
||||
# scroller, and the DOCUMENT keeps the residual page scroll (header
|
||||
# / stats / toolbar / padding / footer). Preconditions, both fail
|
||||
# loudly: the card must actually scroll (every row reachable), and
|
||||
# the page must still scroll at all.
|
||||
sh = page.evaluate("() => document.documentElement.scrollHeight")
|
||||
assert sh > 1.5 * VIEWPORT_H, (
|
||||
f"the Sources table must be long enough to scroll "
|
||||
f"(scrollHeight={sh}, want > {1.5 * VIEWPORT_H:.0f} at a "
|
||||
f"{VIEWPORT_W}×{VIEWPORT_H} viewport) — add more generated docs"
|
||||
assert sh > VIEWPORT_H + 200, (
|
||||
f"the Sources page must be scrollable (scrollHeight={sh}, want > "
|
||||
f"{VIEWPORT_H + 200} at a {VIEWPORT_W}×{VIEWPORT_H} viewport) — "
|
||||
"the page collapsed to a fixed layout"
|
||||
)
|
||||
card = page.evaluate(
|
||||
"""() => {
|
||||
const c = document.querySelector(
|
||||
".table-wrap[aria-label='Indexed documents']");
|
||||
return c ? { sh: c.scrollHeight, ch: c.clientHeight } : null;
|
||||
}"""
|
||||
)
|
||||
assert card is not None and card["sh"] > card["ch"], (
|
||||
f"the phase-156 card must be the table's scroller "
|
||||
f"(scrollHeight={None if card is None else card['sh']}, "
|
||||
f"clientHeight={None if card is None else card['ch']})"
|
||||
)
|
||||
max_scroll = sh - VIEWPORT_H
|
||||
|
||||
# Mid-page scroll (~1 viewport down — the pre-fix un-pin point): the
|
||||
# navbar is still stuck at the top.
|
||||
page.evaluate("() => window.scrollTo(0, 800)")
|
||||
# Mid-page scroll (half the document's available scroll): the
|
||||
# navbar is still stuck at the top. The DEEP-scroll proof — a scroll
|
||||
# far past one viewport, the audit's ~−1264px repro — is test 2's
|
||||
# long-document surface: phase 156's bounded card left Sources as
|
||||
# the residual-scroll page.
|
||||
page.evaluate(f"() => window.scrollTo(0, {max_scroll // 2})")
|
||||
assert (
|
||||
page.evaluate("() => window.scrollY") >= VIEWPORT_H - TOP_TOL_PX
|
||||
abs(page.evaluate("() => window.scrollY") - max_scroll // 2) <= TOP_TOL_PX
|
||||
), "the test scroll must land"
|
||||
_assert_top_is_zero(page, ".app-header")
|
||||
|
||||
# And at the very bottom (the audit's −1264px repro): stuck, and
|
||||
# still visible.
|
||||
# And at the very bottom of the page scroll: stuck, and still
|
||||
# visible.
|
||||
page.evaluate("() => window.scrollTo(0, 999999)")
|
||||
assert page.evaluate("() => window.scrollY") >= max_scroll - TOP_TOL_PX, (
|
||||
"the test scroll must land at the document bottom"
|
||||
)
|
||||
_assert_top_is_zero(page, ".app-header")
|
||||
expect(page.locator(".app-header")).to_be_visible()
|
||||
|
||||
@@ -290,12 +329,32 @@ def test_doc_header_stuck_at_top_on_long_document(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wait_chat_booted(page: Page) -> None:
|
||||
"""The house boot-settle wait (test_chip_sizing_question_cap.py /
|
||||
test_mobile_chat_hamburger_boot.py): app.js adds
|
||||
``#view-chat.chat-booted`` two frames AFTER the boot settles. The
|
||||
phase-88 pre-boot rule
|
||||
(``#view-chat:not(.chat-booted) .composer { position: static }``)
|
||||
keeps the cluster NON-sticky until then — reading the pin before
|
||||
the sentinel lands races app boot (the flake this removes: the
|
||||
sticky assertion measured a pre-boot static composer)."""
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('view-chat')?."
|
||||
"classList.contains('chat-booted')",
|
||||
timeout=15_000,
|
||||
)
|
||||
expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
|
||||
|
||||
|
||||
def test_short_page_stretch_and_resting_composer_intact(
|
||||
page: Page, app_url: str
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
expect(page.locator("#empty-state")).to_be_visible()
|
||||
# The pin under test only exists POST-boot (phase 88 defers the
|
||||
# sticky promotion) — settle the boot BEFORE any measurement.
|
||||
_wait_chat_booted(page)
|
||||
|
||||
# The stretch holds: the body is exactly one viewport tall, and the
|
||||
# grow must not invent scrollable space.
|
||||
|
||||
Reference in New Issue
Block a user