"""Unit: the per-row "Hidden" toggle on the Sources page (phase 105, task 05). ``TODO.md`` L3: "There should be a toggle per input (next to the ignores button) to allow indexing hidden .folders." Every STORED ``git_sources`` row (git or local) gets a native labeled **Hidden** checkbox in the actions cell, LEFT of the "Ignore paths" button (DOM order Hidden · Ignore paths · Remove — A5); when on, the source cell shows a "hidden on" text tag (text + background, never color alone — WCAG 1.4.1); the flip PATCHes ``{"include_hidden": …}`` with the §7.4 never-stale lifecycle (box disables at once; 200 → reload + confirm LAST; failure → the box reverts to the SERVER state and the detail lands in the page-level ``#git-sources-hidden-error`` ``role="alert"`` line — the checkbox is a table-cell control with no dialog of its own). Env-fallback rows (``id`` null) get no checkbox (A3 — nothing is stored to flag). The browser behavior itself is E2E-gated by the phase-105 story suite (``tests/e2e/test_hidden_folders_toggle.py``, task 06); like the phase-89 ``test_source_ignore_paths.py`` house pattern, this module pins the source-level contract a silent regression would break: * the JS row wiring — ``makeRow`` builds the checkbox ONLY in the ``s.id`` branch (class ``git-source-hidden-box``, ``type=checkbox``, ``checked`` from ``s.include_hidden === true`` — server state only, never a prior local flip), the aria-label is the ONLY place the value appears (setAttribute — never innerHTML), the visible "Hidden" text label, and the append ORDER (hidden label → ignore button → Remove); the "hidden on" tag is TEXT in the source cell, iff ``s.id && s.include_hidden === true``; * the JS lifecycle — ``toggleHidden``: the box disables BEFORE the ``PATCH`` (one flip at a time), the body is ``JSON.stringify({ include_hidden: wanted })`` ONLY (the row's ignore list is untouched — task 03's optional field), 200 → clear the error → ``await loadSources()`` → announce (the confirmation is the LAST announcement); non-2xx AND network failure → the box REVERTS to ``s.include_hidden === true`` + re-enables and the detail lands in the page-level alert line; a healed ``loadSources`` clears the line (the phase-89 "happy path heals the error state" precedent); * the cross-file wire contract (SINGLE SOURCE OF TRUTH for the field name) — the JS PATCH body key and the render field both match their ``app/schemas.py`` counterparts (``GitSourcePatchIn.include_hidden`` / ``GitSourceRow``) — a rename on either side breaks the test; * the static shell markup — ``#git-sources-hidden-error`` is a ``role="alert"`` line, hidden by default, reusing the ``.git-source-error`` class, INSIDE ``#view-git-sources`` and AFTER ``#git-sources-table-wrap`` in source order; * styles.css — the ``.git-source-hidden`` family on the house palette: ~44px hit height, the checkbox's ``accent-color: var(--brand)`` (checked-state contrast verified + recorded in the comment), the ``:disabled`` wait state, and the "hidden on" tag (text + a distinct background, never color alone). """ from __future__ import annotations import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] FRONTEND = ROOT / "frontend" # Phase 76 (task 02): the Sources view lives in the ONE-document shell. SHELL_HTML = FRONTEND / "index.html" JS = FRONTEND / "assets" / "git-sources.js" CSS = FRONTEND / "assets" / "styles.css" SCHEMAS = ROOT / "app" / "schemas.py" #: The checkbox + tag classes (pinned verbatim). HIDDEN_BOX_CLASS = "git-source-hidden-box" HIDDEN_LABEL_CLASS = "git-source-hidden" HIDDEN_TAG_CLASS = "git-source-hidden-count" HIDDEN_TAG_TEXT = "hidden on" #: The checkbox's aria-label template — the ONLY place the full #: source value appears (setAttribute, never innerHTML). ARIA_LABEL_TEMPLATE = "`Index hidden folders for ${kindLabel} source: ${value}`" #: The §7.4 lifecycle strings (pinned verbatim). REVERT_STATE = "box.checked = s.include_hidden === true" NETWORK_MESSAGE = "Could not reach the server — the setting was not changed." PATCH_BODY = "JSON.stringify({ include_hidden: wanted })" 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_source_ignore_paths.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 _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}") def _python_class_field(class_name: str) -> str: """The single bool field name declared on a Pydantic class in ``app/schemas.py`` (``bool`` or ``bool | None`` — regex-parsed, no import: the pin is on the source text, so a rename breaks it without a schema load).""" schemas = _text(SCHEMAS) m = re.search( rf"class {class_name}\(BaseModel\):.*?(?=\nclass |\Z)", schemas, re.S ) assert m, f"class {class_name} must exist in app/schemas.py" fields = re.findall( r"^\s*(\w+):\s*bool(?:\s*\|\s*None)?\s*(?==|$)", m.group(0), re.M ) assert len(fields) == 1, f"{class_name} must declare exactly one bool field" return fields[0] # ---------- the row wiring in makeRow ---------- def test_row_checkbox_is_built_in_the_stored_row_branch() -> None: """makeRow: the Hidden checkbox is created ONLY in the ``s.id`` branch (A3 — env-fallback rows get the "from .env" tag, no checkbox). It is a NATIVE labeled checkbox: class git-source-hidden-box, type=checkbox, the visible "Hidden" text, ``checked`` from the SERVER row (``s.include_hidden === true`` — never a prior local flip, §7.4), and the aria-label template is the ONLY place ``value`` appears (setAttribute — never innerHTML). Its change handler runs toggleHidden.""" make = _fn(_js(), "makeRow") branch_i = make.find("if (s.id) {") else_i = make.find("} else {", branch_i) assert branch_i != -1 and else_i > branch_i box_i = make.find('const hiddenBox = document.createElement("input")', branch_i) assert -1 < box_i < else_i, "the checkbox is created in the s.id branch (A3)" assert 'hiddenBox.type = "checkbox"' in make, "a native checkbox" assert f"hiddenBox.className = \"{HIDDEN_BOX_CLASS}\"" in make assert "hiddenBox.checked = s.include_hidden === true" in make, ( "checked state comes ONLY from the server row" ) assert ARIA_LABEL_TEMPLATE in make, "the aria-label template (the only value site)" assert 'hiddenBox.setAttribute(' in make, "the label is set via setAttribute" assert "hiddenBox.innerHTML" not in _js(), "XSS contract: no innerHTML on the box" assert "hiddenLabel.innerHTML" not in _js(), "XSS contract: no innerHTML on the label" assert 'document.createTextNode("Hidden")' in make, ( "the visible text label — never aria-label-only (WCAG)" ) assert "hiddenLabel.append(hiddenBox, document.createTextNode(\"Hidden\"))" in make assert 'hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox))' in make # The env-fallback branch (else) has no checkbox. else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)] assert HIDDEN_BOX_CLASS not in else_slice and "toggleHidden" not in else_slice, ( "env-fallback rows get no checkbox (A3)" ) def test_row_dom_order_is_hidden_ignore_remove() -> None: """makeRow: the append order in the actions cell is Hidden · Ignore paths · Remove (A5 — the toggle sits LEFT of the "Ignore paths" button; Remove stays last).""" make = _fn(_js(), "makeRow") branch_i = make.find("if (s.id) {") else_i = make.find("} else {", branch_i) hidden_append = make.find("actTd.appendChild(hiddenLabel)", branch_i) ignore_i = make.find('const ignoreBtn = document.createElement("button")', branch_i) ignore_append = make.find("actTd.appendChild(ignoreBtn)", branch_i) remove_append = make.find("actTd.appendChild(btn)", branch_i) assert -1 < hidden_append < ignore_i, "the hidden label precedes the ignore button" assert -1 < ignore_append < remove_append, "Remove stays last" assert remove_append < else_i, "all three controls are in the s.id branch" def test_hidden_on_tag_renders_text_in_the_source_cell() -> None: """makeRow: a stored row with the flag ON gets the "hidden on" tag in the SOURCE cell (urlTd — the .git-source-ignore-count idiom: TEXT, never color alone, WCAG 1.4.1), appended after the location ; the tag is the state copy (pinned verbatim).""" make = _fn(_js(), "makeRow") assert "s.id && s.include_hidden === true" in make, ( "the tag only for stored rows with the flag on" ) assert f"hiddenTag.className = \"{HIDDEN_TAG_CLASS}\"" in make assert f"hiddenTag.textContent = \"{HIDDEN_TAG_TEXT}\"" in make, ( "the tag copy is TEXT — never color alone" ) code_i = make.find("urlTd.append(badge, code)") append_i = make.find("urlTd.append(hiddenTag)", code_i) assert -1 < code_i < append_i, "the tag is appended to the source cell" # ---------- the JS lifecycle (toggleHidden) ---------- def test_toggle_sends_patch_and_disables_the_box_first() -> None: """toggleHidden: the box disables BEFORE the PATCH goes out (no double-flip — §7.4); the request is a PATCH to /api/git-sources/{id} whose body is ``{"include_hidden": …}`` ONLY (the row's ignore list is untouched — task 03's optional field).""" body = _fn(_js(), "toggleHidden") disable_i = body.find("box.disabled = true") fetch_i = body.find("`/api/git-sources/${s.id}`") method_i = body.find('method: "PATCH"', fetch_i) body_i = body.find(PATCH_BODY, method_i) assert -1 < disable_i < fetch_i < method_i < body_i, ( "disable → PATCH {include_hidden} (and nothing else)" ) assert "ignore_paths" not in body, "the PATCH body never carries the ignore list" def test_toggle_success_clears_reloads_and_announces_last() -> None: """toggleHidden 200: the error line clears, ``loadSources()`` AWAITS (the row re-renders from the server — the "hidden on" tag lands), and the confirmation is the LAST announcement (the reload's "N sources listed." lands first — the phase-89 order).""" body = _fn(_js(), "toggleHidden") ok_i = body.find("if (r.ok)") hide_i = body.find("hideHiddenError()", ok_i) reload_i = body.find("await loadSources()", ok_i) announce_i = body.find("announce(`Hidden folders", reload_i) assert -1 < ok_i < hide_i < reload_i < announce_i, ( "200: clear error → await loadSources → announce (LAST)" ) assert "for ${value}.`)" in body[announce_i:], "the confirmation names the source" assert "enabled" in body[announce_i:] and "disabled" in body[announce_i:], ( "the confirmation states the direction of the flip" ) def test_toggle_failure_reverts_the_box_and_shows_the_page_alert() -> None: """toggleHidden failure: non-2xx → the server detail (apiDetail) into the page-level alert line + the box REVERTS to the server state + re-enables; network failure → the fixed reachable? line, same revert. The UI never claims a state the server didn't save (PLAN §7.4). The revert + re-enable happen on BOTH failure branches (exactly twice each in the function).""" body = _fn(_js(), "toggleHidden") catch_i = body.find(".catch(") assert catch_i != -1 fail_slice = body[body.find("if (r.ok)"):catch_i] assert "await apiDetail(" in fail_slice, "the server detail is apiDetail-extracted" assert "showHiddenError(" in fail_slice, "the detail lands in the page alert line" assert REVERT_STATE in fail_slice, "the box reverts to the server state" assert "box.disabled = false" in fail_slice, "the box re-enables" net_slice = body[catch_i:] assert NETWORK_MESSAGE in net_slice, "the fixed network copy" assert "showHiddenError(" in net_slice assert REVERT_STATE in net_slice assert "box.disabled = false" in net_slice assert body.count(REVERT_STATE) == 2, "revert on BOTH failure branches" assert body.count("box.disabled = false") == 2, "re-enable on BOTH failure branches" assert "box.disabled = true" in body[: body.find("if (r.ok)")], ( "the box disables at once, before any outcome" ) def test_error_line_helpers_and_the_healed_load_clear() -> None: """showHiddenError/hideHiddenError drive the page-level ``hiddenErrorEl`` (textContent + hidden); ``loadSources``'s success path calls hideHiddenError() AFTER hideLoadError() — a healed list clears the stale line (the phase-89 "happy path heals the error state" precedent).""" js = _js() assert 'const hiddenErrorEl = root.querySelector("#git-sources-hidden-error")' in js, ( "the page-local element grabber (root-scoped, the shell idiom)" ) show = _fn(js, "showHiddenError") assert "hiddenErrorEl.textContent = message" in show assert "hiddenErrorEl.hidden = false" in show hide = _fn(js, "hideHiddenError") assert 'hiddenErrorEl.textContent = ""' in hide assert "hiddenErrorEl.hidden = true" in hide load = _fn(js, "loadSources") hide_load_i = load.find("hideLoadError()") hide_hidden_i = load.find("hideHiddenError()", hide_load_i) assert -1 < hide_load_i < hide_hidden_i, ( "a healed load clears the hidden-toggle line (after the load-error clear)" ) # ---------- the cross-file wire contract (single source of truth) ---------- def test_js_field_name_matches_the_python_patch_schema() -> None: """The cross-file pin: the JS PATCH body key and the render field both equal their ``app/schemas.py`` counterparts — ``GitSourcePatchIn.include_hidden`` (the write side) and ``GitSourceRow.include_hidden`` (the read side). A rename on either side breaks the wire contract and this test.""" js = _js() body = _fn(js, "toggleHidden") m = re.search(r"JSON\.stringify\(\{\s*(\w+)\s*:\s*wanted\s*\}\)", body) assert m, "the PATCH body carries a single {: wanted} object" js_body_key = m.group(1) assert js_body_key == _python_class_field("GitSourcePatchIn"), ( "the JS PATCH body key must match GitSourcePatchIn's field" ) make = _fn(js, "makeRow") rm = re.search(r"s\.(\w+)\s*===\s*true", make) assert rm, "makeRow reads the flag off the server row (s. === true)" js_read_key = rm.group(1) assert js_read_key == _python_class_field("GitSourceRow"), ( "the render field must match GitSourceRow's field" ) assert js_body_key == js_read_key == "include_hidden", ( "one field name for the whole wire contract" ) # ---------- the static shell markup ---------- def test_page_error_line_is_a_role_alert_after_the_table() -> None: """index.html: ``#git-sources-hidden-error`` is a ``

