381 lines
18 KiB
Python
381 lines
18 KiB
Python
"""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
|