"""Unit: the branch-pin UI on the Sources page (phase 135, task 04). ``TODO.md`` L4: "Need to be able to specify the branch for a git source." The Sources manager lets the owner see, set, change, and clear a source's branch (ASSUMPTION B6): * the ADD form gains the optional ``#git-source-branch`` (maxlength 200, autocomplete=off, the visible "optional — default: the repo's default branch" hint) BETWEEN the URL and Token fields; the submit body is ``(url, token, branch) => ({ url, ...(branch ? { branch } : {}), ...(token ? { token } : {}) })`` — a BLANK branch OMITS the key (NULL = the repo's default branch — the pre-phase-135 clone/pull behavior), and 201 clears all three credential-free inputs; * the per-row EDITOR dialog gains ``#ignore-editor-branch`` (after the source line, before the ignore textarea, with the "Leave empty for the repo's default branch." hint line) — VISIBLE FOR GIT ROWS ONLY (local rows hide label + input + hint and the save body OMITS the key — local rows are walked, not cloned; the API's 422 is the backstop), opens PREFILLED from the stored value (``s.branch ?? ""`` — unlike the token, the API reports it, so there IS a field to read it from); * THE TRI-STATE ASYMMETRY: the git row's PATCH body ALWAYS includes ``branch`` (the trimmed value — an EMPTY save is the deliberate CLEAR leg: NULL = the repo's default branch), unlike the token's blank = keep (the token stays the conditional key — untouched); * every STORED git row with a branch shows a ``branch: `` text tag in the source cell (the "hidden on" tag idiom — text + background, never color alone: the text IS the signal); env-fallback rows (no ``s.id``) and NULL-branch rows show no tag; the source cell's title names the branch (hover precision). The browser behavior itself is E2E-gated by the phase-135 story suite (``tests/e2e/test_git_source_branch.py``, task 05); like the phase-121 ``test_git_source_token.py`` house pattern, this module pins the source-level contract a silent regression would break. """ from __future__ import annotations import re from pathlib import Path from app.schemas import GitSourceIn, GitSourceOut, GitSourcePatchIn, GitSourceRow FRONTEND = Path(__file__).resolve().parents[2] / "frontend" SHELL_HTML = FRONTEND / "index.html" GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" ADD_BRANCH_PLACEHOLDER = "main, release/1.2, …" EDIT_BRANCH_PLACEHOLDER = "(default branch)" ADD_HINT_TEXT = "optional — default: the repo's default branch" EDITOR_HINT_TEXT = "Leave empty for the repo's default branch." ADD_BODY_EXPR = ( "(url, token, branch) => ({ url, ...(branch ? { branch } : {}), " "...(token ? { token } : {}) })" ) BRANCH_TAG_CLASS = "git-source-branch-count" BRANCH_TAG_TEXT_EXPR = "`branch: ${s.branch}`" TITLE_EXPR = 's.id && s.branch ? `${value} — branch: ${s.branch}` : value' def _read(path: Path) -> str: return path.read_text(encoding="utf-8") def _js_text() -> str: return _read(GIT_SOURCES_JS) def _fn(js: str, name: str) -> str: """The source of a (possibly async) top-level function via balanced-brace counting (the test_git_source_token.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 _input_tag(html: str, id_attr: str) -> str: m = re.search(rf"]*id=\"{id_attr}\"[^>]*>", html, re.S) assert m, f"missing in the shell" return m.group(0) # --------------------------------------------------------------------------- # The add form — #git-source-branch (between URL and Token) # --------------------------------------------------------------------------- def test_add_form_branch_field_is_optional_text() -> None: """``#git-source-branch`` (task 04 step 1): ``type="text"`` (a branch is a name, not a credential — no masking), the 200-char cap mirrored (the schema's max_length), ``autocomplete="off"``, the placeholder, INSIDE ``#git-source-form`` BETWEEN the URL input and the Token input (before the submit button), and a visible ``", html, re.S) assert label, "a visible ", html, re.S) assert label and "Branch" in label.group(1), "a visible label (WCAG)" assert 'class="ignore-editor-label"' in (label.group(0) if label else ""), ( "the house dialog-label styling" ) hint = re.search( r"

(.*?)

", html, re.S ) assert hint and hint.group(1).strip() == EDITOR_HINT_TEXT, "the muted hint line" def test_editor_opens_prefilled_for_git_hidden_for_local() -> None: """openIgnoreEditor: git rows open the branch field prefilled from the stored value (``s.branch ?? ""`` — there IS a field to read it from, unlike the token) and VISIBLE; local rows HIDE the whole field group (label + input + hint — local rows are walked, not cloned) and clear the value.""" js = _js_text() open_body = _fn(js, "openIgnoreEditor") assert "const branchIsGit = s.kind !== \"local\";" in open_body, ( "the git-row gate" ) prefill_i = open_body.find('ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";') assert prefill_i != -1, "prefilled from the stored value (s.branch ?? \"\")" for frag in ( "ignoreBranchLabel.hidden = !branchIsGit", "ignoreBranchHint.hidden = !branchIsGit", "ignoreBranchInput.hidden = !branchIsGit", ): assert frag in open_body, f"the field group hides for local rows: {frag!r}" label_i = open_body.find("ignoreBranchLabel.hidden = !branchIsGit") hint_i = open_body.find("ignoreBranchHint.hidden = !branchIsGit") assert -1 < label_i < hint_i < prefill_i, "the group hides before the prefill" def test_editor_save_always_sends_branch_for_git_rows() -> None: """saveIgnorePaths: the branch is read (trimmed) before the PATCH; the git row's body ALWAYS includes ``branch`` — ``...(isGitRow ? { branch } : {})`` — an EMPTY value is the deliberate CLEAR leg (the tri-state asymmetry, documented at the read site), while a LOCAL row's body OMITS the key; the token stays the conditional key (blank = keep — untouched) and lands AFTER the branch key.""" js = _js_text() save = _fn(js, "saveIgnorePaths") read_i = save.find('const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";') assert read_i != -1, "the branch is read (trimmed) before the PATCH" assert "const isGitRow = t.kind !== \"local\";" in save, "the git-row gate" # The asymmetry is documented at the read site (the docstring # comment above the read names the clear leg). comment_i = save.find("the tri-state ASYMMETRY", 0, read_i) assert -1 < comment_i < read_i, "the tri-state asymmetry is documented at the read" body_i = save.find("...(isGitRow ? { branch } : {})") tok_i = save.find("...(token ? { token } : {})", body_i) assert -1 < read_i < body_i < tok_i, ( "git rows always send branch (\"\" = clear); the token stays conditional" ) assert "ignore_paths: lines" in save[body_i - 60 : body_i + 60], ( "the ignore_paths replace is untouched" ) def test_editor_close_resets_the_branch_field() -> None: """closeIgnoreEditor: the branch input resets to "" (it re-opens prefilled from the server row — the phase-121 token reset precedent, with a prefilled twist).""" js = _js_text() close_body = _fn(js, "closeIgnoreEditor") assert 'ignoreBranchInput.value = ""' in close_body, "resets on close" # --------------------------------------------------------------------------- # makeRow — the "branch: " tag + the hover title # --------------------------------------------------------------------------- def test_make_row_branch_tag_is_stored_git_rows_only() -> None: """makeRow: the ``branch: `` text tag is appended to the source cell IFF ``s.id && s.branch`` (env-fallback rows carry no id, NULL-branch rows no truthy branch — no tag either way); the tag uses the "hidden on" idiom (text + background — the .git-source-branch-count class, grouped with .git-source-hidden-count in styles.css) and the TEXT is the signal (``branch: `` — never color alone).""" js = _js_text() make = _fn(js, "makeRow") guard_i = make.find("if (s.id && s.branch) {") assert guard_i != -1, "stored git rows with a branch only" block = make[guard_i :] block = block[: block.find("\n }")] assert f"branchTag.className = \"{BRANCH_TAG_CLASS}\";" in block assert f"branchTag.textContent = {BRANCH_TAG_TEXT_EXPR};" in block, ( "the text IS the signal (branch: )" ) assert "urlTd.append(branchTag)" in block, "the tag lands in the source cell" # The tag comes after the hidden tag (the source-cell tag order: # ignored → hidden → branch) and before the cell is appended. hidden_i = make.find('hiddenTag.className = "git-source-hidden-count"') append_i = make.find("tr.appendChild(urlTd)") assert -1 < hidden_i < guard_i < append_i def test_make_row_title_names_the_branch() -> None: """The source cell's title (the hover-precision idiom) includes the pinned branch for stored git rows with one; every other row keeps the bare value (the pre-phase-135 title — the token E2E's to_have_attribute("title", URL) pins stay green).""" js = _js_text() make = _fn(js, "makeRow") assert f"urlTd.title = {TITLE_EXPR};" in make, ( "the title names the branch (stored git rows only)" ) # --------------------------------------------------------------------------- # Cross-file wire contract — the JS key names match the API shapes # --------------------------------------------------------------------------- def test_js_branch_keys_match_the_api_shapes() -> None: """The wire contract: the POST body key and the PATCH body key are both ``branch`` (task 04 step 3 — the branch rides the existing payloads), and every API shape carries the field (task 01/02): ``GitSourceIn`` (create), ``GitSourcePatchIn`` (tri-state), ``GitSourceOut`` + ``GitSourceRow`` (the row the UI renders — ``s.branch`` is read off it). A rename on either side breaks.""" assert "branch" in GitSourceIn.model_fields assert "branch" in GitSourcePatchIn.model_fields assert "branch" in GitSourceOut.model_fields assert "branch" in GitSourceRow.model_fields js = _js_text() assert "...(branch ? { branch } : {})" in js, "the POST body key (blank-omits)" assert "...(isGitRow ? { branch } : {})" in js, "the PATCH body key (always for git)" assert "(s.branch ?? \"\")" in js, "the prefill reads s.branch off the row" # --------------------------------------------------------------------------- # The module docstring — the phase-135 paragraph (the house pattern) # --------------------------------------------------------------------------- def test_module_docstring_carries_the_phase_135_contract() -> None: """The git-sources.js module docstring gained the phase-135 paragraph (the field, the tri-state asymmetry, the tag rule).""" doc = _js_text().split("*/", 2)[0] # the module docstring (first block) for frag in ( "Phase 135 (task 04)", "#git-source-branch", "#ignore-editor-branch", "s.branch ?? \"\"", "THE TRI-STATE ASYMMETRY", "branch: ", "the tag appears/disappears", ): assert frag in doc, f"the module docstring lost: {frag!r}" # --------------------------------------------------------------------------- # styles.css — house-styled rules (no second input language) # --------------------------------------------------------------------------- def test_branch_field_css_rules_are_house_styled() -> None: """styles.css: the add form's branch input joins the URL/token box group (mono, >=44px floor, placeholder + focus rules); the editor's branch field is full panel width at the 44px floor (the token field's box language); the hint line carries the .field-hint values (ink-soft, 0.8rem); the row tag joins the .git-source-hidden-count rule (text + a distinct background, never color alone); the mobile squeeze groups the add input with URL/token.""" css = _read(STYLES_CSS) grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {") assert grouped != -1, "#git-source-branch shares the URL/token box" rule = css[grouped : css.find("}", grouped)] assert "min-height: 44px" in rule and "var(--mono)" in rule assert "#git-source-branch::placeholder" in css, "the muted placeholder" assert "#git-source-branch:focus-visible" in css, "the brand focus border" br = css.find("#ignore-editor-branch {") assert br != -1, "the editor's branch field rule" br_rule = css[br : css.find("}", br)] assert "width: 100%" in br_rule and "min-height: 44px" in br_rule hint = css.find(".ignore-editor-branch-hint {") assert hint != -1, "the muted hint line rule" hint_rule = css[hint : css.find("}", hint)] assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule, ( "the .field-hint values (the token field's hint styling)" ) # Phase 191 (task 03): the name/label/tags row's two tags ("named" and # one tag chip) joined this SAME rule — the reuse that keeps the token # input from shipping its own tag CSS. tag = css.find( ".git-source-hidden-count,\n.git-source-branch-count,\n" ".source-label-state,\n.source-label-tag {" ) assert tag != -1, "the row tag joins the 'hidden on' tag rule" tag_rule = css[tag : css.find("}", tag)] assert "background: var(--bg)" in tag_rule and "border" in tag_rule, ( "text + background (never color alone — WCAG 1.4.1)" ) mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,") assert mobile != -1, "the mobile squeeze groups the branch input"