Files
brain-of-reese/tests/unit/test_source_ignore_paths.py
T
ducoterra 8c706259e9
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 89_source_ignore_paths
All verification complete — TODO.md was already cleared in the roadmap commit; the two extra unit-test diffs are necessary fake-signature adaptations for the new keywords. Everything is green, no fixes were needed.

## Phase 89 — final verification pass: ALL GREEN

**Verified (all 6 task files present in `complete/`):**
- `git_sources.ignore_paths` JSONB column + migration 0013; `alembic downgrade -1 && upgrade head` round-trips (head `0013`)
- Importer: `normalize_ignore_path`/`is_ignored`/`_ignore_for_root`, `ignore` in walk + progress pre-walk, `ignore_by_root` in `import_sources`
- API: GET/POST carry list; admin-only `PATCH` (replace, 404/422 fixed details, anonymous 403)
- Pipelines wired: `_run_sync`, `_run_upload` re-upload, `scripts/import_docs.py`
- Sources-page box: dialog, §7.4 save lifecycle, `N ignored` tag, a11y; env rows get no box

**Test/lint results:**
- `uv run pytest` → 1808 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- `uv run pytest tests/e2e/test_source_ignore_paths.py -v --no-cov` → 6 passed (isolated, DB up)
- Regressions in isolation: `test_git_sources_admin` 6, `test_archive_upload_sources` 5, `test_sync_button` 3, `test_smoke` 3 — all passed
- `uv run ruff check . && uv run pyright` → clean (0 errors)

**Completion criteria:** box→PATCH 200→count+GET round-trip ✅ · sync excludes `ignore/` (no docs/chunks/embeddings/summaries) + prunes newly-ignored (pruned==2) ✅ · no-mid-path rule E2E ✅ · PATCH 404/422/replace/clear/403 ✅ · full gate green ✅ · commit + phase move left to harness per rules.

