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:
@@ -0,0 +1,14 @@
|
||||
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`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,125 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 277 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 136 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 487 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5883 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,16 @@
|
||||
**Task 01 complete — all gates green.**
|
||||
|
||||
**Implemented**
|
||||
- `frontend/assets/document.js`: `PROSE_FORMATS = new Set(["txt","text"])` + `isProseDoc()` (the ONE prose rule, locked A1; image arm unconditional per phase 122 A3); `renderDocument`'s content branch is now three-way — md → `.doc-md` (byte-identical), prose → `<div class="doc-prose">` via `textContent` (never innerHTML), else → `<pre class="doc-raw">` (byte-identical); same `slot`, so image block/summary/additive strip order is preserved.
|
||||
- `frontend/assets/styles.css`: new `.doc-prose` right after `.doc-raw` (72rem `var(--chat-column)` column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals, no animation); `.doc-prose` joined the `[data-view="pages"]` hide list (one grouped declaration, defensive-today comment); stale `.doc-raw`/header comments updated (also `document-modal.js`).
|
||||
|
||||
**Tests / gates (exact)**
|
||||
- `uv run pytest` → **3844 passed** (incl. new: isProseDoc 26-case node truth table, renderDocument format table `txt/text→doc-prose`, `md→doc-md`, `py/yaml/yml/json/j2/conf/pdf/quadlet/""/unknown→doc-raw`, image→`["doc-image","doc-prose"]`, prose-arm never-innerHTML pin, `.doc-prose`/`.doc-raw` CSS pins, hide-list + wide-column 7→8 updates).
|
||||
- `uv run pytest --cov=app` → **TOTAL 99%** (>90, unchanged); `uv run ruff check . && uv run pyright` → clean.
|
||||
- Isolated E2E (all 25 viewer-touching suites + reasoning/timeline/answer_images): all passed, incl. re-run `test_document_viewer` (8), `test_image_documents` (4), `test_pdf_support` (6), `test_chat_answer_clusters` (4).
|
||||
|
||||
**Pre-existing defects fixed** (all fail identically on clean HEAD): image-modal selectors `pre.doc-raw`→`.doc-prose` (3 suites); phase-116 scroll fixture re-seeded `.txt`→`.json` per this phase's task-03 migration (code proof stays on the locked raw path); `test_chat_answer_clusters` msg-body pins re-targeted to the phase-161 reasoning container (+ tool record inside); `test_sticky_navbar` re-targeted to phase 156's bounded catalogue card (test-only, deep-scroll proof named as test 2).
|
||||
|
||||
**Docs**: README updated (viewer content-by-kind contract; image description wraps). AGENTS.md: no change needed.
|
||||
|
||||
**Next pending task**: `165_prose_word_wrap/02_raw_scroll_pins.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
............................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 277 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 136 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 487 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5883 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
All gates green. Final state:
|
||||
|
||||
**Implemented** (task `165_prose_word_wrap/02_raw_scroll_pins.md`)
|
||||
- New `tests/unit/test_prose_word_wrap.py` — 10 static-source pins (no app/browser): prose set is exactly `{"txt","text"}` (parsed from the `PROSE_FORMATS` literal) + `isProseDoc` reads `doc.is_image === true` **first**; `.doc-raw` keeps `white-space: pre` + `overflow-x: auto`; `.bubble pre` keeps `overflow-x: auto` with no `white-space` added; `.md-table-wrap` byte-identical (`{ overflow-x: auto; }`) and `.md-table` keeps `min-width: max-content`; all four phase-116 `.doc-modal .doc-raw` scrollbar rules present **and** scope-pinned (every scrollbar-themed rule in the file starts with `.doc-modal .doc-raw`); `.doc-prose` has no `overflow-x` and no bare `pre`; AA pin `var(--ink)` on `var(--surface)` with zero hex/rgb/hsl/color-mix literals; the `[data-view="pages"]` hide list still holds `.doc-prose` in one grouped declaration; and the `doc-raw` writer scan (comments stripped) proving `document.js`'s raw arm + `styles.css` are the only writers.
|
||||
- Hygiene finding recorded in the module docstring (step 3): `doc-edit.js` fills a `<textarea>` (body never becomes markup) and `shared.js` renders chat bubbles via `renderMarkdown` into `.bubble` — neither is a second content renderer, so the prose rule correctly stays in `isProseDoc` + `.doc-prose`. PDF (`format == "pdf"`) deferral (A4) documented as an owner decision on record.
|
||||
- Verified by mutation: prose-set drift, `.doc-raw` → `pre-wrap`, `.bubble pre` gaining `white-space`, `overflow-x` on `.doc-prose`, scrollbar rule escaping `.doc-modal`, hide list losing `.doc-prose`, and a hex literal each fail exactly one named test (files restored byte-identical after each).
|
||||
|
||||
**Gates**
|
||||
- `uv run pytest tests/unit/test_prose_word_wrap.py -v --no-cov` → 10 passed
|
||||
- `uv run pytest tests/unit -k "additive_toggle or markdown_tables or date_editor or image_documents or document_viewer" -v` → 149 passed
|
||||
- `uv run pytest` → **3854 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90, `app/` untouched)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
|
||||
- Viewer-touching E2E in isolation: `test_document_viewer` (8), `test_image_documents` (4), `test_pdf_strip_additive_selfheal`, `test_pdf_pages_in_sources`, `test_edit_summaries`, `test_summary_in_viewer`, `test_garbage_pdf` (15) — all passed
|
||||
|
||||
**Docs**: no doc impact (test-only task; README already carries the phase-165 content-by-kind contract from task 01; AGENTS.md unchanged).
|
||||
|
||||
**Notable**: pins use comment-stripped source so provenance comments naming `doc-raw`/`scrollbar-color` can't create false positives; the scope pin asserts *exactly 4* themed-scrollbar rules, so a future global restyle breaks it loudly.
|
||||
|
||||
**Next pending task**: `165_prose_word_wrap/03_e2e_prose_wrap.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 277 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 136 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 487 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5883 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,17 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Implemented (task 03, phase 165):**
|
||||
- New `tests/e2e/test_prose_word_wrap.py` (5 tests): module seed (image doc w/ served PNG + ~860-char description, `notes/prose-long.txt` w/ 858-char line + 2nd paragraph, `notes/code-long.py` 906-char control) asserting on **page + modal**: one `.doc-prose` w/ full raw text, `scrollWidth ≤ clientWidth+1`, height > 2.2×line-height, `pre-wrap`, `max-width: 1152px` (72rem), `<img>` above prose, summary suppressed; code control keeps `pre.doc-raw` + real horizontal overflow (locked A1/A3).
|
||||
- Task items 2–4 (migrate `test_document_viewer.py` `.json` fixture, `test_image_documents.py` `.doc-prose`, unit comment) were already carried by tasks 01/02 — verified claims intact, no assertion weakened.
|
||||
- **Defect fixed:** `test_sticky_navbar.py::test_short_page_stretch…` flaked (~1-in-8, reproduced 2×): read `#composer` computed `position` during phase-88's pre-boot static window; added the house `_wait_chat_booted` sentinel wait before measuring (6/6 green after).
|
||||
- README viewer sentence extended (themed phase-116 scrollbar applies to whatever still scrolls); AGENTS.md §7 verified — 72rem reading-column item already covers `.doc-prose`, no change.
|
||||
|
||||
**Gates (exact):**
|
||||
- `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → 5 passed
|
||||
- Regression suites, each isolated `--no-cov`: test_document_viewer 8✓, test_image_documents 4✓, test_pdf_pages_in_sources 4✓, test_pdf_strip_additive_selfheal 5✓, test_edit_summaries 4✓, test_summary_in_viewer 4✓, test_chat_answer_clusters 4✓ (+ test_pdf_support 6✓, test_sticky_navbar 3✓, test_smoke 3✓)
|
||||
- `uv run pytest` → 3854 passed; `--cov=app` → **TOTAL 99%** (>90)
|
||||
- `uv run ruff check .` clean; `uv run pyright` → 0 errors
|
||||
|
||||
**Notes/deviations:** innerText returns source (not soft-wrapped) lines in pre-wrap — paragraph-break proof re-pointed to raw `\n\n` + rendered blank line; wrapping itself is pinned by the scrollWidth/height bars. Per harness rules I did **not** move the phase dir to `complete/` or commit — the harness owns staging/commit (`feat(viewer): wrap image descriptions and plain-text docs as prose instead of scrolling horizontally`).
|
||||
|
||||
**Next pending task:** none in phase 165 (task 03 was the last; phase completes on the harness commit).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 277 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 136 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 487 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5883 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@@ -135,7 +135,14 @@ restores on a refresh — but a shared chat never shows it.
|
||||
### RAG / Knowledge base (`/sources.html`)
|
||||
|
||||
The indexed document list. The *Path* column opens each document in the same
|
||||
**almost-fullscreen modal**. Document rows carry a *Created* date and folder
|
||||
**almost-fullscreen modal** (and `/document.html` renders it through the one
|
||||
shared core). Content renders by kind (phase 165): markdown becomes a
|
||||
rendered document, an image's description or a plain-text (`.txt`) body
|
||||
renders as **wrapping prose** in the 72rem reading column, and code and
|
||||
code-ish formats keep the monospace block with a horizontal scroller
|
||||
(long lines never wrap in code — locked; the themed modal scrollbar of
|
||||
phase 116 keeps applying to whatever still scrolls). Document rows carry
|
||||
a *Created* date and folder
|
||||
rows an *Updated* date (phase 106) — sourced at sync time (git commit dates
|
||||
for git sources, restored metadata for archive uploads, file mtime otherwise;
|
||||
unresolvable or future dates fall back to today) — and the admin can correct a
|
||||
@@ -705,7 +712,9 @@ first-class document:
|
||||
- **Retrieved & displayed.** Image docs appear in the Sources catalog
|
||||
with a small lazy-loaded thumbnail (progressive enhancement — a fetch
|
||||
failure falls back to the doc icon), the document viewer renders the
|
||||
image with the description below it, and a cited image doc renders
|
||||
image with the description below it as **wrapping prose** (phase 165 —
|
||||
the full description reads by scrolling down, never sideways), and a
|
||||
cited image doc renders
|
||||
**in the chat's sources images row** (phase 130: its own
|
||||
`.msg-meta.msg-images` cluster below the chips row — no chip for an
|
||||
image doc, the figure is the citation; alt text = the description;
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* stateless endpoint the /document.html page uses — identical
|
||||
* percent-encoding), and renders through document.js's shared
|
||||
* renderDocument: md/markdown → .doc-md via the escape-first
|
||||
* renderer, other formats → <pre class="doc-raw"> via textContent.
|
||||
* renderer, the prose set (phase 165 — image descriptions,
|
||||
* txt/text) → <div class="doc-prose">, other formats →
|
||||
* <pre class="doc-raw"> via textContent.
|
||||
* A !ok / network failure renders a short "document not found" line
|
||||
* in the content area.
|
||||
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
* so the two surfaces can never drift:
|
||||
*
|
||||
* • md / markdown → the shared escape-first renderer (markdown.js)
|
||||
* in a ≤46rem centered column;
|
||||
* in the 72rem centered reading column;
|
||||
* • prose (phase 165, LOCKED A1 — isProseDoc: an image doc's
|
||||
* description or a txt/text doc) → the content
|
||||
* as a text node inside <div class="doc-prose">,
|
||||
* pre-wrap: it WRAPS at the reading column;
|
||||
* • any other → the raw content as a text node inside
|
||||
* <pre class="doc-raw"> (mono, horizontal
|
||||
* scroll);
|
||||
* scroll — locked A3: code never wraps);
|
||||
* • doc.summary non-empty (phase 36 — phase 30 summaries exist
|
||||
* only on non-markdown docs) → a labeled
|
||||
* .doc-summary section ABOVE the content;
|
||||
@@ -111,7 +115,8 @@
|
||||
* the summary, the WCAG alt contract; a NULL summary falls back to
|
||||
* the title), and the description (doc.content — the ONLY readable
|
||||
* text of the doc) follows in the EXISTING plain-content slot below
|
||||
* (the .doc-raw path; a description is prose, not markdown). The
|
||||
* (phase 165: the .doc-prose path — a description IS prose, so it
|
||||
* wraps; it was never markdown, and never belonged in the 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); an admin-edited
|
||||
@@ -313,6 +318,26 @@ function copyPathButton() {
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ---------- prose set (phase 165, task 01 — LOCKED A1) ----------
|
||||
* The ONE place the prose rule lives (TODO.md L3: "Need word wrap for
|
||||
* image explanations and other long text rather than scrolling
|
||||
* horizontally"). The defect: the description used to land in the
|
||||
* `pre` and each generated line became one unbreakable line — a
|
||||
* horizontal scrollbar where every other text surface wraps.
|
||||
*
|
||||
* The image arm is UNCONDITIONAL (phase 122 A3: an image doc's
|
||||
* `content` IS the vision description — prose by construction,
|
||||
* whatever its extension). The `format` arm covers plain-text docs;
|
||||
* `doc.format` comes from the server's doc_format() (the lowercased
|
||||
* suffix). Code and code-ish formats (py, yaml, json, j2, the quadlet
|
||||
* family, pdf — locked A1/A3: phase 116's scroll contract stands)
|
||||
* never enter this set. */
|
||||
const PROSE_FORMATS = new Set(["txt", "text"]);
|
||||
|
||||
function isProseDoc(doc) {
|
||||
return doc.is_image === true || PROSE_FORMATS.has(doc.format);
|
||||
}
|
||||
|
||||
/* ---------- shared renderer (phase 26, task 02) ----------
|
||||
* Populates the three elements every render surface provides: a title,
|
||||
* a .doc-meta badge row (source · format · mono path · created ·
|
||||
@@ -321,7 +346,8 @@ function copyPathButton() {
|
||||
* section first (phase 36: only when doc.summary is non-empty —
|
||||
* markdown docs and fail-soft rows carry none, so they render exactly
|
||||
* as before), then .doc-md for md/markdown (the shared escape-first
|
||||
* renderer), <pre class="doc-raw"> otherwise. The XSS contract:
|
||||
* renderer), <div class="doc-prose"> for the prose set (phase 165 —
|
||||
* isProseDoc), <pre class="doc-raw"> otherwise. The XSS contract:
|
||||
* innerHTML only through renderMarkdown; every document-derived
|
||||
* string (summary text included) is a text node. */
|
||||
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
@@ -385,7 +411,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
// document) is the ONE branch that replaces the content slot (phase
|
||||
// 163: the PRIMARY class only — an "additive" doc falls through to
|
||||
// the text rendering with the strip behind a toggle, D4/A1): the
|
||||
// markdown/.doc-raw content block AND the labeled Summary panel
|
||||
// markdown/.doc-prose/.doc-raw content block AND the labeled Summary panel
|
||||
// (including its admin edit wiring — moot without the panel) are
|
||||
// NOT built. The per-page descriptions stay in the DB for Q&A (the
|
||||
// importer's content/embeddings/summary are untouched); they are
|
||||
@@ -467,6 +493,15 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
wrap.className = "doc-md";
|
||||
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
|
||||
slot.appendChild(wrap);
|
||||
} else if (isProseDoc(doc)) {
|
||||
// Phase 165 (task 01, LOCKED A2): the prose arm — a TEXT NODE in
|
||||
// the wrapping .doc-prose block (pre-wrap carries the newlines;
|
||||
// no markdown parsing of a description). The XSS contract is
|
||||
// untouched: the content is never parsed as HTML.
|
||||
const prose = document.createElement("div");
|
||||
prose.className = "doc-prose";
|
||||
prose.textContent = doc.content; // text node: never parsed as HTML
|
||||
slot.appendChild(prose);
|
||||
} else {
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "doc-raw";
|
||||
|
||||
@@ -5214,7 +5214,9 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
}
|
||||
.doc-date-status:empty, .doc-date-error:empty { display: none; } /* an empty live line takes no space */
|
||||
|
||||
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||
/* Raw (non-markdown, non-prose) formats: full-width mono pre,
|
||||
horizontal scroll (locked A3, phase 165 — the prose set renders in
|
||||
the wrapping .doc-prose block below, never here). */
|
||||
.doc-raw {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -5230,6 +5232,33 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Prose (phase 165, task 01 — TODO.md L3, LOCKED A1/A2): the image
|
||||
description and plain-text (txt/text) docs as WRAPPING prose — the
|
||||
.doc-md column geometry (72rem reading column, the surface card)
|
||||
with pre-wrap rhythm. The defect: these docs landed in the .doc-raw
|
||||
pre above (white-space: pre), so every generated line was one
|
||||
unbreakable line and a long sentence scrolled sideways. The content
|
||||
stays a text node (pre-wrap carries the paragraph breaks — no
|
||||
markdown parsing: a description is prose, not markup). Tokens only
|
||||
(no new hue — the monochrome invariant; --ink on --surface is the
|
||||
documented 14.5:1 AA pair). Static content — no animation (nothing
|
||||
for prefers-reduced-motion to still). Code keeps the .doc-raw
|
||||
scroller (phase 116 locked A1, here locked A3). */
|
||||
.doc-prose {
|
||||
width: 100%;
|
||||
max-width: var(--chat-column);
|
||||
margin-inline: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem 1.75rem;
|
||||
color: var(--ink);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Image documents (phase 122, task 04): the viewer's image block —
|
||||
the document's persistent bytes (the /api/documents/{id}/image
|
||||
route) above its description (the .doc-raw content slot below). The
|
||||
@@ -5358,9 +5387,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
font-weight: 700; /* pressed = weight + fill, never color alone (B5) */
|
||||
}
|
||||
.doc-views .doc-pages { display: none; } /* Text default — hidden lazy imgs never fetch */
|
||||
/* The text views hide behind the strip on Pages. .doc-prose (phase
|
||||
165, task 01) joins the list DEFENSIVELY today — an image/txt doc
|
||||
never page-qualifies — and becomes load-bearing the day the owner
|
||||
adds "pdf" to PROSE_FORMATS (phase 165 D1 + phase 163 A2). */
|
||||
.doc-views[data-view="pages"] .doc-summary,
|
||||
.doc-views[data-view="pages"] .doc-md,
|
||||
.doc-views[data-view="pages"] .doc-raw,
|
||||
.doc-views[data-view="pages"] .doc-prose,
|
||||
.doc-views[data-view="pages"] .doc-image { display: none; }
|
||||
.doc-views[data-view="pages"] .doc-pages { display: grid; }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -83,6 +83,17 @@ loosened):
|
||||
bar, the phase-12/34 breach) + the ``#doc-copy-announcer``
|
||||
(role=status) + the ``#doc-meta`` row the shared core builds the
|
||||
control into.
|
||||
|
||||
Phase 165 (task 01) — the prose content block (TODO.md L3): the
|
||||
prose-set constant (``PROSE_FORMATS`` + ``isProseDoc`` — the ONE
|
||||
single-site rule), ``renderDocument``'s three-way content branch
|
||||
(markdown → ``.doc-md``, prose (image descriptions, txt/text) →
|
||||
``.doc-prose`` via textContent (never innerHTML), everything else →
|
||||
``.doc-raw`` untouched), the ``isProseDoc`` truth table and the
|
||||
format table as node behavioral runs over the real module, and the
|
||||
``.doc-prose`` CSS (the 72rem reading column + ``pre-wrap`` +
|
||||
``overflow-wrap: anywhere``, tokens only) beside the ``.doc-raw``
|
||||
contract that stays locked (phase 116 A1 / phase 165 A3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1810,3 +1821,284 @@ def test_page_render_wires_the_copy_path_with_the_composed_string() -> None:
|
||||
assert out["failOut"]["selectCount"] == 1
|
||||
assert out["failOut"]["status"] == "Path ready — copy it from the field."
|
||||
assert out["failOut"]["className"] == "doc-copy-pop" # the floating block
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 165 (task 01) — the prose content block: PROSE_FORMATS /
|
||||
# isProseDoc / renderDocument's third branch / the .doc-prose CSS
|
||||
# (TODO.md L3 — image descriptions and txt/text docs WRAP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _content_branch(js: str) -> str:
|
||||
"""The THREE-WAY content branch of renderDocument: from the
|
||||
markdown ``if`` to the close of the raw ``else`` (the exact end
|
||||
anchor: the raw arm's ``slot.appendChild(pre);`` + its closing
|
||||
brace — the _stmt_block brace-walk would swallow the trailing
|
||||
strip-mount statement, so this slice is literal)."""
|
||||
start = js.index("if (doc.format === \"md\" || doc.format === \"markdown\") {")
|
||||
end = js.index("slot.appendChild(pre);\n }") + len("slot.appendChild(pre);\n }")
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def _prose_branch(js: str) -> str:
|
||||
"""The prose arm alone: ``} else if (isProseDoc(doc)) {`` to its
|
||||
closing brace after ``slot.appendChild(prose);``."""
|
||||
start = js.index("} else if (isProseDoc(doc)) {")
|
||||
end = js.index("slot.appendChild(prose);\n }") + len("slot.appendChild(prose);\n }")
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def _prose_rule(js: str) -> str:
|
||||
"""The phase-165 prose rule at module scope: the PROSE_FORMATS
|
||||
const through the close of isProseDoc (extracted together — the
|
||||
function references the const; the _fn brace-walk house pattern)."""
|
||||
start = js.index("const PROSE_FORMATS")
|
||||
end = js.index("}", js.index("function isProseDoc")) + 1
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def test_prose_set_is_one_named_constant() -> None:
|
||||
"""LOCKED A1: the prose rule lives in ONE named constant pair —
|
||||
``const PROSE_FORMATS = new Set(["txt", "text"])`` +
|
||||
``isProseDoc(doc)`` with the image arm UNCONDITIONAL (phase 122
|
||||
A3 — an image doc's content IS the vision description, prose by
|
||||
construction). Grep-verified single-site rule: the content-branch
|
||||
block itself names no format literal — it calls isProseDoc."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
rule = _prose_rule(js)
|
||||
assert 'const PROSE_FORMATS = new Set(["txt", "text"]);' in rule
|
||||
assert "doc.is_image === true || PROSE_FORMATS.has(doc.format)" in rule
|
||||
branch = _content_branch(js)
|
||||
assert "isProseDoc(doc)" in branch, "the prose arm gates on the ONE helper"
|
||||
assert '"txt"' not in branch and '"text"' not in branch, (
|
||||
"no second format literal in the content branch (single-site rule)"
|
||||
)
|
||||
|
||||
|
||||
def test_content_branch_is_three_way_md_prose_raw() -> None:
|
||||
"""renderDocument's content branch is markdown → prose → raw, in
|
||||
that order, all three appending into the SAME ``slot`` (phase 122
|
||||
image block FIRST, phase 36 summary between, phase 163 additive
|
||||
strip last — all unchanged). The markdown and raw arms stay
|
||||
byte-identical to pre-phase (locked A3); the prose arm is
|
||||
textContent-only — the XSS contract (never innerHTML for document
|
||||
content) is PINNED on the branch source."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
render = _fn(js, "renderDocument")
|
||||
md_i = render.find('if (doc.format === "md" || doc.format === "markdown") {')
|
||||
prose_i = render.find("} else if (isProseDoc(doc)) {", md_i)
|
||||
raw_i = render.find("} else {", prose_i)
|
||||
assert -1 < md_i < prose_i < raw_i, "the prose arm sits BETWEEN markdown and raw"
|
||||
# The untouched markdown / raw arms (byte-identical contracts).
|
||||
for stmt in (
|
||||
'wrap.className = "doc-md";',
|
||||
"wrap.innerHTML = renderMarkdown(doc.content);",
|
||||
"slot.appendChild(wrap);",
|
||||
'pre.className = "doc-raw";',
|
||||
"pre.textContent = doc.content;",
|
||||
"slot.appendChild(pre);",
|
||||
):
|
||||
assert stmt in render, f"the pre-phase arms must keep {stmt!r}"
|
||||
# The prose arm: div + class + text node + the same slot.
|
||||
prose_branch = _prose_branch(js)
|
||||
assert 'document.createElement("div")' in prose_branch
|
||||
assert 'prose.className = "doc-prose";' in prose_branch
|
||||
assert "prose.textContent = doc.content;" in prose_branch
|
||||
assert "slot.appendChild(prose);" in prose_branch
|
||||
assert "innerHTML" not in prose_branch, (
|
||||
"the prose arm never touches innerHTML (the XSS contract)"
|
||||
)
|
||||
|
||||
|
||||
#: (doc, expected isProseDoc) — LOCKED A1's exact set:
|
||||
#: {is_image: true} ∪ {format ∈ txt, text}; code, code-ish, pdf,
|
||||
#: unknown, extension-less (no format) and the string-``"true"``
|
||||
#: lookalike all stay on the scrolling side (locked A3).
|
||||
_PROSE_CASES: list[tuple[dict[str, object], bool]] = [
|
||||
({"is_image": True, "format": "png"}, True),
|
||||
({"is_image": True, "format": "webp"}, True),
|
||||
({"is_image": True, "format": "md"}, True), # the image arm wins
|
||||
({"is_image": True, "format": "pdf"}, True), # …regardless of format
|
||||
({"is_image": True}, True), # …even without one
|
||||
({"format": "txt"}, True),
|
||||
({"format": "text"}, True),
|
||||
({"is_image": False, "format": "txt"}, True),
|
||||
({"is_image": False, "format": "text"}, True),
|
||||
({"is_image": "true", "format": "py"}, False), # strict === true
|
||||
({"is_image": False, "format": "py"}, False),
|
||||
({"is_image": False, "format": "yaml"}, False),
|
||||
({"is_image": False, "format": "yml"}, False),
|
||||
({"is_image": False, "format": "json"}, False),
|
||||
({"is_image": False, "format": "j2"}, False),
|
||||
({"is_image": False, "format": "container"}, False), # quadlet family
|
||||
({"is_image": False, "format": "network"}, False),
|
||||
({"is_image": False, "format": "volume"}, False),
|
||||
({"is_image": False, "format": "kube"}, False),
|
||||
({"is_image": False, "format": "pdf"}, False), # phase 165 A4 (deferred)
|
||||
({"is_image": False, "format": "md"}, False),
|
||||
({"is_image": False, "format": "markdown"}, False),
|
||||
({"is_image": False, "format": "dockerfile"}, False),
|
||||
({"is_image": False, "format": "rst"}, False), # unknown suffix
|
||||
({"is_image": False, "format": ""}, False),
|
||||
({"is_image": False}, False), # extension-less (no format at all)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_is_prose_doc_truth_table() -> None:
|
||||
"""Behavioral (node): the REAL isProseDoc over the full truth
|
||||
table — a future format cannot drift into prose by accident
|
||||
(phase 165 D3's pin)."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
script = (
|
||||
_prose_rule(js)
|
||||
+ "\nconst cases = "
|
||||
+ json.dumps([c for c, _ in _PROSE_CASES])
|
||||
+ ";\nconsole.log(JSON.stringify(cases.map((d) => isProseDoc(d))));\n"
|
||||
)
|
||||
assert json.loads(_run_node(script)) == [e for _, e in _PROSE_CASES]
|
||||
|
||||
|
||||
# The renderDocument content-branch harness: the minimal fake DOM of
|
||||
# test_viewer_additive_toggle.py (the house pattern), the real
|
||||
# docImageBlock standing in for the image arm's first block.
|
||||
_CONTENT_BRANCH_FAKE_DOM = """
|
||||
function makeEl(tag) {
|
||||
return {
|
||||
tag, className: "", textContent: "", innerHTML: "", attrs: {}, children: [],
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
append(...cs) { this.children.push(...cs); },
|
||||
replaceChildren(...cs) { this.children = cs; },
|
||||
addEventListener() {},
|
||||
};
|
||||
}
|
||||
globalThis.document = { createElement: (t) => makeEl(t) };
|
||||
function fmtDate(x) { return "DATE:" + x; }
|
||||
function metaBadge(cls, text, title) {
|
||||
const s = makeEl("span");
|
||||
s.className = cls;
|
||||
s.textContent = text;
|
||||
if (title !== undefined) s.setAttribute("title", title);
|
||||
return s;
|
||||
}
|
||||
function copyPathButton() { return makeEl("button"); }
|
||||
function originAnchor() { return makeEl("a"); }
|
||||
async function docAdminReady() { return false; }
|
||||
function wireDateEdit() {}
|
||||
function wireSummaryEdit() {}
|
||||
function renderMarkdown(s) { return s; }
|
||||
function docPagesStrip() { return makeEl("div"); }
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_render_document_content_branch_format_table() -> None:
|
||||
"""Behavioral (node, fake DOM): the REAL renderDocument per
|
||||
format — txt/text → .doc-prose (the TODO's wrapping ask), md /
|
||||
markdown → .doc-md (the escape-first renderer, unchanged), and
|
||||
py/yaml/json/j2/quadlet/pdf/""/unknown → .doc-raw (locked A1/A3:
|
||||
the code scroller unchanged). An is_image doc lands in
|
||||
.doc-prose whatever its format — never a doc-raw sibling."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
script = (
|
||||
_CONTENT_BRANCH_FAKE_DOM
|
||||
+ _prose_rule(js)
|
||||
+ _fn(js, "renderDocument")
|
||||
+ _fn(js, "docImageBlock")
|
||||
+ """
|
||||
const base = {
|
||||
title: "T", path: "p", source: "S", content: "BODY", summary: null,
|
||||
created_at: "c", indexed_at: "i", chunks: 1,
|
||||
};
|
||||
function classesFor(doc) {
|
||||
const contentEl = makeEl("div");
|
||||
renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
|
||||
return contentEl.children.map((c) => c.className);
|
||||
}
|
||||
const out = {};
|
||||
for (const format of ["md", "markdown", "txt", "text", "py", "yaml", "yml",
|
||||
"json", "j2", "conf", "pdf", "container", "network", "volume", "kube",
|
||||
"dockerfile", "rst", ""]) {
|
||||
out[format] = classesFor({ ...base, format });
|
||||
}
|
||||
out["image:png"] = classesFor({ ...base, format: "png", is_image: true,
|
||||
image_url: "/api/documents/x/image" });
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
got = json.loads(_run_node(script))
|
||||
expected = {
|
||||
"md": ["doc-md"],
|
||||
"markdown": ["doc-md"],
|
||||
"txt": ["doc-prose"],
|
||||
"text": ["doc-prose"],
|
||||
"py": ["doc-raw"],
|
||||
"yaml": ["doc-raw"],
|
||||
"yml": ["doc-raw"],
|
||||
"json": ["doc-raw"],
|
||||
"j2": ["doc-raw"],
|
||||
"conf": ["doc-raw"],
|
||||
"pdf": ["doc-raw"], # phase 165 A4: PDF text stays raw (owner call)
|
||||
"container": ["doc-raw"],
|
||||
"network": ["doc-raw"],
|
||||
"volume": ["doc-raw"],
|
||||
"kube": ["doc-raw"],
|
||||
"dockerfile": ["doc-raw"],
|
||||
"rst": ["doc-raw"],
|
||||
"": ["doc-raw"],
|
||||
"image:png": ["doc-image", "doc-prose"], # image block first, prose below
|
||||
}
|
||||
assert got == expected
|
||||
|
||||
|
||||
def test_doc_prose_css_wraps_in_the_reading_column() -> None:
|
||||
"""styles.css: the ``.doc-prose`` rule (immediately after
|
||||
``.doc-raw``) is the .doc-md column with prose rhythm —
|
||||
``white-space: pre-wrap`` + ``overflow-wrap: anywhere`` (the
|
||||
TODO's literal no-horizontal-scroll ask) inside the 72rem
|
||||
``var(--chat-column)`` reading column, phase-08 TOKENS only (zero
|
||||
color literals — the :root-drift contract; --ink on --surface is
|
||||
the documented AA pair), no animation. The ``.doc-raw`` rule
|
||||
keeps the locked code contract (A3): ``white-space: pre`` +
|
||||
``overflow-x: auto``."""
|
||||
css = _read(STYLES_CSS)
|
||||
raw_i = css.find(".doc-raw {")
|
||||
prose_i = css.find(".doc-prose {")
|
||||
assert -1 < raw_i < prose_i, ".doc-prose must sit immediately after .doc-raw"
|
||||
# Provenance comment directly above the rule (house style) — it
|
||||
# cites the TODO, the defect and the locked decisions.
|
||||
comment_start = css.rfind("/*", 0, prose_i)
|
||||
provenance = css[comment_start:prose_i]
|
||||
assert -1 < comment_start < prose_i
|
||||
for citation in ("phase 165", "TODO.md L3", ".doc-md", ".doc-raw", "A1", "A2"):
|
||||
assert citation in provenance, f"the comment must cite {citation!r}"
|
||||
assert "no animation" in provenance
|
||||
rule = css[prose_i : css.find("}", prose_i)]
|
||||
for decl in (
|
||||
"width: 100%",
|
||||
"max-width: var(--chat-column)",
|
||||
"margin-inline: auto",
|
||||
"background: var(--surface)",
|
||||
"border: 1px solid var(--line)",
|
||||
"border-radius: var(--radius)",
|
||||
"box-shadow: var(--shadow)",
|
||||
"padding: 1.5rem 1.75rem",
|
||||
"color: var(--ink)",
|
||||
"white-space: pre-wrap",
|
||||
"overflow-wrap: anywhere",
|
||||
"line-height: 1.55",
|
||||
):
|
||||
assert decl in rule, f".doc-prose must carry {decl!r}"
|
||||
# Tokens only — zero color literals (the :root-drift test parses
|
||||
# built-ins from this file; no new hue — the monochrome invariant).
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule)
|
||||
assert "rgb(" not in rule and "hsl(" not in rule and "color-mix(" not in rule
|
||||
# The untouched code scroller (phase 116 locked A1, phase 165 A3).
|
||||
raw_rule = css[raw_i : css.find("}", raw_i)]
|
||||
assert "white-space: pre" in raw_rule
|
||||
assert "overflow-x: auto" in raw_rule
|
||||
|
||||
@@ -39,7 +39,10 @@ frontend source contracts: the viewer renders the ``<img>`` from
|
||||
Sources row's 48px lazy thumbnail falls back to the document glyph on
|
||||
a failed fetch (the glyph swap keeps the module's single-innerHTML
|
||||
pin). The ext→mime map pin (task 03's ``IMAGE_MIMES`` — one map, one
|
||||
truth with the serve route's ``Content-Type``).
|
||||
truth with the serve route's ``Content-Type``). Phase 165 (task 01,
|
||||
TODO.md L3): the description now renders in the WRAPPING ``.doc-prose``
|
||||
block, not the horizontally-scrolling ``.doc-raw`` pre — the node
|
||||
behavioral pins for the image-doc content branch close this module.
|
||||
|
||||
Task 05 (the RAG display): the shared frame builder
|
||||
(``retriever.source_ref_with_image`` — the chat API's cited AND
|
||||
@@ -1038,8 +1041,10 @@ def test_viewer_renders_the_image_block_from_image_url_with_alt_summary() -> Non
|
||||
assert 'note.className = "doc-image-unavailable"' in js
|
||||
assert 'note.setAttribute("role", "status")' in js
|
||||
assert "Image unavailable" in js
|
||||
# The description stays in the normal content slot below (the
|
||||
# existing plain-content path — doc-raw via textContent).
|
||||
# The content slot below is a text node (never parsed as HTML) —
|
||||
# the raw arm's statement; phase 165 (task 01) moved the IMAGE
|
||||
# description to the wrapping .doc-prose arm (the node behavioral
|
||||
# pins at the end of this module).
|
||||
assert "pre.textContent = doc.content" in js
|
||||
# ONE innerHTML in the module CODE (the XSS contract, unchanged —
|
||||
# the escape-first markdown render; comments may name it, the
|
||||
@@ -1489,3 +1494,171 @@ def test_chat_sources_figure_css_is_compact_and_themed() -> None:
|
||||
"the hover is the flat underline (no background swap)"
|
||||
)
|
||||
assert "background" not in (hover.group(1) if hover else "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 165 (task 01) — the image-doc content branch renders the
|
||||
# description as WRAPPING prose (.doc-prose), never the scrolling pre
|
||||
# (TODO.md L3: "Need word wrap for image explanations ... rather than
|
||||
# scrolling horizontally"). Node behavioral runs over the real
|
||||
# renderDocument + docImageBlock on the minimal fake DOM (the
|
||||
# test_document_viewer.py house pattern).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import json # noqa: E402
|
||||
import shutil # noqa: E402
|
||||
import subprocess # noqa: E402
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
_IMAGE_FAKE_DOM = """
|
||||
function makeEl(tag) {
|
||||
return {
|
||||
tag, className: "", textContent: "", innerHTML: "", attrs: {}, children: [],
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
append(...cs) { this.children.push(...cs); },
|
||||
replaceChildren(...cs) { this.children = cs; },
|
||||
addEventListener() {},
|
||||
};
|
||||
}
|
||||
globalThis.document = { createElement: (t) => makeEl(t) };
|
||||
function fmtDate(x) { return "DATE:" + x; }
|
||||
function metaBadge(cls, text, title) {
|
||||
const s = makeEl("span");
|
||||
s.className = cls;
|
||||
s.textContent = text;
|
||||
if (title !== undefined) s.setAttribute("title", title);
|
||||
return s;
|
||||
}
|
||||
function copyPathButton() { return makeEl("button"); }
|
||||
function originAnchor() { return makeEl("a"); }
|
||||
async function docAdminReady() { return false; }
|
||||
function wireDateEdit() {}
|
||||
function wireSummaryEdit() {}
|
||||
function renderMarkdown(s) { return s; }
|
||||
function docPagesStrip() { return makeEl("div"); }
|
||||
"""
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level function (paren-walk THEN brace-walk —
|
||||
the test_viewer_additive_toggle.py pattern; the module's
|
||||
_function_body starts at the first brace, which on renderDocument
|
||||
is the destructured parameter, not the body)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in document.js"
|
||||
i = js.find("(", start)
|
||||
depth = 0
|
||||
close = i
|
||||
while i < len(js):
|
||||
if js[i] == "(":
|
||||
depth += 1
|
||||
elif js[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
close = i
|
||||
break
|
||||
i += 1
|
||||
brace = js.find("{", close)
|
||||
depth = 0
|
||||
for j in range(brace, len(js)):
|
||||
if js[j] == "{":
|
||||
depth += 1
|
||||
elif js[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : j + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}()")
|
||||
|
||||
|
||||
def _image_render_script() -> str:
|
||||
"""The real renderDocument + docImageBlock + the phase-165 prose
|
||||
rule (the PROSE_FORMATS const, which isProseDoc reads — extracted
|
||||
with the function because the const lives outside it)."""
|
||||
js = DOCUMENT_JS.read_text(encoding="utf-8")
|
||||
prose_start = js.index("const PROSE_FORMATS")
|
||||
prose_end = js.index("}", js.index("function isProseDoc")) + 1
|
||||
parts = [
|
||||
js[prose_start:prose_end],
|
||||
_fn(js, "renderDocument"),
|
||||
_fn(js, "docImageBlock"),
|
||||
]
|
||||
return _IMAGE_FAKE_DOM + "\n".join(parts)
|
||||
|
||||
|
||||
_DESCRIPTION = (
|
||||
"A network diagram of the homelab VLANs: the core switch, the router, "
|
||||
"and three labeled subnets with their gateways."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_image_doc_content_branch_is_doc_prose() -> None:
|
||||
"""The image doc's content branch (phase 165, LOCKED A1/A2): the
|
||||
<img> block FIRST, then the description as a .doc-prose text node
|
||||
— className "doc-prose", textContent the FULL description, and NO
|
||||
doc-raw sibling (the pre was the defect: white-space: pre made
|
||||
every generated line unbreakable). The verbatim-description
|
||||
Summary suppression is unchanged (summary === content → no panel)."""
|
||||
out = _run_node(
|
||||
_image_render_script()
|
||||
+ f"""
|
||||
const doc = {{
|
||||
title: "pic", path: "pic.png", source: "S", format: "png", is_image: true,
|
||||
image_url: "/api/documents/x/image", summary: {json.dumps(_DESCRIPTION)},
|
||||
content: {json.dumps(_DESCRIPTION)},
|
||||
created_at: "c", indexed_at: "i", chunks: 2,
|
||||
}};
|
||||
const contentEl = makeEl("div");
|
||||
renderDocument(doc, {{ titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }});
|
||||
console.log(JSON.stringify(contentEl.children.map(
|
||||
(c) => ({{ className: c.className, text: c.textContent }}))));
|
||||
"""
|
||||
)
|
||||
children = json.loads(out)
|
||||
assert [c["className"] for c in children] == ["doc-image", "doc-prose"], (
|
||||
"the image block stays FIRST; the description is prose (phase 165) "
|
||||
"— no doc-raw sibling and no duplicate Summary panel"
|
||||
)
|
||||
assert children[1]["text"] == _DESCRIPTION, "the full description, as a text node"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_image_doc_edited_summary_renders_above_the_prose_block() -> None:
|
||||
"""The admin-edited-summary case (summary !== content): the
|
||||
Summary panel renders between the image and the prose exactly as
|
||||
before (phase 36/57 shape unchanged) — and the content still lands
|
||||
in .doc-prose, never the pre."""
|
||||
out = _run_node(
|
||||
_image_render_script()
|
||||
+ f"""
|
||||
const doc = {{
|
||||
title: "pic", path: "pic.png", source: "S", format: "png", is_image: true,
|
||||
image_url: "/api/documents/x/image", summary: "An admin note.",
|
||||
content: {json.dumps(_DESCRIPTION)},
|
||||
created_at: "c", indexed_at: "i", chunks: 2,
|
||||
}};
|
||||
const contentEl = makeEl("div");
|
||||
renderDocument(doc, {{ titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }});
|
||||
console.log(JSON.stringify(contentEl.children.map(
|
||||
(c) => ({{ className: c.className, text: c.textContent }}))));
|
||||
"""
|
||||
)
|
||||
children = json.loads(out)
|
||||
assert [c["className"] for c in children] == [
|
||||
"doc-image",
|
||||
"doc-summary",
|
||||
"doc-prose",
|
||||
]
|
||||
assert children[1]["text"] == "" # the panel body carries the summary text
|
||||
assert children[2]["text"] == _DESCRIPTION
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Unit: phase 165 task 02 — the word-wrap fix is PROVABLY SCOPED.
|
||||
|
||||
TODO.md L3 asks for word wrap on "image explanations and other long
|
||||
text". Phase 116's LOCKED A1 keeps horizontal scroll (NO line-wrap) for
|
||||
the code content — "wrapping breaks code readability" — so the fix must
|
||||
wrap exactly the prose set and nothing else. These pins make a future
|
||||
"we fixed wrapping" mean "we wrapped prose", never "we wrapped code" or
|
||||
"we wrapped wide tables":
|
||||
|
||||
* **the prose set is pinned as data** — ``PROSE_FORMATS`` holds exactly
|
||||
``"txt"`` and ``"text"`` (a set-membership assertion over the source
|
||||
line: adding a format is a deliberate, test-editing act), and
|
||||
``isProseDoc`` tests ``doc.is_image`` BEFORE the format arm (phase 122
|
||||
A3 — an image doc's ``content`` IS the vision description, prose by
|
||||
construction whatever its extension);
|
||||
* **the code scrollers are unchanged** — ``.doc-raw`` keeps
|
||||
``white-space: pre`` + ``overflow-x: auto``, ``.bubble pre`` keeps
|
||||
``overflow-x: auto`` with NO ``white-space`` added (the chat code
|
||||
block), and ``.md-table-wrap`` / ``.md-table`` still carry the
|
||||
phase-44 declaration (``overflow-x: auto`` on the wrapper,
|
||||
``min-width: max-content`` on the table) so wide tables still scroll
|
||||
sideways inside the reading column;
|
||||
* **phase 116's scrollbar theme still applies** — the four
|
||||
``.doc-modal .doc-raw`` rules (``scrollbar-width``,
|
||||
``scrollbar-color`` and the three ``::-webkit-scrollbar`` pseudos) are
|
||||
all present and still SCOPED to the modal (no global scrollbar
|
||||
restyle — the TODO's scope);
|
||||
* **``.doc-prose`` never reaches for the raw scroller** — the block has
|
||||
no ``overflow-x`` and no ``white-space: pre;``, only ``pre-wrap`` +
|
||||
``overflow-wrap: anywhere`` (the wrapping contract);
|
||||
* **the AA/contrast pin for the new block** — ``color: var(--ink)`` on
|
||||
``background: var(--surface)`` (the documented 14.5:1 pair, PLAN
|
||||
§7.2) and ZERO color literals in the block (the phase-92
|
||||
zero-literal / monochrome invariant — the ``:root``-drift test parses
|
||||
built-ins out of styles.css).
|
||||
|
||||
Repo-hygiene finding (task 02 step 3, ``grep -rn "doc-raw" frontend/``)
|
||||
— recorded here so the check is repeatable and its result on record:
|
||||
``doc-raw`` appears in exactly TWO writers, both expected. (1) The raw
|
||||
arm of ``app.renderDocument`` in ``frontend/assets/document.js`` (the
|
||||
ONE viewer core, ``pre.className = "doc-raw"``) plus its doc comments;
|
||||
(2) ``frontend/assets/styles.css`` (the base rule, the mobile squeeze,
|
||||
the phase-116 modal scrollbar rules and the ``[data-view="pages"]``
|
||||
hide list). Every other hit in ``frontend/`` is a comment.
|
||||
``frontend/doc-edit.html`` + ``assets/doc-edit.js`` do NOT render
|
||||
document content at all — they fill a ``<textarea>`` (``bodyInput.value
|
||||
= draft.body``, textContent for the status/error lines) for the admin
|
||||
draft editor, so the body never becomes markup and there is no scroller
|
||||
to wrap; ``frontend/shared.html`` + ``assets/shared.js`` render
|
||||
CHAT history through ``renderMarkdown`` into ``.bubble`` (the shared
|
||||
renderer's own ``.md-table-wrap`` / ``.bubble pre`` scrollers, asserted
|
||||
byte-identical above), never ``.doc-raw`` / ``.doc-md`` /
|
||||
``.doc-prose``. Neither is a second content renderer, so the prose rule
|
||||
correctly lives in the ONE place: ``isProseDoc`` + ``.doc-prose``.
|
||||
|
||||
ASSUMPTION (phase 165 A4, owner call — recorded in ``00_phase.md``
|
||||
"Deferred"): PDF-extracted text (``format == "pdf"``) is deliberately
|
||||
OUT of the prose set and stays in ``.doc-raw``. Three completed suites
|
||||
pin ``pre.doc-raw`` for PDFs (``test_pdf_pages_in_sources.py``,
|
||||
``test_pdf_strip_additive_selfheal.py``, ``test_garbage_pdf.py``) and
|
||||
the phase-163 strip contract depends on it; changing it is an owner
|
||||
call, one line (``PROSE_FORMATS.add("pdf")`` + those pins) once made —
|
||||
not an executor choice and not an oversight.
|
||||
|
||||
All assertions are static-source (the ``test_markdown_tables.py`` /
|
||||
``test_date_editor.py`` house pattern): no app, no browser. The
|
||||
measured wrapping proof is the phase's E2E suite
|
||||
(``tests/e2e/test_prose_word_wrap.py``, task 03).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert DOCUMENT_JS.is_file(), f"missing frontend file: {DOCUMENT_JS}"
|
||||
return DOCUMENT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
assert STYLES_CSS.is_file(), f"missing frontend file: {STYLES_CSS}"
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rule(selector: str, css: str | None = None) -> str:
|
||||
"""The FULL rule for ``selector`` (selector text + its ``{…}``
|
||||
block) matched at line start, so ``.doc-raw`` cannot accidentally
|
||||
match ``.doc-modal .doc-raw`` or ``.doc-views … .doc-raw`` (the
|
||||
``_rule_block`` brace-walk of ``test_wide_column_css.py``)."""
|
||||
text = _css() if css is None else css
|
||||
m = re.search(rf"^{re.escape(selector)} \{{", text, re.MULTILINE)
|
||||
assert m, f"styles.css must define a rule for {selector}"
|
||||
start = text.index("{", m.start())
|
||||
depth = 0
|
||||
for i in range(start, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[m.start() : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in the {selector} rule")
|
||||
|
||||
|
||||
def _decls(selector: str) -> str:
|
||||
"""Only the declaration block of ``selector`` (the braces stripped)."""
|
||||
rule = _rule(selector)
|
||||
return rule[rule.index("{") + 1 : rule.rindex("}")]
|
||||
|
||||
|
||||
def _rules() -> list[tuple[str, str]]:
|
||||
"""Every (selector, declarations) pair in styles.css, comments
|
||||
stripped (so a provenance comment naming a property never counts as
|
||||
a declaration) — the scope pins below walk this list."""
|
||||
text = re.sub(r"/\*.*?\*/", "", _css(), flags=re.DOTALL)
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for m in re.finditer(r"([^{}]+)\{", text):
|
||||
selector = re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
# An at-rule header (@media …) is a container, not a selector.
|
||||
if not selector or selector.startswith("@"):
|
||||
continue
|
||||
start = m.end()
|
||||
depth = 1
|
||||
i = start
|
||||
while i < len(text) and depth:
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
pairs.append((selector, text[start : i - 1]))
|
||||
return pairs
|
||||
|
||||
|
||||
def _hide_list_rule() -> str:
|
||||
"""The phase-163 ``[data-view="pages"]`` hide list — a MULTI-selector
|
||||
rule (the selectors are comma-separated lines, ``.doc-prose``
|
||||
joined it in task 01), so it is sliced from its first selector to
|
||||
the ``display: none; }`` that closes it rather than matched with
|
||||
the single-selector ``_rule`` helper."""
|
||||
css = _css()
|
||||
start = css.index('.doc-views[data-view="pages"] .doc-summary')
|
||||
end = css.index("display: none; }", start) + len("display: none; }")
|
||||
return css[start:end]
|
||||
|
||||
|
||||
def _code(text: str) -> str:
|
||||
"""Comment-free source: block comments (``/* … */``, the JSDoc
|
||||
headers that legitimately NAME the classes they describe) and
|
||||
whole-line ``//`` comments stripped — so a writer scan counts CODE
|
||||
only, not prose about the code."""
|
||||
no_block = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
|
||||
return "\n".join(
|
||||
line for line in no_block.splitlines() if not line.lstrip().startswith("//")
|
||||
)
|
||||
|
||||
|
||||
def _prose_rule_js(js: str) -> str:
|
||||
"""The phase-165 prose rule at module scope: ``const
|
||||
PROSE_FORMATS`` through the close of ``isProseDoc`` (the
|
||||
``test_document_viewer.py`` extraction — the function references
|
||||
the const, so they are read as one unit)."""
|
||||
start = js.index("const PROSE_FORMATS")
|
||||
end = js.index("}", js.index("function isProseDoc")) + 1
|
||||
return js[start:end]
|
||||
|
||||
|
||||
# ---------- the prose set is pinned as data (LOCKED A1) ----------
|
||||
|
||||
|
||||
def test_prose_formats_set_contains_exactly_txt_and_text() -> None:
|
||||
"""``PROSE_FORMATS`` contains EXACTLY {"txt", "text"} — parsed out
|
||||
of the source line as a set of quoted members, so adding (or
|
||||
renaming) a format fails this test and has to be an intentional,
|
||||
test-editing decision (phase 165 D3: "a future format cannot drift
|
||||
into prose by accident"). Code, code-ish and pdf are NOT here."""
|
||||
line = re.search(r"const PROSE_FORMATS = new Set\(\[(.*?)\]\);", _js())
|
||||
assert line, "PROSE_FORMATS must be a Set literal at module scope"
|
||||
members = set(re.findall(r'"([^"]*)"', line.group(1)))
|
||||
assert members == {"txt", "text"}, (
|
||||
f"the prose format set is {sorted(members)}, not exactly ['text', 'txt'] "
|
||||
"(locked A1: image descriptions + plain-text prose only)"
|
||||
)
|
||||
# The literal is the only place those strings gate the content arm.
|
||||
assert len(re.findall(r"const PROSE_FORMATS", _js())) == 1, (
|
||||
"the prose set is declared ONCE (the single-site rule)"
|
||||
)
|
||||
|
||||
|
||||
def test_is_prose_doc_reads_is_image_before_the_format_arm() -> None:
|
||||
"""``isProseDoc`` tests ``doc.is_image === true`` FIRST and the
|
||||
``PROSE_FORMATS`` arm second (locked A1 + phase 122 A3): the image
|
||||
arm is unconditional, so an image doc is prose whatever its
|
||||
extension — and the strict ``=== true`` keeps a string lookalike
|
||||
out (the truth table in test_document_viewer.py covers behavior)."""
|
||||
rule = _prose_rule_js(_js())
|
||||
returns = re.findall(r"return (.*?);", rule)
|
||||
assert len(returns) == 1, f"isProseDoc is a one-return predicate, got {returns}"
|
||||
body = returns[0]
|
||||
image_i = body.index("doc.is_image")
|
||||
format_i = body.index("PROSE_FORMATS.has(doc.format)")
|
||||
assert image_i < format_i, "the is_image arm must be evaluated first"
|
||||
assert body[image_i:].startswith("doc.is_image === true ||"), (
|
||||
"the image arm is strict (=== true) and ORs into the format arm"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the code scrollers are present and unchanged (A3) ----------
|
||||
|
||||
|
||||
def test_doc_raw_keeps_the_locked_pre_and_horizontal_scroller() -> None:
|
||||
""".doc-raw — the code/code-ish/unknown/PDF content block — is
|
||||
byte-identical to the pre-phase declaration in the two properties
|
||||
that ARE the contract: white-space: pre (no wrapping) and
|
||||
overflow-x: auto (the scroller). Phase 116's locked A1, restated
|
||||
verbatim as phase 165's locked A3."""
|
||||
decls = _decls(".doc-raw")
|
||||
assert re.search(r"white-space:\s*pre;", decls), (
|
||||
".doc-raw must keep white-space: pre — code never wraps (locked A3)"
|
||||
)
|
||||
assert re.search(r"overflow-x:\s*auto", decls), (
|
||||
".doc-raw must keep overflow-x: auto — the horizontal scroller"
|
||||
)
|
||||
assert "pre-wrap" not in decls, ".doc-raw must not gain pre-wrap"
|
||||
|
||||
|
||||
def test_bubble_pre_scrolls_and_never_gains_a_white_space_rule() -> None:
|
||||
"""The chat/table-surface code block: .bubble pre still scrolls
|
||||
sideways (overflow-x: auto) and still declares NO white-space —
|
||||
the phase-165 fix touched the viewer's prose arm only, so a code
|
||||
span in a chat answer wraps exactly as it did before (wrapping
|
||||
breaks code readability — the reason behind phase 116's locked A1)."""
|
||||
decls = _decls(".bubble pre")
|
||||
assert re.search(r"overflow-x:\s*auto", decls), (
|
||||
".bubble pre must keep its horizontal scroller"
|
||||
)
|
||||
assert "white-space" not in decls, (
|
||||
".bubble pre must carry no white-space declaration (unchanged)"
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_table_scrollers_match_the_phase_44_declaration() -> None:
|
||||
"""Wide GFM tables still scroll, never wrap: .md-table-wrap is
|
||||
STILL the one-declaration scroller (overflow-x: auto) and
|
||||
.md-table still carries the phase-44 task-03 set —
|
||||
border-collapse + width: 100% + min-width: max-content + 0.9rem —
|
||||
byte-for-byte, so the wrapper stays the real scroller and the
|
||||
72rem reading column never breaks."""
|
||||
assert _rule(".md-table-wrap") == ".md-table-wrap { overflow-x: auto; }", (
|
||||
"the table wrapper must stay the single-declaration horizontal scroller"
|
||||
)
|
||||
decls = _decls(".md-table")
|
||||
for declaration in (
|
||||
"border-collapse: collapse",
|
||||
"width: 100%",
|
||||
"min-width: max-content",
|
||||
"font-size: 0.9rem",
|
||||
):
|
||||
assert declaration in decls, f".md-table must keep {declaration!r}"
|
||||
|
||||
|
||||
# ---------- phase 116's scrollbar theme still applies (A1) ----------
|
||||
|
||||
|
||||
def test_phase_116_modal_scrollbar_rules_are_all_present_and_scoped() -> None:
|
||||
"""The four .doc-modal .doc-raw rules — scrollbar-width: thin,
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track),
|
||||
and the ::-webkit-scrollbar / -track / -thumb pseudos — survive
|
||||
this phase intact and stay SCOPED under .doc-modal (no global
|
||||
scrollbar restyle). What still lands in .doc-raw still gets the
|
||||
themed bar: the prose block has no scrollbar to theme because it
|
||||
never overflows horizontally."""
|
||||
css = _css()
|
||||
firefox = _rule(".doc-modal .doc-raw")
|
||||
assert re.search(r"scrollbar-width:\s*thin", firefox)
|
||||
assert re.search(
|
||||
r"scrollbar-color:\s*var\(--scrollbar-thumb\) var\(--scrollbar-track\)", firefox
|
||||
)
|
||||
for pseudo, needle in (
|
||||
(".doc-modal .doc-raw::-webkit-scrollbar", "height: 8px"),
|
||||
(".doc-modal .doc-raw::-webkit-scrollbar-track", "background: var(--scrollbar-track)"),
|
||||
(".doc-modal .doc-raw::-webkit-scrollbar-thumb", "background: var(--scrollbar-thumb)"),
|
||||
):
|
||||
rule = _rule(pseudo, css)
|
||||
assert needle in rule, f"{pseudo} must keep {needle!r}"
|
||||
# Scope pin (phase 116: "no global scrollbar restyle"): every rule
|
||||
# that themes a scrollbar — a scrollbar-color declaration or a
|
||||
# ::-webkit-scrollbar pseudo — is scoped under `.doc-modal .doc-raw`.
|
||||
themed = [
|
||||
sel
|
||||
for sel, decls in _rules()
|
||||
if "scrollbar-color" in decls or "scrollbar" in sel
|
||||
]
|
||||
assert len(themed) == 4, f"expected the 4 modal scrollbar rules, got {themed}"
|
||||
for selector in themed:
|
||||
assert selector.startswith(".doc-modal .doc-raw"), (
|
||||
f"scrollbar rule {selector!r} escaped the modal scope (phase 116 scope pin)"
|
||||
)
|
||||
# The prose block never asks for a themed bar (it has none to theme).
|
||||
assert not [s for s, d in _rules() if ".doc-prose" in s and "scrollbar" in d + s], (
|
||||
".doc-prose must carry no scrollbar theming"
|
||||
)
|
||||
|
||||
|
||||
# ---------- .doc-prose never reaches for the raw scroller ----------
|
||||
|
||||
|
||||
def test_doc_prose_declares_no_horizontal_scroller_and_no_pre() -> None:
|
||||
""".doc-prose is the OPPOSITE contract of .doc-raw: no overflow-x
|
||||
of any kind, and white-space is pre-WRAP (never the bare ``pre``
|
||||
that made every generated line unbreakable). The wrapping comes
|
||||
from white-space: pre-wrap + overflow-wrap: anywhere inside the
|
||||
72rem column — nothing else."""
|
||||
decls = _decls(".doc-prose")
|
||||
assert "overflow-x" not in decls, (
|
||||
".doc-prose must never declare a horizontal scroller (the TODO's ask)"
|
||||
)
|
||||
assert re.search(r"white-space:\s*pre-wrap", decls)
|
||||
assert not re.search(r"white-space:\s*pre;", decls), (
|
||||
".doc-prose must not carry the bare pre (the defect this phase fixes)"
|
||||
)
|
||||
assert re.search(r"overflow-wrap:\s*anywhere", decls)
|
||||
assert re.search(r"max-width:\s*var\(--chat-column\)", decls), (
|
||||
"the prose block rides the 72rem reading column"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the AA/contrast pin for the new block ----------
|
||||
|
||||
|
||||
def test_doc_prose_uses_the_documented_aa_pair_and_zero_literals() -> None:
|
||||
""".doc-prose pairs color: var(--ink) with background:
|
||||
var(--surface) — the documented 14.5:1 pair (PLAN §7.2, WCAG AA) —
|
||||
and carries NO color literal at all (no hex, no rgb()/rgba(), no
|
||||
hsl(), no color-mix): the phase-92 zero-literal invariant that
|
||||
keeps the block correct under every saved theme, and what the
|
||||
:root-drift test relies on when it parses styles.css."""
|
||||
decls = _decls(".doc-prose")
|
||||
assert re.search(r"color:\s*var\(--ink\)", decls)
|
||||
assert re.search(r"background:\s*var\(--surface\)", decls)
|
||||
for pattern, name in (
|
||||
(r"#[0-9a-fA-F]{3,8}\b", "hex"),
|
||||
(r"\brgba?\(", "rgb()/rgba()"),
|
||||
(r"\bhsla?\(", "hsl()/hsla()"),
|
||||
(r"color-mix\(", "color-mix()"),
|
||||
):
|
||||
assert not re.search(pattern, decls), f".doc-prose must carry no {name} literal"
|
||||
|
||||
|
||||
# ---------- the raw writers are the two expected ones (step 3) ----------
|
||||
|
||||
|
||||
def test_doc_raw_writers_are_the_raw_branch_and_the_phase_116_css_only() -> None:
|
||||
"""The task-02 hygiene pass as an executable pin: the only FILE that
|
||||
writes the doc-raw element is document.js's raw arm (one
|
||||
``className`` assignment, inside renderDocument), and the only
|
||||
other doc-raw writers in frontend/ are styles.css rules (the base
|
||||
rule, the mobile squeeze, the phase-116 modal scrollbar rules and
|
||||
the [data-view="pages"] hide list). doc-edit.js renders a
|
||||
<textarea> and shared.js renders chat bubbles — neither is a second
|
||||
content renderer, so neither needs the prose rule (see the module
|
||||
docstring for the full finding)."""
|
||||
js = _js()
|
||||
assert js.count('className = "doc-raw"') == 1, (
|
||||
"document.js must assign doc-raw in exactly one place (the raw arm)"
|
||||
)
|
||||
# A WRITER is code that emits the class (JS className/innerHTML, an
|
||||
# HTML class attribute) — a doc comment naming it is not one.
|
||||
writer = re.compile(r'className\s*=\s*"doc-raw"|class="[^"]*\bdoc-raw\b')
|
||||
for path in sorted(FRONTEND.rglob("*")):
|
||||
if not path.is_file() or path.suffix not in {".js", ".html"}:
|
||||
continue
|
||||
writes = len(writer.findall(_code(path.read_text(encoding="utf-8"))))
|
||||
if path == DOCUMENT_JS:
|
||||
assert writes == 1, f"{path.name} must hold exactly the one raw arm"
|
||||
else:
|
||||
assert writes == 0, (
|
||||
f"{path.name} must not write doc-raw — the writers are "
|
||||
"renderDocument's raw arm and styles.css only"
|
||||
)
|
||||
# The two expected writers DO hold it (the pin is not vacuous).
|
||||
assert "doc-raw" in _css(), "styles.css is the second expected doc-raw writer"
|
||||
|
||||
|
||||
def test_prose_block_mounts_where_the_raw_block_did() -> None:
|
||||
"""The prose arm appends into the SAME ``slot`` the raw arm uses,
|
||||
so the phase-122 image block stays FIRST, the phase-36 summary
|
||||
panel between, and the phase-163 additive strip last — the ordering
|
||||
both surfaces rely on. The [data-view="pages"] hide list carries
|
||||
.doc-prose alongside .doc-raw (the additive/PDF case's text view),
|
||||
so Pages still hides the text view for a prose doc."""
|
||||
js = _js()
|
||||
for arm in ("wrap", "prose", "pre"):
|
||||
assert js.count(f"slot.appendChild({arm});") == 1, (
|
||||
f"the {arm} arm must mount into slot exactly once"
|
||||
)
|
||||
# One slot binding, three arms (the shared mount point), and the
|
||||
# strip still lands in the wrapper last.
|
||||
assert "let slot = contentEl;" in js, "the three arms share one slot binding"
|
||||
assert "views.appendChild(docPagesStrip(doc, pageUrls));" in js
|
||||
hide = _hide_list_rule()
|
||||
for cls in (".doc-summary", ".doc-md", ".doc-raw", ".doc-prose", ".doc-image"):
|
||||
assert cls in hide, f"the [data-view=\"pages\"] hide list must include {cls}"
|
||||
assert hide.count("display: none;") == 1, "the hide list stays ONE grouped declaration"
|
||||
@@ -82,6 +82,15 @@ def _code(js: str) -> str:
|
||||
return re.sub(r"//.*?$|/\*.*?\*/", "", js, flags=re.S | re.M)
|
||||
|
||||
|
||||
def _prose_rule(js: str) -> str:
|
||||
"""The phase-165 prose rule (the PROSE_FORMATS const + isProseDoc,
|
||||
module scope) — renderDocument's prose gate, needed beside the
|
||||
extracted functions for the node runs."""
|
||||
start = js.index("const PROSE_FORMATS")
|
||||
end = js.index("}", js.index("function isProseDoc")) + 1
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, timeout=60
|
||||
@@ -193,8 +202,9 @@ def test_view_toggle_css() -> None:
|
||||
style, never color alone, B5) + the CSS-driven view swap
|
||||
(``.doc-views .doc-pages`` hidden by default; ``[data-view=
|
||||
"pages"]`` hides the Summary panel / .doc-md / .doc-raw /
|
||||
.doc-image and shows the strip as the grid). Provenance comment
|
||||
above the block (house style)."""
|
||||
.doc-prose (phase 165, task 01) / .doc-image and shows the strip as
|
||||
the grid — one grouped declaration). Provenance comment above the
|
||||
block (house style)."""
|
||||
css = _read(STYLES_CSS)
|
||||
toggle_i = css.find(".doc-view-toggle {")
|
||||
assert toggle_i != -1, "styles.css must style .doc-view-toggle"
|
||||
@@ -212,10 +222,18 @@ def test_view_toggle_css() -> None:
|
||||
"the pressed state is text weight + fill — never color alone (B5)"
|
||||
)
|
||||
# The CSS-driven swap: hidden strip by default; pages hides the
|
||||
# text view and shows the strip.
|
||||
# text view and shows the strip. The hide list is ONE grouped
|
||||
# declaration (phase 165: .doc-prose joins it — defensive today,
|
||||
# load-bearing if the owner ever adds "pdf" to PROSE_FORMATS).
|
||||
assert '.doc-views .doc-pages { display: none; }' in css
|
||||
for sel in (".doc-summary", ".doc-md", ".doc-raw", ".doc-image"):
|
||||
assert f'.doc-views[data-view="pages"] {sel}' in css
|
||||
expected = ",\n".join(
|
||||
f'.doc-views[data-view="pages"] {sel}'
|
||||
for sel in (".doc-summary", ".doc-md", ".doc-raw", ".doc-prose", ".doc-image")
|
||||
) + " { display: none; }"
|
||||
assert css.count(expected) == 1, (
|
||||
"the text-view hide list is exactly ONE grouped declaration "
|
||||
"over .doc-summary/.doc-md/.doc-raw/.doc-prose/.doc-image"
|
||||
)
|
||||
assert '.doc-views[data-view="pages"] .doc-pages { display: grid; }' in css
|
||||
|
||||
|
||||
@@ -266,7 +284,7 @@ function describe(el) {
|
||||
|
||||
def _render_script() -> str:
|
||||
js = _read(DOCUMENT_JS)
|
||||
parts = [
|
||||
parts = [_prose_rule(js)] + [
|
||||
_fn(js, n)
|
||||
for n in ("renderDocument", "docViewToggle", "viewToggleButton", "docPagesStrip")
|
||||
]
|
||||
|
||||
@@ -17,9 +17,12 @@ without a browser:
|
||||
``.shared-shell``, ``.doc-md``, ``.doc-image`` (phase 122, task 04),
|
||||
``.doc-pages`` (phase 148, task 03 — the page strip),
|
||||
``.doc-view-toggle`` (phase 163, task 04 — the Text/Pages toggle
|
||||
above the additive views), and ``.doc-summary:has(+ .doc-md)`` —
|
||||
each capped with ``max-width: var(--chat-column)`` and NOTHING else
|
||||
in the file uses the token for a max-width (exactly seven rules);
|
||||
above the additive views), ``.doc-prose`` (phase 165, task 01 — the
|
||||
wrapping prose block: image descriptions and txt/text docs ride the
|
||||
SAME reading column as their .doc-md sibling), and
|
||||
``.doc-summary:has(+ .doc-md)`` — each capped with
|
||||
``max-width: var(--chat-column)`` and NOTHING else
|
||||
in the file uses the token for a max-width (exactly eight rules);
|
||||
* the flipped negative pin — ZERO literal ``max-width: 46rem`` rules
|
||||
remain (the phase-27/59/91 form-column caps are retired): the form
|
||||
shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``)
|
||||
@@ -124,8 +127,10 @@ def test_the_reading_columns_use_the_token() -> None:
|
||||
viewer's page strip rides the SAME reading column in the shared
|
||||
#doc-content slot), .doc-view-toggle (phase 163, task 04 — the
|
||||
Text/Pages toggle sits above the additive views, aligned with
|
||||
them) and .doc-summary:has(+ .doc-md) each cap with
|
||||
max-width: var(--chat-column) — and exactly those seven rules use
|
||||
them), .doc-prose (phase 165, task 01 — the wrapping prose block
|
||||
is .doc-md's column with prose rhythm) and
|
||||
.doc-summary:has(+ .doc-md) each cap with
|
||||
max-width: var(--chat-column) — and exactly those eight rules use
|
||||
the token for a max-width (no other selector)."""
|
||||
css = _css()
|
||||
for selector in (
|
||||
@@ -135,12 +140,13 @@ def test_the_reading_columns_use_the_token() -> None:
|
||||
".doc-image",
|
||||
".doc-pages",
|
||||
".doc-view-toggle",
|
||||
".doc-prose",
|
||||
".doc-summary:has(+ .doc-md)",
|
||||
):
|
||||
assert "max-width: var(--chat-column)" in _rule_block(css, selector), (
|
||||
f"{selector} must cap with max-width: var(--chat-column)"
|
||||
)
|
||||
assert css.count("max-width: var(--chat-column)") == 7, (
|
||||
assert css.count("max-width: var(--chat-column)") == 8, (
|
||||
"exactly the reading-column selectors use the token"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user