feat(kb): edit + re-embed document summaries from the viewer (admin)
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"""Unit: the admin summary-edit affordance in the viewer (phase 57,
|
||||
task 02).
|
||||
|
||||
The browser behavior itself is E2E-gated by the phase-57 story suite;
|
||||
like the other frontend-adjacent unit files (test_save_chat_ui.py
|
||||
pattern), this module pins the JS/CSS markers the edit contract depends
|
||||
on, so a silent regression is caught without a browser:
|
||||
|
||||
* the ``docAdminReady()`` gate — the module-cached /api/whoami promise
|
||||
(header.js's ``fetchIsAdmin``, the SAME single request per page the
|
||||
shared header makes — no second whoami call site in document.js);
|
||||
non-admin / fetch failure → NO button, NO wiring (the public viewer
|
||||
is byte-for-byte the phase-36 section: the bare ``section.append
|
||||
(title, body)`` construction stays first, the admin affordance is a
|
||||
post-render ``.then`` on the gate promise);
|
||||
* the editor construction — the header row (``.doc-summary-head`` with
|
||||
the bare h2 + a real ``type="button"`` Edit), the swap-in
|
||||
``<textarea class="doc-summary-editor">`` prefilled via ``.value``
|
||||
(never innerHTML — XSS contract), Save / Cancel buttons, and the
|
||||
``role="status"`` ``aria-live="polite"`` live region;
|
||||
* the exact PATCH call — ``/api/documents/summary`` with method PATCH
|
||||
and the ``{source, path, summary}`` body (the pair from the doc
|
||||
object — the same values the modal core carries);
|
||||
* the outcomes — success re-renders the text node via ``textContent``
|
||||
("Summary updated."), an empty save that clears removes the panel
|
||||
("Summary cleared." — the renderer only draws it for non-empty
|
||||
summaries), Cancel restores the text node, and a failure (non-ok OR
|
||||
network) keeps the editor open with the user's text and shows
|
||||
neutral retry copy (phase-55 convention); the double-click guard
|
||||
releases in the ``finally`` — never stale;
|
||||
* styles.css — the five new ``.doc-summary-*`` classes (plus the two
|
||||
layout wrappers) on the house dark-tech palette (phase-08 tokens),
|
||||
8rem-min editor, 24px+ edit target, the ``[hidden]`` override,
|
||||
``:focus-visible`` via the global outline rule, no CDN.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return DOCUMENT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a (possibly async, possibly nested) function via
|
||||
balanced-brace counting (works for top-level and the editor's
|
||||
inner helpers alike)."""
|
||||
for prefix in ("async function ", "function "):
|
||||
start = js.find(f"{prefix}{name}(")
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(js.find("{", start), len(js)):
|
||||
if js[i] == "{":
|
||||
depth += 1
|
||||
elif js[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}()")
|
||||
raise AssertionError(f"{name}() must exist in document.js")
|
||||
|
||||
|
||||
# ---------- the admin gate (D4: the viewer stays public) ----------
|
||||
|
||||
|
||||
def test_doc_admin_ready_wraps_the_cached_whoami_promise() -> None:
|
||||
"""docAdminReady() exists and resolves the module-cached whoami
|
||||
promise (header.js's fetchIsAdmin — one request per page, shared
|
||||
with initSharedHeader) to a strict boolean: a non-admin OR any
|
||||
fetch failure resolves false (the anonymous viewer)."""
|
||||
js = _js()
|
||||
body = _fn(js, "docAdminReady")
|
||||
assert "await fetchIsAdmin()" in body, (
|
||||
"the gate must reuse the cached whoami promise (no new call site)"
|
||||
)
|
||||
assert "=== true" in body, "a strict boolean — only an authenticated admin"
|
||||
assert "} catch {" in body and "return false" in body, (
|
||||
"any failure resolves false — the anonymous viewer"
|
||||
)
|
||||
|
||||
|
||||
def test_no_second_whoami_call_site_in_document_js() -> None:
|
||||
"""document.js never fetches /api/whoami itself: header.js's
|
||||
fetchIsAdmin is the SINGLE whoami call site for the whole frontend
|
||||
(the cached promise), so the gate adds no request of its own — and
|
||||
no admin-only network call exists for anonymous visitors (the only
|
||||
admin call, the PATCH, lives inside the wired editor)."""
|
||||
js = _js()
|
||||
assert 'fetch("/api/whoami")' not in js, (
|
||||
"whoami must come from the header.js cached promise"
|
||||
)
|
||||
assert 'from "./header.js"' in js and "fetchIsAdmin" in js
|
||||
|
||||
|
||||
def test_gate_runs_after_the_phase36_base_construction() -> None:
|
||||
"""The .doc-summary section is built for EVERYONE exactly as phase
|
||||
36 (the anonymous byte-for-byte shape): the base construction
|
||||
(className, the bare h2 label, the .doc-summary-text node, the
|
||||
append) precedes the gate call, and the admin wiring runs ONLY in
|
||||
the gate's success branch (``if (admin) wireSummaryEdit(...)``)."""
|
||||
js = _js()
|
||||
base = js.find('section.className = "doc-summary"')
|
||||
label = js.find('title.textContent = "Summary"')
|
||||
text_node = js.find('body.className = "doc-summary-text"')
|
||||
append = js.find("section.append(title, body)")
|
||||
mount = js.find("contentEl.appendChild(section)")
|
||||
gate = js.find("void docAdminReady().then(")
|
||||
wiring = js.find("if (admin) wireSummaryEdit(section, doc);")
|
||||
assert 0 < base < label < text_node < append < mount < gate < wiring, (
|
||||
"phase-36 base construction first; the admin affordance is a "
|
||||
"post-render gate branch (anonymous DOM is never touched)"
|
||||
)
|
||||
assert "wireSummaryEdit(section, doc)" in js[gate:]
|
||||
|
||||
|
||||
# ---------- the editor construction ----------
|
||||
|
||||
|
||||
def test_edit_button_is_a_real_button_in_the_header_row() -> None:
|
||||
"""The Edit affordance: a real ``type="button"`` with the visible
|
||||
text "Edit" and the .doc-summary-edit class, added to a
|
||||
.doc-summary-head row that keeps the bare h2 label (label left,
|
||||
button right — the anonymous section keeps its bare h2)."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
assert 'editBtn.type = "button"' in body
|
||||
assert 'editBtn.className = "doc-summary-edit"' in body
|
||||
assert 'editBtn.textContent = "Edit"' in body
|
||||
assert 'head.className = "doc-summary-head"' in body
|
||||
assert "head.append(title, editBtn)" in body
|
||||
# The header row REPLACES the bare h2 as the section's first child.
|
||||
assert "section.replaceChildren(head, body)" in body
|
||||
|
||||
|
||||
def test_editor_swap_builds_textarea_save_cancel_and_live_region() -> None:
|
||||
"""Edit swaps the .doc-summary-text node for the inline editor:
|
||||
a <textarea class="doc-summary-editor"> prefilled via ``.value``
|
||||
(NEVER innerHTML — the XSS contract), Save / Cancel real
|
||||
type=buttons, and a <p class="doc-summary-status" role="status"
|
||||
aria-live="polite"> live region. The Edit button hides while the
|
||||
editor is open (no re-open mid-edit) and the textarea gets focus.
|
||||
The ENTIRE wiring is textContent/.value-only — no innerHTML
|
||||
anywhere (summary text is user-storable)."""
|
||||
js = _js()
|
||||
body = _fn(js, "wireSummaryEdit")
|
||||
assert 'editor.className = "doc-summary-editor"' in body
|
||||
assert (
|
||||
'editor.value = typeof doc.summary === "string" ? doc.summary : ""' in body
|
||||
), "prefill via .value — value, not innerHTML"
|
||||
assert 'saveBtn.type = "button"' in body
|
||||
assert 'saveBtn.className = "doc-summary-save"' in body
|
||||
assert 'saveBtn.textContent = "Save"' in body
|
||||
assert 'cancelBtn.type = "button"' in body
|
||||
assert 'cancelBtn.className = "doc-summary-cancel"' in body
|
||||
assert 'cancelBtn.textContent = "Cancel"' in body
|
||||
assert 'status.className = "doc-summary-status"' in body
|
||||
assert 'status.setAttribute("role", "status")' in body
|
||||
assert 'status.setAttribute("aria-live", "polite")' in body
|
||||
# The swap: text node out, editor parts in, focus in.
|
||||
assert "section.replaceChildren(head, editor, actions, status)" in body
|
||||
assert "editor.focus()" in body
|
||||
hide = body.find("editBtn.hidden = true")
|
||||
swap = body.find("section.replaceChildren(head, editor, actions, status)")
|
||||
focus = body.find("editor.focus()")
|
||||
assert -1 < hide < swap < focus, "hide Edit → swap → focus the textarea"
|
||||
# The bindings.
|
||||
assert 'editBtn.addEventListener("click", openEditor)' in body
|
||||
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
|
||||
# XSS contract: the whole affordance is textContent/.value only
|
||||
# (comments stripped — the word may appear in a note, never in code).
|
||||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||||
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
|
||||
|
||||
|
||||
# ---------- the PATCH round-trip ----------
|
||||
|
||||
|
||||
def test_save_patches_the_exact_endpoint_with_the_doc_pair() -> None:
|
||||
"""Save → PATCH /api/documents/summary (the phase-57 admin
|
||||
endpoint) with the EXACT body shape {source, path, summary} — the
|
||||
pair from the doc object (the same values the modal core carries),
|
||||
JSON content type. This is the ONLY admin-only call in document.js
|
||||
(exactly one call site, inside the wired editor — anonymous
|
||||
visitors never have it)."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/documents/summary"') == 1, (
|
||||
"exactly one PATCH call site (inside wireSummaryEdit)"
|
||||
)
|
||||
body = _fn(js, "wireSummaryEdit")
|
||||
fetch_i = body.find('fetch("/api/documents/summary"')
|
||||
assert fetch_i != -1, "the PATCH must live in the wired editor"
|
||||
assert 'method: "PATCH"' in body[fetch_i:]
|
||||
assert '"Content-Type": "application/json"' in body[fetch_i:]
|
||||
assert (
|
||||
"JSON.stringify({ source: doc.source, path: doc.path, summary: value })"
|
||||
in body
|
||||
), "the exact body shape: {source, path, summary}"
|
||||
|
||||
|
||||
def test_save_success_rerenders_text_node_and_announces() -> None:
|
||||
"""A 200 update syncs the doc object (a later re-open prefills the
|
||||
CURRENT summary), announces "Summary updated." through
|
||||
closeEditor — which re-renders the text node via textContent ONLY
|
||||
(XSS contract) from the doc object."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
ok_i = body.find("if (!res.ok)")
|
||||
json_i = body.find("await res.json()")
|
||||
null_i = body.find("if (data.summary === null)")
|
||||
sync_i = body.find("doc.summary = data.summary")
|
||||
announce_i = body.find('closeEditor("Summary updated.")')
|
||||
assert -1 < ok_i < json_i < null_i < sync_i < announce_i, (
|
||||
"non-ok checked first → JSON → clear branch → doc sync → announce"
|
||||
)
|
||||
close = _fn(body, "closeEditor")
|
||||
assert "body.textContent = doc.summary" in close, (
|
||||
"the display state re-renders the text node from the doc object"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_save_clears_and_removes_the_panel() -> None:
|
||||
"""An empty save that clears (response summary === null, D4) syncs
|
||||
the doc object, announces "Summary cleared." in the live region,
|
||||
and removes the panel a short beat LATER (setTimeout — the
|
||||
confirmation stays readable before the panel leaves the DOM; the
|
||||
renderer only draws it for non-empty summaries). Failures keep the
|
||||
panel (their slices carry no removal)."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
null_i = body.find("if (data.summary === null)")
|
||||
sync_i = body.find("doc.summary = null")
|
||||
announce_i = body.find('status.textContent = "Summary cleared."')
|
||||
remove_i = body.find("setTimeout(() => section.remove(), 2000)")
|
||||
assert -1 < null_i < sync_i < announce_i < remove_i, (
|
||||
"the null branch: sync → announce → delayed removal"
|
||||
)
|
||||
# Exactly two removals in the whole affordance, both tied to a
|
||||
# CLEARED state (the clear branch + the closeEditor empty guard —
|
||||
# no failure path removes the panel).
|
||||
assert body.count("section.remove()") == 2
|
||||
|
||||
|
||||
def test_cancel_restores_the_text_node() -> None:
|
||||
"""Cancel restores the display state: the .doc-summary-text node
|
||||
back in the section, re-rendered from the doc object (the CURRENT
|
||||
stored summary — the node was never mutated, only swapped out),
|
||||
the (empty) live region kept, and the Edit button un-hidden +
|
||||
re-focused (focus returns to the opener). A summary that is GONE
|
||||
(a clear landed while the editor was open — Cancel right after a
|
||||
successful empty save) never renders an empty panel: the guard
|
||||
drops the panel instead."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
close = _fn(body, "closeEditor")
|
||||
assert 'status.textContent = message' in close
|
||||
assert "editBtn.hidden = false" in close
|
||||
guard = 'typeof doc.summary !== "string" || doc.summary.trim() === ""'
|
||||
guard_i = close.find(guard)
|
||||
remove_i = close.find("section.remove()")
|
||||
sync_i = close.find("body.textContent = doc.summary")
|
||||
restore_i = close.find("section.replaceChildren(head, body, status)")
|
||||
focus_i = close.find("editBtn.focus()")
|
||||
assert -1 < guard_i < remove_i < sync_i < restore_i < focus_i, (
|
||||
"empty guard first; otherwise re-render → restore → focus"
|
||||
)
|
||||
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
|
||||
|
||||
|
||||
def test_failure_keeps_the_editor_with_neutral_copy() -> None:
|
||||
"""A failed Save (non-ok HTTP OR network) keeps the editor open
|
||||
with the user's text (no swap back, no panel removal) and shows
|
||||
neutral retry copy (phase-55 convention — no sign-in wording):
|
||||
"…try again." for a non-ok response, "…is the app reachable?" for
|
||||
the network path."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
nonok = body.find("if (!res.ok)")
|
||||
neutral = body.find("Couldn't update the summary — try again.")
|
||||
assert -1 < nonok < neutral, "the non-ok branch lands on the neutral copy"
|
||||
catch_i = body.find("} catch {")
|
||||
reachable = body.find("Couldn't update the summary — is the app reachable?")
|
||||
assert -1 < catch_i < reachable, "the network path carries the reachable? copy"
|
||||
assert "signed in" not in body, "no sign-in wording (neutral retry copy)"
|
||||
# The two FAILURE branches (the non-ok early return and the network
|
||||
# catch) never restore or remove — the editor stays open with the
|
||||
# user's text (only the clear branch removes the panel).
|
||||
nonok_slice = body[nonok:body.find("await res.json()")]
|
||||
assert "section.remove()" not in nonok_slice
|
||||
assert "closeEditor" not in nonok_slice
|
||||
catch_slice = body[catch_i:body.find("finally")]
|
||||
assert "section.remove()" not in catch_slice
|
||||
assert "closeEditor" not in catch_slice
|
||||
|
||||
|
||||
def test_save_double_click_guard_releases_in_finally() -> None:
|
||||
"""One PATCH at a time: Save disables itself BEFORE the fetch and
|
||||
re-enables in the ``finally`` (every outcome — success, clear,
|
||||
non-ok, network — never leaves a stale disabled button, PLAN §7.4)."""
|
||||
body = _fn(_js(), "wireSummaryEdit")
|
||||
disable_i = body.find("saveBtn.disabled = true")
|
||||
fetch_i = body.find('fetch("/api/documents/summary"')
|
||||
finally_i = body.find("finally")
|
||||
enable_i = body.find("saveBtn.disabled = false")
|
||||
assert -1 < disable_i < fetch_i < finally_i < enable_i, (
|
||||
"disable before the fetch; re-enable in the finally"
|
||||
)
|
||||
assert body.count("saveBtn.disabled = true") == 1
|
||||
assert body.count("saveBtn.disabled = false") == 1
|
||||
|
||||
|
||||
# ---------- styles.css ----------
|
||||
|
||||
|
||||
def test_new_summary_edit_classes_present() -> None:
|
||||
"""styles.css carries the five task-named .doc-summary-* classes
|
||||
(plus the two layout wrappers the wiring emits) — the house
|
||||
dark-tech palette (phase-08 tokens), system fonts, no CDN."""
|
||||
css = _css()
|
||||
for cls in (
|
||||
".doc-summary-edit",
|
||||
".doc-summary-editor",
|
||||
".doc-summary-save",
|
||||
".doc-summary-cancel",
|
||||
".doc-summary-status",
|
||||
".doc-summary-head",
|
||||
".doc-summary-actions",
|
||||
):
|
||||
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
|
||||
f"styles.css must style {cls}"
|
||||
)
|
||||
assert "url(http" not in css and "@import url(" not in css, (
|
||||
"no CDN (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
def test_summary_edit_css_targets_and_palette() -> None:
|
||||
"""The house AA palette on the edit affordance: the edit target is
|
||||
24px+ with a 3px global-outline focus (the global :focus-visible
|
||||
rule — no local override needed); the editor is a full-width block
|
||||
textarea with the 8rem min-height; the save pill is the solid
|
||||
brand family (--bg on --brand = 5.2:1, AA, borderless); the ghost
|
||||
buttons ride the ink-soft 5.1:1-on-surface pair; the status line
|
||||
is ink-soft (AA). The [hidden] override must beat the edit
|
||||
button's display rule (the editor hides Edit while open)."""
|
||||
css = _css()
|
||||
edit = css[css.find(".doc-summary-edit {") :]
|
||||
edit = edit[: edit.find("\n}")]
|
||||
assert "min-height: 24px" in edit, "the 24px+ edit target (task 02)"
|
||||
assert "var(--line)" in edit and "var(--ink-soft)" in edit
|
||||
hidden = css.find(".doc-summary-edit[hidden]")
|
||||
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
|
||||
"the hidden attr must beat the base display rule"
|
||||
)
|
||||
editor = css[css.find(".doc-summary-editor {") :]
|
||||
editor = editor[: editor.find("\n}")]
|
||||
for prop in (
|
||||
"display: block",
|
||||
"width: 100%",
|
||||
"min-height: 8rem",
|
||||
"resize: vertical",
|
||||
"var(--bg)",
|
||||
"var(--ink)",
|
||||
):
|
||||
assert prop in editor, f".doc-summary-editor must keep {prop}"
|
||||
save = css[css.find(".doc-summary-save {") :]
|
||||
save = save[: save.find("\n}")]
|
||||
assert "background: var(--brand)" in save and "color: var(--bg)" in save
|
||||
assert "border: 0" in save, "the solid brand pill family (Save/Share)"
|
||||
status = css[css.find(".doc-summary-status {") :]
|
||||
status = status[: status.find("\n}")]
|
||||
assert "var(--ink-soft)" in status, "AA status copy on --surface (5.1:1)"
|
||||
# :focus-visible via the GLOBAL 3px outline rule (no local
|
||||
# suppression anywhere for these controls).
|
||||
assert ":focus-visible {" in css
|
||||
assert "outline: 3px solid var(--brand)" in css
|
||||
Reference in New Issue
Block a user