**Deviations:** none blocking — E2E pins `files == 4` (overview's "5" was an off-by-one vs its own 6-file tree, documented in-test); `tests/unit/test_importer.py` + `test_sync_button.py` test-double fakes extended for the new keywords (needed for the suite to stay green).

**Next pending phase:** none — `todo/` holds only this phase.
2026-09-09 01:45:42 -04:00

547 lines
26 KiB
Python

"""Unit: the per-source ignore-paths editor on the Sources page
(phase 89, task 05).
``TODO.md`` L3: "They should be able to type these files and folders
into a box on the sources page." Phase 89 gives every STORED source
row (git or local) a one-path-per-line ignore box on
``/git-sources.html``: a page-local alertdialog (the EXACT
``#remove-confirm-dialog`` pattern, phase 69) with a visible label, a
mono textarea prefilled from the row, the §7.4 never-stale save
lifecycle ("Saving…" while the ``PATCH /api/git-sources/{id}`` is
out — the A5 replace round-trips through ``GET``), and full a11y
(focus on Cancel, Escape / backdrop close as CANCEL, focus return,
``role="alert"`` error line that keeps the textarea content on 422).
Rows with a list show the ``N ignored`` count tag; env-fallback rows
(``id`` null) get NO box (A3 — nothing is stored to edit).
The browser behavior itself is E2E-gated by the phase-89 story suite
(``tests/e2e/test_source_ignore_paths.py``, task 06); like the other
frontend-adjacent unit files (the
``test_remove_confirm_modal.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* the static ``#ignore-editor-dialog`` markup — ``role=
"alertdialog"`` + ``aria-modal`` + ``aria-labelledby``, hidden by
default, inside the manager, the visible ``<label for=…>`` (never
aria-label-only), the ``role="alert"`` error line, real
``type="button"`` buttons, the mono textarea (``rows=6`` /
``spellcheck="false"``);
* the JS lifecycle — ``openIgnoreEditor`` (textContent-only source
population with makeRow's ``value`` expression, the textarea
prefilled from ``(s.ignore_paths || [])``, error cleared, focus on
Cancel), cancel = Escape / Cancel button / backdrop (no request;
focus returns to the trigger; a no-op while a PATCH is in flight),
``saveIgnorePaths`` (§7.4 in-flight state: both buttons disable +
"Saving…", blank lines dropped client-side, success → close /
reload / announce — the update confirmation LAST, non-2xx → the
in-dialog alert line with the textarea content KEPT, network →
the fixed reachable? line, re-enable in the finally);
* the row wiring — the "Ignore paths" button exists ONLY in the
``s.id`` branch of ``makeRow`` (left of Remove; the aria-label is
the only place the value appears — never innerHTML), the ``N
ignored`` count tag on rows with a list (text, never color alone);
* styles.css — the dialog / button / count-tag rules on the house
dark-tech palette (phase-08 tokens only, no CDN, no blur),
≥44px targets, the ``[hidden]`` override, the mono full-width
textarea;
* the no-collision guard — every ``id="ignore-editor-…"`` appears
EXACTLY ONCE in the shell (the phase-46/76 contract).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
# Phase 76 (task 02): git-sources.html is folded into the ONE-document
# shell — the dialog markup lives in the Sources view section of
# index.html (next to #remove-confirm-dialog).
SHELL_HTML = FRONTEND / "index.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The dialog's static ids (the no-collision guard + the markup pins
#: key on exactly these).
DIALOG_IDS = (
"ignore-editor-dialog",
"ignore-editor-title",
"ignore-editor-source",
"ignore-editor-copy",
"ignore-editor-textarea",
"ignore-editor-error",
"ignore-editor-cancel",
"ignore-editor-save",
)
#: The §7.4 in-flight label + the idle label (pinned verbatim).
SAVING_LABEL = "Saving…"
IDLE_LABEL = "Save"
COUNT_CLASS = "git-source-ignore-count"
ROW_BTN_CLASS = "git-source-ignore"
ARIA_LABEL_TEMPLATE = "`Edit ignored paths for ${kindLabel} source: ${value}`"
#: The success announce — the update confirmation is the LAST
#: announcement (the reload's "N sources listed." must not overwrite
#: it, the same order as the remove flow).
ANNOUNCE_OK = "Ignored paths updated for ${value}"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(JS)
def _css() -> str:
return _text(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_remove_confirm_modal.py helper)."""
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 git-sources.js")
def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
"""The <tag … id=…> element's full markup (a balanced-tag walk —
the dialog nests the backdrop / panel / actions divs)."""
marker = f'id="{id_attr}"'
i = html.find(marker)
assert i != -1, f"missing id={id_attr} in the shell's Sources view"
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
assert opens, f"no <{tag}> owns id={id_attr}"
open_i = opens[-1]
depth = 0
for m in re.finditer(rf"<{tag}\b[^>]*>|</{tag}>", html[open_i :]):
if m.group(0).startswith(f"</{tag}>"):
depth -= 1
else:
depth += 1
if depth == 0:
return html[open_i : open_i + m.end()]
raise AssertionError(f"unbalanced <{tag}> for id={id_attr}")
def _css_rule(css: str, selector: str) -> str:
"""The declarations of a simple one-line-opening rule (comments
stripped first — a house comment may legally carry braces)."""
clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
start = clean.find(f"{selector} {{")
assert start != -1, f"missing rule {selector} in styles.css"
depth = 0
for i in range(clean.find("{", start), len(clean)):
if clean[i] == "{":
depth += 1
elif clean[i] == "}":
depth -= 1
if depth == 0:
return clean[start : i + 1]
raise AssertionError(f"unbalanced braces in {selector}")
# ---------- the static dialog markup ----------
def test_dialog_markup_is_the_locked_alertdialog() -> None:
"""#ignore-editor-dialog: role="alertdialog" + aria-modal +
aria-labelledby, hidden by default, INSIDE the manager
(#git-sources-content, directly after #remove-confirm-dialog —
the static-markup convention, stable E2E selectors); every
static id present; the error line is role="alert" + hidden;
both buttons are real type="button"; the textarea is mono-
capable (rows=6, spellcheck=false, the A1 prefix placeholder)."""
html = _text(SHELL_HTML)
frag = _element_block(html, "ignore-editor-dialog")
open_tag = frag[: frag.find(">") + 1]
assert 'role="alertdialog"' in open_tag
assert 'aria-modal="true"' in open_tag
assert 'aria-labelledby="ignore-editor-title"' in open_tag
assert 'aria-describedby="ignore-editor-copy"' in open_tag
assert "hidden" in open_tag, "the dialog ships hidden"
# Placed in the manager, right after the remove-confirm dialog
# (the same placement logic).
assert html.find('id="git-sources-content"') < html.find('id="ignore-editor-dialog"')
assert html.find('id="remove-confirm-dialog"') < html.find('id="ignore-editor-dialog"')
for child in DIALOG_IDS:
assert f'id="{child}"' in frag, f"missing #{child} in the dialog"
# The visible label (WCAG — never aria-label-only): a real
# <label for="ignore-editor-textarea"> with the house label class.
label = re.search(
r"<label[^>]*class=\"ignore-editor-label\"[^>]*for=\"ignore-editor-textarea\"[^>]*>(.*?)</label>",
frag,
re.S,
)
assert label and label.group(1).strip(), "a visible block label for the textarea"
# The error line: role="alert", hidden by default.
err = re.search(r"<p[^>]*id=\"ignore-editor-error\"[^>]*>", frag)
assert err and 'role="alert"' in err.group(0) and "hidden" in err.group(0)
# The mono box: rows + spellcheck off + the A1 placeholder.
ta = re.search(r"<textarea[^>]*id=\"ignore-editor-textarea\"[^>]*>", frag, re.S)
assert ta, "#ignore-editor-textarea is a real <textarea>"
for attr in ('rows="6"', 'spellcheck="false"', 'placeholder="my/files/"'):
assert attr in ta.group(0), f"the textarea carries {attr}"
for btn in ("ignore-editor-cancel", "ignore-editor-save"):
m = re.search(rf"<button[^>]*id=\"{btn}\"[^>]*>", frag)
assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button"
cancel = re.search(r'<button[^>]*id="ignore-editor-cancel"[^>]*>(.*?)</button>', frag, re.S)
save = re.search(r'<button[^>]*id="ignore-editor-save"[^>]*>(.*?)</button>', frag, re.S)
assert cancel and cancel.group(1).strip() == "Cancel"
assert save and save.group(1).strip() == IDLE_LABEL
# The helper copy states the A1 prefix rule in plain words.
copy = re.search(r"<p[^>]*id=\"ignore-editor-copy\"[^>]*>(.*?)</p>", frag, re.S)
assert copy, "the helper copy paragraph"
copy_norm = re.sub(r"\s+", " ", copy.group(1))
assert "No wildcards" in copy_norm and "middle of a path" in copy_norm
def test_dialog_ids_appear_exactly_once() -> None:
"""The no-collision guard (the phase-46/76 contract): every
``id="ignore-editor-…"`` in the NEW markup appears EXACTLY ONCE
in the whole shell — no duplicated id (the dialog is the only
owner of each id)."""
html = _text(SHELL_HTML)
for id_ in DIALOG_IDS:
n = html.count(f'id="{id_}"')
assert n == 1, f'id={id_} appears {n} times in index.html (must be exactly 1)'
# ---------- the row wiring in makeRow ----------
def test_row_button_exists_only_in_the_stored_row_branch() -> None:
"""makeRow: the "Ignore paths" button is created ONLY in the
``s.id`` branch (A3 — env-fallback rows get the "from .env" tag,
no button) and sits BEFORE the Remove button (left of Remove).
The button is class git-source-ignore, type=button, textContent
label (never innerHTML — the value appears ONLY in the aria-
label, built via setAttribute); its click opens the editor."""
make = _fn(_js(), "makeRow")
branch_i = make.find("if (s.id) {")
else_i = make.find("} else {", branch_i)
bind_i = make.find("openIgnoreEditor(s, ignoreBtn)")
assert -1 < branch_i < bind_i < else_i, (
"the ignore button is created in the s.id branch (the phase-88 pin idiom)"
)
remove_i = make.find('btn.className = "git-source-remove"', bind_i)
assert remove_i > bind_i, "the ignore button is LEFT of Remove"
assert f'ignoreBtn.className = "{ROW_BTN_CLASS}"' in make
assert "ignoreBtn.type = \"button\"" in make
assert ARIA_LABEL_TEMPLATE in make, "the aria-label template (the only value site)"
assert "ignoreBtn.textContent = \"Ignore paths\"" in make, (
"a static text label — never icon-only"
)
assert "ignoreBtn.innerHTML" not in _js(), "XSS contract: no innerHTML on the button"
# The env-fallback branch (else) has no ignore button.
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
assert ROW_BTN_CLASS not in else_slice and "openIgnoreEditor" not in else_slice, (
"env-fallback rows get no ignore button (A3)"
)
def test_count_tag_shows_n_ignored_text_next_to_the_location() -> None:
"""makeRow: a stored row with a non-empty list gets the
``N ignored`` count tag in the LOCATION cell (after the
<code>) — text via textContent (never color alone, WCAG 1.4.1);
the list reads ``(s.ignore_paths || [])`` — the same expression
openIgnoreEditor prefills from (the GET round-trip marker)."""
make = _fn(_js(), "makeRow")
assert "s.id && (s.ignore_paths || []).length > 0" in make, (
"the count tag only for stored rows with a list"
)
assert f'count.className = "{COUNT_CLASS}"' in make
assert "count.textContent = `${s.ignore_paths.length} ignored`" in make, (
"N ignored — text, never color alone"
)
# It lands in the location cell (urlTd), not the actions cell.
append_i = make.find("urlTd.append(count)")
code_i = make.find("urlTd.append(badge, code)")
assert -1 < code_i < append_i, "the tag is appended to the location cell"
# ---------- the JS lifecycle ----------
def test_open_prefills_and_focuses_cancel() -> None:
"""openIgnoreEditor(s, triggerBtn): the source value is
textContent ONLY (never innerHTML — the credential-masking
discipline, phase 32) with makeRow's exact ``value`` expression;
the textarea prefills ``(s.ignore_paths || []).join("\\n")``
(one path per line — the round-trip marker); the error line
clears; the dialog unhides; the trigger is recorded; the keydown
handler attaches; and focus lands on Cancel — the safe default
(AFTER the unhide)."""
body = _fn(_js(), "openIgnoreEditor")
assert "s.kind === \"local\"" in body, "the kind-typed value branch"
assert (
"ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body
), "the same `value` expression makeRow uses, via textContent"
assert "ignoreSourceEl.innerHTML" not in _js(), "XSS contract: textContent only"
assert 'ignoreTextarea.value = (s.ignore_paths || []).join("\\n")' in body, (
"the textarea prefills the stored list, one path per line"
)
assert "ignoreErrorEl.hidden = true" in body, "a new attempt starts clean"
assert "ignoreTriggerBtn = triggerBtn" in body, "the trigger is recorded"
trigger_i = body.find("ignoreTriggerBtn = triggerBtn")
unhide_i = body.find("ignoreDialog.hidden = false")
attach_i = body.find('document.addEventListener("keydown", onIgnoreDialogKeydown)')
focus_i = body.find("ignoreCancelBtn.focus()")
assert -1 < trigger_i < unhide_i < attach_i < focus_i, (
"record the trigger → unhide → attach keydown → focus Cancel"
)
def test_cancel_paths_close_without_a_request() -> None:
"""Cancel (Cancel button / Escape / backdrop) closes as cancel:
the dialog hides, the textarea + error line reset, the buttons
reset ("Save"), the keydown handler detaches, and focus RETURNS
to the recorded trigger — and the cancel path never fetches. A
cancel while a PATCH is in flight is a no-op (no half-cancel of
an in-progress save)."""
js = _js()
cancel = _fn(js, "cancelIgnoreEditor")
guard_i = cancel.find("if (ignoreInFlight) return")
close_i = cancel.find("closeIgnoreEditor()")
assert -1 < guard_i < close_i, "the in-flight guard precedes the close"
assert "fetch" not in cancel, "cancel never sends a request"
close = _fn(js, "closeIgnoreEditor")
hide_i = close.find("ignoreDialog.hidden = true")
reset_ta_i = close.find('ignoreTextarea.value = ""')
clear_i = close.find("ignoreErrorEl.hidden = true")
reset_btn_i = close.find('"Save"')
detach_i = close.find('document.removeEventListener("keydown", onIgnoreDialogKeydown)')
save_i = close.find("const trigger = ignoreTriggerBtn")
null_i = close.find("ignoreTriggerBtn = null")
focus_i = close.find("trigger.focus()")
assert -1 < hide_i < reset_ta_i < clear_i < reset_btn_i, (
"hide → reset textarea → clear error → reset buttons"
)
assert reset_btn_i < detach_i < save_i < null_i < focus_i, (
"→ detach → save trigger → focus return"
)
assert "ignoreCancelBtn.disabled = false" in close
assert "ignoreSaveBtn.disabled = false" in close
def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None:
"""While open (handler attached on document in openIgnoreEditor,
detached in closeIgnoreEditor): Escape → preventDefault +
cancelIgnoreEditor; the dim backdrop AND the Cancel button wire
to cancelIgnoreEditor (only Save wires to saveIgnorePaths)."""
js = _js()
keydown = _fn(js, "onIgnoreDialogKeydown")
esc_i = keydown.find('e.key === "Escape"')
prevent_i = keydown.find("e.preventDefault()", esc_i)
cancel_i = keydown.find("cancelIgnoreEditor()", esc_i)
assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel"
assert 'ignoreCancelBtn.addEventListener("click", cancelIgnoreEditor)' in js
assert 'ignoreBackdrop.addEventListener("click", cancelIgnoreEditor)' in js
assert 'ignoreSaveBtn.addEventListener("click", saveIgnorePaths)' in js
def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
"""saveIgnorePaths: the box's lines are split on newlines,
trimmed, and empty lines DROPPED (a blank line is a separator,
not an entry — the server still rejects empties defensively,
A4). The §7.4 in-flight state precedes the PATCH: both buttons
disable + the save relabels "Saving…" — one
``PATCH /api/git-sources/{id}`` with the lines as the whole
body list (A5 replace). 200 → close (focus return) →
loadSources (the count tag lands) → announce (the update
confirmation is the LAST announcement). Non-2xx: the in-dialog
role=alert line (apiDetail, 422 shape-aware), the dialog STAYS
open and the textarea content is KEPT. Network failure: the
fixed reachable? line. The finally re-enables BOTH buttons +
relabels "Save" — never stale on any outcome."""
body = _fn(_js(), "saveIgnorePaths")
guard_i = body.find("if (!ignoreTarget || ignoreInFlight) return")
parse_i = body.find('.split("\\n")')
trim_i = body.find(".map((l) => l.trim())", parse_i)
drop_i = body.find(".filter(Boolean)", trim_i)
inflight_i = body.find("ignoreInFlight = true", drop_i)
dis_c = body.find("ignoreCancelBtn.disabled = true", inflight_i)
dis_s = body.find("ignoreSaveBtn.disabled = true", inflight_i)
label_i = body.find(f'"{SAVING_LABEL}"', dis_s)
fetch_i = body.find("`/api/git-sources/${", label_i)
method_i = body.find('method: "PATCH"', fetch_i)
body_i = body.find("JSON.stringify({ ignore_paths: lines })", method_i)
assert -1 < guard_i < parse_i < trim_i < drop_i < inflight_i, (
"guard → split + trim + drop blank lines → in-flight"
)
assert -1 < dis_c < dis_s < label_i < fetch_i < method_i < body_i, (
"disable both + 'Saving…' → the PATCH with the lines"
)
# Success: close → reload → announce (the exact order) — the
# update confirmation is the LAST announcement: the reload's
# "N sources listed." must not overwrite it.
ok_i = body.find("if (r.ok)")
close_i = body.find("closeIgnoreEditor()", ok_i)
reload_i = body.find("await loadSources()", close_i)
announce_i = body.find(f"announce(`{ANNOUNCE_OK}`)", reload_i)
assert -1 < ok_i < close_i < reload_i < announce_i, (
"200: close → loadSources → announce (LAST)"
)
assert body.count("closeIgnoreEditor()") == 1, (
"only the success path closes — failures stay open for one retry"
)
# Non-2xx: the in-dialog alert line (apiDetail, 422 shape-aware);
# the dialog stays open and the textarea content is KEPT.
catch_i = body.find("} catch {")
nonok_slice = body[ok_i:catch_i]
assert "await apiDetail(r," in nonok_slice, (
"the server detail is apiDetail-extracted (422 shape-aware)"
)
assert "ignoreErrorEl.hidden = false" in nonok_slice, "the error line shows"
assert "ignoreTextarea.value" not in nonok_slice.split("if (r.ok)")[1], (
"the textarea content is KEPT on failure (the instruction survives)"
)
# Network: the fixed reachable? line.
net_i = body.find("Could not save the ignored paths — is the app reachable?", catch_i)
assert -1 < net_i < body.find("finally"), "the network copy lands in the catch"
# Never stale: the finally re-enables BOTH buttons + relabels.
fin = body[body.find("finally"):]
assert "ignoreInFlight = false" in fin
assert "ignoreCancelBtn.disabled = false" in fin
assert "ignoreSaveBtn.disabled = false" in fin
assert f'ignoreSaveBtn.textContent = "{IDLE_LABEL}"' in fin
def test_module_docstring_carries_the_phase_89_contract() -> None:
"""The git-sources.js module docstring gained the phase-89
bullet: the editor is per-STORED-row (A3 env rows excluded),
one path per line (the A1 prefix rule), the §7.4 "Saving…"
lifecycle, and the A5 replace round-trip."""
doc = _js().split("*/", 2)[0] # the module docstring (first block)
for frag in (
"Phase 89 (task 05)",
"ignore-paths editor",
"#ignore-editor-dialog",
"one path per line",
f'"{SAVING_LABEL}"',
"LAST announcement",
"N ignored",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------- styles.css ----------
def test_dialog_css_rules_present_and_house_tokens_only() -> None:
"""styles.css carries the ignore-editor class family on the house
dark-tech palette (phase-08 tokens): the overlay + backdrop +
panel (the EXACT .remove-confirm treatment), the title / source /
copy / visible label / mono textarea / error / actions / two-
button chrome; the [hidden] override; no blur (the phase-08
no-blur perf anchor); no CDN."""
css = _css()
for selector in (
".ignore-editor",
".ignore-editor-backdrop",
".ignore-editor-panel",
".ignore-editor-title",
".ignore-editor-source",
".ignore-editor-copy",
".ignore-editor-label",
".ignore-editor-textarea",
".ignore-editor-error",
".ignore-editor-actions",
".ignore-editor-btn",
".ignore-editor-cancel",
".ignore-editor-save",
):
assert f"{selector} " in css or f"{selector}." in css or f"{selector}[" in css, (
f"styles.css must style {selector}"
)
hidden = css.find(".ignore-editor[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 60], (
"the hidden attr must beat the display rule"
)
# No blur (the phase-08 no-blur perf anchor) — the backdrop rule
# itself must not carry backdrop-filter (comments stripped).
backdrop = re.sub(r"/\*.*?\*/", "", _css_rule(css, ".ignore-editor-backdrop"), flags=re.S)
assert "backdrop-filter" not in backdrop, "no blur (phase-08 perf anchor)"
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_textarea_rule_is_mono_and_full_width() -> None:
"""The box itself: full panel width (width: 100%) + the mono
stack (var(--mono)) + a comfortable min-height — the house
comment cites phase 89 on the block."""
rule = _css_rule(_css(), ".ignore-editor-textarea")
assert "width: 100%" in rule, "full panel width"
assert "var(--mono)" in rule, "the mono stack"
assert "min-height" in rule, "a comfortable minimum height"
# The section comment cites phase 89 (house comment style).
css = _css()
header = css[css.rfind("/*", 0, css.find(".ignore-editor {")) : css.find(".ignore-editor {")]
assert "phase 89" in header, "the house comment cites phase 89"
def test_dialog_button_and_target_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both dialog buttons >=44px;
Cancel is the ghost ink-soft family (5.1:1 on --surface) with the
brand-soft hover (12.4:1); Save is the solid brand family (--bg
text on --brand 5.2:1, the .new-chat-btn convention) with the
lightened hover; the error line is the err pair; the panel caps
at the 46rem chat-column width or the viewport; the visible
label is ink-soft (5.1:1) — never a label-less textarea."""
css = _css()
btn = _css_rule(css, ".ignore-editor-btn")
assert "min-height: 44px" in btn and "min-width: 44px" in btn
cancel = _css_rule(css, ".ignore-editor-cancel")
assert "var(--ink-soft)" in cancel and "transparent" in cancel
cancel_hover = _css_rule(css, ".ignore-editor-cancel:hover:not(:disabled)")
assert "var(--brand-soft)" in cancel_hover and "var(--brand-ink)" in cancel_hover
save = _css_rule(css, ".ignore-editor-save")
assert "var(--brand)" in save and "var(--bg)" in save, (
"Save: the solid brand family (--bg text on --brand, 5.2:1)"
)
save_hover = _css_rule(css, ".ignore-editor-save:hover:not(:disabled)")
assert "background" in save_hover, "the hover lightens the fill"
err = _css_rule(css, ".ignore-editor-error")
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = _css_rule(css, ".ignore-editor-panel")
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
label = _css_rule(css, ".ignore-editor-label")
assert "display: block" in label and "var(--ink-soft)" in label, (
"a visible block label (WCAG — never aria-label-only)"
)
def test_row_button_and_count_tag_css_rules() -> None:
"""The row chrome: .git-source-ignore reuses the
.git-source-remove idiom (>=44px target, same size/spacing) in a
NEUTRAL secondary fill with the brand-soft hover (distinct from
the destructive Remove's err hover); .git-source-ignore-count is
a small inline tag — text + a distinct background (never color
alone), AA on both theme surfaces (--ink on --bg 16.7:1)."""
css = _css()
row = _css_rule(css, ".git-source-ignore")
assert "min-height: 44px" in row and "min-width: 44px" in row, "the >=44px rule"
assert "var(--ink-soft)" in row, "the neutral secondary resting fill"
row_hover = _css_rule(css, ".git-source-ignore:hover:not(:disabled)")
assert "var(--brand-soft)" in row_hover and "var(--brand-ink)" in row_hover, (
"the hover takes the brand pair (Remove hovers to the err pair)"
)
assert "var(--err-" not in row, "not the destructive err family"
count = _css_rule(css, ".git-source-ignore-count")
assert "var(--ink)" in count and "var(--bg)" in count, (
"text + a distinct background (never color alone)"
)