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/`.
747 lines
35 KiB
Python
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"
|