All gates verified. Final report: **Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)** - Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs). - `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%) - `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation - `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed - `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins) - Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅ - **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**. - Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit. - **Next pending phase:** `164_chat_error_honesty`
432 lines
18 KiB
Python
432 lines
18 KiB
Python
"""Unit: the additive PDF strip view (phase 163, task 04 — D4, LOCKED A1/A6).
|
|
|
|
Frontend source-contract pins + node behavioral runs (the
|
|
``test_document_viewer.py`` house pattern) over ``document.js``'s
|
|
``renderDocument`` — the ONE shared core (Sources modal AND
|
|
/document.html):
|
|
|
|
* the MODE branch — ``page_urls`` + ``page_strip_mode === "additive"``
|
|
falls through to the text-doc rendering (Summary panel + content,
|
|
the pre-148 branch reused) wrapped in a ``.doc-views`` container
|
|
with the ``.doc-view-toggle`` group above it (Text pressed by
|
|
default, LOCKED A6); ``page_urls`` with the mode ABSENT or
|
|
``"primary"`` keeps the phase-148 strip-only branch VERBATIM (append
|
|
the strip to ``contentEl`` + return — no toggle, no wrapper, no
|
|
Summary panel); falsy ``page_urls`` is untouched;
|
|
* the toggle semantics — ``role="group"`` + ``aria-label="Document
|
|
view"``, two ``<button type="button">`` labeled ``Text`` / ``Pages``
|
|
(fixed strings, no doc-derived text), ``aria-pressed`` true/false
|
|
initial state, and a click mutating ONLY the two ``aria-pressed``
|
|
states + the wrapper's ``data-view`` attribute (CSS-driven show/
|
|
hide — the strip node stays mounted, no re-render, no refetch);
|
|
* the never-build-HTML rule — createElement + properties/textContent
|
|
only in the new code path (the module's ONE innerHTML stays the
|
|
escape-first markdown render); the strip imgs keep
|
|
``loading = "lazy"`` (the hidden strip never fetches);
|
|
* the ``.doc-view-toggle`` CSS — the house ghost-pill idiom, the
|
|
pressed state as text + style (never color alone, B5), ≥44px
|
|
targets, and the ``data-view`` show/hide contract.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
HAVE_NODE = shutil.which("node") is not None
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _fn(js: str, name: str) -> str:
|
|
"""The source of a top-level function via balanced-brace counting
|
|
(the ``test_document_viewer.py`` pattern)."""
|
|
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 _code(js: str) -> str:
|
|
"""The source with comments stripped (the never-build-HTML pins)."""
|
|
return re.sub(r"//.*?$|/\*.*?\*/", "", js, flags=re.S | re.M)
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Source pins — the renderDocument mode branch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_document_branches_on_strip_mode() -> None:
|
|
"""The mode branch (LOCKED A2 — the tolerant read): the additive
|
|
flag keys on the EXACT ``doc.page_strip_mode === "additive"``
|
|
string (absent / "primary" / anything else ⇒ primary), and the
|
|
primary/absent strip branch stays the phase-148 shape — append
|
|
``docPagesStrip`` to ``contentEl`` and RETURN — evaluated BEFORE
|
|
any wrapper/toggle construction."""
|
|
render = _fn(_read(DOCUMENT_JS), "renderDocument")
|
|
assert 'doc.page_strip_mode === "additive"' in render, (
|
|
"only the exact additive string switches on the toggle"
|
|
)
|
|
gate_i = render.find('const additive = stripEligible && doc.page_strip_mode === "additive";')
|
|
primary_i = render.find("if (stripEligible && !additive) {")
|
|
strip_i = render.find("contentEl.appendChild(docPagesStrip(doc, pageUrls));")
|
|
return_i = render.find("return;", strip_i)
|
|
wrapper_i = render.find('views.className = "doc-views";')
|
|
assert -1 < gate_i < primary_i < strip_i < return_i < wrapper_i, (
|
|
"the primary/absent branch (strip into contentEl + return) precedes "
|
|
"the additive wrapper — its DOM stays the phase-148 shape verbatim"
|
|
)
|
|
# The wrapper + toggle: ONE .doc-views container, the toggle BEFORE
|
|
# it, both into contentEl; the text view lands in the wrapper.
|
|
append_i = render.find("contentEl.append(docViewToggle(views), views);")
|
|
assert wrapper_i < append_i, "the wrapper is built, then appended after its toggle"
|
|
assert 'views.setAttribute("data-view", "text");' in render, (
|
|
"LOCKED A6 — Text is the default view"
|
|
)
|
|
# The strip mounts ONCE inside the wrapper, after the text view —
|
|
# never rebuilt on a flip (no docPagesStrip call outside the two
|
|
# branches).
|
|
assert render.count("docPagesStrip(doc, pageUrls)") == 2
|
|
mount_i = render.rfind("views.appendChild(docPagesStrip(doc, pageUrls));")
|
|
assert mount_i > render.find('slot.appendChild(pre);') or mount_i > render.rfind(
|
|
"slot.appendChild(wrap);"
|
|
), "the strip mounts AFTER the text view inside the wrapper"
|
|
# The non-additive path is untouched: slot === contentEl (the
|
|
# pre-148 appends keep their exact shape through the slot var).
|
|
assert "let slot = contentEl;" in render
|
|
|
|
|
|
def test_primary_absent_branch_builds_no_toggle() -> None:
|
|
"""The primary/absent branch is toggle-free by construction: the
|
|
strip-branch block (from the ``pageUrls`` read to its ``return``)
|
|
contains NO ``doc-view-toggle`` / ``doc-views`` construction — the
|
|
strip-only DOM stays byte-identical to pre-phase-163."""
|
|
render = _fn(_read(DOCUMENT_JS), "renderDocument")
|
|
primary_start = render.find("if (stripEligible && !additive)")
|
|
branch = render[render.find("const pageUrls =") : render.find("return;", primary_start)]
|
|
assert "doc-view-toggle" not in branch
|
|
assert "doc-views" not in branch
|
|
assert branch.count("appendChild") == 1, (
|
|
"the primary branch is exactly the phase-148 append + return"
|
|
)
|
|
|
|
|
|
def test_toggle_never_builds_html_and_labels_are_fixed() -> None:
|
|
"""The never-build-HTML rule: the toggle builders use
|
|
createElement + properties/textContent only — no innerHTML /
|
|
outerHTML — and the labels are the FIXED strings ``Text`` /
|
|
``Pages`` (no doc-derived text inside the toggle). The module's
|
|
ONE innerHTML (the escape-first markdown render) survives."""
|
|
js = _read(DOCUMENT_JS)
|
|
toggle = _fn(js, "docViewToggle")
|
|
btn = _fn(js, "viewToggleButton")
|
|
for body in (toggle, btn):
|
|
code = _code(body)
|
|
assert "innerHTML" not in code and "outerHTML" not in code
|
|
assert 'document.createElement("div")' in toggle
|
|
assert 'group.setAttribute("role", "group")' in toggle
|
|
assert 'group.setAttribute("aria-label", "Document view")' in toggle
|
|
assert 'document.createElement("button")' in btn
|
|
assert 'btn.type = "button"' in btn
|
|
assert 'btn.className = "doc-view-btn"' in btn
|
|
assert 'viewToggleButton("Text")' in toggle
|
|
assert 'viewToggleButton("Pages")' in toggle
|
|
# The module-wide XSS contract: exactly one innerHTML, the
|
|
# renderMarkdown line.
|
|
whole = _code(js)
|
|
assert whole.count("innerHTML") == 1
|
|
assert "outerHTML" not in whole
|
|
|
|
|
|
def test_strip_imgs_stay_lazy() -> None:
|
|
"""The phase-148 lazy-img pin re-runs green: the strip builder is
|
|
UNMODIFIED — every page ``<img>`` keeps ``loading = "lazy"`` (the
|
|
additive default view never fetches the strip)."""
|
|
strip = _fn(_read(DOCUMENT_JS), "docPagesStrip")
|
|
assert 'img.loading = "lazy";' in strip
|
|
|
|
|
|
def test_view_toggle_css() -> None:
|
|
"""styles.css: the ``.doc-view-toggle`` group (the chat-column
|
|
reading width, the pill pair adjacent) + the ``.doc-view-btn``
|
|
ghost pill (≥44px target, ink-soft text — AA) + the PRESSED state
|
|
(``[aria-pressed="true"]`` — brand fill + font-weight 700: text +
|
|
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)."""
|
|
css = _read(STYLES_CSS)
|
|
toggle_i = css.find(".doc-view-toggle {")
|
|
assert toggle_i != -1, "styles.css must style .doc-view-toggle"
|
|
comment_start = css.rfind("/*", 0, toggle_i)
|
|
assert -1 < comment_start < toggle_i and "phase 163" in css[comment_start:toggle_i]
|
|
btn_i = css.find(".doc-view-btn {")
|
|
pressed_i = css.find('.doc-view-btn[aria-pressed="true"]')
|
|
assert toggle_i < btn_i < pressed_i
|
|
btn_rule = css[btn_i : css.find("}", btn_i)]
|
|
for decl in ("min-height: 44px", "color: var(--ink-soft)", "border: 1px solid var(--line)"):
|
|
assert decl in btn_rule
|
|
pressed_rule = css[pressed_i : css.find("}", pressed_i)]
|
|
assert "background: var(--brand)" in pressed_rule
|
|
assert "font-weight: 700" in pressed_rule, (
|
|
"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.
|
|
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
|
|
assert '.doc-views[data-view="pages"] .doc-pages { display: grid; }' in css
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node behavioral runs — the real renderDocument over a minimal fake DOM
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FAKE_DOM = """
|
|
function makeEl(tag) {
|
|
return {
|
|
tag, className: "", textContent: "", attrs: {}, children: [], handlers: {},
|
|
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(ev, fn) { this.handlers[ev] = fn; },
|
|
click() { this.handlers.click(); },
|
|
};
|
|
}
|
|
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 docImageBlock() { return makeEl("div"); }
|
|
function describe(el) {
|
|
return {
|
|
tag: el.tag,
|
|
className: el.className,
|
|
attrs: el.attrs,
|
|
text: el.textContent,
|
|
children: el.children.map(describe),
|
|
};
|
|
}
|
|
"""
|
|
|
|
|
|
def _render_script() -> str:
|
|
js = _read(DOCUMENT_JS)
|
|
parts = [
|
|
_fn(js, n)
|
|
for n in ("renderDocument", "docViewToggle", "viewToggleButton", "docPagesStrip")
|
|
]
|
|
return _FAKE_DOM + "\n".join(parts)
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_additive_doc_renders_text_views_and_toggle() -> None:
|
|
"""Behavioral (node, fake DOM): an ADDITIVE doc renders the TEXT
|
|
doc exactly as a non-qualifying one — Summary panel + content —
|
|
inside ``.doc-views`` (data-view="text"), with the toggle group
|
|
FIRST in the content slot: role=group, aria-label="Document
|
|
view", the two ``doc-view-btn`` buttons labeled Text/Pages, Text
|
|
aria-pressed="true" (LOCKED A6), and the strip MOUNTED inside the
|
|
wrapper (figures + lazy imgs) after the text view."""
|
|
out = _run_node(
|
|
_render_script()
|
|
+ """
|
|
const doc = {
|
|
title: "Letumdei", path: "book.pdf", source: "Library", format: "pdf",
|
|
summary: "A book.", content: "BODY TEXT", created_at: "c", indexed_at: "i",
|
|
chunks: 9, page_urls: ["/api/documents/x/pages/1", "/api/documents/x/pages/2"],
|
|
page_strip_mode: "additive",
|
|
};
|
|
const contentEl = makeEl("div");
|
|
renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
|
|
console.log(JSON.stringify(describe(contentEl)));
|
|
"""
|
|
)
|
|
content = json.loads(out)
|
|
assert [c["className"] for c in content["children"]] == [
|
|
"doc-view-toggle",
|
|
"doc-views",
|
|
], "the toggle group sits above the .doc-views wrapper"
|
|
toggle, views = content["children"]
|
|
assert toggle["attrs"]["role"] == "group"
|
|
assert toggle["attrs"]["aria-label"] == "Document view"
|
|
labels = [c["text"] for c in toggle["children"]]
|
|
assert labels == ["Text", "Pages"]
|
|
assert [c["attrs"]["aria-pressed"] for c in toggle["children"]] == ["true", "false"], (
|
|
"Text pressed by default (LOCKED A6)"
|
|
)
|
|
for c in toggle["children"]:
|
|
assert c["tag"] == "button"
|
|
assert views["attrs"]["data-view"] == "text"
|
|
assert [c["className"] for c in views["children"]] == [
|
|
"doc-summary",
|
|
"doc-raw",
|
|
"doc-pages",
|
|
], "additive = Summary panel + text content + the mounted strip"
|
|
# pre.textContent — a text node, never parsed as HTML
|
|
assert views["children"][1]["text"] == "BODY TEXT"
|
|
strip = views["children"][2]
|
|
assert len(strip["children"]) == 2, "one figure per page_urls entry"
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_primary_and_absent_modes_keep_the_strip_only_dom() -> None:
|
|
"""Behavioral: mode "primary" AND an absent mode (the pre-163
|
|
stamped row — the live Letumdei pre-sync shape) keep the phase-148
|
|
DOM byte-identically: the content slot holds EXACTLY the
|
|
``.doc-pages`` strip — no wrapper, no toggle, no Summary panel,
|
|
no text block."""
|
|
out = _run_node(
|
|
_render_script()
|
|
+ """
|
|
const results = [];
|
|
for (const mode of ["primary", undefined]) {
|
|
const doc = {
|
|
title: "Manual", path: "m.pdf", source: "Library", format: "pdf",
|
|
summary: "A manual.", content: "SHOULD NOT SHOW", created_at: "c",
|
|
indexed_at: "i", chunks: 4,
|
|
page_urls: ["/api/documents/y/pages/1"],
|
|
};
|
|
if (mode !== undefined) doc.page_strip_mode = mode;
|
|
const contentEl = makeEl("div");
|
|
renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
|
|
results.push(describe(contentEl));
|
|
}
|
|
console.log(JSON.stringify(results));
|
|
"""
|
|
)
|
|
primary, absent = json.loads(out)
|
|
for content in (primary, absent):
|
|
assert len(content["children"]) == 1
|
|
assert content["children"][0]["className"] == "doc-pages"
|
|
classes = json.dumps(content)
|
|
assert "doc-view-toggle" not in classes
|
|
assert "doc-views" not in classes
|
|
assert "doc-summary" not in classes
|
|
assert "doc-raw" not in classes, "the strip replaces the text block (primary class)"
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_text_doc_dom_is_untouched() -> None:
|
|
"""Behavioral: a doc with no page_urls is UNTOUCHED — Summary
|
|
panel + content appended straight to the content slot, no
|
|
wrapper, no toggle (the byte-identity property for every
|
|
non-PDF)."""
|
|
out = _run_node(
|
|
_render_script()
|
|
+ """
|
|
const doc = {
|
|
title: "Note", path: "n.pdf", source: "Library", format: "pdf",
|
|
summary: "S.", content: "TEXT", created_at: "c", indexed_at: "i", chunks: 1,
|
|
};
|
|
const contentEl = makeEl("div");
|
|
renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
|
|
console.log(JSON.stringify(describe(contentEl)));
|
|
"""
|
|
)
|
|
content = json.loads(out)
|
|
assert [c["className"] for c in content["children"]] == ["doc-summary", "doc-raw"]
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_toggle_flips_view_without_rerender_or_refetch() -> None:
|
|
"""Behavioral: the toggle handler mutates ONLY the two
|
|
aria-pressed states + the wrapper's data-view. Clicking Pages
|
|
flips data-view to "pages"; clicking back to Text reverses; the
|
|
strip node STAYS MOUNTED the whole time (same object, no
|
|
re-append — the flip-flop fetches nothing) and the wrapper's
|
|
children never change count."""
|
|
out = _run_node(
|
|
_render_script()
|
|
+ """
|
|
const doc = {
|
|
title: "Book", path: "b.pdf", source: "Library", format: "pdf",
|
|
summary: "S.", content: "TEXT", created_at: "c", indexed_at: "i", chunks: 2,
|
|
page_urls: ["/api/documents/z/pages/1", "/api/documents/z/pages/2"],
|
|
page_strip_mode: "additive",
|
|
};
|
|
const contentEl = makeEl("div");
|
|
renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
|
|
const toggle = contentEl.children[0];
|
|
const views = contentEl.children[1];
|
|
const [textBtn, pagesBtn] = toggle.children;
|
|
const stripBefore = views.children[2];
|
|
const countBefore = views.children.length;
|
|
pagesBtn.click();
|
|
const afterPages = {
|
|
view: views.getAttribute("data-view"),
|
|
pressed: toggle.children.map((b) => b.getAttribute("aria-pressed")),
|
|
};
|
|
textBtn.click();
|
|
const afterText = {
|
|
view: views.getAttribute("data-view"),
|
|
pressed: toggle.children.map((b) => b.getAttribute("aria-pressed")),
|
|
stripSame: views.children[2] === stripBefore,
|
|
countSame: views.children.length === countBefore,
|
|
};
|
|
console.log(JSON.stringify({ afterPages, afterText }));
|
|
"""
|
|
)
|
|
res = json.loads(out)
|
|
assert res["afterPages"] == {"view": "pages", "pressed": ["false", "true"]}
|
|
assert res["afterText"] == {
|
|
"view": "text",
|
|
"pressed": ["true", "false"],
|
|
"stripSame": True,
|
|
"countSame": True,
|
|
}, "the strip node persists across flips — no re-render, no refetch"
|