546 lines
25 KiB
Python
546 lines
25 KiB
Python
"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``).
|
|
|
|
No Python logic exists for this task — the behavior lives in
|
|
``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` +
|
|
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
|
the other frontend-adjacent unit files (``test_history_page.py``,
|
|
``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS
|
|
markers the edit loop depends on, so a silent regression is caught
|
|
without a browser:
|
|
|
|
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
|
|
minimal-flow-page lineage): skip-link, the SLIM header (brand +
|
|
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
|
|
column, NOT ``--chat-column``), the ``container`` frame;
|
|
* the form contract: ``#draft-title`` / ``#draft-path`` /
|
|
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
|
|
"Push to docs branch" copy) + the back link, ``#push-status``
|
|
(``role="status" aria-live="polite"``) and the hidden ``#push-error``
|
|
(``role="alert"``);
|
|
* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the
|
|
no-JS ``?next=`` fallback (the page is static; the API is the
|
|
authority — the draft endpoints are admin-only regardless);
|
|
* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts``
|
|
call), the token handling (missing → "No draft specified.",
|
|
non-uuid → "Draft not found." with no fetch), the three API paths
|
|
(GET draft / PUT edits / POST push — the PUT runs BEFORE the push:
|
|
the endpoint commits the row, so unsaved edits would push stale
|
|
text), the §7.4 never-stale lifecycle (disable + "Pushing…",
|
|
re-enable in the finally), the success line
|
|
(``Pushed to <branch> — commit <sha7>.``), the failure banner
|
|
(git's stderr trimmed to its first meaningful lines, fields
|
|
preserved), and VALUES-not-innerHTML everywhere.
|
|
|
|
The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js
|
|
bundled) and the cache-busting registration (``/doc-edit.html`` in
|
|
``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` /
|
|
``test_caching.py``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
ASSETS = FRONTEND / "assets"
|
|
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
|
DOC_EDIT_JS = ASSETS / "doc-edit.js"
|
|
STYLES_CSS = ASSETS / "styles.css"
|
|
|
|
|
|
def _html() -> str:
|
|
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
|
|
return DOC_EDIT_HTML.read_text(encoding="utf-8")
|
|
|
|
|
|
def _js() -> str:
|
|
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
|
|
return DOC_EDIT_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 top-level ``function <name>(...)`` (to its close)."""
|
|
start = js.find(f"function {name}(")
|
|
assert start != -1, f"{name}() must exist in doc-edit.js"
|
|
return js[start : js.find("\n}\n", start) + 4]
|
|
|
|
|
|
# ---------- the house shell (AGENTS.md rule 5) ----------
|
|
|
|
|
|
def test_page_scaffold_slim_header_and_landmarks() -> None:
|
|
"""The minimal-flow-page scaffold (the login.html/shared.html
|
|
lineage): skip-link, the SLIM header (brand + the "Back to chat"
|
|
link to / — and NO nav: this is a flow page, not one of the app's
|
|
pages), ``<main id="main" class="app-main" tabindex="-1">`` with
|
|
the ``container`` frame, and the house footer."""
|
|
html = _html()
|
|
assert '<a class="skip-link" href="#main">Skip to content</a>' in html
|
|
assert 'class="app-header"' in html
|
|
# The slim header: the brand + the back link.
|
|
assert '<span class="brand-text">Brain of <strong>Reese</strong></span>' in html
|
|
back = re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', html)
|
|
assert back, "the header must carry the 'Back to chat' link to /"
|
|
assert "<span>Back to chat</span>" in html
|
|
# And NO nav — the flow-page lineage (no hamburger, no app-nav).
|
|
assert 'id="app-nav"' not in html, "the slim header ships no nav"
|
|
assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger"
|
|
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
|
assert '<div class="container doc-edit-shell">' in html
|
|
assert 'class="app-footer"' in html
|
|
|
|
|
|
def test_page_title_and_description() -> None:
|
|
"""The page's identity: the house title shape (<name> · Brain of
|
|
Reese) + a description naming the admin-only flow."""
|
|
html = _html()
|
|
assert "<title>Edit doc · Brain of Reese</title>" in html
|
|
desc = re.search(r'<meta name="description" content="([^"]+)"', html)
|
|
assert desc, "the page must carry a meta description"
|
|
assert "admin" in desc.group(1).lower()
|
|
|
|
|
|
# ---------- the form contract ----------
|
|
|
|
|
|
def test_form_fields_have_labels_and_ids() -> None:
|
|
"""The three fields (task 06): #draft-title (text), #draft-path
|
|
(text), #draft-body (textarea) — each with a VISIBLE
|
|
``<label for=…>`` (WCAG input-label rule), the text inputs
|
|
``required`` (the browser's native prompt is the first line of
|
|
sanity), the body a <textarea>."""
|
|
html = _html()
|
|
for field_id, tag in (
|
|
("draft-title", "input"),
|
|
("draft-path", "input"),
|
|
("draft-body", "textarea"),
|
|
):
|
|
assert f'<label for="{field_id}">' in html, (
|
|
f"#{field_id} needs a visible label"
|
|
)
|
|
field = re.search(rf"<{tag}[^>]*id=\"{field_id}\"[^>]*>", html)
|
|
assert field, f"#{field_id} is missing"
|
|
title = re.search(r"<input[^>]*id=\"draft-title\"[^>]*>", html)
|
|
path = re.search(r"<input[^>]*id=\"draft-path\"[^>]*>", html)
|
|
body = re.search(r"<textarea[^>]*id=\"draft-body\"[^>]*>", html)
|
|
assert title and path and body, "the draft field tags are missing"
|
|
title, path, body = title.group(0), path.group(0), body.group(0)
|
|
for f in (title, path, body):
|
|
assert "required" in f, "the native `required` is the first line"
|
|
assert "type=\"text\"" in title and "type=\"text\"" in path
|
|
|
|
|
|
def test_push_button_and_back_link_actions() -> None:
|
|
"""The actions (task 06): #push-doc-btn — the primary, exact copy
|
|
"Push to docs branch" — and the back link to / (the form's second
|
|
action; the header carries its own copy)."""
|
|
html = _html()
|
|
btn = re.search(r"<button[^>]*id=\"push-doc-btn\"[^>]*>", html)
|
|
assert btn, "#push-doc-btn is missing"
|
|
assert "type=\"submit\"" in btn.group(0), (
|
|
"the push button submits the form (the handler preventDefaults)"
|
|
)
|
|
assert ">Push to docs branch</button>" in html, (
|
|
"the exact house copy: 'Push to docs branch'"
|
|
)
|
|
# A back link inside the actions row (href="/").
|
|
actions = html[html.find('class="doc-edit-actions"'):]
|
|
actions = actions[: actions.find("</form>")]
|
|
assert re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', actions), (
|
|
"the actions row carries its own back link to /"
|
|
)
|
|
|
|
|
|
def test_feedback_live_region_and_error_banner() -> None:
|
|
"""The "never stale" feedback contract (phase 55 convention,
|
|
task 06): #push-status is the polite live region
|
|
(role="status" aria-live="polite"); #push-error is the alert
|
|
banner — SHIPS hidden (role="alert")."""
|
|
html = _html()
|
|
status = re.search(r'<[a-z]+[^>]*id="push-status"[^>]*>', html)
|
|
assert status, "#push-status is missing"
|
|
assert 'role="status"' in status.group(0)
|
|
assert 'aria-live="polite"' in status.group(0)
|
|
error = re.search(r'<[a-z]+[^>]*id="push-error"[^>]*>', html)
|
|
assert error, "#push-error is missing"
|
|
assert 'role="alert"' in error.group(0)
|
|
assert "hidden" in error.group(0), "the error banner ships hidden"
|
|
|
|
|
|
# ---------- the admin gate (the sources-gate pattern) ----------
|
|
|
|
|
|
def test_admin_gate_ships_hidden_with_no_js_fallback() -> None:
|
|
"""The gate: the EXACT .sources-gate pattern (phase 16/35/50),
|
|
ship-hidden (the admin never sees it; the content div ships hidden
|
|
too — anonymous-safe), the labelled h2, and the Sign in link whose
|
|
static ?next= returns the admin to THIS page after login (the
|
|
no-JS fallback)."""
|
|
html = _html()
|
|
gate = re.search(r'<section[^>]*class="sources-gate"[^>]*id="doc-edit-gate"[^>]*>', html)
|
|
assert gate, "the #doc-edit-gate section (sources-gate pattern) is missing"
|
|
assert "hidden" in gate.group(0), "the gate ships hidden"
|
|
assert 'aria-labelledby="doc-edit-gate-title"' in gate.group(0)
|
|
assert '<h2 id="doc-edit-gate-title">' in html
|
|
assert '<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>' in html
|
|
# The content ships hidden too (the gate is what anonymous sees).
|
|
content = re.search(r'<div[^>]*id="doc-edit-content"[^>]*>', html)
|
|
assert content and "hidden" in content.group(0), (
|
|
"#doc-edit-content must ship hidden (anonymous-safe)"
|
|
)
|
|
|
|
|
|
# ---------- scripts + no CDN ----------
|
|
|
|
|
|
def test_script_load_order_and_no_cdn() -> None:
|
|
"""The house script order: brand.js (classic) FIRST, the doc-edit.js
|
|
module second; NO direct header.js <script> tag (single-evaluation
|
|
design — doc-edit.js imports it relatively); no external
|
|
src=/href= (AGENTS.md rule 6 — No CDN)."""
|
|
html = _html()
|
|
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
|
assert srcs == ["assets/brand.js", "/assets/doc-edit.js"], (
|
|
f"doc-edit.html must load brand.js (classic, first) + the "
|
|
f"doc-edit.js module, got {srcs}"
|
|
)
|
|
js = _js()
|
|
assert 'from "./header.js"' in js, (
|
|
"doc-edit.js must import the shared header module relatively"
|
|
)
|
|
assert '"/assets/header.js"' not in js
|
|
assert 'src="http' not in html and 'href="http' not in html, (
|
|
"no CDN: every asset is local (AGENTS.md rule 6)"
|
|
)
|
|
|
|
|
|
# ---------- boot: the whoami gate ----------
|
|
|
|
|
|
def test_anonymous_boot_makes_no_drafts_request() -> None:
|
|
"""The whoami gate in the boot IIFE: ``fetchIsAdmin()`` (the
|
|
header.js cached whoami — the single /api/whoami call site) decides
|
|
the gate. Anonymous: the gate shows, the content stays hidden, a
|
|
bare return — and NO /api/doc-drafts call on the wire (the draft
|
|
API is admin-only regardless; the story E2E pins the request
|
|
log). Only the admin path reaches the token read + loadDraft."""
|
|
js = _js()
|
|
assert "fetchIsAdmin" in js, "the gate must run on the cached whoami"
|
|
boot = js[js.find("(async () => {"):]
|
|
assert boot, "the boot IIFE must exist"
|
|
gate_i = boot.find("const admin = await fetchIsAdmin();")
|
|
assert gate_i != -1, "boot must await fetchIsAdmin() first"
|
|
branch = boot[gate_i : boot.find("return;", gate_i)]
|
|
assert "fetch(" not in branch, (
|
|
"the anonymous branch must not fetch anything (no draft leak)"
|
|
)
|
|
assert "gateEl.hidden = false" in branch
|
|
assert "contentEl.hidden = true" in branch
|
|
# The admin path: the gate hides, the content reveals, the token
|
|
# is read, and only THEN does the draft load.
|
|
after = boot[boot.find("return;", gate_i):]
|
|
assert "gateEl.hidden = true" in after
|
|
assert "contentEl.hidden = false" in after
|
|
token_i = after.find('new URLSearchParams(window.location.search).get("draft")')
|
|
assert token_i != -1, "boot must read ?draft=<token>"
|
|
assert after.find("await loadDraft(token)") > token_i
|
|
|
|
|
|
def test_token_missing_and_malformed_copy() -> None:
|
|
"""The token handling: missing → the error banner "No draft
|
|
specified."; a non-uuid token → "Draft not found." with NO fetch
|
|
(the shared.js malformed-token precedent — a 422 validation line
|
|
is framework noise, not a house message)."""
|
|
js = _js()
|
|
boot = js[js.find("(async () => {"):]
|
|
missing_i = boot.find('showError("No draft specified.")')
|
|
assert missing_i != -1, "the missing-token banner copy is pinned"
|
|
# The uuid shape check gates the fetch (malformed → no request).
|
|
malformed_i = boot.find("UUID_RE.test(token)")
|
|
assert malformed_i != -1, "the uuid shape check must gate the fetch"
|
|
after_malformed = boot[malformed_i : malformed_i + 300]
|
|
assert 'showError("Draft not found.")' in after_malformed
|
|
assert "fetch(" not in after_malformed, ("a malformed token must not fetch")
|
|
# The regex is the 8-4-4-4-12 uuid shape (case-insensitive).
|
|
assert "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i" in js
|
|
# And draftToken (the push's credential) is set only after the
|
|
# checks — the load follows.
|
|
assert boot.find("draftToken = token;") > malformed_i
|
|
assert boot.find("await loadDraft(token)") > boot.find("draftToken = token;")
|
|
|
|
|
|
# ---------- the three API paths ----------
|
|
|
|
|
|
def test_the_three_api_paths() -> None:
|
|
"""The edit loop's three draft API paths (task 06): GET
|
|
/api/doc-drafts/<token> (load — in loadDraft), PUT
|
|
/api/doc-drafts/<token> (persist the edits) and POST
|
|
/api/doc-drafts/<token>/push (the single mutation). The PUT runs
|
|
BEFORE the push: the push endpoint commits the ROW's
|
|
title/path/body, so an unsaved edit would push stale text."""
|
|
js = _js()
|
|
load = _fn(js, "loadDraft")
|
|
assert 'fetch(`/api/doc-drafts/${token}`)' in load, (
|
|
"loadDraft must GET the draft by token"
|
|
)
|
|
assert "push" not in load.lower().replace("pushing", ""), (
|
|
"loadDraft must not push (it only loads)"
|
|
)
|
|
push_fn = js[js.find("function wirePush() {"):]
|
|
put_i = push_fn.find('method: "PUT"')
|
|
post_i = push_fn.find("/push")
|
|
assert put_i != -1 and post_i != -1, "the PUT + the POST /push must both exist"
|
|
assert put_i < post_i, "the PUT (persist edits) must run BEFORE the push"
|
|
assert 'fetch(`/api/doc-drafts/${draftToken}/push`, {' in push_fn, (
|
|
"the push endpoint is POST /api/doc-drafts/<token>/push"
|
|
)
|
|
# Exactly one call per path — no duplicate fetch sites.
|
|
assert js.count("doc-drafts") >= 3
|
|
|
|
|
|
def test_load_fill_is_values_not_innerhtml() -> None:
|
|
"""The 200 body fills the three fields with VALUES
|
|
(``.value`` = textContent discipline) — NEVER innerHTML: the body
|
|
is user-derived markdown, and the title/path may contain anything
|
|
but markup. The whole file builds no HTML at all (the page markup
|
|
is static; JS only reads/sets values and hidden flags)."""
|
|
js = _js()
|
|
load = _fn(js, "loadDraft")
|
|
assert "titleInput.value = draft.title" in load
|
|
assert "pathInput.value = draft.path" in load
|
|
assert "bodyInput.value = draft.body" in load
|
|
assert "innerHTML" not in js, "doc-edit.js must never build HTML"
|
|
|
|
|
|
def test_load_outcome_copy() -> None:
|
|
"""loadDraft's failure lines: 404 → "Draft not found." (no
|
|
enumeration — one message for every unknown token), other non-2xx
|
|
→ the server's detail (422 shape-aware), a network failure → the
|
|
fixed one-line copy."""
|
|
js = _js()
|
|
load = _fn(js, "loadDraft")
|
|
assert 'showError("Draft not found.")' in load
|
|
assert "r.status === 404" in load
|
|
assert "apiDetail(" in load, "non-2xx must surface the server detail"
|
|
assert "is the app running?" in load, "the network-failure line"
|
|
# The 404 check runs before the generic non-2xx arm.
|
|
assert load.find("r.status === 404") < load.find("if (!r.ok)")
|
|
|
|
|
|
# ---------- push: the never-stale lifecycle ----------
|
|
|
|
|
|
def test_push_sanity_checks_before_any_request() -> None:
|
|
"""The client-side sanity (the server is the authority — it
|
|
re-runs the guard-rails): non-empty title, non-empty body, no
|
|
".." in the path. Each violation lands the error banner, focuses
|
|
the offending field, and returns BEFORE any fetch — and without a
|
|
token the banner says "No draft specified." (no fetch)."""
|
|
js = _js()
|
|
fn = js[js.find("function wirePush() {"):]
|
|
title_i = fn.find('showError("Enter a title for the doc.")')
|
|
body_i = fn.find('showError("The doc body must not be empty.")')
|
|
path_i = fn.find("path.includes(\"..\")")
|
|
assert title_i != -1 and body_i != -1 and path_i != -1, (
|
|
"the three sanity checks must exist"
|
|
)
|
|
assert title_i < body_i < path_i, "title, body, path — in field order"
|
|
# Each violation focuses its field (keyboard a11y).
|
|
assert "titleInput.focus()" in fn
|
|
assert "bodyInput.focus()" in fn
|
|
assert "pathInput.focus()" in fn
|
|
# No token → the banner, no fetch (the first fetch comes later).
|
|
notoken_i = fn.find('showError("No draft specified.")')
|
|
first_fetch = fn.find("await fetch(")
|
|
assert -1 < notoken_i < first_fetch
|
|
|
|
|
|
def test_push_disables_relabels_and_reenables() -> None:
|
|
"""The §7.4 never-stale lifecycle: the button disables +
|
|
relabels "Pushing…" AND the live region says "Pushing…" while the
|
|
request is out; the finally re-enables the button with its idle
|
|
label (IDLE_LABEL = the exact static copy) on EVERY outcome —
|
|
success OR failure, success AND failure."""
|
|
js = _js()
|
|
fn = js[js.find("function wirePush() {"):]
|
|
disable_i = fn.find("pushBtn.disabled = true")
|
|
relabel_i = fn.find('pushBtn.textContent = "Pushing…"')
|
|
status_i = fn.find('setStatus("Pushing…")')
|
|
assert disable_i != -1 and relabel_i != -1 and status_i != -1, (
|
|
"disable + relabel + status before the requests"
|
|
)
|
|
fetch_i = fn.find("await fetch(")
|
|
assert -1 < status_i < fetch_i, "the status line precedes the first request"
|
|
finally_i = fn.find("} finally {")
|
|
assert finally_i != -1, "the finally block is the never-stale guarantee"
|
|
after_finally = fn[finally_i:]
|
|
assert "pushBtn.disabled = false" in after_finally
|
|
assert "pushBtn.textContent = IDLE_LABEL" in after_finally
|
|
# The idle label IS the static button copy (a mismatch would
|
|
# relabel the button into an unknown state on success).
|
|
assert 'const IDLE_LABEL = "Push to docs branch";' in js
|
|
|
|
|
|
def test_push_success_line_branch_and_sha7() -> None:
|
|
"""The 200 outcome: the live region reads
|
|
`Pushed to <branch> — commit <sha7>.` — the branch from the API,
|
|
the commit sha TRUNCATED to its first seven chars for display (the
|
|
full value stays in the API/draft row), the exact em-dash shape.
|
|
The button re-enables (a re-push after further edits is a NEW
|
|
commit — the D3 ASSUMPTION)."""
|
|
js = _js()
|
|
fn = js[js.find("function wirePush() {"):]
|
|
assert (
|
|
"`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`"
|
|
in fn
|
|
), "the success line is 'Pushed to <branch> — commit <sha7>.'"
|
|
# The success line is set AFTER the push response is read.
|
|
json_i = fn.find("await r.json()")
|
|
ok_i = fn.find('`Pushed to ${pushed.branch}')
|
|
assert -1 < json_i < ok_i
|
|
|
|
|
|
def test_push_failure_banner_trims_git_detail_and_keeps_fields() -> None:
|
|
"""The failure outcome: the #push-error banner with the API's
|
|
detail — for a git 502 that is git's stderr, trimmed to its first
|
|
meaningful lines (trimGitDetail: blank lines + the "hint:" chatter
|
|
dropped, at most three lines, single-line details untouched) — the
|
|
fields are PRESERVED (no input is cleared anywhere in the file)
|
|
and the stale success line is cleared so only the error claims the
|
|
outcome. Network failure → the fixed one-line copy."""
|
|
js = _js()
|
|
fn = js[js.find("function wirePush() {"):]
|
|
assert "trimGitDetail(" in fn, "the failure detail must pass the trimmer"
|
|
assert "apiDetail(" in fn, "the detail must be the API's (422-shape-aware)"
|
|
# No field is ever cleared: the user's edits survive a failed push.
|
|
for field in ('titleInput.value = ""', 'pathInput.value = ""',
|
|
'bodyInput.value = ""', "titleInput.value=''",
|
|
"pathInput.value=''", "bodyInput.value=''"):
|
|
assert field not in js, f"a failed push must keep the edits, not {field!r}"
|
|
assert "is the app running?" in fn, "the network-failure line"
|
|
# The stale success line is cleared on failure (one claim at a
|
|
# time — the PUT-failure arm clears it too).
|
|
fail_i = fn.find("trimGitDetail(")
|
|
assert fn.rfind('setStatus("")', 0, fail_i) > 0, (
|
|
"a failed push clears the status line before the banner"
|
|
)
|
|
put_fail_i = fn.find('showError(await apiDetail(put')
|
|
assert put_fail_i != -1
|
|
assert fn.rfind('setStatus("")', 0, put_fail_i) > 0, (
|
|
"a failed PUT also clears the stale status line"
|
|
)
|
|
|
|
trim = _fn(js, "trimGitDetail")
|
|
assert 'l.startsWith("hint:")' in trim, "the 'hint:' chatter is dropped"
|
|
assert "slice(0, 3)" in trim, "at most three meaningful lines"
|
|
assert "filter(" in trim and ".trim()" in trim
|
|
|
|
|
|
def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
|
|
"""The trimmer's contract in one place: non-empty lines that are
|
|
not hint: lines, up to three, space-joined, with a fallback for an
|
|
all-hint/empty detail (the banner must never be blank)."""
|
|
js = _js()
|
|
trim = _fn(js, "trimGitDetail")
|
|
assert "split(\"\\n\")" in trim
|
|
assert 'join(" ")' in trim
|
|
assert '|| "The push failed."' in trim, "the empty-detail fallback"
|
|
|
|
|
|
# ---------- styles.css: the new classes ----------
|
|
|
|
|
|
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
|
|
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
|
|
form column, not a reading column — it must NOT ride
|
|
--chat-column, so phase 58's wide-desktop doubling never stretches
|
|
the form), centered, a flex column on the container frame."""
|
|
css = _css()
|
|
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
|
|
assert block, "styles.css must style .doc-edit-shell"
|
|
body = block.group(1)
|
|
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
|
|
assert "--chat-column" not in body, (
|
|
"the form column does not ride --chat-column (phase 58 must "
|
|
"not stretch it)"
|
|
)
|
|
assert "margin-inline: auto" in body
|
|
assert "flex-direction: column" in body
|
|
|
|
|
|
def test_back_link_and_push_button_css() -> None:
|
|
""".doc-edit-back: the ghost language (>=44px target, --line
|
|
border, ink-soft on the --surface bar), pushed right
|
|
(margin-left: auto); #push-doc-btn: the brand primary (dark ink on
|
|
brand 5.2:1 — never white on brand), >=44px, a :disabled state
|
|
(the "Pushing…" affordance)."""
|
|
css = _css()
|
|
back = re.search(r"\.doc-edit-back \{([\s\S]*?)\n\}", css)
|
|
assert back, "styles.css must style .doc-edit-back"
|
|
bbody = back.group(1)
|
|
assert "min-height: 44px" in bbody
|
|
assert "border: 1px solid var(--line)" in bbody
|
|
assert "var(--ink-soft)" in bbody
|
|
assert "margin-left: auto" in bbody
|
|
btn = re.search(r"#push-doc-btn \{([\s\S]*?)\n\}", css)
|
|
assert btn, "styles.css must style #push-doc-btn"
|
|
tbody = btn.group(1)
|
|
assert "background: var(--brand)" in tbody
|
|
assert "color: var(--bg)" in tbody, "dark ink on brand (never white)"
|
|
assert "min-height: 44px" in tbody
|
|
assert re.search(r"#push-doc-btn:disabled \{[^}]*opacity[^}]*\}", css), (
|
|
"the disabled (Pushing…) state must be styled"
|
|
)
|
|
|
|
|
|
def test_form_fields_css_mono_and_min_height() -> None:
|
|
"""#draft-path and #draft-body are MONO (the path is machine data;
|
|
the body is markdown) on the inset bg fill; #draft-body carries
|
|
the pinned min-height: 20rem; the inputs keep the 44px floor."""
|
|
css = _css()
|
|
pair = re.search(
|
|
r"#draft-title,\n#draft-path \{([\s\S]*?)\n\}", css
|
|
)
|
|
assert pair, "styles.css must style the two text inputs"
|
|
assert "min-height: 44px" in pair.group(1)
|
|
# The DEDICATED #draft-path rule (the pair above shares the name in
|
|
# its selector list — search past the pair's closing brace).
|
|
path = re.search(
|
|
r"#draft-path \{([\s\S]*?)\n\}", css[pair.end():]
|
|
)
|
|
assert path and "var(--mono)" in path.group(1), "#draft-path must be mono"
|
|
body = re.search(r"#draft-body \{([\s\S]*?)\n\}", css)
|
|
assert body, "styles.css must style #draft-body"
|
|
bbody = body.group(1)
|
|
assert "var(--mono)" in bbody, "#draft-body must be mono"
|
|
assert "min-height: 20rem" in bbody, "the pinned 20rem body floor"
|
|
assert "resize: vertical" in bbody
|
|
|
|
|
|
def test_status_and_error_css_families() -> None:
|
|
""".doc-edit-status: the ok family (ok-ink on ok-bg 10.6:1) when
|
|
a push outcome has landed, the dashed placeholder when empty;
|
|
.doc-edit-error: the err family (err-ink on err-bg 9.3:1,
|
|
err-line border) with long-word breaking (git paths). The global
|
|
3px :focus-visible ring covers the new controls (AGENTS.md rule 5)."""
|
|
css = _css()
|
|
status = re.search(r"\.doc-edit-status \{([\s\S]*?)\n\}", css)
|
|
assert status, "styles.css must style .doc-edit-status"
|
|
sbody = status.group(1)
|
|
assert "var(--ok-bg)" in sbody and "var(--ok-ink)" in sbody
|
|
assert re.search(r"\.doc-edit-status:empty \{", css), (
|
|
"the empty status must be the dashed placeholder"
|
|
)
|
|
error = re.search(r"\.doc-edit-error \{([\s\S]*?)\n\}", css)
|
|
assert error, "styles.css must style .doc-edit-error"
|
|
ebody = error.group(1)
|
|
assert "var(--err-bg)" in ebody and "var(--err-ink)" in ebody
|
|
assert "var(--err-line)" in ebody
|
|
assert "overflow-wrap: anywhere" in ebody
|
|
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
|