"""Unit: the admin-only date editor in the viewer (phase 106, task 09, D7). The owner asked for the creation date to be editable "so users can correct for errors" — admin-only, in the shared viewer core (modal + ``/document.html`` through the ONE ``renderDocument`` — no per-surface copy), on the phase-57 ``wireSummaryEdit`` idiom (the same ``docAdminReady()`` gate on the module-cached whoami promise — no second request per page; the public badge row stays byte-for-byte the task-08 shape for non-admins). The task-05 endpoint (``PATCH /api/documents/date`` — set + flag manual; null = the D7 CLEAR, the flag drops) is the single source this file cross-checks against. The browser behavior itself is E2E-gated by the phase's dedicated suite (``tests/e2e/test_document_dates.py``, task 10); like the other frontend-adjacent unit files (the ``test_summary_edit_ui.py`` / ``test_sources_dates.py`` house pattern), this module pins the source-level contract a silent regression would break: * the ``docAdminReady()`` gate — ``wireDateEdit`` is called ONLY in the gate's ``if (admin)`` branch (one call site, after the badge row is built for everyone — the anonymous DOM is never touched); * the ``Edit date`` affordance — a real ``type="button"`` with the ``aria-label`` ``Edit creation date: /`` (setAttribute, never innerHTML), inserted AFTER the task-08 Created badge; * the editor construction — the button swaps in-place for a box with a native ```` (``aria-label="Document creation date"``, prefilled with the stored date's UTC date part via ``.value`` — never innerHTML), Save / Cancel text buttons, the muted "Revert to sync" clear affordance (the phase-57 "clear = explicit" contrast), a ``role="status"`` live line and a ``role="alert"`` error line; * the exact PATCH — ``/api/documents/date`` with method PATCH and the ``{source, path, date}`` body; the endpoint string appears exactly ONCE in the JS (the single-source cross-file check against ``app/api/docs.py``'s route); Revert sends ``date: null``; * the response-driven re-render — the badge is re-rendered from the RESPONSE's ``created_at`` (``res.created_at`` — never the input's optimistic value); * the §7.4 never-stale lifecycle — the controls disable IMMEDIATELY on Save/Revert (one PATCH at a time), an EMPTY input disables Save (the explicit Revert is the only clear path — no accidental wipes), a failure (non-2xx or network) lands the server detail (or the canned retry copy) in the ``role="alert"`` line, reverts the input to the stored date, keeps the editor open, and re-enables in the ``finally``; the success confirmation lands AFTER the badge update (the phase-89 last-announce order); * styles.css — the eight editor classes next to the summary-editor family, the phase-106 D7 provenance comment with the recorded WCAG pairs, the ``[hidden]`` override, the ``cursor: wait`` disabled idiom, the global 3px ``:focus-visible`` ring (no per-control 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" DOCS_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "docs.py" 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. The brace count starts AFTER the parameter list (a destructured parameter may carry braces of its own — renderDocument's target object).""" for prefix in ("async function ", "function "): start = js.find(f"{prefix}{name}(") if start != -1: depth = 0 i = js.find("(", start) 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}()") raise AssertionError(f"{name}() must exist in document.js") # ---------- the admin gate (the phase-57 split) ---------- def test_wire_date_edit_is_called_only_behind_doc_admin_ready() -> None: """renderDocument (the ONE shared core — modal + page): the badge row is built for EVERYONE first (task-08 shape), and the wiring runs ONLY in the gate's ``if (admin)`` branch on the module-cached ``docAdminReady()`` promise — the string sequence ``docAdminReady().then`` … ``wireDateEdit``, one call site in the whole file (definition + call), so a non-admin / token holder / failed whoami keeps exactly the task-08 badge row (no button, no wiring, no admin-only network call).""" js = _js() render = _fn(js, "renderDocument") row = render.find("metaEl.replaceChildren(") gate = render.find("void docAdminReady().then(") wiring = render.find("if (admin) wireDateEdit(metaEl, doc);") assert -1 < row < gate < wiring, ( "the badge row is built for everyone BEFORE the admin gate; " "the wiring is only in the gate's success branch" ) # The gate's .then lands wireDateEdit (not the summary edit). assert "wireDateEdit(metaEl, doc)" in render[gate : gate + 120] # Exactly two occurrences in the whole file: the definition and # the gated call — no second wiring site (no per-surface copy). assert js.count("wireDateEdit(") == 2, ( "wireDateEdit has one definition and one (gated) call site" ) def test_gate_reuses_the_cached_whoami_promise() -> None: """docAdminReady() resolves header.js's fetchIsAdmin (the SAME single request per page the shared header makes — no second whoami call site in document.js, no admin-only network call for anonymous visitors).""" js = _js() body = _fn(js, "docAdminReady") assert "await fetchIsAdmin()" in body assert "=== true" in body assert 'fetch("/api/whoami")' not in js, ( "whoami must come from the header.js cached promise" ) # ---------- the Edit date affordance ---------- def test_edit_date_button_is_a_real_button_after_the_created_badge() -> None: """The affordance: a real ``type="button"`` with the visible text "Edit date", the .doc-date-edit class, and the aria-label ``Edit creation date: /`` (setAttribute — the document-derived pair is user-storable, never innerHTML), inserted AFTER the task-08 Created badge (the insertion point). A meta row without the Created badge (should not happen — the core always builds it) is a no-op, not a crash.""" body = _fn(_js(), "wireDateEdit") assert 'metaEl.querySelector(".doc-created")' in body assert "if (!createdBadge) return;" in body assert 'editBtn.type = "button"' in body assert 'editBtn.className = "doc-date-edit"' in body assert 'editBtn.textContent = "Edit date"' in body assert "editBtn.setAttribute(" in body assert "Edit creation date: ${doc.source}/${doc.path}" in body, ( "the aria-label is the 'Edit creation date: /' template" ) assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in body, ( "the button lands AFTER the Created badge" ) # ---------- the editor construction ---------- def test_editor_swaps_in_date_input_save_cancel_revert_and_live_lines() -> None: """Edit swaps the button for an inline box in the badge row: a native (aria-label "Document creation date", prefilled via ``.value`` with the stored date's UTC date part — ``new Date(doc.created_at).toISOString().slice(0, 10)`` — NEVER innerHTML), Save / Cancel real type=buttons, the muted "Revert to sync" clear affordance, a role=status/aria-live=polite live line, and a role=alert error line. The order is hide the button → insert the box after the Created badge → focus the input (the phase-57 swap pattern).""" body = _fn(_js(), "wireDateEdit") assert 'input.type = "date"' in body assert 'input.className = "doc-date-input"' in body assert 'input.setAttribute("aria-label", "Document creation date")' in body assert "new Date(doc.created_at).toISOString().slice(0, 10)" in body, ( "the prefill is the stored date's UTC date part (D3 stores UTC)" ) assert 'input.value = storedValue()' in body, "prefill via .value (XSS contract)" assert 'saveBtn.type = "button"' in body assert 'saveBtn.className = "doc-date-save"' in body assert 'saveBtn.textContent = "Save"' in body assert 'cancelBtn.type = "button"' in body assert 'cancelBtn.className = "doc-date-cancel"' in body assert 'cancelBtn.textContent = "Cancel"' in body assert 'revertBtn.type = "button"' in body assert 'revertBtn.className = "doc-date-revert"' in body assert 'revertBtn.textContent = "Revert to sync"' in body assert 'status.className = "doc-date-status"' in body assert 'status.setAttribute("role", "status")' in body assert 'status.setAttribute("aria-live", "polite")' in body assert 'errorLine.className = "doc-date-error"' in body assert 'errorLine.setAttribute("role", "alert")' in body hide = body.find("editBtn.hidden = true") swap = body.find('createdBadge.insertAdjacentElement("afterend", box)') focus = body.find("input.focus()") assert -1 < hide < swap < focus, "hide Edit → insert box → focus the input" # The swap keeps all six editor parts in the box. assert ( "box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine)" in body ) # XSS contract: the whole wiring 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 (the single source) ---------- def test_save_patches_the_single_date_endpoint() -> None: """Save → PATCH /api/documents/date (the task-05 admin endpoint) with the EXACT body shape {source, path, date} — the pair from the doc object, JSON content type. The endpoint string appears exactly ONCE in the JS (the single-source cross-file check — the one call site inside the wired editor matches app/api/docs.py's route; anonymous visitors never have it).""" js = _js() assert js.count('"/api/documents/date"') == 1, ( "exactly one occurrence of the endpoint string in document.js" ) body = _fn(js, "wireDateEdit") fetch_i = body.find('fetch("/api/documents/date"') 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 "date: dateValue" in body, "the body shape: {source, path, date}" assert "source: doc.source" in body and "path: doc.path" in body # The cross-file check: the JS endpoint matches the Python route. docs = DOCS_PY.read_text(encoding="utf-8") assert '@router.patch("/documents/date"' in docs, ( "app/api/docs.py registers the route the JS PATCHes" ) def test_revert_sends_date_null_the_clear_path() -> None: """The "Revert to sync" affordance (the D7 CLEAR — the manual flag drops, the stored date stands until the next sync) sends ``{source, path, date: null}``: the revert binding calls saveDate(null) — the ONLY null call site.""" body = _fn(_js(), "wireDateEdit") assert body.count("void saveDate(null)") == 1, ( "exactly one null (clear) call site" ) revert_i = body.find('revertBtn.addEventListener("click"') null_i = body.find("void saveDate(null)", revert_i) assert -1 < revert_i < null_i, "the null call is the revert binding" save_i = body.find('saveBtn.addEventListener("click"') value_i = body.find("void saveDate(input.value)", save_i) assert -1 < save_i < value_i, "Save sends the input's (non-empty) value" def test_badge_rerenders_from_the_response_not_the_input() -> None: """The UI shows exactly what the server stored: on 200 the doc object syncs from the RESPONSE (``res.created_at``) and the badge's text re-renders from ``res.created_at`` (plus the ISO title — the ellipsis-precision idiom). The input's optimistic value never feeds the badge anywhere in the file.""" js = _js() body = _fn(js, "wireDateEdit") json_i = body.find("const res = await r.json();") sync_i = body.find("doc.created_at = res.created_at;") badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`") title_i = body.find('createdBadge.setAttribute("title", res.created_at);') assert -1 < json_i < sync_i < badge_i, "JSON → doc sync → badge re-render" assert -1 < badge_i < title_i, "the title follows the same response" assert "fmtDate(input.value)" not in js, ( "the badge is response-driven — never the input's optimistic value" ) def test_status_announces_after_the_badge_update() -> None: """The success confirmations (the role=status live line) land AFTER the badge re-render (the phase-89 last-announce order): 'Date saved for /.' for a set, 'Reverted to sync-managed date.' for the clear.""" body = _fn(_js(), "wireDateEdit") badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`") saved_i = body.find("`Date saved for ${doc.source}/${doc.path}.`") reverted_i = body.find('"Reverted to sync-managed date."') assert -1 < badge_i < reverted_i < saved_i, ( "the badge updates BEFORE either confirmation (last-announce order)" ) # ---------- §7.4 never-stale lifecycle ---------- def test_submit_disables_controls_before_the_fetch() -> None: """One PATCH at a time: Save/Revert lock ALL the editor controls (input + Save + Cancel + Revert) IMMEDIATELY — before the fetch — so a double-submit is impossible (PLAN §7.4).""" body = _fn(_js(), "wireDateEdit") lock_fn = _fn(body, "setControlsLocked") assert "input.disabled = locked" in lock_fn assert "cancelBtn.disabled = locked" in lock_fn assert "revertBtn.disabled = locked" in lock_fn lock_i = body.find("setControlsLocked(true)") fetch_i = body.find('fetch("/api/documents/date"') assert -1 < lock_i < fetch_i, "the controls lock BEFORE the fetch" def test_empty_input_disables_save() -> None: """An empty type=date input is NOT the clear path: Save disables itself on an empty input (the explicit Revert below handles the clear — no accidental wipes). The locked state owns the controls while a PATCH is in flight (the input is disabled then).""" body = _fn(_js(), "wireDateEdit") lock_fn = _fn(body, "setControlsLocked") assert 'saveBtn.disabled = locked || input.value === ""' in lock_fn listener_i = body.find('input.addEventListener("input"') assert listener_i != -1, "the input event keeps Save in sync" assert 'if (!input.disabled) saveBtn.disabled = input.value === "";' in body def test_failure_reverts_input_announces_alert_and_reenables() -> None: """A failed Save/Revert (non-2xx OR network) lands the server detail (the git-sources.js apiDetail shape) — or the canned 'Couldn't save the date — try again.' on a non-JSON body — into the role=alert line, reverts the input to the stored date, and re-enables the controls in the ``finally`` (every outcome, never stale). The UI never claims a state the server didn't save.""" body = _fn(_js(), "wireDateEdit") nonok = body.find("if (!r.ok)") detail = body.find("errorLine.textContent = await apiDetail(") canned = body.find("Couldn't save the date — try again.") revert_nonok = body.find("input.value = storedValue()", nonok) assert -1 < nonok < detail < canned < revert_nonok, ( "non-ok: server detail (canned fallback) → alert line → input reverts" ) catch_i = body.find("} catch {") canned2 = body.find("Couldn't save the date — try again.", catch_i) revert_catch = body.find("input.value = storedValue()", catch_i) assert -1 < catch_i < canned2 < revert_catch, ( "network failure: canned retry copy → input reverts" ) finally_i = body.find("finally {") unlock = body.find("setControlsLocked(false)", finally_i) assert -1 < finally_i < unlock, "the controls re-enable in the finally" def test_failure_keeps_the_editor_open() -> None: """Neither failure branch (the non-ok early return, the network catch) collapses the editor or clears the user's view — the alert line + the reverted stored value are visible (the editor stays open; only Cancel and the success beat collapse it).""" body = _fn(_js(), "wireDateEdit") nonok_slice = body[body.find("if (!r.ok)") : body.find("const res = await r.json();")] assert "closeEditor" not in nonok_slice, "non-ok keeps the editor open" catch_slice = body[body.find("} catch {") : body.find("finally {")] assert "closeEditor" not in catch_slice, "the network catch keeps the editor open" def test_cancel_closes_without_a_patch() -> None: """Cancel collapses back to the badge row + the Edit button (focus returns to the opener) and sends NO PATCH (the stored value is untouched — the badge was never mutated).""" body = _fn(_js(), "wireDateEdit") cancel_i = body.find('cancelBtn.addEventListener("click"') close_i = body.find("closeEditor()", cancel_i) assert -1 < cancel_i < close_i, "Cancel closes the editor" assert "fetch" not in body[cancel_i : close_i], "Cancel sends no PATCH" close_fn = _fn(body, "closeEditor") assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in close_fn assert "box.remove()" in close_fn assert "editBtn.focus()" in close_fn, "focus returns to the opener" # ---------- styles.css ---------- def test_date_editor_classes_present_next_to_the_summary_family() -> None: """styles.css carries the eight editor classes (the button, the box, the input, Save / Cancel, the muted revert, the status and the alert line) placed NEXT to the summary-editor rule family (after .doc-summary-status, before the raw-format block), with the house palette (phase-08 tokens) and no CDN.""" css = _css() for cls in ( ".doc-date-edit", ".doc-date-editor", ".doc-date-input", ".doc-date-save", ".doc-date-cancel", ".doc-date-revert", ".doc-date-status", ".doc-date-error", ): assert f"{cls} " in css, f"styles.css must style {cls}" assert ( css.find(".doc-summary-status:empty") < css.find(".doc-date-edit {") < css.find(".doc-raw {") ), "the editor family sits next to the summary-editor rules" assert "url(http" not in css and "@import url(" not in css, ( "no CDN (AGENTS.md rule 6)" ) def test_date_editor_css_provenance_and_contrast() -> None: """The phase-106 D7 provenance comment sits directly above the button rule with the verified WCAG pairs recorded (house style): the ink-soft row family (5.1:1 on --surface), the brand pill Save (--bg on --brand = 5.2:1), the input ink on --bg (16.7:1), and the alert line's err pair (9.1:1 on --err-bg) — all ≥4.5:1. The [hidden] override beats the button's display, and :focus-visible rides the global 3px outline rule (no per-control rule — the phase-105 checkbox idiom).""" css = _css() rule_i = css.find(".doc-date-edit {") comment_start = css.rfind("/*", 0, rule_i) comment_end = css.find("*/", comment_start) assert -1 < comment_start < rule_i and comment_end < rule_i, ( "a comment block must sit directly above the button rule" ) header = css[comment_start:comment_end] assert "phase 106" in header.lower() and "D7" in header, ( "the provenance comment cites phase 106 + D7" ) for pair in ("5.1:1", "5.2:1", "9.1:1", "16.7:1"): assert pair in header, f"the verified contrast pair {pair} is recorded" hidden = css.find(".doc-date-edit[hidden]") assert hidden != -1 and "display: none" in css[hidden : hidden + 80], ( "the hidden attr must beat the button's display rule" ) assert ":focus-visible {" in css assert "outline: 3px solid var(--brand)" in css def test_date_editor_disabled_uses_the_wait_idiom() -> None: """The :disabled state on the editor controls is the .git-source-remove:disabled idiom (opacity + cursor: wait — one PATCH at a time), and the empty live lines take no space (display: none on :empty).""" css = _css() dis = css.find(".doc-date-save:disabled") assert dis != -1, "the disabled rule names the editor controls" block = css[dis : css.find("}", dis)] for cls in ( ".doc-date-save:disabled", ".doc-date-cancel:disabled", ".doc-date-revert:disabled", ".doc-date-input:disabled", ): assert cls in block, f"the disabled idiom covers {cls}" assert "opacity: 0.5" in block and "cursor: wait" in block empty = css.find(".doc-date-status:empty") assert empty != -1, "the empty live lines hide themselves" empty_block = css[empty : css.find("}", empty)] assert "display: none" in empty_block