`` reusing the existing ``.git-source-error`` class, ``role="alert"`` + hidden by default, INSIDE ``#view-git-sources`` and AFTER ``#git-sources-table-wrap`` in source order (the checkbox is a table-cell control, so its failure announces at page level — the ignore dialog carries its own error INSIDE the modal). It appears exactly once.""" html = _text(SHELL_HTML) tag = re.search(r']*id="git-sources-hidden-error"[^>]*>', html) assert tag, "#git-sources-hidden-error must be a real

" open_tag = tag.group(0) for attr in ('class="git-source-error"', 'role="alert"', "hidden"): assert attr in open_tag, f"the error line carries {attr}" view_i = html.find('id="view-git-sources"') wrap_i = html.find('id="git-sources-table-wrap"') err_i = html.find('id="git-sources-hidden-error"') next_i = html.find('id="view-history"') assert -1 < view_i < wrap_i < err_i < next_i, ( "the line sits inside #view-git-sources, after #git-sources-table-wrap" ) assert html.count('id="git-sources-hidden-error"') == 1, ( "the id is unique in the shell" ) # ---------- styles.css ---------- def test_hidden_checkbox_css_rules_exist_with_accent_color() -> None: """styles.css carries the phase-105 family: .git-source-hidden (inline-flex, 44px hit height matching the action buttons, the house ink), the checkbox (sized, ``accent-color: var(--brand)``), the :disabled wait state (the .git-source-remove:disabled idiom), and the "hidden on" tag (text + a distinct background — never color alone). The checkbox comment RECORDS the verified checked-state contrast (house style).""" css = _css() label = _css_rule(css, ".git-source-hidden") assert "display: inline-flex" in label assert "height: 44px" in label, "the ~44px hit height matches the action buttons" assert "var(--ink)" in label box = _css_rule(css, '.git-source-hidden input[type="checkbox"]') assert "accent-color: var(--brand)" in box, "the --brand checkbox fill" disabled = _css_rule(css, ".git-source-hidden:disabled") assert "opacity" in disabled and "cursor: wait" in disabled, ( "the .git-source-remove:disabled idiom" ) tag = _css_rule(css, ".git-source-hidden-count") assert "var(--ink)" in tag and "var(--bg)" in tag, ( "text + a distinct background (never color alone)" ) assert "url(http" not in css and "@import url(" not in css, ( "no CDN (AGENTS.md rule 6)" ) def test_checkbox_contrast_is_verified_and_recorded() -> None: """House style: the checked-state contrast of the native widget is VERIFIED and the ratio RECORDED in the comment above the checkbox rule (the executor note the task template carries): the --bg-on---brand house pairing (5.2:1) on the built-in theme, and the theme tab's AA gate (4.5:1) for every saved palette.""" css = _css() rule_i = css.find('.git-source-hidden input[type="checkbox"] {') assert rule_i != -1 header = css[css.rfind("/*", 0, rule_i):rule_i] assert "VERIFIED" in header, "the contrast is verified (the executor note)" assert "5.2:1" in header, "the built-in --bg-on---brand ratio is recorded" assert "4.5:1" in header, "the AA gate for themed palettes is recorded" # ---------- the module docstring ---------- def test_module_docstring_carries_the_phase_105_contract() -> None: """The git-sources.js module docstring gained the phase-105 entry: the per-row Hidden checkbox (makeRow) → PATCH {include_hidden} (task 03's optional field) → loadSources + announce; failure reverts the box + #git-sources-hidden-error (role=alert); env-fallback rows get no checkbox (A3).""" doc = _js().split("*/", 2)[0] # the module docstring (first block) for frag in ( "Phase 105 (task 05)", "toggleHidden", "{ include_hidden }", "#git-sources-hidden-error", "env-fallback rows get NO checkbox", "REVERTS to the server state", "LAST announcement", ): assert frag in doc, f"the module docstring lost: {frag!r}"