"""Unit: the phase-59 "Save as doc" button (task 05). No Python logic exists beyond the one-line ``app/api/config.py`` flag — the behavior lives in ``frontend/assets/app.js`` + ``brand.js`` + ``styles.css``, and it is E2E-gated by the story suite (task 07). Like the other frontend-adjacent unit files (``test_frontend_brand.py``), this module pins the JS/CSS markers the story depends on, so a silent regression in the button layer is caught without a browser — plus the ``app/api/config.py`` unit pin (the response dict's ``docs_repo_configured`` bool tracks ``settings.docs_configured``). """ from __future__ import annotations import re from pathlib import Path from typing import Any from app.config import Settings FRONTEND = Path(__file__).resolve().parents[2] / "frontend" BRAND_JS = FRONTEND / "assets" / "brand.js" APP_JS = FRONTEND / "assets" / "app.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _text(path: Path) -> str: return path.read_text(encoding="utf-8") def _settings(**kwargs: Any) -> Settings: """Build Settings without reading a .env file (deterministic tests). Same house pattern as tests/integration/test_doc_drafts_api.py — ``_env_file`` exists at runtime (pydantic-settings) but is not in the static signature, hence the ignore on the call. """ kwargs.setdefault("_env_file", None) return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) # --------------------------------------------------------------------------- # app/api/config.py — the unit pin (the response dict gains the flag) # --------------------------------------------------------------------------- def test_app_config_dict_carries_the_docs_flag() -> None: """The ``app_config`` response dict gains ``docs_repo_configured`` — a real bool that tracks ``settings.docs_configured``: false (inert) while BOR_DOCS_REPO is empty, true the moment it is non-empty.""" from app.api.config import app_config s = _settings() body = app_config(s) # Phase 62 (task 01): the response grew to the six-key set — the # phase-62 UI customization keys ride the SAME endpoint. assert set(body) == { "app_name", "version", "docs_repo_configured", "input_placeholder", "footer_text", "theme", } assert body["docs_repo_configured"] is s.docs_configured assert body["docs_repo_configured"] is False s2 = _settings(docs_repo="/srv/docs-repo") assert app_config(s2)["docs_repo_configured"] is True # --------------------------------------------------------------------------- # brand.js — the flag + promise are surfaced the way app_name is # --------------------------------------------------------------------------- def test_brand_js_surfaces_the_docs_flag_inert_by_default() -> None: """window.BOR_DOCS_REPO_CONFIGURED is a classic-script global: false at parse time (inert — hidden for everyone until proven), BEFORE the /api/config fetch starts (the same ordering pin as window.BOR_BRAND).""" js = _text(BRAND_JS) assert "window.BOR_DOCS_REPO_CONFIGURED = false;" in js default_idx = js.find("window.BOR_DOCS_REPO_CONFIGURED = false;") # The real fetch statement (the file-header comment mentions the # fetch too — anchor on the parse-time const, not the comment). fetch_idx = js.find('BOR_CONFIG_PROMISE = fetch("/api/config"') assert 0 <= default_idx < fetch_idx, ( "the inert flag default must be set at top level before the fetch" ) def test_brand_js_exposes_the_config_promise_and_sets_the_flag() -> None: """The SAME boot fetch's promise is exposed at parse time (window.BOR_CONFIG_PROMISE — app.js's boot awaits it), the flag lands the moment the answer arrives, and the promise NEVER rejects (the error arm warns + resolves null — the loadHealth house style).""" js = _text(BRAND_JS) assert "window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;" in js assert "window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;" in js # The flag is a strict boolean: only the literal JSON true flips it. assert "=== true" in js assert "console.warn" in js assert "return null;" in js # --------------------------------------------------------------------------- # app.js — boot wiring: the flag is final before any bubble renders # --------------------------------------------------------------------------- def test_app_js_boot_awaits_config_before_capturing_the_flag() -> None: """The boot IIFE awaits brand.js's parse-time promise (never rejecting — a defensive fallback covers a missing global) and then captures docsRepoConfigured — BEFORE any bubble renders (restoreConversation), so a restored conversation of a configured admin gets the button exactly once: no flash, no re-render, no second fetch.""" js = _text(APP_JS) assert "let docsRepoConfigured = false;" in js await_idx = js.find("await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());") capture_idx = js.find("docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;") restore_idx = js.find("restoreConversation();") assert await_idx >= 0 and await_idx < capture_idx, ( "the flag capture must follow the config-promise await" ) assert restore_idx > 0 and capture_idx < restore_idx, ( "the flag must be final BEFORE the restored conversation renders" ) # --------------------------------------------------------------------------- # app.js — the button: gating, ARIA, one per bubble # --------------------------------------------------------------------------- def test_app_js_button_gates_on_admin_and_configured() -> None: """The single guard: admin (the whoami gate Tune uses) AND docs_repo_configured — otherwise the function injects NOTHING (anonymous, or unconfigured admin, or deflected scope — same as Tune). One button per bubble; the .msg-meta row is reused (or created plain) and a role=list row gets a listitem button (ARIA).""" js = _text(APP_JS) fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {") assert fn_idx != -1, "appendSaveAsDocButton missing" fn_end = js.find("async function saveAsDoc", fn_idx) fn_body = js[fn_idx:fn_end] assert "if (!isAdmin || !docsRepoConfigured) return;" in fn_body assert 'meta.querySelector(".save-as-doc-btn")' in fn_body, ( "the one-button-per-bubble guard is missing" ) assert "meta.getAttribute(\"role\") === \"list\"" in fn_body assert "btn.setAttribute(\"role\", \"listitem\")" in fn_body def test_app_js_button_carries_the_class_and_label() -> None: """The .save-as-doc-btn class (the CSS right-alignment hook) + the house label "Save as doc" (an accessible button name — the icon is aria-hidden decoration).""" js = _text(APP_JS) fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {") fn_body = js[fn_idx : js.find("async function saveAsDoc", fn_idx)] assert 'btn.className = "save-as-doc-btn"' in fn_body assert 'btn.type = "button"' in fn_body assert "Save as doc" in fn_body # The file glyph is aria-hidden decoration (the label carries the # accessible name) — the icon constant, which the function consumes. icon_idx = js.find("const SAVE_AS_DOC_ICON") icon_body = js[icon_idx : js.find("const DOC_TITLE_MAX", icon_idx)] assert 'aria-hidden="true"' in icon_body, "the icon must be aria-hidden" assert 'SAVE_AS_DOC_ICON + "Save as doc"' in fn_body def test_app_js_call_sites_pass_the_raw_markdown() -> None: """Three call sites, each passing the RAW persisted markdown (never the rendered HTML): the live `done` branch (exactly the string rememberBrainTurn stores, so a reload offers the identical draft), the empty-answer fallback bubble (parity with the done path), and the restore path (m.text). A stopped partial is a note, not an answer — the restore gates on !m.stopped; the live stop path and the pagehide partial never call the helper at all.""" js = _text(APP_JS) assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, ( "the live done branch must pass the raw persisted text" ) assert "appendSaveAsDocButton(fwrap, fallback);" in js, ( "the empty-answer fallback bubble must get the button too" ) assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, ( "the restore path must pass m.text and skip stopped records" ) # The live call sits next to the Tune button (same meta row scope). tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable") save_idx = js.find('appendSaveAsDocButton(wrap, finalText || acc || "…");') assert tune_idx > 0 and tune_idx < save_idx # The stop finalize keeps its Tune button but gains NO save button # (a stopped partial is a note, not an answer) — none between the # stop call site and the pagehide handler (which persists, it does # not render). stop_idx = js.find("appendTuneButton(wrap); // admin-only; parity with the restore path") pagehide_idx = js.find("pagehide", stop_idx) assert stop_idx > 0 and stop_idx < pagehide_idx assert "appendSaveAsDocButton" not in js[stop_idx:pagehide_idx], ( "the stopped partial (note, not answer) must not get the button" ) # --------------------------------------------------------------------------- # app.js — the click: payload, slug rule, navigation, failure copy # --------------------------------------------------------------------------- def test_app_js_default_title_is_the_last_user_question() -> None: """The default title: the LAST user question's text, whitespace-collapsed, ≤120 chars (the phase-50 auto-title convention — the chat auto-title targets the FIRST question, the docs default the LAST). Defensive "Note" with no user record.""" js = _text(APP_JS) assert "const DOC_TITLE_MAX = 120;" in js fn_idx = js.find("function defaultDocTitle() {") assert fn_idx != -1, "defaultDocTitle missing" fn_body = js[fn_idx : js.find("function docSlug", fn_idx)] assert "conversation.length - 1" in fn_body, ( "the LAST user record wins (iterate backwards)" ) assert 'conversation[i].who === "user"' in fn_body assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body assert '|| "Note"' in fn_body def test_app_js_slug_rule() -> None: """The default in-repo path slug: lowercase → runs of non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note" (the phase-59 locked assumption; a 60-cut mid dash-run is trimmed again so the path never dangles).""" js = _text(APP_JS) fn_idx = js.find("function docSlug(title) {") assert fn_idx != -1, "docSlug missing" fn_body = js[fn_idx : js.find("function appendSaveAsDocButton", fn_idx)] assert ".toLowerCase()" in fn_body assert '.replace(/[^a-z0-9]+/g, "-")' in fn_body assert '.replace(/^-+|-+$/g, "")' in fn_body assert ".slice(0, 60)" in fn_body assert '|| "note"' in fn_body # The default in-repo path is docs/.md. assert "docs/${docSlug(title)}.md" in js def test_app_js_post_payload_and_navigation() -> None: """Click → POST /api/doc-drafts {title, path, body: markdown} (the raw markdown is the body — never HTML) → 201 → location.assign("/doc-edit.html?draft=" + token). A double-click guard disables the button until the outcome (released in the finally — never stale); failure shows the neutral one-line banner (phase-55 convention) and never navigates.""" js = _text(APP_JS) fn_idx = js.find("async function saveAsDoc(btn, markdown) {") assert fn_idx != -1, "saveAsDoc missing" fn_body = js[fn_idx : fn_idx + 3000] assert 'fetch("/api/doc-drafts"' in fn_body assert 'JSON.stringify({ title, path, body: markdown })' in fn_body assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body assert "btn.disabled = true" in fn_body assert "btn.disabled = false" in fn_body assert "showErrorBanner(" in fn_body # The neutral one-line failure copy (phase-55 convention). assert "Couldn't save the answer as a doc" in fn_body def test_app_js_retry_landing_keeps_save_rightmost() -> None: """markLastRetryable re-appends the save button AFTER the Retry button lands on the same (last) bubble — the auto-margined buttons split the row's free space between them, so DOM order decides the right edge: "Save as doc" stays the bottom-right action even on the last bubble (which also carries Retry).""" js = _text(APP_JS) fn_idx = js.find("function markLastRetryable() {") assert fn_idx != -1 fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx) fn_body = js[fn_idx:fn_end] assert "appendRetryButton(lastBrainWrap);" in fn_body assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body # "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's # identifier gone from app.js (substring), so the local stays distinct. assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body assert "saveBtn" not in _text(APP_JS), ( "the phase-55 pin: no saveBtn identifier in app.js" ) # --------------------------------------------------------------------------- # styles.css — the .tune-btn visual family + the right alignment # --------------------------------------------------------------------------- def test_styles_css_save_as_doc_btn_is_right_aligned() -> None: """.save-as-doc-btn exists, carries the bottom-right declaration (margin-inline-start: auto) and the .tune-btn visual family (pill, >=44px target, line border, ink-soft palette); :focus-visible is the global rule, the hover rule is per-class.""" css = _text(STYLES_CSS) m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css) assert m, "the .save-as-doc-btn rule is missing" block = m.group(0) assert "margin-inline-start: auto;" in block, ( "the bottom-right requirement lives on the button's class" ) assert "min-height: 44px;" in block # WCAG touch target (the family) assert "border-radius: 999px;" in block assert "border: 1px solid var(--line);" in block assert "var(--ink-soft)" in block assert ".save-as-doc-btn:hover" in css assert ".save-as-doc-btn svg" in css # the 14px house glyph sizing assert ":focus-visible" in css # the global focus ring (AGENTS §5)