Files
brain-of-reese/tests/unit/test_source_labels_editor_ui.py
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
2026-10-19 06:13:32 -04:00

747 lines
35 KiB
Python

"""Unit: the per-source name/label/tags editor on the Sources page
(phase 191, task 03).
``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus
the model when prompting if you have a project name or a standard name
you reference things by." Tasks 01/02 stored that (``source_labels``,
migration 0030) and exposed it (``/api/source-labels``); this task is the
surface the owner types into — a SECOND list on ``/git-sources.html``
whose row list is the catalogue ``GET /api/source-labels`` (the union of
every registry row's DERIVED name and every indexed document source, so
git, local AND upload sources all get an editor without a second API):
* the markup — the ``#source-labels-section`` card inside the admin-gated
``#git-sources-content`` (the page's gate is this list's gate: the
anonymous path returns at the gate and never fetches it), its own
``.table-wrap`` (the phase-156 bounded panel + sticky header — the
class alone, no divergent card rule), its ``role="alert"`` load-error
line, its empty state, and ONE shared
``<template id="source-label-row-template">`` cloned per row: the
read-only mono identity cell, the three fields (Name ``maxlength=120``,
Label ``maxlength=200``, the Tags token input ``maxlength=32``), the
``type="button"`` Save / Reset pair and the ``role="status"`` save-state
line;
* THE ALIAS CONTRACT (D1/A1) — the identity cell is a ``<code>`` and the
template carries NO input there: a label row never renames
``Document.source``, a locator, a citation or a saved chat;
* the JS lifecycle — ``loadLabels`` → ``renderLabelRows`` → ``makeLabelRow``
(``template.content`` + ``cloneNode``, then the per-clone id rewrite so
every ``<label for>`` stays unique), the token input (Enter / comma adds
a chip, Backspace drops the last, the ``normalize_tags`` caps mirrored),
the dirty paint (``.is-dirty`` + "unsaved changes", per ROW), and Save /
Reset on the row's OWN ``saveInFlight`` flag — the ``PUT`` / ``DELETE``
response is the new truth (NO second GET), the 404 arm names itself
("That source no longer exists") and the ``finally`` settles every
outcome so "Saving…" can never stick (§7.4);
* styles.css — the chips and the state tag JOIN the phase-89 tag rule
(``.git-source-hidden-count`` / ``.git-source-branch-count`` /
``.source-label-state`` / ``.source-label-tag`` are ONE rule — no new
tag-input CSS), the fields take the ``.ignore-editor-token`` box
language, the buttons keep the ≥44px floor, the empty state is shared
with ``.git-sources-empty``, and the new block stays on palette
variables (no raw hex, no new hue).
The browser behaviour is E2E-gated by the phase-191 story suite
(``tests/e2e/test_source_name_labels.py``, task 06); like
``test_source_ignore_paths.py`` / ``test_git_source_branch_ui.py`` (the
house pattern for this page), this module pins the source-level contract a
silent regression would break.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The static ids the section ships (the no-collision guard keys on these
#: — the ids inside the template are the FIRST row's and makeLabelRow
#: rewrites the clones').
SECTION_IDS = (
"source-labels-section",
"source-labels-title",
"source-labels-error",
"source-labels-wrap",
"source-labels-table",
"source-labels-tbody",
"source-labels-empty",
"source-label-row-template",
)
#: The template's field ids (rewritten per clone to ``<base>-<n>``).
TEMPLATE_FIELD_IDS = (
"source-label-name",
"source-label-label",
"source-label-tags",
"source-label-editor",
)
#: The save-state line's copy, pinned verbatim (§7.4 + the 404 arm).
STATUS_SAVED = "saved"
STATUS_DIRTY = "unsaved changes"
STATUS_SAVING = "Saving…"
STATUS_RESETTING = "Resetting…"
NOTE_GONE = "That source no longer exists — reload the page."
#: The caps of ``app/rag/source_labels`` mirrored client-side.
TAG_MAX_CHARS = 32
TAGS_MAX_COUNT = 10
#: The functions this module inspects (all live inside ``mount(root)``).
LABEL_FNS = (
"storedFromEntry",
"hasStoredLabel",
"labelSummaryText",
"paintLabelTags",
"addLabelTags",
"readLabelForm",
"fillLabelForm",
"labelFormDirty",
"clearLabelError",
"setLabelStatus",
"paintLabelRowState",
"updateLabelRowUI",
"setLabelEditorOpen",
"labelFailureNote",
"saveLabelRow",
"resetLabelRow",
"makeLabelRow",
"loadLabels",
"renderLabelRows",
"showLabelsError",
"hideLabelsError",
)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _html() -> str:
return _read(SHELL_HTML)
def _js() -> str:
return _read(JS)
def _css() -> str:
return _read(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) 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 _input_tag(html: str, class_attr: str) -> str:
m = re.search(rf"<input[^>]*{class_attr}[^>]*>", html, re.S)
assert m, f"missing <input class*=…{class_attr}> in the shell"
return re.sub(r"\s+", " ", m.group(0))
def _element_by_id(html: str, id_attr: str) -> str:
m = re.search(rf'<[a-z]+ [^>]*id="{id_attr}"[^>]*>', html, re.S)
assert m, f"missing the #{id_attr} element in the shell"
return re.sub(r"\s+", " ", m.group(0))
def _div_span(html: str, open_tag: str) -> str:
"""The substring from an opening ``<div …>`` to its matching
``</div>`` (balanced div-tag counting — the phase-174 helper)."""
start = html.index(open_tag)
depth = 0
for m in re.finditer(r"<div\b|</div>", html[start:]):
if m.group(0) == "</div>":
depth -= 1
if depth == 0:
return html[start : start + m.end()]
else:
depth += 1
raise AssertionError(f"unbalanced divs from {open_tag!r}")
def _label_for(html: str, field_id: str) -> str:
m = re.search(rf'<label[^>]*for="{field_id}"[^>]*>(.*?)</label>', html, re.S)
assert m, f"missing <label for={field_id}> (WCAG — never aria-label-only)"
return re.sub(r"\s+", " ", m.group(0))
def _css_rule(css: str, selector: str) -> str:
"""The BODY of one CSS rule (these rules carry no nested braces)."""
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m is not None, f"rule for {selector!r} not found in styles.css"
return " ".join(m.group(1).split())
def _css_selector_list(css: str, needle: str) -> str:
"""The SELECTOR LIST of the rule that styles ``needle`` (the last
selector before the ``{``), so a shared rule is detectable."""
i = css.find(needle)
assert i != -1, f"{needle!r} is not in styles.css"
open_brace = css.find("{", i)
head = css.rfind("}", 0, i)
start = head + 1 if head != -1 else 0
return " ".join(css[start:open_brace].split())
def _template_block(html: str) -> str:
start = html.index('<template id="source-label-row-template">')
end = html.index("</template>", start)
return html[start:end]
# ---------------------------------------------------------------------------
# The section scaffold (index.html)
# ---------------------------------------------------------------------------
def test_section_ships_inside_the_admin_gated_content() -> None:
"""The card exists, is labelled by its own heading, and sits INSIDE
``#git-sources-content`` — the reveal git-sources.js performs only in
the ADMIN branch, which is what keeps the anonymous visitor from ever
fetching ``/api/source-labels`` (the page's gate is this list's gate)."""
html = _html()
section = _element_by_id(html, "source-labels-section")
assert 'class="source-labels"' in section
assert 'aria-labelledby="source-labels-title"' in section
content = _div_span(html, '<div id="git-sources-content" hidden>')
assert 'id="source-labels-section"' in content, (
"the section must live inside #git-sources-content (anonymous ships hidden)"
)
assert 'id="source-labels-title"' in content
# The heading is real copy about the alias, not a placeholder.
title = re.search(
r'<h2 class="source-labels-title" id="source-labels-title">(.*?)</h2>', html, re.S
)
assert title and "Names, labels and tags" in title.group(1)
def test_section_carries_its_own_error_line_and_empty_state() -> None:
"""``#source-labels-error`` is the list's own ``role="alert"`` line
(hidden until the GET fails) and ``#source-labels-empty`` the empty
state (hidden by default) — the two states a failed or empty catalogue
may reach, so the page is never stuck (the ``#git-sources-load-error``
discipline applied to this page's second list)."""
html = _html()
err = _element_by_id(html, "source-labels-error")
assert 'role="alert"' in err, "a failed catalogue is an alert, not silence"
assert " hidden" in err, "the error line ships hidden"
empty = _element_by_id(html, "source-labels-empty")
assert " hidden" in empty, "the empty state ships hidden"
assert "No sources to name yet." in html
def test_table_is_a_bounded_sticky_panel_with_scoped_columns() -> None:
"""The wrapper carries the shared ``.table-wrap`` class (the ONE
bounded-panel + sticky-header card rule, phase 156 — an unbounded
bespoke card would be the regression) and the table has two scoped
column headers, the second one naming all three fields."""
html = _html()
wrap = _element_by_id(html, "source-labels-wrap")
assert "table-wrap" in wrap, "the shared bounded/sticky card class"
assert 'role="region"' in wrap and 'tabindex="0"' in wrap, (
"a scroll region is keyboard-reachable and named (WCAG 2.1.1/1.3.1)"
)
assert 'id="source-labels-tbody"' in html
table_i = html.index('id="source-labels-table"')
tail = html[table_i : html.index("</table>", table_i)]
assert '<th scope="col">Source</th>' in tail
assert '<th scope="col">Name, label and tags</th>' in tail
assert '<caption class="visually-hidden">' in tail
def test_new_ids_appear_exactly_once() -> None:
"""The no-collision guard (the phase-46/76 contract): every static id
this task adds appears EXACTLY ONCE in the shell."""
html = _html()
for id_attr in SECTION_IDS + TEMPLATE_FIELD_IDS:
n = len(re.findall(rf'id="{re.escape(id_attr)}"', html))
assert n == 1, f'id="{id_attr}" appears {n} times in index.html (must be 1)'
# ---------------------------------------------------------------------------
# The shared row template + the alias contract
# ---------------------------------------------------------------------------
def test_template_ships_one_row_with_the_three_capped_fields() -> None:
"""ONE shared template, cloned per row: the Name (≤120), Label (≤200)
and Tags (≤32) inputs — ``type="text"`` + ``autocomplete="off"`` +
the ``maxlength`` mirroring the API caps (so an over-long submission
never round-trips as a 422) — each with a real visible
``<label for>`` (WCAG, never aria-label-only)."""
tpl = _template_block(_html())
assert "<tr" in tpl, "the template holds one row (a <tr> parses in a template)"
for cls, cap, field_id in (
("source-label-name-input", 120, "source-label-name"),
("source-label-label-input", 200, "source-label-label"),
("source-label-tags-input", 32, "source-label-tags"),
):
tag = _input_tag(tpl, cls)
assert 'type="text"' in tag, f"{cls} is a plain text field"
assert f'maxlength="{cap}"' in tag, f"{cls} mirrors the {cap}-char API cap"
assert 'autocomplete="off"' in tag, f"{cls} offers no browser autofill"
assert f'id="{field_id}"' in tag, f"{cls} carries the template id JS rewrites"
assert _label_for(tpl, field_id), f"{cls} has a visible <label for>"
def test_identity_cell_is_read_only_text_never_an_input() -> None:
"""D1/A1 pinned in markup: the identity cell is a ``<code>`` — the
derived source name — and the ONLY element the row's identity area
contains; the editor is a sibling cell, so a label can never become a
rename of ``Document.source``, a citation or a ``source/path`` locator."""
tpl = _template_block(_html())
m = re.search(
r'<td class="source-label-id-cell">(.*?)</td>', tpl, re.S
)
assert m, "the identity cell exists"
cell = m.group(1)
assert '<code class="source-label-source">' in cell
assert "<input" not in cell, "the identity cell has NO input (an alias, not a rename)"
assert 'class="source-label-state"' in cell, "the stored-label state tag (text, 1.4.1)"
id_i = tpl.index('class="source-label-id-cell"')
edit_i = tpl.index('class="source-label-edit-cell"')
assert id_i < edit_i, "the identity cell precedes the editor (it is not replaced)"
def test_editor_ships_closed_with_the_a11y_pair_and_state_line() -> None:
"""The expandable block: the toggle is a real ``type="button"`` with
``aria-expanded="false"``, the editor div ships ``hidden`` (JS sets
``aria-controls`` to the editor's per-clone id), Save ships
``disabled`` (nothing is dirty on paint) and Reset ships ``hidden``
(a row with no stored label has nothing to reset — the phase-168
per-row reset shape). The save-state line is a live region."""
tpl = _template_block(_html())
toggle = re.search(r'<button[^>]*class="source-label-toggle"[^>]*>', tpl, re.S)
assert toggle and 'type="button"' in toggle.group(0)
assert 'aria-expanded="false"' in toggle.group(0), "collapsed by default"
editor = re.search(r'<div class="source-label-editor"[^>]*>', tpl, re.S)
assert editor and " hidden" in editor.group(0), "the editor ships closed"
save = re.search(r'<button[^>]*class="source-label-save"[^>]*>', tpl, re.S)
assert save and 'type="button"' in save.group(0) and "disabled" in save.group(0)
reset = re.search(r'<button[^>]*class="source-label-reset"[^>]*>', tpl, re.S)
assert reset and 'type="button"' in reset.group(0) and " hidden" in reset.group(0)
status = re.search(r'<span class="source-label-status"[^>]*>', tpl, re.S)
assert status and 'role="status"' in status.group(0)
assert 'aria-live="polite"' in status.group(0), "the save state announces"
# ---------------------------------------------------------------------------
# The load path
# ---------------------------------------------------------------------------
def test_load_labels_fetches_the_catalogue_once_and_heals() -> None:
"""``loadLabels`` is ONE ``GET /api/source-labels`` (no method key =
GET), renders from ``data.labels``, and heals its own error line on
the success path BEFORE rendering (an error followed by a good
refresh clears it — the phase-77 rule for this page's lists). A
network blip and a non-2xx both land on the role=alert line."""
js = _js()
body = _fn(js, "loadLabels")
assert 'const LABELS_ENDPOINT = "/api/source-labels";' in js
assert "fetch(LABELS_ENDPOINT)" in body
assert 'method: "' not in body, "the catalogue read is a GET"
assert body.count("fetch(") == 1, "exactly one fetch per load"
assert "Array.isArray(data.labels)" in body, "renders from the labels array"
heal_i = body.find("hideLabelsError();")
render_i = body.find("renderLabelRows(")
assert -1 < heal_i < render_i, "the success path heals the error before rendering"
assert body.find("showLabelsError(") < body.find("hideLabelsError();")
for fallback in (
"Could not reach the server — the source names are not shown.",
"The server could not list the source names",
"The server sent an unreadable label list — try again.",
):
assert fallback in body, f"missing the failure line {fallback!r}"
def test_render_replaces_rows_and_syncs_the_list_states() -> None:
"""A re-render REPLACES the rows (never appends — a re-show must not
duplicate), and the table card / empty state flip off the row count."""
js = _js()
body = _fn(js, "renderLabelRows")
clear_i = body.find("labelsTbody.replaceChildren()")
append_i = body.find("labelsTbody.appendChild(")
assert -1 < clear_i < append_i, "the clear precedes the fill"
assert 'labelsWrap.hidden = !hasRows' in body
assert 'labelsEmptyEl.hidden = hasRows' in body
def test_error_state_hides_the_table_and_the_empty_state() -> None:
"""While the error is up it is the ONLY claim about the list (the
``showLoadError`` discipline): both the table and the empty state go
hidden, and ``hideLabelsError`` clears the text on the way out."""
js = _js()
show = _fn(js, "showLabelsError")
assert "labelsErrorEl.textContent = message" in show
assert "labelsErrorEl.hidden = false" in show
assert "labelsWrap.hidden = true" in show
assert "labelsEmptyEl.hidden = true" in show
hide = _fn(js, "hideLabelsError")
assert 'labelsErrorEl.textContent = ""' in hide
assert "labelsErrorEl.hidden = true" in hide
def test_labels_fetch_is_admin_only_and_refreshes_the_list_flows() -> None:
"""Every ``loadLabels()`` call site sits AFTER the ``fetchIsAdmin``
gate (anonymous never asks), the boot loads it alongside the sources,
and each flow that CHANGES the source list (add / remove / a finished
upload) refreshes it — so a new source is nameable without a reload."""
js = _js()
gate = js.find("const admin = await fetchIsAdmin();")
assert gate != -1
anon = js[gate : js.find("return;", gate)]
assert "loadLabels" not in anon, "the anonymous branch never fetches labels"
# The gate sits at the END of mount (the boot), so the pin is the
# branch itself + the boot's own call after it: nothing before the
# gate runs for an anonymous visitor, and the mount tail is where the
# first catalogue read happens.
boot = js[gate:]
assert "await loadLabels();" in boot, "the admin boot loads the catalogue"
assert boot.index("await loadLabels();") < boot.index("await loadSources();"), (
"the two lists load side by side at boot (neither waits on the other)"
)
# add / remove / upload settle paths
add = _fn(js, "wireAddForm")
assert "await loadSources(); // the new row lands in the table" in add
assert js.count("await loadLabels();") >= 3, "add + remove + boot refresh labels"
assert "loadLabels(); // phase 191: the uploaded source joins the name list" in js
# the re-show refresh keeps the phase-77 listener verbatim + a second
# listener for the second list (so an unrelated table reload never
# wipes a half-typed name draft)
refresh_listeners = re.findall(
r'addEventListener\("bor:view-refresh", \(\) => (\w+)\(\)\)', js
)
assert refresh_listeners == ["loadSources", "loadLabels"], refresh_listeners
# ---------------------------------------------------------------------------
# Save / Reset — the §7.4 contract, per row
# ---------------------------------------------------------------------------
def test_save_puts_the_whole_form_and_uses_the_response_as_truth() -> None:
"""Save: ``PUT /api/source-labels/<source>`` (``encodeURIComponent`` —
a source name is a path segment), the three keys always ride (an empty
Name is the deliberate CLEAR leg), and on 200 the ROW RE-READS ITS
STORED LABEL FROM THE RESPONSE — no second GET anywhere in the save
path (task 03: "the response is the new truth")."""
js = _js()
body = _fn(js, "saveLabelRow")
fetch_i = body.find(
"fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`"
)
assert fetch_i != -1, "the PUT targets the per-source path"
assert 'method: "PUT"' in body[fetch_i : fetch_i + 220]
assert "body: JSON.stringify(form)" in body, "the whole form is sent"
form_i = body.find("const form = readLabelForm(view);")
assert -1 < form_i < fetch_i, "the form is read before the request"
ok_i = body.find("if (r.ok)")
stored_i = body.find("view.stored = storedFromEntry(", ok_i)
json_i = body.find("await r.json()", ok_i)
assert -1 < ok_i < json_i < stored_i, "the response body IS the new truth"
assert "loadLabels()" not in body, "no second GET after a save"
assert "loadSources()" not in body, "a save never reloads the sources table"
assert "fillLabelForm(view)" in body[stored_i:], "the fields re-paint from the response"
assert "setLabelStatus(view, LABEL_STATUS_SAVED, null)" in body
def test_save_and_reset_guard_on_the_rows_own_in_flight_flag() -> None:
"""The §7.4 guard scoped PER ROW (the phase-168 ``saveInFlight``
pattern): the early return precedes everything, the flag is set BEFORE
the fetch, and the ``finally`` drops it on every outcome — a second
click while a request is out is ignored and no row is ever left
claiming "Saving…" after a terminal frame."""
js = _js()
for name, label in (
("saveLabelRow", "LABEL_STATUS_SAVING"),
("resetLabelRow", "LABEL_STATUS_RESETTING"),
):
body = _fn(js, name)
guard_i = body.find("if (view.saveInFlight) return;")
set_i = body.find("view.saveInFlight = true;")
status_i = body.find(f"setLabelStatus(view, {label}, null);")
fetch_i = body.find("fetch(`")
finally_i = body.find("} finally {")
clear_i = body.find("view.saveInFlight = false;", finally_i)
assert -1 < guard_i < set_i < status_i < fetch_i, (
f"{name}: guard → flag → in-flight label → fetch"
)
assert finally_i != -1 and clear_i != -1, f"{name}: the finally must drop the flag"
assert body.index("view.saveBtn.disabled = true") < fetch_i
assert body.index("view.resetBtn.disabled = true") < fetch_i
assert "updateLabelRowUI(view);" in body[clear_i:], (
f"{name}: the settle re-decides Save from the dirty state"
)
def test_reset_deletes_and_clears_the_row() -> None:
"""Reset: ``DELETE /api/source-labels/<source>`` → the stored label is
nulled from the response and the fields clear with it, settling on the
same "saved" line (the row now matches the server)."""
body = _fn(_js(), "resetLabelRow")
fetch_i = body.find(
"fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`"
)
assert fetch_i != -1
assert 'method: "DELETE"' in body[fetch_i : fetch_i + 200]
ok_i = body.find("if (r.ok)")
stored_i = body.find("view.stored = storedFromEntry(cleared || {})", ok_i)
assert stored_i != -1, "the stored label is nulled from the response"
assert body.find("fillLabelForm(view)", stored_i) != -1, "the fields clear"
assert "setLabelStatus(view, LABEL_STATUS_SAVED, null)" in body
assert "loadLabels()" not in body, "no second GET after a reset"
def test_the_404_note_names_the_arm() -> None:
"""A 404 means the source left the catalogue — a retry cannot help —
so the one-line note names it (task 03), while every other non-2xx
carries the server's own detail (``apiDetail``, 422 shape-aware)."""
js = _js()
assert f'const LABEL_NOTE_GONE = "{NOTE_GONE}";' in js
body = _fn(js, "labelFailureNote")
gone_i = body.find("if (r.status === 404) return LABEL_NOTE_GONE;")
detail_i = body.find("return await apiDetail(r, fallback);")
assert -1 < gone_i < detail_i, "the 404 arm first, then the server detail"
for name in ("saveLabelRow", "resetLabelRow"):
assert "labelFailureNote(r," in _fn(js, name), f"{name} uses the shared note"
def test_a_failed_saves_note_survives_the_dirty_paint() -> None:
"""A failed save leaves the row DIRTY, and the request's ``finally``
calls ``updateLabelRowUI`` — so the dirty hint used to overwrite the
note the same request had just written, and the one error whose advice
is a reload rather than a retype ("That source no longer exists —
reload the page.") disappeared in the tick that produced it (the
defect the phase-191 story suite walked into). Pinned: the error line
returns from the paint untouched, Save keeps its dirty-driven
``disabled`` decision (a retry is legitimate for every arm but the
404), and the ONLY thing that clears a note is the owner editing
again — the settle path never does."""
js = _js()
paint = _fn(js, "updateLabelRowUI")
disable_i = paint.find("view.saveBtn.disabled = !dirty;")
guard_i = paint.find('if (view.statusEl.classList.contains("is-error")) return;')
hint_i = paint.find('setLabelStatus(view, LABEL_STATUS_DIRTY, "hint")')
assert -1 < disable_i < guard_i < hint_i, (
"the error guard sits after the Save paint, before the hint"
)
assert "setLabelStatus" not in paint[guard_i:hint_i], "the note is untouched"
clear = _fn(js, "clearLabelError")
assert 'if (view.statusEl.classList.contains("is-error"))' in clear
assert 'setLabelStatus(view, "", null);' in clear
row = _fn(js, "makeLabelRow")
for opener in (
'input.addEventListener("input", () => {',
'tagsInput.addEventListener("keydown", (e) => {',
):
i = row.find(opener)
assert i != -1, f"missing {opener}"
assert "clearLabelError(view);" in row[i : i + 220], f"{opener} clears the stale note"
# A collapse clears it the same way (it refills from the stored label).
assert 'setLabelStatus(view, "", null);' in _fn(js, "setLabelEditorOpen")
def test_status_line_copy_is_pinned_verbatim() -> None:
"""The save-state line's whole vocabulary (§7.4 + the dirty hint) —
pinned so a silent copy change cannot break the E2E and the
screen-reader contract with it."""
js = _js()
for literal in (
f'const LABEL_STATUS_SAVED = "{STATUS_SAVED}";',
f'const LABEL_STATUS_DIRTY = "{STATUS_DIRTY}";',
f'const LABEL_STATUS_SAVING = "{STATUS_SAVING}";',
f'const LABEL_STATUS_RESETTING = "{STATUS_RESETTING}";',
):
assert literal in js, f"missing {literal!r}"
dirty_paint = _fn(js, "updateLabelRowUI")
assert 'setLabelStatus(view, LABEL_STATUS_DIRTY, "hint")' in dirty_paint
assert 'view.tr.classList.toggle("is-dirty", dirty)' in dirty_paint
assert "if (view.saveInFlight) return;" in dirty_paint, (
"a keystroke mid-request never overwrites the in-flight label"
)
# ---------------------------------------------------------------------------
# The token input
# ---------------------------------------------------------------------------
def test_token_input_adds_on_enter_or_comma_and_drops_the_last_on_backspace() -> None:
"""The token input's widget rules (task 03): Enter or a comma commits
the token (``preventDefault`` — a comma never types, Enter never
submits), Backspace on an empty input drops the LAST chip, and leaving
the field commits a half-typed token."""
js = _js()
body = _fn(js, "makeLabelRow")
enter_i = body.find('if (e.key === "Enter" || e.key === ",") {')
assert enter_i != -1
assert "e.preventDefault();" in body[enter_i : enter_i + 200]
add_i = body.find("addLabelTags(view, tagsInput.value);", enter_i)
back_i = body.find(
'if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length)'
)
assert -1 < enter_i < add_i < back_i, "Enter/comma precedes the Backspace rule"
pop_i = body.find("view.tags.pop();", back_i)
assert -1 < back_i < pop_i < body.find("paintLabelTags(view);", back_i)
blur_i = body.find('tagsInput.addEventListener("blur"')
assert blur_i > back_i, "blur commits a pending token"
def test_tag_caps_are_the_servers_and_the_first_case_wins() -> None:
"""The caps are ``normalize_tags``' numbers mirrored client-side
(32 chars, 10 chips) and the de-dupe keeps the FIRST case — the chip
list the owner sees is the list the API stores."""
js = _js()
assert f"const LABEL_TAG_MAX_CHARS = {TAG_MAX_CHARS};" in js
assert f"const LABEL_TAGS_MAX_COUNT = {TAGS_MAX_COUNT};" in js
body = _fn(js, "addLabelTags")
assert ".slice(0, LABEL_TAG_MAX_CHARS)" in body
assert "if (!tag) continue;" in body, "an empty token is dropped, never stored"
assert "t.toLowerCase() === tag.toLowerCase()" in body, "case-insensitive de-dupe"
assert "view.tags.length >= LABEL_TAGS_MAX_COUNT" in body, "the 10-chip cap"
assert "LABEL_TAGS_MAX_COUNT" in _fn(js, "paintLabelTags")
def test_chips_render_as_text_in_the_shared_tag_class() -> None:
"""Each chip is an ``<li class="source-label-tag">`` written with
``textContent`` (the XSS contract — owner text is never HTML) and the
class is the one the shared phase-89 tag rule styles, so the widget
adds NO tag-input CSS (task 03 step 1)."""
js = _js()
body = _fn(js, "paintLabelTags")
assert 'li.className = "source-label-tag";' in body
assert "li.textContent = tag;" in body
assert "innerHTML" not in body
selector = _css_selector_list(_css(), ".source-label-tag {")
assert ".git-source-hidden-count" in selector, (
"the chip joins the phase-89 tag rule (no new tag-input CSS)"
)
assert ".source-label-state" in selector, "the state tag shares it too"
# ---------------------------------------------------------------------------
# makeLabelRow — the clone
# ---------------------------------------------------------------------------
def test_make_label_row_clones_the_template_and_rewrites_the_ids() -> None:
"""One shared template, cloned per row: ``template.content`` +
``cloneNode(true)``, then the per-clone id rewrite (input id + the
matching ``<label for>``) and the ``aria-controls`` link — without it
every clone would duplicate the template's ids and every label would
aim at the first match (WCAG 1.3.1)."""
js = _js()
body = _fn(js, "makeLabelRow")
assert "labelRowTemplate.content" in body
assert "proto.cloneNode(true)" in body
assert 'input.id = `${base}-${seq}`;' in body
assert 'lbl.setAttribute("for", input.id);' in body
assert 'editor.id = `source-label-editor-${seq}`;' in body
assert 'toggleBtn.setAttribute("aria-controls", editor.id);' in body
assert 'codeEl.textContent = source;' in body, "the identity is textContent"
for fn in LABEL_FNS:
assert "innerHTML" not in _fn(js, fn), f"{fn} must never touch innerHTML"
def test_row_state_paint_names_the_source_on_every_control() -> None:
"""The state paint: the "named" tag + summary appear only when a label
is stored, Reset is visible only then (the phase-168 per-row reset),
and every control's ``aria-label`` names the row's source (the
phase-89/105 idiom — the clipped label stays reachable)."""
js = _js()
body = _fn(js, "paintLabelRowState")
assert "view.stateTag.hidden = !labelled" in body
assert "view.resetBtn.hidden = !labelled" in body
assert 'labelled ? "Edit name" : "Name this source"' in body
for label in (
"${labelled ? \"Edit\" : \"Set\"} the name, label and tags for source: ${view.source}",
"Save the name, label and tags for source: ${view.source}",
"Reset the name, label and tags of source: ${view.source}",
):
assert label in body, f"missing the aria-label template {label!r}"
def test_collapse_discards_the_draft_instead_of_staling_it() -> None:
"""Closing the editor refills it from the STORED label and clears the
status line (no request) — a closed row never claims an edit the
server never got; a request in flight freezes the row."""
js = _js()
body = _fn(js, "setLabelEditorOpen")
assert "if (view.saveInFlight) return;" in body
assert 'view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");' in body
open_i = body.find("view.editor.hidden = !open;")
fill_i = body.find("fillLabelForm(view);")
clear_i = body.find('setLabelStatus(view, "", null);')
assert -1 < open_i < fill_i, "a collapse refills from the stored label"
assert fill_i < clear_i < body.find("updateLabelRowUI(view);")
def test_stored_shape_folds_nulls_to_the_empty_form() -> None:
"""``storedFromEntry`` folds the API's nulls / missing keys to ``\"\"``
and ``[]`` (a missing row and a cleared label are ONE state — D1), and
``hasStoredLabel`` is what drives the "named" tag and Reset."""
js = _js()
body = _fn(js, "storedFromEntry")
assert 'typeof e.display_name === "string"' in body
assert "Array.isArray(e.tags)" in body
stored = _fn(js, "hasStoredLabel")
assert "stored.display_name || stored.label || stored.tags.length" in stored
# ---------------------------------------------------------------------------
# styles.css — the reuse contract
# ---------------------------------------------------------------------------
def test_new_block_reuses_the_page_language() -> None:
"""The new rules stay on the page's own language: the field box is the
``.ignore-editor-token`` box (mono, the line border, the 44px floor),
the row's three buttons keep the ≥44px touch target, the empty state is
SHARED with ``.git-sources-empty``, and the dirty mark is the phase-168
``.is-dirty`` stripe."""
css = _css()
field = _css_rule(css, ".source-label-input")
assert "min-height: 44px" in field
assert "font-family: var(--mono)" in field
assert "border: 1px solid var(--line)" in field
for selector in (".source-label-toggle", ".source-label-save", ".source-label-reset"):
assert "min-height: 44px" in _css_rule(css, selector), f"{selector} lost the floor"
selector = _css_selector_list(css, ".source-labels-empty {")
assert ".git-sources-empty" in selector, "the empty-state rule is shared, not copied"
dirty = _css_rule(css, ".source-label-row.is-dirty > td:first-child")
assert "box-shadow: inset 3px 0 0 var(--brand)" in dirty, "the phase-168 stripe"
def test_new_css_uses_palette_variables_only() -> None:
"""No new hue, no CDN: every colour in the block is a palette
variable (the phase-08/92 monochrome invariant — a raw hex would not
follow a saved theme)."""
css = _css()
start = css.index("/* ---------- Source names, labels and tags (phase 191, task 03)")
end = css.index("/* ---------- History refresh button", start)
block = css[start:end]
assert block, "the labelled block exists"
offenders = re.findall(r"#[0-9a-fA-F]{3,8}\b", block)
assert not offenders, f"raw hex colours in the phase-191 block: {offenders}"
assert "url(" not in block, "no external asset (the no-CDN rule)"
assert "blur(" not in block, "the phase-08 no-blur anchor"