**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2299 lines
105 KiB
Python
2299 lines
105 KiB
Python
"""Unit: document viewer (phase 10).
|
||
|
||
Python side:
|
||
* ``doc_format`` — format from the path suffix (incl. ``.markdown`` and the
|
||
no-suffix fallback) + the phase-102 extensionless name-token rule
|
||
(``Dockerfile`` → ``dockerfile`` with a configured token, suffixes still
|
||
unconditional, no-arg calls byte-identical to the suffix-only rule);
|
||
* the content endpoint's 200/404 mapping — tested WITHOUT a database by
|
||
stubbing the session via FastAPI's dependency override (unknown pairs and
|
||
traversal-style paths map to 404 ``{detail: "document not found"}``;
|
||
known pairs map to the full ``DocContent`` shape).
|
||
|
||
Frontend side:
|
||
* the viewer URL builder — its real query-encoding behavior (paths with
|
||
spaces/slashes) executed under node when available, plus source pins that
|
||
run everywhere;
|
||
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
|
||
loaded by the pages that use it via a relative ``<script src>`` before
|
||
the module scripts;
|
||
* phase 26 — the shared ``renderDocument`` export in ``document.js``, the
|
||
import-safe page guard, the modal skeleton on chat + Sources, and the
|
||
modal module's close/focus/URL contract.
|
||
|
||
Phase 148 (task 02) — the content payload's rendered-page routes
|
||
(the viewer strip, task 03, reads from them): the ``DocContent``
|
||
omission rule — ``page_urls`` ABSENT on the wire when ``None`` (never
|
||
``null``; a non-qualifying doc's payload is byte-identical to
|
||
pre-phase) — and the content endpoint (stubbed session) — a
|
||
page-qualifying PDF row (``page_count`` set) adds ``page_urls`` = the
|
||
exact 1-based ``/api/documents/<id>/pages/<i>`` list over
|
||
``1..page_count``.
|
||
|
||
Phase 163 (task 01) — the content payload's STRIP CLASS signal
|
||
(``page_strip_mode``: ``"primary"`` = the fallback class, the strip IS
|
||
the view; ``"additive"`` = the pictorial class, text + a Text/Pages
|
||
toggle): the model-level omission rule (ABSENT when ``None``, never
|
||
``null``), and the endpoint (stubbed session) — the key rides
|
||
``page_urls`` for a qualifying row, a PRE-PHASE row (``page_count`` set,
|
||
mode NULL — the exact live ``Letumdei`` state) serializes it ABSENT while
|
||
keeping ``page_urls``, and the whole strip block keys off
|
||
``page_count``, so a row with a stray mode and no strip carries neither
|
||
key (every non-qualifying payload byte-identical to pre-phase).
|
||
|
||
Phase 145 (task 03) — the content payload's git origin fields + the
|
||
``renderDocument`` "Original" anchor (modal AND /document.html, one
|
||
site):
|
||
* the ``DocContent`` omission rule — both origin keys ABSENT on the
|
||
wire when ``None`` (never ``null``; non-git payload byte-identical);
|
||
* the content endpoint (stubbed session) — a matching ``kind='git'``
|
||
row adds ``origin_url`` (+ ``origin_branch`` for a pinned row), a
|
||
legacy embedded-credential row is served sanitized, and local /
|
||
ambiguous sources keep the pre-phase key set;
|
||
* the anchor itself (source pins + a node behavioral check over a
|
||
minimal fake DOM) — ``createElement("a")``, the full URL verbatim as
|
||
``href`` AND visible text (the copyable-link contract),
|
||
``target="_blank" rel="noopener"``, the ``title`` = full URL (+
|
||
`` — branch: <name>`` when pinned), inserted AFTER the path code and
|
||
BEFORE the Created badge (absent → the pre-phase six children), no
|
||
innerHTML/outerHTML (the module's ONE innerHTML survives), and the
|
||
``.doc-origin`` CSS mirroring the ``.doc-path`` ellipsis idiom with
|
||
the ``.doc-link`` affordance (+ the ≤640px squeeze).
|
||
|
||
Phase 153 (task 03) — unit pins for the URL contract and the
|
||
copy-path strings (additions beside the task-01/02 pins; nothing
|
||
loosened):
|
||
* the phase-13 back-resolution contract, guarded BOTH as a byte-for-byte
|
||
source pin of the resolution block AND as a node behavioral run of
|
||
that exact extracted block (missing / empty / garbage /
|
||
protocol-relative / pseudo-protocol ``back`` → ``/sources.html`` +
|
||
the "Sources" label; ``/`` → the "Chat" label; any other same-origin
|
||
relative → the "Back" label — the phase-13 pins themselves are
|
||
untouched);
|
||
* the copy-path composition end-to-end under node — the modal's
|
||
``wireCopyPath`` click handler and the page's ``render()`` wiring
|
||
each send the EXACT ``source + "/" + path`` bytes to the (fake)
|
||
clipboard for a source/path pair with spaces + slashes, and the
|
||
rejected-clipboard failure prefills the fallback field with that
|
||
same composed string (success / failure strings + the prefill,
|
||
byte-for-byte);
|
||
* the ``document.html`` Copy-path static nodes — the page's
|
||
``header.doc-header`` (the ``.doc-copy-pop`` block's anchor —
|
||
absent, the float would fall back to the meta row and grow the
|
||
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).
|
||
|
||
Phase 181 (task 04) — the SVG vector preview: ``docImageBlock`` adds
|
||
the ``doc-image-img--vector`` modifier ONLY when
|
||
``doc.format === "svg"`` (the base class is always kept) — a
|
||
``viewBox``-only SVG has NO intrinsic size, so the default 300×150
|
||
replaced-element box would sit small inside the full-width
|
||
``.doc-image`` card — pinned as a node behavioral run of the real
|
||
``docImageBlock`` (svg → the compound class, ``src`` from
|
||
``image_url`` never inlined, ``alt`` = summary with the title
|
||
fallback; png → the class string byte-identical to the base class)
|
||
plus the ``.doc-image-img--vector`` CSS pins (``width: 100%`` +
|
||
``height: auto`` + ``object-fit: contain`` under the base rule's
|
||
70vh clamp, zero colour literals, the base ``.doc-image-img`` rule
|
||
untouched).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.docs import doc_format
|
||
from app.db import get_db
|
||
from app.main import create_app
|
||
from app.models import Document, GitSource
|
||
from app.schemas import DocContent
|
||
from tests.conftest import ADMIN_PASSWORD
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
APP_JS = FRONTEND / "assets" / "app.js"
|
||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
||
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
|
||
|
||
HAVE_NODE = shutil.which("node") is not None
|
||
|
||
|
||
def _read(path: Path) -> str:
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# doc_format — format-from-suffix (phase 10, PLAN §4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("path", "expected"),
|
||
[
|
||
("kubernetes.md", "md"),
|
||
("notes/sub/deep.markdown", "markdown"),
|
||
("NOTES/ARCHIVE.MD", "md"),
|
||
("homelab/container_gitlab/gitlab-compose.yaml", "yaml"),
|
||
("homelab/networking/static-dns.json", "json"),
|
||
("homelab/scripts/uptime_probe.py", "py"),
|
||
("homelab/ssh/ssh_aliases.txt", "txt"),
|
||
("noext", "text"), # no suffix → fallback
|
||
("a/b", "text"), # no suffix → fallback
|
||
],
|
||
)
|
||
def test_doc_format_from_suffix(path: str, expected: str) -> None:
|
||
assert doc_format(path) == expected
|
||
|
||
|
||
def test_doc_format_no_args_extensionless_falls_back_to_text() -> None:
|
||
"""The no-arg contract: default ``extensions=frozenset()`` keeps the
|
||
pre-phase-102 result for every path — an extensionless name that LOOKS
|
||
like a configured token still badges ``text`` without the token set."""
|
||
assert doc_format("Dockerfile") == "text"
|
||
assert doc_format("README") == "text"
|
||
|
||
|
||
#: A token set in the dotted form ``import_extension_set`` passes it.
|
||
_TOKEN_EXTS = frozenset({".md", ".dev", ".dockerfile", ".containerfile"})
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("path", "extensions", "expected"),
|
||
[
|
||
# Name-token branch (phase 102): suffix-less name in the set.
|
||
("services/api/Dockerfile", _TOKEN_EXTS, "dockerfile"),
|
||
("DOCKERFILE", _TOKEN_EXTS, "dockerfile"), # case-insensitive name
|
||
("Containerfile", _TOKEN_EXTS, "containerfile"),
|
||
# Suffix precedence: display never depends on the import list.
|
||
("Dockerfile.dev", _TOKEN_EXTS, "dev"), # suffixed → its suffix rules
|
||
("readme.rst", _TOKEN_EXTS, "rst"), # out-of-scope suffix still badges
|
||
("notes/README.dev", _TOKEN_EXTS, "dev"),
|
||
("dockerfile.bak", _TOKEN_EXTS, "bak"), # lookalike → suffix, not name
|
||
("kubernetes.md", frozenset(), "md"), # empty set: suffix unconditional
|
||
# Extensionless names NOT in the set fall back to text — exact
|
||
# name only, no partial names.
|
||
("README", _TOKEN_EXTS, "text"),
|
||
("mydockerfile", _TOKEN_EXTS, "text"),
|
||
],
|
||
)
|
||
def test_doc_format_with_token_set(
|
||
path: str, extensions: frozenset[str], expected: str
|
||
) -> None:
|
||
assert doc_format(path, extensions) == expected
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Content endpoint mapping — stubbed session, no database required
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, row: object) -> None:
|
||
self._row = row
|
||
|
||
def first(self) -> object:
|
||
return self._row
|
||
|
||
|
||
class _FakeScalars:
|
||
"""The resolver's ``db.scalars(...).all()`` — only the git rows
|
||
ever reach the caller (the ``kind = 'git'`` WHERE clause is
|
||
emulated, the task-01 ``_GitOnlySession`` pattern)."""
|
||
|
||
def __init__(self, rows: tuple[GitSource, ...]) -> None:
|
||
self._rows = [row for row in rows if row.kind == "git"]
|
||
|
||
def all(self) -> list[GitSource]:
|
||
return self._rows
|
||
|
||
|
||
class _FakeSession:
|
||
def __init__(self, row: object, git_rows: tuple[GitSource, ...] = ()) -> None:
|
||
self._row = row
|
||
self._git_rows = git_rows
|
||
|
||
def execute(self, _stmt: object) -> _FakeResult:
|
||
return _FakeResult(self._row)
|
||
|
||
def scalars(self, _stmt: object) -> _FakeScalars:
|
||
return _FakeScalars(self._git_rows)
|
||
|
||
|
||
def _client_with_row(row: object, git_rows: tuple[GitSource, ...] = ()) -> TestClient:
|
||
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
|
||
(``None`` → no matching document row) and, for the phase-145
|
||
origin lookup, *git_rows* (default: no rows — the non-git corner).
|
||
Phase 79: the endpoint is user-gated, so the client signs in as
|
||
the admin first — these tests pin the CONTENT mapping (200/404/
|
||
422), not the auth contract (which ``test_auth_api.py`` pins)."""
|
||
app = create_app()
|
||
app.dependency_overrides[get_db] = lambda: _FakeSession(row, git_rows)
|
||
client = TestClient(app)
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||
return client
|
||
|
||
|
||
def test_content_unknown_pair_maps_to_404() -> None:
|
||
with _client_with_row(None) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "nope.md"},
|
||
)
|
||
assert r.status_code == 404
|
||
assert r.json() == {"detail": "document not found"}
|
||
|
||
|
||
def test_content_traversal_style_path_maps_to_404() -> None:
|
||
"""``../``-style values are just non-existent rows → 404, never a
|
||
file read (DB-only endpoint, no filesystem access)."""
|
||
with _client_with_row(None) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "../../etc/passwd"},
|
||
)
|
||
assert r.status_code == 404
|
||
assert r.json() == {"detail": "document not found"}
|
||
assert "root:" not in r.text # nothing leaked
|
||
|
||
|
||
def test_content_known_pair_maps_to_doc_content() -> None:
|
||
doc = Document(
|
||
source="Homelab",
|
||
path="notes/deep mark.md",
|
||
full_path="/tmp/deep mark.md",
|
||
title="Deep Mark",
|
||
content="# Deep Mark\n\nbody",
|
||
content_hash="f" * 64,
|
||
is_image=False, # phase 122: the stub session never applies defaults
|
||
)
|
||
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
|
||
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) # phase 106
|
||
with _client_with_row((doc, 3)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
# Wire-additive (phase 106, task 05): the pre-date keys are all
|
||
# still there, joined by ``created_at`` — and (phase 122, task 04)
|
||
# by ``is_image`` (ALWAYS present; text docs: false) while
|
||
# ``image_url`` is ABSENT (never null — the omission rule).
|
||
assert set(body) == {
|
||
"source", "path", "title", "format", "summary", "created_at",
|
||
"content", "indexed_at", "chunks", "is_image",
|
||
}
|
||
assert body["is_image"] is False
|
||
assert "image_url" not in body # absent — never null (text doc)
|
||
assert body["source"] == "Homelab"
|
||
assert body["path"] == "notes/deep mark.md"
|
||
assert body["title"] == "Deep Mark"
|
||
assert body["format"] == "md"
|
||
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
||
assert body["content"] == "# Deep Mark\n\nbody"
|
||
assert body["created_at"] == "2026-08-20T09:00:00+00:00" # phase 106
|
||
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
|
||
assert body["chunks"] == 3
|
||
|
||
|
||
def test_content_requires_both_params() -> None:
|
||
with _client_with_row(None) as client:
|
||
assert client.get("/api/documents/content").status_code == 422
|
||
assert client.get("/api/documents/content", params={"source": "Homelab"}).status_code == 422
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 145 (task 03) — the content payload's git origin fields + the
|
||
# renderDocument "Original" anchor (modal AND /document.html, one site)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _text_doc() -> Document:
|
||
"""The content-endpoint stub's document row (a markdown doc — no
|
||
summary, the pre-phase shape)."""
|
||
doc = Document(
|
||
source="Homelab",
|
||
path="notes/deep mark.md",
|
||
full_path="/tmp/deep mark.md",
|
||
title="Deep Mark",
|
||
content="# Deep Mark\n\nbody",
|
||
content_hash="f" * 64,
|
||
is_image=False, # phase 122: the stub session never applies defaults
|
||
)
|
||
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
|
||
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC)
|
||
return doc
|
||
|
||
|
||
def _text_doc_content(
|
||
origin_url: str | None = None,
|
||
origin_branch: str | None = None,
|
||
page_urls: list[str] | None = None,
|
||
page_strip_mode: str | None = None,
|
||
) -> DocContent:
|
||
"""One text doc's content payload (the pre-phase shape) with the
|
||
phase-145 origin fields + the phase-148 ``page_urls`` + the
|
||
phase-163 ``page_strip_mode`` optionally set."""
|
||
return DocContent(
|
||
source="S",
|
||
path="a.md",
|
||
title="A",
|
||
format="md",
|
||
created_at="2026-01-01T00:00:00+00:00",
|
||
content="body",
|
||
indexed_at="2026-01-01T00:00:00+00:00",
|
||
chunks=1,
|
||
origin_url=origin_url,
|
||
origin_branch=origin_branch,
|
||
page_urls=page_urls,
|
||
page_strip_mode=page_strip_mode,
|
||
)
|
||
|
||
|
||
def test_doc_content_origin_fields_absent_on_the_wire_when_unset() -> None:
|
||
"""The model-level omission rule (the :func:`_drop_absent_share_url`
|
||
precedent, the ``image_url`` shape): a non-git document (no
|
||
``origin_url``) serializes with BOTH origin keys ABSENT — never
|
||
``null`` — so the payload is byte-identical to pre-phase. A
|
||
sanitized-URL-only payload (an unpinned row) drops
|
||
``origin_branch``; a pinned row keeps both keys."""
|
||
dumped = _text_doc_content().model_dump()
|
||
assert "origin_url" not in dumped # absent — never null
|
||
assert "origin_branch" not in dumped # absent — never null
|
||
dumped = _text_doc_content(origin_url="https://host/x.git").model_dump()
|
||
assert dumped["origin_url"] == "https://host/x.git"
|
||
assert "origin_branch" not in dumped # NULL = the default branch — nothing to show
|
||
dumped = _text_doc_content(
|
||
origin_url="https://host/x.git", origin_branch="docs"
|
||
).model_dump()
|
||
assert dumped["origin_branch"] == "docs"
|
||
|
||
|
||
def test_doc_content_page_urls_absent_on_the_wire_when_unset() -> None:
|
||
"""Phase 148 (task 02) — the model-level omission rule (the
|
||
``image_url`` precedent): a non-qualifying document (``page_urls``
|
||
``None`` — a text doc, an image, a text-heavy PDF) serializes with
|
||
the key ABSENT — never ``null`` — so its payload is byte-identical
|
||
to pre-phase. A page-qualifying PDF keeps the exact route list."""
|
||
dumped = _text_doc_content().model_dump()
|
||
assert "page_urls" not in dumped # absent — never null
|
||
urls = ["/api/documents/d/pages/1", "/api/documents/d/pages/2"]
|
||
dumped = _text_doc_content(page_urls=urls).model_dump()
|
||
assert dumped["page_urls"] == urls # the exact 1-based route list
|
||
|
||
|
||
def test_doc_content_page_strip_mode_absent_on_the_wire_when_unset() -> None:
|
||
"""Phase 163 (task 01, LOCKED A2) — the model-level omission rule
|
||
(the ``image_url`` / ``page_urls`` precedent): no strip class
|
||
(``page_strip_mode`` ``None`` — every non-qualifying doc AND every
|
||
pre-phase row) serializes the key ABSENT — never ``null`` — so those
|
||
payloads are byte-identical to pre-phase. A stamped row carries the
|
||
class verbatim, beside ``page_urls``."""
|
||
dumped = _text_doc_content().model_dump()
|
||
assert "page_strip_mode" not in dumped # absent — never null
|
||
for mode in ("primary", "additive"):
|
||
dumped = _text_doc_content(
|
||
page_urls=["/api/documents/d/pages/1"], page_strip_mode=mode
|
||
).model_dump()
|
||
assert dumped["page_strip_mode"] == mode # the class verbatim
|
||
|
||
|
||
def _page_pdf_doc(
|
||
page_count: int | None = 2, page_strip_mode: str | None = None
|
||
) -> Document:
|
||
"""A page-QUALIFYING PDF row (phase 148): ``page_count`` set — the
|
||
endpoint derives ``page_urls`` from it (the exact 1-based list).
|
||
``page_strip_mode`` (phase 163) is the strip CLASS; ``None`` is the
|
||
PRE-PHASE row shape (the column NULL while the strip stands)."""
|
||
doc = Document(
|
||
id=uuid.uuid4(),
|
||
source="Homelab",
|
||
path="ikea/besta.pdf",
|
||
full_path="/tmp/besta.pdf",
|
||
title="BESTA frame (white) assembly",
|
||
content="[Page 1 illustration: the parts tray]…",
|
||
content_hash="e" * 64,
|
||
is_image=False, # phase 122: the stub session never applies defaults
|
||
page_count=page_count,
|
||
page_strip_mode=page_strip_mode,
|
||
)
|
||
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
|
||
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC)
|
||
return doc
|
||
|
||
|
||
def test_content_page_urls_carried_for_a_page_qualifying_pdf() -> None:
|
||
"""Phase 148 (task 02) — the endpoint (stubbed session, no
|
||
database): a page-qualifying PDF row (``page_count`` set) carries
|
||
``page_urls`` = the EXACT 1-based
|
||
``/api/documents/<id>/pages/<i>`` list over ``1..page_count``
|
||
(the viewer strip's source, task 03 — a per-page render gap is
|
||
still listed; the route 404s that N). Every pre-phase key stands
|
||
untouched (``is_image`` false, ``image_url`` ABSENT — the PDF is
|
||
not an image doc; the document's TEXT stays user-gated, only the
|
||
page bytes are public)."""
|
||
doc = _page_pdf_doc()
|
||
with _client_with_row((doc, 4)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "ikea/besta.pdf"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["page_urls"] == [
|
||
f"/api/documents/{doc.id}/pages/1",
|
||
f"/api/documents/{doc.id}/pages/2",
|
||
] # the exact 1-based list, count == page_count
|
||
assert body["is_image"] is False
|
||
assert "image_url" not in body # absent — never null (not an image doc)
|
||
# Phase 163 (task 01): the pre-phase row (mode NULL) keeps the strip
|
||
# and serializes the class ABSENT — the viewer treats that as
|
||
# "primary", i.e. today's DOM.
|
||
assert "page_strip_mode" not in body
|
||
|
||
|
||
@pytest.mark.parametrize("mode", ["primary", "additive"])
|
||
def test_content_page_strip_mode_carried_for_a_stamped_strip(mode: str) -> None:
|
||
"""Phase 163 (task 01, LOCKED A2/A3) — the endpoint (stubbed
|
||
session, no
|
||
database): a page-qualifying PDF whose row carries the class
|
||
(``"primary"`` = the fallback class, ``"additive"`` = the pictorial
|
||
class) carries ``page_strip_mode`` VERBATIM next to ``page_urls`` —
|
||
the signal the viewer branches on so an additive strip never hides
|
||
the text (task 04). Nothing else in the payload moves."""
|
||
doc = _page_pdf_doc(page_strip_mode=mode)
|
||
with _client_with_row((doc, 4)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "ikea/besta.pdf"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["page_strip_mode"] == mode # the class verbatim
|
||
assert body["page_urls"] == [
|
||
f"/api/documents/{doc.id}/pages/1",
|
||
f"/api/documents/{doc.id}/pages/2",
|
||
] # the strip rides along, unchanged
|
||
|
||
|
||
def test_content_strip_block_keys_off_page_count_never_mode_alone() -> None:
|
||
"""Phase 163 (task 01) — the class never appears alone: the whole
|
||
strip block keys off ``page_count``, so a row whose column somehow
|
||
holds a mode while ``page_count`` is NULL serializes NEITHER
|
||
``page_strip_mode`` nor ``page_urls`` — the payload is byte-identical
|
||
to a plain non-qualifying document (there is no strip to show, and
|
||
the viewer must never be told a class for pages that do not exist)."""
|
||
doc = _page_pdf_doc(page_count=None, page_strip_mode="additive")
|
||
with _client_with_row((doc, 1)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "ikea/besta.pdf"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "page_strip_mode" not in body # absent — the strip block is off
|
||
assert "page_urls" not in body # absent — never null
|
||
|
||
|
||
def test_content_non_qualifying_payload_key_set_is_byte_identical() -> None:
|
||
"""Phase 163 (task 01) — the byte-identity pin: a non-qualifying PDF
|
||
row (``page_count`` NULL, mode NULL) carries EXACTLY the pre-phase
|
||
key set — neither strip key joins it (``is_image`` always present,
|
||
``image_url`` / the origin keys / both strip keys absent)."""
|
||
doc = _page_pdf_doc(page_count=None)
|
||
with _client_with_row((doc, 1)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "ikea/besta.pdf"},
|
||
)
|
||
assert r.status_code == 200
|
||
assert set(r.json()) == {
|
||
"source",
|
||
"path",
|
||
"title",
|
||
"format",
|
||
"summary",
|
||
"created_at",
|
||
"content",
|
||
"indexed_at",
|
||
"chunks",
|
||
"is_image",
|
||
}
|
||
|
||
|
||
def test_content_origin_url_carried_for_a_matching_git_row() -> None:
|
||
"""The endpoint (stubbed session, no database): a ``kind='git'``
|
||
row whose URL basename matches the document's source adds
|
||
``origin_url`` — the sanitized clone URL — to the payload, and
|
||
every pre-phase key stands untouched. The row pins no branch →
|
||
``origin_branch`` is ABSENT (never ``null``)."""
|
||
row = GitSource(url="https://github.com/reese/Homelab.git", kind="git")
|
||
with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert set(body) == {
|
||
"source", "path", "title", "format", "summary", "created_at",
|
||
"content", "indexed_at", "chunks", "is_image", "origin_url",
|
||
}
|
||
assert body["origin_url"] == "https://github.com/reese/Homelab.git"
|
||
assert "origin_branch" not in body # absent — never null (unpinned row)
|
||
|
||
|
||
def test_content_origin_branch_carried_for_a_pinned_row() -> None:
|
||
"""A pinned row (phase 135 ``branch``) also carries
|
||
``origin_branch`` — the branch the title attribute will show."""
|
||
row = GitSource(
|
||
url="https://github.com/reese/Homelab.git", kind="git", branch="docs"
|
||
)
|
||
with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
body = r.json()
|
||
assert body["origin_url"] == "https://github.com/reese/Homelab.git"
|
||
assert body["origin_branch"] == "docs"
|
||
|
||
|
||
def test_content_origin_url_sanitizes_a_legacy_embedded_credential_row() -> None:
|
||
"""End-to-end (stubbed): a legacy row whose credential is still
|
||
embedded in the stored URL never leaks it — the payload carries
|
||
the phase-121 sanitized URL (the resolver's guarantee, one mapping
|
||
for the whole app)."""
|
||
row = GitSource(url="https://user:tok@example.com/reese/Homelab.git", kind="git")
|
||
with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
body = r.json()
|
||
assert body["origin_url"] == "https://example.com/reese/Homelab.git"
|
||
assert "user:tok" not in r.text # the credential stays out of the wire
|
||
|
||
|
||
def test_content_origin_fields_absent_for_local_and_ambiguous_sources() -> None:
|
||
"""No git row (a plain local source) OR two rows sharing the
|
||
basename (ambiguous — never guess) → both origin keys ABSENT from
|
||
the JSON (not ``null`` — the omission pinned on the wire; the
|
||
payload is byte-identical to pre-phase, the ``test_content_known_
|
||
pair_maps_to_doc_content`` key-set pin)."""
|
||
local_only = (_text_doc(), 3)
|
||
with _client_with_row(local_only) as client: # no git rows at all
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
assert r.status_code == 200
|
||
assert "origin_url" not in r.json()
|
||
assert "origin_branch" not in r.json()
|
||
|
||
ambiguous = (
|
||
GitSource(url="https://a.example/org/Homelab.git", kind="git"),
|
||
GitSource(url="git@b.example:other/Homelab.git", kind="git"),
|
||
)
|
||
with _client_with_row(local_only, git_rows=ambiguous) as client:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
||
)
|
||
assert r.status_code == 200
|
||
assert "origin_url" not in r.json()
|
||
assert "origin_branch" not in r.json()
|
||
|
||
|
||
# ---------- the renderDocument "Original" anchor (the shared core) ----------
|
||
|
||
|
||
def _fn(js: str, name: str) -> str:
|
||
"""The source of a top-level function via balanced-brace counting
|
||
(the ``test_date_editor.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 test_origin_anchor_is_a_copyable_external_link() -> None:
|
||
"""The anchor (house-style source contract): ``createElement("a")``
|
||
(the never-build-HTML rule), the ``doc-origin`` class, ``href =
|
||
doc.origin_url``, ``target="_blank" rel="noopener"`` (the modal's
|
||
"Full page" escape-hatch idiom), the full URL VERBATIM as the
|
||
visible text (the copyable-link contract — no decorative label),
|
||
and the ``title`` = the full URL, plus `` — branch: <name>`` when
|
||
the payload pins a branch (the pathCode precision idiom)."""
|
||
body = _fn(_read(DOCUMENT_JS), "originAnchor")
|
||
assert 'document.createElement("a")' in body
|
||
assert 'a.className = "doc-origin"' in body
|
||
assert "a.href = doc.origin_url" in body
|
||
assert 'a.target = "_blank"' in body
|
||
assert 'a.rel = "noopener"' in body
|
||
assert "a.textContent = doc.origin_url" in body, (
|
||
"the visible text is the full URL verbatim (copy-pasteable)"
|
||
)
|
||
branch_title = 'doc.origin_url + " — branch: " + doc.origin_branch'
|
||
assert 'doc.origin_branch ? ' + branch_title + " : doc.origin_url" in body, (
|
||
"the title carries the full URL (+ the pinned branch when present)"
|
||
)
|
||
# The never-build-HTML shape: no innerHTML/outerHTML in the new code.
|
||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||
assert "innerHTML" not in code and "outerHTML" not in code
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_origin_anchor_behaves_for_branch_and_no_branch_variants() -> None:
|
||
"""Behavioral check (node, minimal fake DOM) of the real anchor
|
||
builder: the exact ``href``/``target``/``rel``/``textContent``/
|
||
``title`` for a git doc, branch and no-branch variants."""
|
||
fn = _fn(_read(DOCUMENT_JS), "originAnchor")
|
||
fake_dom = (
|
||
"globalThis.document = { createElement: (tag) => {"
|
||
' const el = { tag, className: "", href: "", target: "", rel: "",'
|
||
' textContent: "", setAttribute(k, v) { this[k] = v; } };'
|
||
" return el; } };"
|
||
)
|
||
out = _run_node(
|
||
fake_dom
|
||
+ "\n" + fn
|
||
+ '\nconst g1 = originAnchor({ origin_url: "https://github.com/reese/skills.git",'
|
||
' origin_branch: "main" });' + "\n"
|
||
+ '\nconst g2 = originAnchor({ origin_url: "https://github.com/reese/skills.git" });' + "\n"
|
||
+ "console.log(JSON.stringify(g1));\nconsole.log(JSON.stringify(g2));"
|
||
)
|
||
j1, j2 = (json.loads(line) for line in out.splitlines())
|
||
for j in (j1, j2):
|
||
assert j["tag"] == "a"
|
||
assert j["className"] == "doc-origin"
|
||
assert j["href"] == "https://github.com/reese/skills.git"
|
||
assert j["target"] == "_blank"
|
||
assert j["rel"] == "noopener"
|
||
# The full URL VERBATIM as the visible text (copy-pasteable).
|
||
assert j["textContent"] == "https://github.com/reese/skills.git"
|
||
# The title: the full URL, + the pinned branch when present.
|
||
assert j1["title"] == "https://github.com/reese/skills.git — branch: main"
|
||
assert j2["title"] == "https://github.com/reese/skills.git"
|
||
|
||
|
||
def test_origin_anchor_sits_after_the_path_code_before_the_created_badge() -> None:
|
||
"""renderDocument (the ONE shared core — modal AND /document.html):
|
||
the anchor joins the meta row AFTER the path ``code`` and BEFORE
|
||
the Created badge — and ONLY when the payload carries
|
||
``origin_url``. Absent (a non-git document — the field simply
|
||
missing on the wire) → the gate builds the empty array, the
|
||
spread adds nothing, and the meta row's children are EXACTLY the
|
||
pre-phase six (the existing badge-order pins pass unchanged)."""
|
||
render = _fn(_read(DOCUMENT_JS), "renderDocument")
|
||
gate_i = render.find("const originItems = doc.origin_url ? [originAnchor(doc)] : [];")
|
||
call_i = render.find("metaEl.replaceChildren(")
|
||
path_i = render.find("pathCode,", call_i)
|
||
spread_i = render.find("...originItems,", call_i)
|
||
created_i = render.find('metaBadge("doc-created"', call_i)
|
||
assert -1 < gate_i < call_i < path_i < spread_i < created_i, (
|
||
"the anchor gate precedes the row build and lands AFTER the path code, "
|
||
"BEFORE the Created badge"
|
||
)
|
||
# The gate: present only on a truthy origin_url — an ABSENT field
|
||
# (undefined) is falsy → [] → the pre-phase children stand.
|
||
assert "doc.origin_url ? [originAnchor(doc)] : []" in render
|
||
# The spread is the meta row's ONLY conditional child (the six
|
||
# pre-phase children are the fixed ones).
|
||
assert render[call_i:].count("...") == 1
|
||
|
||
|
||
def test_origin_anchor_keeps_the_module_xss_contract() -> None:
|
||
"""The module's ONE innerHTML (the escape-first markdown render)
|
||
survives — the new anchor adds no HTML-building of its own
|
||
(createElement + properties/textContent only)."""
|
||
code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", _read(DOCUMENT_JS), flags=re.S))
|
||
assert code.count("innerHTML") == 1
|
||
assert "outerHTML" not in code
|
||
|
||
|
||
def test_doc_origin_css_mirrors_the_doc_path_ellipsis_idiom() -> None:
|
||
"""styles.css: the ``.doc-origin`` rule sits next to the
|
||
``.doc-path`` family (mono, 0.75rem, the same 26rem ellipsis
|
||
budget + 6rem floor) with the ``.doc-link`` affordance
|
||
(brand-ink, underline on hover/focus — the existing link
|
||
tokens), provenance-cited, and the ≤640px block squeezes it like
|
||
the path (16rem — the row's own wrap/clip rules are unchanged:
|
||
the anchor is one more inline item)."""
|
||
css = _read(FRONTEND / "assets" / "styles.css")
|
||
rule_i = css.find(".doc-origin {")
|
||
assert rule_i != -1, "styles.css must style .doc-origin"
|
||
# Provenance comment directly above the rule (house style).
|
||
comment_start = css.rfind("/*", 0, rule_i)
|
||
assert -1 < comment_start < rule_i and "Phase 145 (task 03)" in css[comment_start:rule_i]
|
||
# Next to the .doc-path family (before .doc-chunks).
|
||
assert css.find(".doc-path {") < rule_i < css.find(".doc-chunks {")
|
||
rule = css[rule_i : css.find("}", rule_i)]
|
||
for decl in (
|
||
"font-family: var(--mono)",
|
||
"font-size: 0.75rem",
|
||
"max-width: 26rem",
|
||
"white-space: nowrap",
|
||
"overflow: hidden",
|
||
"text-overflow: ellipsis",
|
||
"min-width: 6rem",
|
||
"color: var(--brand-ink)",
|
||
"text-decoration: none",
|
||
):
|
||
assert decl in rule, f".doc-origin must carry {decl!r}"
|
||
# The .doc-link hover/focus affordance (the existing link idiom).
|
||
assert ".doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; }" in css
|
||
# The ≤640px squeeze mirrors .doc-path's 16rem (same media block).
|
||
mobile = css.find("@media (max-width: 640px)")
|
||
path_sq = css.find(".doc-path { max-width: 16rem; }", mobile)
|
||
origin_sq = css.find(".doc-origin { max-width: 16rem; }", mobile)
|
||
close_640 = css.find("@media", mobile + 1)
|
||
assert -1 < path_sq < origin_sq < (close_640 if close_640 != -1 else len(css)), (
|
||
"the mobile block squeezes the anchor like the path"
|
||
)
|
||
assert "url(http" not in css and "@import url(" not in css, (
|
||
"no CDN (AGENTS.md rule 6)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Viewer URL builder — encoded query (spaces/slashes in real paths)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
|
||
"""Both entry points (chat chips, Sources rows) build the same
|
||
encoded viewer URL — kept as each link's ``href`` (no-JS /
|
||
context-menu escape hatch to the dedicated viewer).
|
||
|
||
Phase 13: the chat builder additionally carries ``back=/`` (encoded
|
||
%2F) so the viewer's back button returns to the chat; Sources links
|
||
intentionally omit the param (the viewer's /sources.html default).
|
||
|
||
Phase 26: the left-click no longer opens a new tab — it is
|
||
intercepted (preventDefault) and routed to openDocumentModal from
|
||
the shared modal module; no ``target="_blank"" survives on either
|
||
entry point."""
|
||
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
|
||
assert '"/document.html?source=" + encodeURIComponent(' in js, name
|
||
assert '"&path=" + encodeURIComponent(' in js, name
|
||
assert 'target = "_blank"' not in js, f"{name}: phase 26 — no new tabs"
|
||
|
||
app_js = _read(APP_JS)
|
||
# Chat: 3-arg builder with back defaulting to the chat page.
|
||
assert 'function documentUrl(source, path, back = "/")' in app_js
|
||
assert '"&back=" + encodeURIComponent(back)' in app_js
|
||
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
|
||
assert 'chip.addEventListener("click"' in app_js
|
||
assert "e.preventDefault()" in app_js
|
||
assert "openDocumentModal(s.source, s.path, chip, \"/\")" in app_js
|
||
|
||
sources_js = _read(SOURCES_JS)
|
||
# Sources: unchanged 2-arg builder — no back param in the URL.
|
||
assert "function documentUrl(source, path)" in sources_js
|
||
assert 'link.className = "doc-link"' in sources_js
|
||
assert "link.href = documentUrl(d.source, d.path)" in sources_js
|
||
assert 'link.addEventListener("click"' in sources_js
|
||
assert "openDocumentModal(d.source, d.path, link)" in sources_js
|
||
# The full path stays the hover name on the ellipsized cell AND the link.
|
||
assert "pathTd.title = d.path" in sources_js
|
||
assert "link.title = d.path" in sources_js
|
||
|
||
|
||
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 _extract_function(js: str, name: str) -> str:
|
||
pat = rf"(?:export )?function {name}\(source, path(?:, back = (?:\"/\"|null))?\) \{{.*?\n\}}"
|
||
match = re.search(pat, js, re.S)
|
||
assert match, f"{name}(source, path) not found"
|
||
return match.group(0).replace("export ", "", 1)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_viewer_url_builder_encodes_spaces_and_slashes() -> None:
|
||
"""Behavioral check of the real builder (app.js) under node: slashes
|
||
and spaces in source/path values must come out percent-encoded, and
|
||
the back target is appended + encoded (phase 13)."""
|
||
fn = _extract_function(_read(APP_JS), "documentUrl")
|
||
out = _run_node(
|
||
f"{fn}\n"
|
||
"console.log(documentUrl('Homelab', 'kubernetes.md'));\n"
|
||
"console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n"
|
||
"console.log(documentUrl('H omelab', 'a/b.md'));\n"
|
||
"console.log(documentUrl('Homelab', 'kubernetes.md', '/sources.html'));"
|
||
)
|
||
assert out.splitlines() == [
|
||
"/document.html?source=Homelab&path=kubernetes.md&back=%2F",
|
||
"/document.html?source=Homelab&path=notes%2Fmy%20file.yaml&back=%2F",
|
||
"/document.html?source=H%20omelab&path=a%2Fb.md&back=%2F",
|
||
"/document.html?source=Homelab&path=kubernetes.md&back=%2Fsources.html",
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared renderer extraction (phase 10 step 2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_renderer_extracted_to_shared_markdown_js() -> None:
|
||
"""The renderer moved to assets/markdown.js (not duplicated in app.js)
|
||
and BOTH pages load it via a relative <script src> before their module
|
||
scripts — so the globals exist when app.js/document.js run."""
|
||
md = _read(MARKDOWN_JS)
|
||
# Phase 182 (task 06) added the SECOND, optional `opts` argument — the
|
||
# renderer still lives here exactly once, which is what this pin is
|
||
# about (the argument is pinned by tests/unit/test_frontend_verbatim_markdown.py).
|
||
assert "function renderMarkdown(md" in md
|
||
assert "function escapeHtml(s)" in md
|
||
|
||
app_js = _read(APP_JS)
|
||
assert "function renderMarkdown" not in app_js, "renderer must live in markdown.js"
|
||
assert "function escapeHtml" not in app_js
|
||
|
||
for page in ("index.html", "document.html"):
|
||
html = _read(FRONTEND / page)
|
||
assert re.search(r'<script src="assets/markdown\.js"></script>', html), (
|
||
f"{page} must load markdown.js via a relative <script src>"
|
||
)
|
||
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
||
f"{page}: markdown.js must load before the module script"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
|
||
"""Behavioral check (node) that the extracted renderer still escapes
|
||
first: hostile content never becomes live HTML; basic transforms work."""
|
||
js = _read(MARKDOWN_JS)
|
||
out = _run_node(
|
||
js
|
||
+ "\nconsole.log(renderMarkdown('# Title\\n\\n<script>alert(1)</script>"
|
||
+ "\\n\\n**bold** and `code`'));"
|
||
)
|
||
html = out.strip()
|
||
assert "<script>" not in html # never live HTML
|
||
assert "<script>alert(1)</script>" in html
|
||
assert "<strong>bold</strong>" in html
|
||
assert "<code>code</code>" in html
|
||
assert "<h3>Title</h3>" in html
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 26 — shared renderDocument + the document modal wiring
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_render_document_exported_and_modal_imports_it() -> None:
|
||
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
||
exact renderer the standalone page and the modal share (no drift).
|
||
The modal module imports it relatively, and BOTH view scripts
|
||
(app.js — the chat view at shell boot — and sources.js — the RAG
|
||
view module) import the modal module relatively — no direct
|
||
<script> tag (the header.js single-evaluation design: esbuild
|
||
inlines it into the bundle, one module instance per document).
|
||
"""
|
||
doc_js = _read(DOCUMENT_JS)
|
||
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
||
# #doc-content elements under exactly these names.
|
||
assert "export function renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js, (
|
||
"document.js must export renderDocument(doc, { titleEl, metaEl, contentEl })"
|
||
)
|
||
# The standalone page renders through the SAME shared function with its
|
||
# own page elements (no second renderer copy).
|
||
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js
|
||
modal_js = _read(MODAL_JS)
|
||
assert 'from "./document.js"' in modal_js
|
||
assert "export function openDocumentModal(" in modal_js
|
||
assert "export function closeDocumentModal(" in modal_js
|
||
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
|
||
assert 'from "./document-modal.js"' in js, (
|
||
f"{name}: must import the modal module relatively"
|
||
)
|
||
text = _read(INDEX_HTML)
|
||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||
"index.html: no direct document-modal.js <script> tag "
|
||
"(single-evaluation design — the view scripts import it)"
|
||
)
|
||
|
||
|
||
def test_document_js_page_init_is_import_safe() -> None:
|
||
"""Phase 26: the /document.html-specific init (back-link resolution,
|
||
whoami, content load) runs ONLY when #doc-title exists — the modal
|
||
module's `import { renderDocument } from "./document.js"` on the
|
||
chat/sources pages must have no side effects."""
|
||
js = _read(DOCUMENT_JS)
|
||
guard = js.find('querySelector("#doc-title")')
|
||
back_href = js.find("backLink.href = backTarget")
|
||
load_call = js.rfind("load();")
|
||
assert 0 < guard < back_href < load_call, (
|
||
"the viewer-page init must sit inside the #doc-title guard "
|
||
"(after it, and load() must be the guarded entry point)"
|
||
)
|
||
|
||
|
||
def test_shell_carries_the_single_modal_skeleton() -> None:
|
||
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
|
||
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
|
||
view's second copy was dropped in the fold; BOTH view scripts
|
||
(app.js chat chips, sources.js RAG row links) open documents
|
||
through openDocumentModal(...) against that single instance
|
||
(document-modal.js resolves it by document-level querySelector at
|
||
import). The a11y frame is included: role=dialog + aria-modal, a
|
||
labelled close control, a focusable content target (tabindex=-1),
|
||
and a role=status announcer. Hidden by default — inert until JS
|
||
opens it."""
|
||
text = _read(INDEX_HTML)
|
||
assert text.count('id="doc-modal"') == 1, (
|
||
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
|
||
)
|
||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
|
||
assert 'id="doc-modal-backdrop"' in text
|
||
assert 'id="doc-modal-panel"' in text
|
||
assert 'role="dialog"' in text and 'aria-modal="true"' in text
|
||
assert 'id="doc-modal-title"' in text
|
||
assert 'id="doc-modal-meta"' in text
|
||
assert 'id="doc-modal-desc"' in text
|
||
assert 'id="doc-modal-open"' in text
|
||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text)
|
||
assert re.search(r'id="doc-modal-close"[^>]*aria-label="Close document"', text)
|
||
|
||
|
||
def test_shell_loads_markdown_before_its_modules() -> None:
|
||
"""Phase 26 + phase 76 (task 02): the modal renders md documents
|
||
in the RAG view too — so the shell loads the classic markdown.js
|
||
(global renderMarkdown) via a relative <script src> BEFORE its
|
||
module scripts (app.js — the chat view — and the lazy view
|
||
modules' modal imports), exactly as the old sources.html did."""
|
||
html = _read(INDEX_HTML)
|
||
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
|
||
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
||
"index.html: markdown.js must load before the module scripts"
|
||
)
|
||
|
||
|
||
def test_modal_close_contract_pins() -> None:
|
||
"""Phase 26: the modal closes on the close button, on backdrop
|
||
click, and on Escape (captured document-level, so it works from any
|
||
focus position); focus returns to the triggering control
|
||
(best-effort); the fetch goes to the stateless content endpoint with
|
||
the same percent-encoding the page uses, and success renders through
|
||
the shared renderDocument; the "Full page" link is rebuilt on open."""
|
||
js = _read(MODAL_JS)
|
||
assert 'e.key === "Escape"' in js
|
||
assert 'addEventListener("keydown"' in js
|
||
assert "backdropEl.addEventListener(\"click\", closeDocumentModal)" in js
|
||
assert "closeEl.addEventListener(\"click\", closeDocumentModal)" in js
|
||
assert "triggerEl.focus" in js # best-effort focus restore
|
||
assert '"/api/documents/content?source=" + encodeURIComponent(' in js
|
||
assert '"&path=" + encodeURIComponent(' in js
|
||
# The shared renderer (not a copy), called with the modal's own
|
||
# #doc-modal-title / #doc-modal-meta / #doc-modal-content elements.
|
||
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in js
|
||
assert 'openEl.href = fullPageUrl(source, path, currentBack)' in js
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_modal_url_builders_encode_like_the_page() -> None:
|
||
"""Behavioral check (node) of the modal's own URL builders: the
|
||
content fetch and the "Full page" href must come out percent-encoded
|
||
exactly like the page's builders (slashes/spaces in real paths).
|
||
Phase 153 (task 01): the "Full page" builder's optional ``back``
|
||
(default null) — a null/absent ``back`` keeps the URL byte-identical
|
||
to pre-phase (no ``&back=`` at all — the Sources path), and a truthy
|
||
``back`` is appended + percent-encoded (``"/"`` → ``%2F``)."""
|
||
js = _read(MODAL_JS)
|
||
content_fn = _extract_function(js, "contentUrl")
|
||
full_fn = _extract_function(js, "fullPageUrl")
|
||
out = _run_node(
|
||
content_fn
|
||
+ full_fn
|
||
+ "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
|
||
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));\n"
|
||
+ "console.log(fullPageUrl('s', 'p'));\n"
|
||
+ "console.log(fullPageUrl('s', 'p', '/'));\n"
|
||
+ "console.log(fullPageUrl('H omelab', 'a/b.md', '/sources.html'));"
|
||
)
|
||
assert out.splitlines() == [
|
||
"/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
|
||
# pre-phase byte-identical: no ``back`` arg → no ``&back=``
|
||
"/document.html?source=H%20omelab&path=a%2Fb.md",
|
||
"/document.html?source=s&path=p",
|
||
"/document.html?source=s&path=p&back=%2F",
|
||
"/document.html?source=H%20omelab&path=a%2Fb.md&back=%2Fsources.html",
|
||
]
|
||
|
||
|
||
def test_viewer_js_rendering_contracts() -> None:
|
||
"""document.js: raw formats go in via textContent (never parsed as
|
||
HTML), markdown via the shared renderer, 404 → designed not-found
|
||
state, and (phase 13) the back link resolves the ``back`` param —
|
||
same-origin relative URLs only, /sources.html default, no browser
|
||
history heuristics (both entry points are fresh tabs)."""
|
||
js = _read(DOCUMENT_JS)
|
||
assert "pre.textContent = doc.content" in js # raw formats: text node
|
||
assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer
|
||
assert "showNotFound" in js
|
||
# Phase 13: deterministic back-target resolution, no history heuristics.
|
||
assert "history.length" not in js
|
||
assert "window.history.back" not in js
|
||
assert 'backParam.startsWith("/")' in js # same-origin relative only…
|
||
assert 'backParam.startsWith("//")' in js # …and not protocol-relative
|
||
assert 'backLink.href = backTarget' in js # deterministic anchor navigation
|
||
assert '"/sources.html"' in js # default target + no-JS fallback value
|
||
assert '"Chat"' in js and '"Sources"' in js # labels for the two entry points
|
||
assert "encodeURIComponent" in js # content fetch uses the same encoding
|
||
|
||
|
||
def _stmt_block(js: str, start_marker: str, last_stmt: str) -> str:
|
||
"""Extract the source of a statement group: from ``start_marker``
|
||
to the balanced close of the ``{…}`` opened by ``last_stmt``
|
||
(the brace walk is the house style — no third-party parsers)."""
|
||
start = js.find(start_marker)
|
||
assert start != -1, f"marker not found: {start_marker!r}"
|
||
stmt_i = js.find(last_stmt, start)
|
||
assert stmt_i != -1, f"statement not found: {last_stmt!r}"
|
||
brace_i = js.find("{", stmt_i)
|
||
depth = 0
|
||
for j in range(brace_i, 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 from {last_stmt!r}")
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_phase13_back_resolution_guard() -> None:
|
||
"""The phase-13 back-resolution contract, guarded in BOTH layers
|
||
(task 03; the phase-13 pins in ``test_viewer_js_rendering_
|
||
contracts`` are untouched — this runs beside them):
|
||
|
||
1. SOURCE — the resolution block itself, extracted from the live
|
||
``document.js`` and pinned byte-for-byte (the exact statements,
|
||
including the two-label ternary);
|
||
2. BEHAVIOR — that exact extracted block (not a re-implementation)
|
||
run under node over a fake ``params``/``backLink``:
|
||
|
||
* missing / empty / garbage (absolute cross-origin) /
|
||
protocol-relative / pseudo-protocol ``back`` → the
|
||
``/sources.html`` default + the "Sources" label;
|
||
* ``/`` → the same-origin chat root WINS + the "Chat" label;
|
||
* any other same-origin relative → wins verbatim + the
|
||
"Back" label (and an explicit ``/sources.html`` keeps the
|
||
"Sources" label).
|
||
|
||
A refactor that re-introduces a browser-history heuristic, an
|
||
origin check, or a different label mapping fails the behavior run
|
||
(and a reformat of the block fails the byte pin)."""
|
||
js = _read(DOCUMENT_JS)
|
||
block = _stmt_block(
|
||
js,
|
||
'const backParam = params.get("back") || "";',
|
||
"if (backLabel) {",
|
||
)
|
||
# 1. the byte-for-byte source pin (the exact statements)
|
||
assert 'const backParam = params.get("back") || "";' in block
|
||
assert 'backParam.startsWith("/") && !backParam.startsWith("//")' in block
|
||
assert "? backParam" in block and ': "/sources.html";' in block
|
||
assert "backLink.href = backTarget;" in block
|
||
assert 'backTarget === "/"' in block # the label ternary…
|
||
assert '"Chat"' in block
|
||
assert 'backTarget === "/sources.html"' in block
|
||
assert '"Sources"' in block
|
||
assert '"Back"' in block # …and the three labels
|
||
|
||
# 2. the behavior — the EXACT extracted block, run under node
|
||
script = (
|
||
"const block = " + json.dumps(block) + ";\n"
|
||
"function run(v) {\n"
|
||
" const params = { get: (k) => (k === 'back' ? v : null) };\n"
|
||
" const label = { textContent: '' };\n"
|
||
" const backLink = { href: '',"
|
||
' querySelector: (s) => (s === "span" ? label : null) };\n'
|
||
' const f = new Function("params", "backLink",'
|
||
' block + "\\nreturn backTarget;");\n'
|
||
" const target = f(params, backLink);\n"
|
||
" return [target, label.textContent];\n"
|
||
"}\n"
|
||
"const cases = [null, '', 'https://evil.com/x', '//evil.com',"
|
||
" 'javascript:alert(1)', '/', '/sources.html', '/chat?x=1'];\n"
|
||
"for (const v of cases) console.log(JSON.stringify([v, ...run(v)]));\n"
|
||
)
|
||
out = [json.loads(line) for line in _run_node(script).splitlines()]
|
||
assert out == [
|
||
[None, "/sources.html", "Sources"], # missing
|
||
["", "/sources.html", "Sources"], # empty
|
||
["https://evil.com/x", "/sources.html", "Sources"], # garbage (cross-origin)
|
||
["//evil.com", "/sources.html", "Sources"], # protocol-relative
|
||
["javascript:alert(1)", "/sources.html", "Sources"], # pseudo-protocol
|
||
["/", "/", "Chat"], # the chat root wins
|
||
["/sources.html", "/sources.html", "Sources"], # explicit, same label
|
||
["/chat?x=1", "/chat?x=1", "Back"], # other same-origin relative wins
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 153 (task 02) — "Copy path" (the standalone titlebar meta row +
|
||
# the modal meta block — one shared-core control, per-surface wiring)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
DOCUMENT_MODAL_JS = _read(MODAL_JS) # the modal owner (the live asset)
|
||
|
||
COPY_PATH_STR = 'doc.source + "/" + doc.path' # the copied string (ASSUMPTION A1)
|
||
|
||
STATUS_TERNARY = (
|
||
'status.textContent = ok ? "Path copied." : "Path ready — copy it from the field."'
|
||
) # the status wording pair (owner-locked fallback wording, path-adapted)
|
||
|
||
|
||
def _fn_async(js: str, name: str) -> str:
|
||
"""Like _fn, but includes a leading ``async `` when the function is
|
||
async (the phase-153 helpers are async)."""
|
||
start = js.find(f"function {name}(")
|
||
assert start != -1, f"{name}() must exist"
|
||
if js[max(0, start - 6) : start] == "async ":
|
||
start -= 6
|
||
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 test_copy_path_button_built_in_the_shared_core() -> None:
|
||
"""renderDocument (the ONE shared core — modal AND /document.html):
|
||
the Copy-path control joins the meta row AFTER the path code and
|
||
BEFORE the origin anchor (one build site — the surfaces can never
|
||
drift). The button is a labeled icon+text control: type=button,
|
||
class doc-copy-path, an aria-hidden SVG (createElementNS — the
|
||
never-build-HTML rule stands: the module's ONE innerHTML stays the
|
||
escape-first markdown render) and the VISIBLE "Copy path" text as
|
||
the accessible name (no redundant aria-label)."""
|
||
fn = _fn(_read(DOCUMENT_JS), "copyPathButton")
|
||
assert 'document.createElement("button")' in fn
|
||
assert "b.type = \"button\"" in fn
|
||
assert 'b.className = "doc-copy-path"' in fn
|
||
assert "document.createElementNS(SVG_NS, \"svg\")" in fn
|
||
assert 'icon.setAttribute("aria-hidden", "true")' in fn
|
||
assert 'label.textContent = "Copy path"' in fn
|
||
assert "innerHTML" not in re.sub(r"//.*?$|/\*.*?\*/", "", fn, flags=re.S | re.M)
|
||
rd = _fn(_read(DOCUMENT_JS), "renderDocument")
|
||
assert "copyPathButton()" in rd
|
||
# meta row order: path code → copy button → origin anchor (spread) →
|
||
# created badge (the phase-145 pin's slot, unchanged).
|
||
code_i = rd.find("pathCode,")
|
||
btn_i = rd.find("copyBtn,")
|
||
spread_i = rd.find("...originItems,")
|
||
created_i = rd.find("metaBadge(\"doc-created\"")
|
||
assert 0 <= code_i < btn_i < spread_i < created_i
|
||
|
||
|
||
def test_page_copy_path_helper_pins() -> None:
|
||
"""document.js's page-local helper (the per-page duplication house
|
||
style — the modal module keeps its own copy; no shared module):
|
||
* the copied string is `doc.source + "/" + doc.path` (the read-tool
|
||
form — the same two values the meta row renders);
|
||
* navigator.clipboard.writeText in a try (a non-secure http origin
|
||
rejecting the clipboard is the fallback trigger);
|
||
* the status ternary: "Path copied." (success, visual-only —
|
||
aria-hidden, the toast idiom) / "Path ready — copy it from the
|
||
field." (failure — the owner-locked fallback wording,
|
||
path-adapted);
|
||
* a re-click re-attempts (the previous .doc-copy-pop block is
|
||
removed first — a failed copy never clears the previous state
|
||
into nothing);
|
||
* the success status auto-clears ~4s (the block is dropped if it
|
||
is still connected);
|
||
* the page's #doc-copy-announcer (role=status) reads the outcome
|
||
(one a11y announcer — the visible node is aria-hidden);
|
||
* the fallback field: input-like (type text, readOnly, class
|
||
doc-path-fallback, prefilled with the path, selects its content
|
||
on focus, auto-focused with preventScroll — the
|
||
.share-link-fallback pattern);
|
||
* the page's state floats in a .doc-copy-pop block anchored under
|
||
the button (in the sticky header — the meta row never wraps or
|
||
grows the bar, the phase-12/34 contract)."""
|
||
js = _read(DOCUMENT_JS)
|
||
assert "async function copyPathWithFallback(btn, path)" in js
|
||
assert "function makePathFallbackField(path)" in js
|
||
assert 'document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());' in js
|
||
assert "await navigator.clipboard.writeText(path);" in js
|
||
try_i = js.find("try {\n await navigator.clipboard.writeText(path);\n } catch")
|
||
assert try_i != -1, "the writeText call must sit in a try (the rejection fallback)"
|
||
assert js.count(STATUS_TERNARY) == 1
|
||
assert "status.setAttribute(\"aria-hidden\", \"true\")" in js # visual-only status
|
||
assert 'document.querySelector("#doc-copy-announcer")' in js
|
||
assert "if (block.isConnected) block.remove();" in js
|
||
assert "}, 4000);" in js # the auto-clear beat
|
||
assert js.count(COPY_PATH_STR) == 1 # the copied string, wired in render()
|
||
assert 'metaEl.querySelector(".doc-copy-path")' in js # the render() wiring
|
||
# the fallback field (makePathFallbackField)
|
||
f = _fn(js, "makePathFallbackField")
|
||
assert 'document.createElement("input")' in f
|
||
assert "field.type = \"text\"" in f
|
||
assert "field.readOnly = true" in f
|
||
assert 'field.className = "doc-path-fallback"' in f
|
||
assert "field.value = path" in f
|
||
assert 'field.setAttribute("aria-label", "Document path")' in f
|
||
assert "field.addEventListener(\"focus\", () => field.select())" in f
|
||
# the floating block (page surface)
|
||
p = _fn_async(js, "copyPathWithFallback")
|
||
assert 'block.className = "doc-copy-pop"' in p
|
||
assert 'document.querySelector("header.doc-header")' in p
|
||
assert "br.bottom - hr.top + 6" in p # anchored under the button
|
||
assert "field.focus({ preventScroll: true })" in p
|
||
|
||
|
||
def test_modal_copy_path_helper_pins() -> None:
|
||
"""document-modal.js's page-local helper (the per-page duplication
|
||
house style):
|
||
* the copied string is `doc.source + "/" + doc.path` (the read-tool
|
||
form — the same two values the meta row renders);
|
||
* writeText in a try; the status ternary (the same owner-locked
|
||
wording as the page);
|
||
* the nodes land right after the "Copy path" control
|
||
(insertAdjacentElement("afterend") — the meta block wraps, so
|
||
they may take a new row);
|
||
* a re-click re-attempts (the previous .doc-copy-status /
|
||
.doc-path-fallback state is removed first — a failed copy never
|
||
clears the previous state into nothing);
|
||
* success auto-clears ~4s; #doc-modal-desc (setDesc) reads the
|
||
outcome (one a11y announcer — the visible node is aria-hidden);
|
||
* the renderDocument call is followed by the wireCopyPath wiring
|
||
(the control is live on every open)."""
|
||
js = DOCUMENT_MODAL_JS
|
||
assert "async function copyPathWithFallback(metaEl, path)" in js
|
||
assert "function wireCopyPath(metaEl, doc)" in js
|
||
assert "let modalCopyTimer = 0" in js
|
||
assert "await navigator.clipboard.writeText(path);" in js
|
||
assert js.count(STATUS_TERNARY) == 1
|
||
assert 'status.setAttribute("aria-hidden", "true")' in js
|
||
assert js.count('".doc-copy-status, .doc-path-fallback"') == 1 # the re-attempt cleanup
|
||
assert js.count('insertAdjacentElement("afterend"') == 3 # 1 success + 2 failure
|
||
assert 'setDesc("Path copied.")' in js
|
||
assert 'setDesc("Path ready — copy it from the field.")' in js
|
||
assert "if (status.isConnected) status.remove();" in js
|
||
assert "}, 4000);" in js
|
||
assert js.count(COPY_PATH_STR) == 1 # wired in wireCopyPath
|
||
assert 'metaEl.querySelector(".doc-copy-path")' in js
|
||
# renderDocument → wireCopyPath, in openDocumentModal's fetch .then
|
||
# (the LAST occurrence — the docstring names the render earlier):
|
||
rd_i = js.rfind("renderDocument(doc, { titleEl, metaEl, contentEl });")
|
||
wire_i = js.rfind("wireCopyPath(metaEl, doc)")
|
||
assert rd_i != -1 and wire_i != -1
|
||
assert 0 < wire_i - rd_i < 300 # right after the render
|
||
# the shared core builds the button for BOTH surfaces — the modal
|
||
# module never builds one itself (no per-surface copy):
|
||
assert "copyPathButton" not in js
|
||
|
||
|
||
def test_document_html_carries_the_copy_announcer() -> None:
|
||
"""The /document.html page's role=status live region for the copy
|
||
outcome — visually hidden (the visible status line is the
|
||
aria-hidden floating node; this node is the sole a11y announcer,
|
||
the share pattern)."""
|
||
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
|
||
i = html.find('id="doc-copy-announcer"')
|
||
assert i != -1
|
||
node = html[i - 200 : i + 200]
|
||
assert "visually-hidden" in node
|
||
assert "role=\"status\"" in node
|
||
assert "aria-live" in node
|
||
|
||
|
||
def test_document_html_carries_the_copy_path_static_nodes() -> None:
|
||
"""The ``document.html`` Copy-path static nodes (task 03 — the
|
||
Copy-path node on the full page): the floating ``.doc-copy-pop``
|
||
block is anchored in the page's ``header.doc-header`` (absent,
|
||
the page helper's ``if (header)`` guard would drop the block and
|
||
the status would never float — the meta row is never grown, the
|
||
phase-12/34 contract); the ``#doc-copy-announcer`` (role=status,
|
||
the sole a11y announcer) and the ``#doc-meta`` row (where the
|
||
shared core builds the control) must all be present for the
|
||
page surface to work."""
|
||
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
|
||
assert '<header class="doc-header">' in html, (
|
||
"the page's sticky header is the copy block's anchor"
|
||
)
|
||
assert 'id="doc-copy-announcer"' in html, "the announcer node is missing"
|
||
assert 'role="status"' in html, "the announcer must be a live region"
|
||
assert 'id="doc-meta"' in html, "the meta row (the control's home) is missing"
|
||
|
||
|
||
def test_copy_path_css() -> None:
|
||
"""The Copy-path styles (phase 153, task 02):
|
||
* .doc-copy-path — the row-action language (inline-flex icon +
|
||
label), neutral secondary fill, brand-soft hover pair,
|
||
focus-visible outline; the ≤640px block gives it the ≥44px
|
||
touch-target floor (PLAN §7.1);
|
||
* .doc-copy-status — the visible status (ink-soft on surface —
|
||
the meta row's 5.1:1 pair), serving BOTH surfaces;
|
||
* .doc-copy-pop — the page's floating state block (position
|
||
absolute in the sticky header — never grows the bar), z-index
|
||
30 inside the header's z-20 root context;
|
||
* .doc-path-fallback — the .share-link-fallback pattern (input
|
||
variant): mono, ellipsis, surface fill, --line border."""
|
||
css = (FRONTEND / "assets" / "styles.css").read_text(encoding="utf-8")
|
||
bp = css.find(".doc-copy-path {")
|
||
assert bp != -1
|
||
b = css[bp : css.find("}", bp)]
|
||
assert "display: inline-flex" in b
|
||
assert "gap: 0.3rem" in b
|
||
assert "cursor: pointer" in b
|
||
assert ".doc-copy-path svg" in css
|
||
assert ".doc-copy-path:hover" in css
|
||
assert ".doc-copy-path:focus-visible" in css
|
||
assert ".doc-copy-path { min-height: 44px; min-width: 44px; }" in css # mobile floor
|
||
sp = css.find(".doc-copy-status {")
|
||
assert sp != -1
|
||
s = css[sp : css.find("}", sp)]
|
||
assert "color: var(--ink-soft)" in s
|
||
pp = css.find(".doc-copy-pop {")
|
||
assert pp != -1
|
||
p = css[pp : css.find("}", pp)]
|
||
assert "position: absolute" in p
|
||
assert "z-index: 30" in p
|
||
fp = css.find(".doc-path-fallback {")
|
||
assert fp != -1
|
||
f = css[fp : css.find("}", fp)]
|
||
assert "font-family: var(--mono)" in f
|
||
assert "text-overflow: ellipsis" in f
|
||
assert ".doc-path-fallback:hover" in css
|
||
assert ".doc-path-fallback:focus-visible" in css
|
||
|
||
|
||
def _copy_path_page_fixture() -> str:
|
||
"""The shared node fixture: a fake DOM (elements + query registry +
|
||
tracked pop blocks) + a fake clipboard (resolves or rejects) + a
|
||
fake timer (captures the fn + delay)."""
|
||
return """
|
||
function makeEl(tag) {
|
||
const el = {
|
||
tag, children: [], attrs: {}, style: {},
|
||
className: "", type: "", readOnly: false, value: "", title: "",
|
||
isConnected: true, focusCount: 0, selectCount: 0, listeners: {},
|
||
setAttribute(k, v) { this.attrs[k] = v; },
|
||
appendChild(c) { this.children.push(c); return c; },
|
||
append(...cs) { this.children.push(...cs); },
|
||
insertAdjacentElement(pos, node) {
|
||
if (pos === "afterend") this.after.unshift(node);
|
||
return node;
|
||
},
|
||
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
|
||
focus() { this.focusCount += 1; (this.listeners.focus || []).forEach((f) => f()); },
|
||
select() { this.selectCount += 1; },
|
||
remove() { this.isConnected = false; },
|
||
getBoundingClientRect() {
|
||
return { top: 10, left: 20, bottom: 44, right: 90, width: 70, height: 34 };
|
||
},
|
||
};
|
||
el.after = [];
|
||
return el;
|
||
}
|
||
const created = [];
|
||
const registry = {};
|
||
let written = [];
|
||
let reject = false;
|
||
let timerFn = null;
|
||
let timerDelay = null;
|
||
globalThis.setTimeout = (fn, delay) => { timerFn = fn; timerDelay = delay; return 1; };
|
||
globalThis.clearTimeout = () => {};
|
||
// Node 24's globalThis.navigator is GETTER-ONLY (a plain assignment
|
||
// silently fails in sloppy mode) — defineProperty is the only way to
|
||
// stand up the fake clipboard.
|
||
Object.defineProperty(globalThis, "navigator", {
|
||
configurable: true,
|
||
value: {
|
||
clipboard: {
|
||
writeText: async (s) => { written.push(s); if (reject) throw new Error("denied"); },
|
||
},
|
||
},
|
||
});
|
||
globalThis.document = {
|
||
createElement: (t) => { const el = makeEl(t); created.push(el); return el; },
|
||
createElementNS: (ns, t) => makeEl(t),
|
||
querySelector: (sel) => {
|
||
if (!registry[sel]) registry[sel] = makeEl("root:" + sel);
|
||
return registry[sel];
|
||
},
|
||
querySelectorAll: (sel) =>
|
||
sel === ".doc-copy-pop"
|
||
? created.filter((c) => c.className === "doc-copy-pop" && c.isConnected)
|
||
: [],
|
||
};
|
||
let copyStatusTimer = 0;
|
||
"""
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_page_copy_path_behavior_under_node() -> None:
|
||
"""The page's copyPathWithFallback under node (a fake DOM + a fake
|
||
clipboard):
|
||
* success — the clipboard receives the EXACT `source + "/" + path`
|
||
string (spaces + slashes intact — the read-tool form,
|
||
ASSUMPTION A1), the visible status is "Path copied." (the
|
||
aria-hidden node), the announcer reads the same text, the block
|
||
is anchored under the button in the header (top/left computed
|
||
from the rects), and the auto-clear timer is scheduled at 4000
|
||
ms and drops the block when it fires;
|
||
* failure — no write, the pre-filled readOnly fallback field
|
||
(doc-path-fallback, aria-labelled "Document path") is
|
||
auto-focused and selects its content on focus, the status reads
|
||
"Path ready — copy it from the field." (the owner-locked
|
||
wording, path-adapted) and the announcer reads it too;
|
||
* a re-click re-attempts (failure again → exactly one connected
|
||
state block; then success → the field is replaced by the
|
||
success status — never cleared into nothing)."""
|
||
js = _copy_path_page_fixture()
|
||
js += _fn(_read(DOCUMENT_JS), "makePathFallbackField")
|
||
js += "\n" + _fn_async(_read(DOCUMENT_JS), "copyPathWithFallback")
|
||
js += """
|
||
async function main() {
|
||
let out = {};
|
||
const header = document.querySelector("header.doc-header");
|
||
const btn = document.createElement("button");
|
||
const ok1 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
|
||
const pops = () => created.filter((c) => c.className === "doc-copy-pop");
|
||
const pop1 = pops()[pops().length - 1];
|
||
out = {
|
||
ok1,
|
||
written: written.slice(),
|
||
popClass: pop1.className,
|
||
inHeader: header.children.includes(pop1),
|
||
top: pop1.style.top,
|
||
left: pop1.style.left,
|
||
statusText: pop1.children.find((c) => c.className === "doc-copy-status").textContent,
|
||
statusAria: pop1.children.find((c) => c.className === "doc-copy-status").attrs["aria-hidden"],
|
||
announcer: registry["#doc-copy-announcer"].textContent,
|
||
timerDelay,
|
||
connected: pops().filter((p) => p.isConnected).length,
|
||
};
|
||
timerFn(); // the auto-clear fires
|
||
out.cleared = !pop1.isConnected;
|
||
reject = true;
|
||
const ok2 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
|
||
const pop2 = pops().filter((p) => p.isConnected)[0];
|
||
const field = pop2.children.find((c) => c.tag === "input");
|
||
out.ok2 = ok2;
|
||
out.fieldValue = field.value;
|
||
out.fieldType = field.type;
|
||
out.fieldReadOnly = field.readOnly;
|
||
out.fieldClass = field.className;
|
||
out.fieldAria = field.attrs["aria-label"];
|
||
out.fieldTitle = field.title;
|
||
out.fieldFocus = field.focusCount;
|
||
out.fieldSelect = field.selectCount;
|
||
out.failStatus = pop2.children.find((c) => c.className === "doc-copy-status").textContent;
|
||
out.failAnnouncer = registry["#doc-copy-announcer"].textContent;
|
||
out.connectedAfterFail = pops().filter((p) => p.isConnected).length;
|
||
await copyPathWithFallback(btn, "H omelab/a/b c.md"); // re-attempt (fail again)
|
||
out.connectedAfterRetry = pops().filter((p) => p.isConnected).length;
|
||
reject = false;
|
||
const ok3 = await copyPathWithFallback(btn, "x/y.md"); // re-attempt (success)
|
||
out.ok3 = ok3;
|
||
out.lastWritten = written[written.length - 1];
|
||
const pop3 = pops().filter((p) => p.isConnected)[0];
|
||
out.successReplacesField = !pop3.children.some((c) => c.tag === "input");
|
||
console.log(JSON.stringify(out));
|
||
}
|
||
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
|
||
"""
|
||
r = _run_node(js)
|
||
out = json.loads(r)
|
||
assert out["ok1"] is True
|
||
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
|
||
assert out["popClass"] == "doc-copy-pop"
|
||
assert out["inHeader"] is True
|
||
assert out["top"] == "40px" and out["left"] == "0px" # anchored under the button
|
||
assert out["statusText"] == "Path copied."
|
||
assert out["statusAria"] == "true" # visual only
|
||
assert out["announcer"] == "Path copied." # the a11y region
|
||
assert out["timerDelay"] == 4000
|
||
assert out["connected"] == 1
|
||
assert out["cleared"] is True # the auto-clear dropped the block
|
||
assert out["ok2"] is False
|
||
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
|
||
assert out["fieldType"] == "text"
|
||
assert out["fieldReadOnly"] is True
|
||
assert out["fieldClass"] == "doc-path-fallback"
|
||
assert out["fieldAria"] == "Document path"
|
||
assert out["fieldTitle"] == "Document path — click, then copy (Ctrl/Cmd+C)"
|
||
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
|
||
assert out["fieldSelect"] == 1 # the focus listener selected the content
|
||
assert out["failStatus"] == "Path ready — copy it from the field."
|
||
assert out["failAnnouncer"] == "Path ready — copy it from the field."
|
||
assert out["connectedAfterFail"] == 1
|
||
assert out["connectedAfterRetry"] == 1 # the re-click replaced, never cleared
|
||
assert out["ok3"] is True
|
||
assert out["lastWritten"] == "x/y.md"
|
||
assert out["successReplacesField"] is True # the field was swapped for the status
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_modal_copy_path_behavior_under_node() -> None:
|
||
"""The modal's copyPathWithFallback under node (a fake DOM + a
|
||
fake clipboard + the setDesc announcer):
|
||
* success — the clipboard receives the EXACT `source + "/" + path`
|
||
string, the "Path copied." status lands right after the control
|
||
(insertAdjacentElement("afterend")), the visible node is
|
||
aria-hidden, #doc-modal-desc reads the outcome, and the
|
||
auto-clear timer (4000 ms) drops the status when it fires;
|
||
* failure — the pre-filled readOnly fallback field + the
|
||
"Path ready — copy it from the field." status land in DOM order
|
||
[field, status] right after the control (the meta block wraps),
|
||
the field is auto-focused and selects its content on focus, and
|
||
#doc-modal-desc reads the outcome;
|
||
* a re-click re-attempts (the previous state is removed first —
|
||
exactly one connected state pair after the retry — a failed
|
||
copy never clears the previous state into nothing)."""
|
||
js = """
|
||
""" + _copy_path_page_fixture()
|
||
js += """
|
||
const desc = [];
|
||
function setDesc(t) { desc.push(t); }
|
||
let modalCopyTimer = 0;
|
||
"""
|
||
js += "\n" + _fn_async(DOCUMENT_MODAL_JS, "copyPathWithFallback")
|
||
js += """
|
||
async function main() {
|
||
let out = {};
|
||
const inserted = [];
|
||
const btn = makeEl("button");
|
||
btn.className = "doc-copy-path";
|
||
btn.insertAdjacentElement = function (pos, node) {
|
||
if (pos === "afterend") { this.after.unshift(node); inserted.push(node); }
|
||
return node;
|
||
};
|
||
const metaEl = {
|
||
appendChild(c) { inserted.push(c); return c; },
|
||
append(...cs) { inserted.push(...cs); },
|
||
querySelector(sel) { return sel === ".doc-copy-path" ? btn : null; },
|
||
querySelectorAll() { return inserted; },
|
||
};
|
||
const ok1 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
|
||
const live = () =>
|
||
btn.after.filter(
|
||
(n) =>
|
||
(n.className === "doc-copy-status" || n.className === "doc-path-fallback") &&
|
||
n.isConnected,
|
||
);
|
||
out = {
|
||
ok1,
|
||
written: written.slice(),
|
||
after: live().map((n) => n.className),
|
||
statusText: live().find((n) => n.className === "doc-copy-status").textContent,
|
||
statusAria: live().find((n) => n.className === "doc-copy-status").attrs["aria-hidden"],
|
||
desc: desc.slice(),
|
||
timerDelay,
|
||
};
|
||
timerFn(); // the auto-clear fires
|
||
out.cleared = !btn.after[0].isConnected;
|
||
reject = true;
|
||
const ok2 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
|
||
const lv = live();
|
||
const field = lv.find((n) => n.className === "doc-path-fallback");
|
||
out.ok2 = ok2;
|
||
out.afterFail = lv.map((n) => n.className);
|
||
out.fieldValue = field.value;
|
||
out.fieldType = field.type;
|
||
out.fieldReadOnly = field.readOnly;
|
||
out.fieldAria = field.attrs["aria-label"];
|
||
out.fieldFocus = field.focusCount;
|
||
out.fieldSelect = field.selectCount;
|
||
out.failStatus = lv.find((n) => n.className === "doc-copy-status").textContent;
|
||
out.descFail = desc[desc.length - 1];
|
||
out.connectedAfterFail = lv.length;
|
||
await copyPathWithFallback(metaEl, "H omelab/a/b c.md"); // re-attempt (fail again)
|
||
out.connectedAfterRetry = live().length;
|
||
console.log(JSON.stringify(out));
|
||
}
|
||
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
|
||
"""
|
||
r = _run_node(js)
|
||
out = json.loads(r)
|
||
assert out["ok1"] is True
|
||
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
|
||
assert out["after"] == ["doc-copy-status"] # right after the control
|
||
assert out["statusText"] == "Path copied."
|
||
assert out["statusAria"] == "true" # visual only
|
||
assert out["desc"] == ["Path copied."] # the a11y region (setDesc)
|
||
assert out["timerDelay"] == 4000
|
||
assert out["cleared"] is True # the auto-clear dropped the status
|
||
assert out["ok2"] is False
|
||
assert out["afterFail"] == ["doc-path-fallback", "doc-copy-status"] # field first, status after
|
||
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
|
||
assert out["fieldType"] == "text"
|
||
assert out["fieldReadOnly"] is True
|
||
assert out["fieldAria"] == "Document path"
|
||
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
|
||
assert out["fieldSelect"] == 1 # the focus listener selected the content
|
||
assert out["failStatus"] == "Path ready — copy it from the field."
|
||
assert out["descFail"] == "Path ready — copy it from the field."
|
||
assert out["connectedAfterFail"] == 2 # one field + one status
|
||
assert out["connectedAfterRetry"] == 2 # the re-click replaced, never cleared
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 153 (task 03) — the copy-path composition, end-to-end under node
|
||
# (the click handler + render() wiring → the EXACT composed bytes on the
|
||
# (fake) clipboard; the pre-existing pins above call the helper with a
|
||
# pre-composed string — these run the composition step itself)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
PUMP = "async function pump() { for (let i = 0; i < 300; i++) await Promise.resolve(); }\n"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_wire_copy_path_click_composes_the_exact_source_path_string() -> None:
|
||
"""The modal's ``wireCopyPath`` click handler, executed end-to-end
|
||
under node (the REAL ``wireCopyPath`` + the REAL ``
|
||
copyPathWithFallback`` from the live ``document-modal.js``, the
|
||
DOM/clipboard/timers faked): for ``source = "H omelab"`` (space) +
|
||
``path = "a/b c.md"`` (slashes + a space), a click sends the
|
||
EXACT ``"H omelab/a/b c.md"`` bytes to the clipboard (byte-for-
|
||
byte — a refactor that reorders, re-encodes, or trims the
|
||
composition breaks the exact string). The success beat announces
|
||
the exact success string; a REJECTED clipboard then prefills the
|
||
fallback field with that SAME composed string (the exact prefill,
|
||
readOnly, focused + selected — the "never clears the previous
|
||
state" re-click stands: the second status replaces the first)."""
|
||
modal_js = _read(MODAL_JS)
|
||
wire = _fn(modal_js, "wireCopyPath")
|
||
helper = _fn_async(modal_js, "copyPathWithFallback")
|
||
script = (
|
||
_copy_path_page_fixture()
|
||
+ "\nlet modalCopyTimer = 0;\n"
|
||
+ "const descCalls = [];\n"
|
||
+ "function setDesc(t) { descCalls.push(t); }\n"
|
||
+ "let createdInput = null;\n"
|
||
+ "document.createElement = (t) => { const el = makeEl(t);"
|
||
+ " if (t === 'input') createdInput = el; return el; };\n"
|
||
+ "const btn = makeEl('button');\n"
|
||
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
|
||
+ " querySelectorAll: () => [], replaceChildren() {} };\n"
|
||
+ helper
|
||
+ "\n" + wire
|
||
+ "\n" + PUMP
|
||
+ "wireCopyPath(metaEl, { source: 'H omelab', path: 'a/b c.md' });\n"
|
||
+ "const newestStatus = () => (btn.after.find((e) => e.className === 'doc-copy-status')"
|
||
+ " || { textContent: undefined }).textContent;\n"
|
||
+ "const click = btn.listeners.click[0];\n"
|
||
+ "click(); await pump();\n"
|
||
+ "const okOut = { written: written.slice(), status: newestStatus(),"
|
||
+ " descCalls: descCalls.slice() };\n"
|
||
+ "reject = true; click(); await pump();\n"
|
||
+ "const failOut = { input: createdInput && createdInput.value,"
|
||
+ " readOnly: createdInput && createdInput.readOnly,"
|
||
+ " focusCount: createdInput && createdInput.listeners.focus.length,"
|
||
+ " selectCount: createdInput && createdInput.selectCount,"
|
||
+ " status: newestStatus() };\n"
|
||
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
|
||
)
|
||
out = json.loads(_run_node(script))
|
||
# the exact composed bytes (spaces + slashes) on the clipboard
|
||
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
|
||
"the click handler must send the exact source + '/' + path bytes"
|
||
)
|
||
assert out["okOut"]["status"] == "Path copied."
|
||
assert out["okOut"]["descCalls"] == ["Path copied."]
|
||
# the rejected-clipboard failure: the prefill is the SAME composed string
|
||
assert out["failOut"]["input"] == "H omelab/a/b c.md", (
|
||
"the fallback field must prefill with the exact composed string"
|
||
)
|
||
assert out["failOut"]["readOnly"] is True
|
||
assert out["failOut"]["focusCount"] == 1
|
||
assert out["failOut"]["selectCount"] == 1
|
||
assert out["failOut"]["status"] == "Path ready — copy it from the field."
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_page_render_wires_the_copy_path_with_the_composed_string() -> None:
|
||
"""The page's ``render()`` wiring, executed end-to-end under node
|
||
(the REAL ``render`` + the page-local ``copyPathWithFallback`` +
|
||
``makePathFallbackField`` from the live ``document.js``, the
|
||
DOM/clipboard/timers faked): a click on the shared core's
|
||
``.doc-copy-path`` control sends the EXACT ``"H omelab/a/b c.md"``
|
||
bytes to the clipboard (the ``doc.source + "/" + doc.path``
|
||
composition, byte-for-byte). The success beat floats the status in
|
||
the ``header.doc-header`` block (the meta row is never grown —
|
||
the phase-12/34 contract); the REJECTED-clipboard failure floats
|
||
the fallback field prefilled with that SAME composed string
|
||
(readOnly, focused + selected) beside the exact failure status, and
|
||
the role=status announcer mirror gets the same text."""
|
||
js = _read(DOCUMENT_JS)
|
||
render_fn = _fn(js, "render")
|
||
helper = _fn_async(js, "copyPathWithFallback")
|
||
field_fn = _fn(js, "makePathFallbackField")
|
||
script = (
|
||
_copy_path_page_fixture()
|
||
+ "const brand = () => 'Brain of Reese';\n"
|
||
+ "const rendered = [];\n"
|
||
+ "function renderDocument(d, els) { rendered.push(d); }\n"
|
||
+ "const btn = makeEl('button');\n"
|
||
+ "const titleEl = {};\n"
|
||
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
|
||
+ " replaceChildren() {} };\n"
|
||
+ "const contentEl = {};\n"
|
||
+ "const header = document.querySelector('header.doc-header');\n"
|
||
+ field_fn
|
||
+ "\n" + helper
|
||
+ "\n" + render_fn
|
||
+ "\n" + PUMP
|
||
+ "render({ source: 'H omelab', path: 'a/b c.md', title: 'T' });\n"
|
||
+ "const click = btn.listeners.click[0];\n"
|
||
+ "click(); await pump();\n"
|
||
+ "const okBlock = header.children[0];\n"
|
||
+ "const okOut = { written: written.slice(), blockChildren: okBlock.children.length,"
|
||
+ " status: okBlock.children[0].textContent,"
|
||
+ " announcer: document.querySelector('#doc-copy-announcer').textContent };\n"
|
||
+ "reject = true; click(); await pump();\n"
|
||
+ "const failBlock = header.children[header.children.length - 1];\n"
|
||
+ "const field = failBlock.children[0]; const failStatus = failBlock.children[1];\n"
|
||
+ "const failOut = { value: field.value, readOnly: field.readOnly,"
|
||
+ " focusCount: field.listeners.focus.length, selectCount: field.selectCount,"
|
||
+ " status: failStatus.textContent, className: failBlock.className };\n"
|
||
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
|
||
)
|
||
out = json.loads(_run_node(script))
|
||
# the exact composed bytes (spaces + slashes) on the clipboard
|
||
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
|
||
"render() must wire the click to the exact source + '/' + path bytes"
|
||
)
|
||
# the float lives in the header (never the meta row): one status child
|
||
assert out["okOut"]["blockChildren"] == 1
|
||
assert out["okOut"]["status"] == "Path copied."
|
||
assert out["okOut"]["announcer"] == "Path copied." # the a11y mirror
|
||
# the rejected-clipboard failure: the prefill is the SAME composed string
|
||
assert out["failOut"]["value"] == "H omelab/a/b c.md", (
|
||
"the page fallback field must prefill with the exact composed string"
|
||
)
|
||
assert out["failOut"]["readOnly"] is True
|
||
assert out["failOut"]["focusCount"] == 1
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 181 (task 04) — the SVG vector preview: docImageBlock's format
|
||
# gate (base class + the doc-image-img--vector modifier, LOCKED A3:
|
||
# bytes only ever through <img>) and the .doc-image-img--vector CSS
|
||
# (a viewBox-only SVG has no intrinsic size — width: 100% spans the
|
||
# .doc-image card, object-fit: contain survives the 70vh clamp,
|
||
# palette-neutral). The chat figure (app.js) and the Sources
|
||
# thumbnail (sources.js) are VERIFIED non-changes (design D7): both
|
||
# key off image_url alone and their boxes pin the img
|
||
# (.source-image-img 96px cap, .kb-doc-thumb-img fixed 48px box).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_doc_image_block_gates_the_vector_modifier_on_format() -> None:
|
||
"""Source pins (node-free): docImageBlock selects the compound
|
||
class on ``doc.format === "svg"`` and the bare base class
|
||
otherwise (every current .doc-image-img rule keeps applying);
|
||
the bytes stay an <img> src property assignment — never inlined
|
||
(LOCKED A3), never innerHTML. The comment records the rationale
|
||
(no-intrinsic-size / 300×150) and the legacy-TEXT honesty
|
||
(XML-in-.doc-raw is the truth; no content sniffing)."""
|
||
js = _read(DOCUMENT_JS)
|
||
fn = _fn(js, "docImageBlock")
|
||
assert 'doc.format === "svg"' in fn
|
||
assert '"doc-image-img doc-image-img--vector"' in fn
|
||
assert ': "doc-image-img";' in fn, "the raster path keeps the bare base class"
|
||
assert "img.src = doc.image_url;" in fn, "the bytes URL stays a property assignment"
|
||
assert "innerHTML" not in fn
|
||
# The rationale + honesty comments (house style: why, not what).
|
||
for citation in ("Phase 181", "viewBox", "300\u00d7150", "LOCKED A3", ".doc-raw"):
|
||
assert citation in fn, f"the comment must cite {citation!r}"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_doc_image_block_svg_yields_the_vector_class_png_unchanged() -> None:
|
||
"""Behavioral (node, the house fake DOM): the REAL docImageBlock —
|
||
an SVG doc renders class="doc-image-img doc-image-img--vector"
|
||
with src = image_url and alt = the summary (title fallback when
|
||
the summary is null/blank); a PNG doc's class string is the base
|
||
class ONLY (byte-identical to pre-phase — the phase-122/133
|
||
raster surfaces cannot drift); the wrapper stays .doc-image."""
|
||
js = _read(DOCUMENT_JS)
|
||
# The docs ride in via json.dumps (the summary carries quotes and
|
||
# an em-dash — the alt contract must survive verbatim).
|
||
cases = {
|
||
"svg": {
|
||
"format": "svg",
|
||
"image_url": "/api/documents/7/image",
|
||
"summary": 'Vector diagram "topo" \u2014 3 shapes',
|
||
"title": "topo",
|
||
},
|
||
"svgNoSummary": {
|
||
"format": "svg",
|
||
"image_url": "/api/documents/9/image",
|
||
"summary": None,
|
||
"title": "rack",
|
||
},
|
||
"svgBlankSummary": {
|
||
"format": "svg",
|
||
"image_url": "/api/documents/10/image",
|
||
"summary": " ",
|
||
"title": "blank",
|
||
},
|
||
"png": {
|
||
"format": "png",
|
||
"image_url": "/api/documents/8/image",
|
||
"summary": "A photo",
|
||
"title": "t",
|
||
},
|
||
}
|
||
script = (
|
||
_CONTENT_BRANCH_FAKE_DOM
|
||
+ _fn(js, "docImageBlock")
|
||
+ "\nconst cases = "
|
||
+ json.dumps(cases)
|
||
+ """
|
||
;
|
||
function imageFor(doc) {
|
||
const wrap = docImageBlock(doc);
|
||
const img = wrap.children[0];
|
||
return {
|
||
wrapClass: wrap.className, imgClass: img.className, src: img.src, alt: img.alt,
|
||
};
|
||
}
|
||
const out = {};
|
||
for (const [k, doc] of Object.entries(cases)) out[k] = imageFor(doc);
|
||
console.log(JSON.stringify(out));
|
||
"""
|
||
)
|
||
out = json.loads(_run_node(script))
|
||
assert out["svg"] == {
|
||
"wrapClass": "doc-image",
|
||
"imgClass": "doc-image-img doc-image-img--vector",
|
||
"src": "/api/documents/7/image",
|
||
"alt": 'Vector diagram "topo" \u2014 3 shapes',
|
||
}
|
||
# alt falls back to the title (the WCAG alt contract, unchanged).
|
||
assert out["svgNoSummary"]["alt"] == "rack"
|
||
assert out["svgBlankSummary"]["alt"] == "blank"
|
||
assert out["svgBlankSummary"]["imgClass"] == "doc-image-img doc-image-img--vector"
|
||
# The raster class string is byte-identical to pre-phase (base only).
|
||
assert out["png"]["imgClass"] == "doc-image-img"
|
||
assert out["png"]["src"] == "/api/documents/8/image"
|
||
|
||
|
||
def test_doc_image_vector_css_spans_the_card_without_squashing() -> None:
|
||
"""styles.css: ``.doc-image-img--vector`` (immediately after
|
||
``.doc-image-img``) is ``width: 100%`` + ``height: auto`` +
|
||
``object-fit: contain`` — a viewBox-only SVG (no intrinsic size:
|
||
the default replaced-element box is 300\u00d7150) spans the
|
||
full-width .doc-image card, and contain letterboxes instead of
|
||
squashing when the base rule's max-height: 70vh clamps a tall
|
||
drawing. Zero colour literals (palette-neutral — the monochrome
|
||
invariant); the base .doc-image-img rule is UNTOUCHED (the
|
||
phase-122 raster contract)."""
|
||
css = _read(STYLES_CSS)
|
||
base_i = css.find(".doc-image-img {")
|
||
vec_i = css.find(".doc-image-img--vector {")
|
||
assert -1 < base_i < vec_i, ".doc-image-img--vector must sit next to the base rule"
|
||
# Provenance comment directly above the rule (house style).
|
||
comment_start = css.rfind("/*", 0, vec_i)
|
||
provenance = css[comment_start:vec_i]
|
||
assert -1 < comment_start < vec_i
|
||
for citation in ("phase 181", "viewBox", "300\u00d7150", "object-fit", "doc.format"):
|
||
assert citation in provenance, f"the comment must cite {citation!r}"
|
||
assert "no animation" in provenance
|
||
rule = css[vec_i : css.find("}", vec_i)]
|
||
for decl in ("width: 100%", "height: auto", "object-fit: contain"):
|
||
assert decl in rule, f".doc-image-img--vector must carry {decl!r}"
|
||
# Palette-neutral — zero colour literals (no new hue).
|
||
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 base rule (the raster + onerror contract).
|
||
base_rule = css[base_i : css.find("}", base_i)]
|
||
for decl in ("max-width: 100%", "max-height: 70vh", "height: auto"):
|
||
assert decl in base_rule, f"the base rule must keep {decl!r}"
|
||
|
||
|
||
def test_figure_and_thumbnail_key_off_image_url_alone() -> None:
|
||
"""Design D7's verified non-changes: the chat figure
|
||
(appendSourceImageFigure) and the Sources KB-tree thumbnail
|
||
(docThumb) resolve their bytes from ``image_url`` alone — no
|
||
format field on the wire, none added — and their CSS boxes pin
|
||
the img (.source-image-img: 96px cap + object-fit: contain; the
|
||
.kb-doc-thumb-img: the fixed 48px box), so a size-less SVG
|
||
scales inside each cap exactly like a raster. Pins: neither
|
||
function sniffs a format at all (the deliberate non-change,
|
||
design D7), the phase-181 comments sit where they were made,
|
||
and the two rules keep their box declarations."""
|
||
app_js = _read(APP_JS)
|
||
assert app_js.count("function appendSourceImageFigure(") == 1
|
||
fig = _fn(app_js, "appendSourceImageFigure")
|
||
assert "img.src = s.image_url;" in fig
|
||
assert "format" not in fig, "no format sniffing on the chat figure (D7)"
|
||
fn_start = app_js.index("function appendSourceImageFigure(")
|
||
assert "phase 181" in app_js[max(0, fn_start - 2400) : fn_start].lower(), (
|
||
"the deliberate-non-change comment sits above the figure"
|
||
)
|
||
sources_js = _read(SOURCES_JS)
|
||
thumb_start = sources_js.index("function docThumb(d)")
|
||
thumb_end = sources_js.index("\n }", thumb_start)
|
||
thumb = sources_js[thumb_start:thumb_end]
|
||
assert "img.src = d.image_url;" in thumb
|
||
assert "format" not in thumb, "no format sniffing on the thumbnail (D7)"
|
||
branch_i = sources_js.index("if (d.is_image) {")
|
||
assert "phase 181" in sources_js[branch_i - 900 : branch_i].lower(), (
|
||
"the verified-non-change comment sits above the is_image branch"
|
||
)
|
||
css = _read(STYLES_CSS)
|
||
cap_i = css.find(".source-image-img {")
|
||
cap = css[cap_i : css.find("}", cap_i)]
|
||
assert "max-height: 96px" in cap and "object-fit: contain" in cap
|
||
box_i = css.find(".kb-doc-thumb-img {")
|
||
box = css[box_i : css.find("}", box_i)]
|
||
assert "width: 100%" in box and "height: 100%" in box
|