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/`.
This commit is contained in:
@@ -13,6 +13,7 @@ import json
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
@@ -27,6 +28,7 @@ from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL
|
||||
from app.rag.llm import StreamPiece, ToolCallPiece
|
||||
from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt
|
||||
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
||||
from app.rag.source_labels import SourceLabelView
|
||||
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
@@ -1817,3 +1819,114 @@ def test_deflected_turn_resolves_nothing_and_logs_zero(
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done and done[0]["deflected"] is True
|
||||
assert "answer_text" not in done[0]
|
||||
|
||||
|
||||
# ---------- phase 191 (D4, task 05): the turn's prompt learns the name ----------
|
||||
|
||||
|
||||
def _label_view(
|
||||
source: str = "Homelab",
|
||||
display_name: str | None = None,
|
||||
label: str | None = None,
|
||||
) -> SourceLabelView:
|
||||
return SourceLabelView(source=source, display_name=display_name, label=label, tags=())
|
||||
|
||||
|
||||
def test_plan_turn_high_prompt_carries_the_source_name() -> None:
|
||||
"""The HIGH branch hands the label views to ``build_high_prompt``: the
|
||||
suggested block gains `` name="…"`` / `` label="…"`` right after
|
||||
``source="…"`` — the model sees the name→docs mapping."""
|
||||
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
|
||||
names = {"Homelab": _label_view(display_name="the git server", label="self-hosted forge")}
|
||||
plan = chat_api.plan_turn(
|
||||
[_chunk(doc, 0.90, cosine=0.90)],
|
||||
_settings(threshold=0.30),
|
||||
names=names,
|
||||
)
|
||||
assert plan.deflected is False
|
||||
assert 'source="Homelab" name="the git server" label="self-hosted forge" path=' in (
|
||||
plan.system_prompt
|
||||
)
|
||||
assert "Upgrading Gitea." in plan.system_prompt # the body is untouched
|
||||
|
||||
|
||||
def test_plan_turn_without_names_is_byte_identical() -> None:
|
||||
"""THE kill pin on the call-through: ``names`` omitted, ``None`` and
|
||||
``{}`` each build the exact pre-phase prompt (and every other TurnPlan
|
||||
field is identical)."""
|
||||
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
|
||||
chunks = [_chunk(doc, 0.90, cosine=0.90)]
|
||||
settings = _settings(threshold=0.30)
|
||||
pre = chat_api.plan_turn(chunks, settings)
|
||||
for names in (None, {}):
|
||||
plan = chat_api.plan_turn(chunks, settings, names=names)
|
||||
assert plan.system_prompt == pre.system_prompt
|
||||
assert plan.deflected == pre.deflected
|
||||
assert [d.id for d in plan.suggested_docs] == [d.id for d in pre.suggested_docs]
|
||||
assert ' name="' not in pre.system_prompt
|
||||
|
||||
|
||||
def test_plan_turn_low_prompt_ignores_names() -> None:
|
||||
"""A deflected turn has no document blocks: the LOW prompt is
|
||||
byte-identical whether or not the source is named (D4 touches the
|
||||
suggested tier only)."""
|
||||
doc = _doc("Mongolia Trivia", "MONGOLIA_CONTENT")
|
||||
settings = _settings(threshold=0.62)
|
||||
chunks = [_chunk(doc, 0.05, cosine=0.10, fts_hit=True)]
|
||||
pre = chat_api.plan_turn(chunks, settings)
|
||||
plan = chat_api.plan_turn(chunks, settings, names={"Homelab": _label_view("Homelab", "x")})
|
||||
assert plan.deflected is True
|
||||
assert plan.system_prompt == pre.system_prompt
|
||||
# The pre-phase deflected build, unchanged, is what the LOW branch owes.
|
||||
assert plan.system_prompt == build_deflect_prompt(
|
||||
weak_hit_titles(chunks), notes=[], kb_overview=""
|
||||
)
|
||||
|
||||
|
||||
def test_chat_module_issues_exactly_one_label_read_per_turn() -> None:
|
||||
"""The marker pin for the "one query per turn" contract: the endpoint
|
||||
reads the label rows ONCE (``all_labels``) and derives both the D2 hit
|
||||
set and the prompt map from that read — no second label query, and task
|
||||
04's convenience wrapper (which would re-query) is gone from the
|
||||
module."""
|
||||
src = Path(chat_api.__file__).read_text(encoding="utf-8")
|
||||
assert src.count("all_labels(") == 1
|
||||
assert "question_name_hit_sources(" not in src
|
||||
assert src.count("names=labels") == 1, "the single read is what the prompt receives"
|
||||
assert "question_name_hits(" in src, "the pure D2 rule stays applied per view"
|
||||
|
||||
|
||||
def test_endpoint_single_label_read_feeds_flags_and_prompt(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The one-read contract on the wire (task 04 + task 05): the ONE
|
||||
``all_labels`` result of a turn sets the D2 boost flags AND the
|
||||
``<document>`` name/label attributes — one query, two surfaces."""
|
||||
_session, llm = gate_env
|
||||
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
|
||||
chunk = _chunk(doc, 0.90, cosine=0.90)
|
||||
assert chunk.source_name_hit is False # the pre-flag state
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([chunk]))
|
||||
views = {
|
||||
"Homelab": SourceLabelView(
|
||||
source="Homelab",
|
||||
display_name="the git server",
|
||||
label="self-hosted forge",
|
||||
tags=("gitea",),
|
||||
)
|
||||
}
|
||||
reads: list[int] = []
|
||||
|
||||
def _counting_all_labels(_db: Any) -> dict[str, SourceLabelView]:
|
||||
reads.append(1)
|
||||
return views
|
||||
|
||||
monkeypatch.setattr(chat_api, "all_labels", _counting_all_labels)
|
||||
frames = _ask(client, "how do I restart the git server?")
|
||||
assert [f for f in frames if f.get("type") == "done"], "the turn completed"
|
||||
assert len(reads) == 1, "ONE label read for the whole turn"
|
||||
assert chunk.source_name_hit is True, "the same read sets the task-04 boost flag"
|
||||
system = llm.seen[0][0]["content"]
|
||||
assert 'source="Homelab" name="the git server" label="self-hosted forge"' in system
|
||||
|
||||
@@ -45,6 +45,9 @@ def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
# selection-time document score is ON by default (0.005 — the
|
||||
# owner-tunable starting point; ``0`` is the kill switch).
|
||||
assert s.name_hit_bonus == 0.005
|
||||
# Phase 191, D3: the SECOND bounded selection bonus (the source-name
|
||||
# hit) ships ON at the same phase-119 starting point.
|
||||
assert s.source_name_hit_bonus == 0.005
|
||||
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
|
||||
assert s.max_output_tokens == 32_768
|
||||
# Phase 17: the model's thinking streams by default (kill-switch off).
|
||||
@@ -553,6 +556,36 @@ def test_name_hit_bonus_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
_settings()
|
||||
|
||||
|
||||
def test_source_name_hit_bonus_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 191, D3 (LOCKED A3): the bounded SOURCE-name bonus (the
|
||||
second, independent selection knob over the ``source_labels`` row)
|
||||
is ON by default at the phase-119 starting point (0.005) and
|
||||
env-tunable so the owner re-tunes live (the ``BOR_NAME_HIT_BONUS``
|
||||
pattern). ``0`` is legal — the byte-identical kill switch."""
|
||||
monkeypatch.delenv("BOR_SOURCE_NAME_HIT_BONUS", raising=False)
|
||||
s = _settings()
|
||||
assert s.source_name_hit_bonus == 0.005
|
||||
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "0")
|
||||
assert _settings().source_name_hit_bonus == 0.0
|
||||
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "0.01")
|
||||
assert _settings().source_name_hit_bonus == 0.01
|
||||
|
||||
|
||||
def test_source_name_hit_bonus_rejects_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``0`` is the byte-identical kill switch (the pre-phase-191
|
||||
selection order) — a NEGATIVE bonus would demote source-named
|
||||
documents (the exact opposite of D2/D3), so the validator fails
|
||||
loudly at startup naming the field (the ``name_hit_bonus``
|
||||
pattern, phase 191)."""
|
||||
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "-0.1")
|
||||
with pytest.raises(ValidationError, match="source_name_hit_bonus"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_retrieval_ef_search_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -373,7 +373,13 @@ def test_branch_field_css_rules_are_house_styled() -> None:
|
||||
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)"
|
||||
)
|
||||
tag = css.find(".git-source-hidden-count,\n.git-source-branch-count {")
|
||||
# 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, (
|
||||
|
||||
@@ -398,8 +398,14 @@ def test_hidden_checkbox_css_rules_exist_with_accent_color() -> None:
|
||||
)
|
||||
# Phase 135 (task 04): the "branch: <name>" state tag joined this
|
||||
# rule — the "hidden on" tag keeps the same idiom (text + a
|
||||
# distinct background, never color alone).
|
||||
tag = _css_rule(css, ".git-source-hidden-count,\n.git-source-branch-count")
|
||||
# distinct background, never color alone). Phase 191 (task 03): the
|
||||
# name/label/tags row's "named" tag and its tag CHIPS joined the same
|
||||
# rule (the token input reuses this tag, it ships none of its own).
|
||||
tag = _css_rule(
|
||||
css,
|
||||
".git-source-hidden-count,\n.git-source-branch-count,\n"
|
||||
".source-label-state,\n.source-label-tag",
|
||||
)
|
||||
assert "var(--ink)" in tag and "var(--bg)" in tag, (
|
||||
"text + a distinct background (never color alone)"
|
||||
)
|
||||
|
||||
@@ -10,13 +10,22 @@ pins the deterministic ``Sources: …`` inventory tail (LOCKED A2): the
|
||||
format/order/counts, the 1-doc survival, the happy-path stored content,
|
||||
the byte-identical zero-doc / ``LLMError`` paths, and the byte-identical
|
||||
model input.
|
||||
|
||||
And the phase-191 display names (task 05, D4): the INPUT lines lead with
|
||||
the owner's ``display_name`` when the source has a label row (the raw
|
||||
source name is replaced in the ``lite`` model's input ONLY — the stored
|
||||
row and its ``Sources: …`` tail keep the derived name), byte-identical
|
||||
for every unnamed source, and fed by exactly ONE ``all_labels`` read per
|
||||
run (the marker pins at the end).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
@@ -24,6 +33,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import Document, KbOverview
|
||||
from app.rag import overview as overview_module
|
||||
from app.rag.llm import LLMError
|
||||
from app.rag.overview import (
|
||||
KB_OVERVIEW_MODE,
|
||||
@@ -35,6 +45,8 @@ from app.rag.overview import (
|
||||
regenerate_overview,
|
||||
)
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.source_labels import all_labels as real_all_labels
|
||||
from app.rag.source_labels import set_label
|
||||
|
||||
REPLY = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
|
||||
|
||||
@@ -650,3 +662,208 @@ def test_regenerate_llm_error_preserves_an_existing_inventory_tail(
|
||||
assert row is not None and row.content == previous
|
||||
finally:
|
||||
_truncate_documents(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 191 (D4, task 05): the input lines carry the owner's display name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The owner's alias for the ``gitea-infra`` source (phase 191 D1 — an
|
||||
#: alias, never the identity).
|
||||
GIT_SERVER = "the git server"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clean_source_labels(db: Session):
|
||||
"""Empty ``source_labels`` before and after (migration-0030 state)."""
|
||||
db.execute(text("DELETE FROM source_labels"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM source_labels"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- build_overview_prompt: the names map (pure) ----------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"names",
|
||||
[None, {}, {"Other": "another name"}],
|
||||
ids=["names=None", "names={}", "no-entry-for-the-source"],
|
||||
)
|
||||
def test_input_lines_without_a_names_entry_are_byte_identical_to_pre_phase(
|
||||
names: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""THE kill pin (phase 191 D4): no map, an empty map and a map that
|
||||
misses the row's source each build the pre-phase input byte-for-byte —
|
||||
the same cap, the same separator, the same order."""
|
||||
rows = [
|
||||
("Homelab", "k8s.md", "Kubernetes", "A cluster.\nSource: Homelab/k8s.md"),
|
||||
("Homelab", "borg.md", "Borg", None),
|
||||
]
|
||||
pre = build_overview_prompt(rows, max_chars=10_000)
|
||||
assert build_overview_prompt(rows, max_chars=10_000, names=names) == pre
|
||||
|
||||
|
||||
def test_input_line_leads_with_the_display_name_when_named() -> None:
|
||||
"""A named source: the display name REPLACES the raw source name in the
|
||||
lite model's input — the path / title / summary halves untouched."""
|
||||
rows = [("gitea-infra", "docs/install.md", "Install", "The install notes.")]
|
||||
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
|
||||
assert user == f"{GIT_SERVER} — docs/install.md — Install — The install notes."
|
||||
assert "gitea-infra" not in user
|
||||
|
||||
|
||||
def test_input_line_without_an_entry_keeps_the_raw_source_name() -> None:
|
||||
"""Mixed catalogue: only the named source's lines change — every other
|
||||
line stays exactly as the pre-phase builder wrote it."""
|
||||
rows = [
|
||||
("gitea-infra", "a.md", "A", "Sum A."),
|
||||
("Homelab", "b.md", "B", "Sum B."),
|
||||
]
|
||||
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
|
||||
assert user.splitlines() == [
|
||||
f"{GIT_SERVER} — a.md — A — Sum A.",
|
||||
"Homelab — b.md — B — Sum B.",
|
||||
]
|
||||
|
||||
|
||||
def test_named_summary_less_line_has_no_dangling_dash() -> None:
|
||||
"""The pre-phase "no summary → no trailing field" rule survives the
|
||||
name swap."""
|
||||
_system, user = build_overview_prompt(
|
||||
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": GIT_SERVER}
|
||||
)
|
||||
assert user == f"{GIT_SERVER} — a.md — A"
|
||||
|
||||
|
||||
def test_named_entry_with_an_empty_display_name_falls_back_to_the_source() -> None:
|
||||
"""Defensive: an empty name in the map is "no name" (the API trims
|
||||
empty submissions to NULL, so this never reaches the wire — the input
|
||||
line must never lead with a blank)."""
|
||||
_system, user = build_overview_prompt(
|
||||
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": ""}
|
||||
)
|
||||
assert user == "gitea-infra — a.md — A"
|
||||
|
||||
|
||||
def test_names_take_part_in_the_input_cap() -> None:
|
||||
"""The cap counts the whole line (the name included) — the truncation
|
||||
contract is unchanged: cut at the cap, marker on its own line."""
|
||||
rows = [("gitea-infra", "a.md", "A", None)]
|
||||
_system, plain = build_overview_prompt(rows, max_chars=10)
|
||||
_system, named = build_overview_prompt(rows, max_chars=10, names={"gitea-infra": GIT_SERVER})
|
||||
assert named != plain
|
||||
assert named.endswith(TRUNCATION_MARKER)
|
||||
assert named.startswith(GIT_SERVER[:10])
|
||||
|
||||
|
||||
# ---------- regenerate_overview: the ONE all_labels read (DB) ----------
|
||||
|
||||
|
||||
def test_regenerate_model_input_carries_the_display_name(
|
||||
db: Session, clean_overview, clean_source_labels
|
||||
) -> None:
|
||||
"""The wire half of D4: the single read feeds the input lines, and the
|
||||
unnamed source's line is untouched."""
|
||||
_truncate_documents(db)
|
||||
set_label(db, "gitea-infra", display_name=GIT_SERVER)
|
||||
db.commit()
|
||||
try:
|
||||
base = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
_add_doc(db, "gitea-infra", "a.md", "A", "Sum A.", created_at=base + timedelta(days=1))
|
||||
_add_doc(db, "Homelab", "b.md", "B", "Sum B.", created_at=base)
|
||||
llm = _FakeLLM()
|
||||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||||
user = llm.messages[1]["content"]
|
||||
assert user.splitlines() == [
|
||||
f"{GIT_SERVER} — a.md — A — Sum A.",
|
||||
"Homelab — b.md — B — Sum B.",
|
||||
]
|
||||
finally:
|
||||
_truncate_documents(db)
|
||||
|
||||
|
||||
def test_regenerate_stored_content_keeps_the_derived_names(
|
||||
db: Session, clean_overview, clean_source_labels
|
||||
) -> None:
|
||||
"""The ALIAS contract (D1) on the stored row: the deterministic
|
||||
``Sources: …`` inventory tail names sources by their DERIVED name —
|
||||
a rename never changes the identity the rest of the app keys on."""
|
||||
_truncate_documents(db)
|
||||
set_label(db, "gitea-infra", display_name=GIT_SERVER, label="self-hosted forge")
|
||||
db.commit()
|
||||
try:
|
||||
_add_doc(db, "gitea-infra", "a.md", "A")
|
||||
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
|
||||
row = db.get(KbOverview, 1)
|
||||
assert row is not None
|
||||
assert row.content == REPLY + "\nSources: gitea-infra (1 doc)"
|
||||
assert GIT_SERVER not in row.content
|
||||
finally:
|
||||
_truncate_documents(db)
|
||||
|
||||
|
||||
def test_regenerate_without_label_rows_is_byte_identical_to_pre_phase(
|
||||
db: Session, clean_overview, clean_source_labels
|
||||
) -> None:
|
||||
"""No label rows (the whole-KB kill pin): the model input and the
|
||||
stored content are exactly the pre-phase bytes."""
|
||||
_truncate_documents(db)
|
||||
try:
|
||||
_add_doc(db, "Homelab", "a.md", "A", "Sum A.")
|
||||
llm = _FakeLLM()
|
||||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||||
assert llm.messages[1]["content"] == "Homelab — a.md — A — Sum A."
|
||||
row = db.get(KbOverview, 1)
|
||||
assert row is not None and row.content == REPLY + "\nSources: Homelab (1 doc)"
|
||||
finally:
|
||||
_truncate_documents(db)
|
||||
|
||||
|
||||
def test_regenerate_reads_the_label_rows_once(
|
||||
db: Session, clean_overview, clean_source_labels, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ONE ``all_labels`` read per run (the task's single-query contract):
|
||||
the regenerator calls it exactly once and derives the name map from
|
||||
those views — no second query."""
|
||||
_truncate_documents(db)
|
||||
set_label(db, "gitea-infra", display_name=GIT_SERVER)
|
||||
db.commit()
|
||||
calls: list[int] = []
|
||||
|
||||
def _counting_all_labels(session: Session) -> dict:
|
||||
calls.append(1)
|
||||
return real_all_labels(session)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.rag.overview.all_labels",
|
||||
_counting_all_labels,
|
||||
)
|
||||
try:
|
||||
_add_doc(db, "gitea-infra", "a.md", "A")
|
||||
_add_doc(db, "gitea-infra", "b.md", "B")
|
||||
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
_truncate_documents(db)
|
||||
|
||||
|
||||
# ---------- the marker pins: one read, in the right function ----------
|
||||
|
||||
OVERVIEW_PY = Path(overview_module.__file__).resolve().read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_overview_module_issues_exactly_one_all_labels_read() -> None:
|
||||
"""The marker pin: the ONE ``all_labels(` call in the module — the
|
||||
overview builder never queries labels on its own."""
|
||||
assert OVERVIEW_PY.count("all_labels(") == 1
|
||||
|
||||
|
||||
def test_the_single_read_lives_in_regenerate_overview() -> None:
|
||||
"""The read sits in the caller (which already holds the session), not
|
||||
in the pure builder — so the builder stays a pure function of its
|
||||
arguments and one read covers the whole run."""
|
||||
src = inspect.getsource(regenerate_overview)
|
||||
assert src.count("all_labels(") == 1
|
||||
assert "all_labels(" not in inspect.getsource(build_overview_prompt)
|
||||
|
||||
@@ -39,6 +39,12 @@ phase-124 ``show_image`` clause through one mechanism, and every build
|
||||
the flags do not select stays byte-identical (the constant's own sha pin
|
||||
lives in :mod:`tests.unit.test_prompt_lock`).
|
||||
|
||||
And the phase-191 source names (task 05, D4): a ``<document>`` block can
|
||||
carry the owner's label row as the ADDITIVE ``name`` / ``label``
|
||||
attributes (order ``source`` → ``name?`` → ``label?`` → ``path`` →
|
||||
``title`` → ``date``, XML-escaped values), with no map / no entry for the
|
||||
source byte-identical to pre-phase (the kill pin).
|
||||
|
||||
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
|
||||
final sentence ("cite the document(s) you used, by path", phase 118)
|
||||
is REPLACED — the HIGH prompt carries the discipline sentence exactly
|
||||
@@ -65,6 +71,7 @@ from app.rag.prompts import (
|
||||
SHOW_TEXT_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_attr,
|
||||
_base,
|
||||
_document_body,
|
||||
_extract_context_line,
|
||||
@@ -74,6 +81,7 @@ from app.rag.prompts import (
|
||||
build_steering_section,
|
||||
)
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.source_labels import SourceLabelView
|
||||
|
||||
#: A small multi-line outline standing in for the lite-generated one.
|
||||
OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
|
||||
@@ -1944,3 +1952,204 @@ def test_high_prompt_pdf_image_doc_block_exact_shape() -> None:
|
||||
assert prompt.index(line) < prompt.index("A diagram of the cluster.")
|
||||
assert block_start < prompt.index(line) < prompt.index("</document>")
|
||||
assert "The second page of the guide" not in prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 191 (D4, task 05): the ``<document>`` block learns the owner's name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The owner's display name for the fixture source (phase 191 D1 — an
|
||||
#: ALIAS beside the derived name, never a replacement for it).
|
||||
GIT_SERVER = "the git server"
|
||||
|
||||
#: The owner's one-line description of the same source.
|
||||
FORGE_LABEL = "self-hosted forge + Gitea"
|
||||
|
||||
|
||||
def _label_view(
|
||||
source: str = "Homelab",
|
||||
display_name: str | None = None,
|
||||
label: str | None = None,
|
||||
tags: tuple[str, ...] = (),
|
||||
) -> SourceLabelView:
|
||||
"""One label-row view (task 01's frozen dataclass) for the pins."""
|
||||
return SourceLabelView(
|
||||
source=source,
|
||||
display_name=display_name,
|
||||
label=label,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
|
||||
def _block_head(prompt: str) -> str:
|
||||
"""The opening tag of the FIRST ``<document>`` block (attribute
|
||||
order/escaping pins read it whole, so nothing is asserted loosely)."""
|
||||
start = prompt.index("<document ")
|
||||
return prompt[start : prompt.index(">", start) + 1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"names",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"Other": _label_view("Other", display_name=GIT_SERVER, label=FORGE_LABEL)},
|
||||
],
|
||||
ids=["names=None", "names={}", "no-entry-for-the-source"],
|
||||
)
|
||||
def test_high_prompt_without_a_label_entry_is_byte_identical_to_pre_phase(
|
||||
names: dict[str, SourceLabelView] | None,
|
||||
) -> None:
|
||||
"""THE kill pin (phase 191 D4): no map, an empty map and a map with no
|
||||
entry for the document's source each build a prompt byte-identical to
|
||||
the pre-phase build — no ``name``, no ``label``, no stray space."""
|
||||
doc = _doc(
|
||||
"kubernetes.md",
|
||||
"Talos Linux on three nodes.",
|
||||
"Kubernetes Homelab Cluster",
|
||||
summary="A Talos Linux cluster on three nodes.",
|
||||
)
|
||||
pre = build_high_prompt([doc])
|
||||
assert build_high_prompt([doc], names=names) == pre
|
||||
assert ' name="' not in pre and ' label="' not in pre
|
||||
|
||||
|
||||
def test_no_label_block_keeps_the_exact_pre_phase_head() -> None:
|
||||
"""The block head for a label-less source, byte for byte (the shape the
|
||||
phase-106/118/125 pins rely on: ``source`` → ``path`` → ``title`` →
|
||||
``date``, single-spaced)."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes Homelab Cluster", summary="S.")
|
||||
assert _block_head(build_high_prompt([doc], names={})) == (
|
||||
'<document source="Homelab" path="kubernetes.md" '
|
||||
'title="Kubernetes Homelab Cluster" date="2024-06-15">'
|
||||
)
|
||||
|
||||
|
||||
def test_document_block_name_attribute_sits_immediately_after_source() -> None:
|
||||
"""A ``display_name``-only row: exactly `` name="…"`` added, directly
|
||||
after ``source="…"`` and before ``path`` (the attribute-order pin)."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
prompt = build_high_prompt([doc], names={"Homelab": _label_view(display_name=GIT_SERVER)})
|
||||
assert _block_head(prompt) == (
|
||||
'<document source="Homelab" name="the git server" path="kubernetes.md" '
|
||||
'title="Kubernetes" date="2024-06-15">'
|
||||
)
|
||||
assert ' label="' not in prompt
|
||||
|
||||
|
||||
def test_document_block_label_only_view_adds_exactly_the_label_attribute() -> None:
|
||||
"""A ``label``-only row gains ONE attribute — an unset field adds
|
||||
NOTHING (never an empty ``name=""``)."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
prompt = build_high_prompt([doc], names={"Homelab": _label_view(label=FORGE_LABEL)})
|
||||
assert _block_head(prompt) == (
|
||||
'<document source="Homelab" label="self-hosted forge + Gitea" '
|
||||
'path="kubernetes.md" title="Kubernetes" date="2024-06-15">'
|
||||
)
|
||||
assert ' name="' not in prompt
|
||||
|
||||
|
||||
def test_document_block_row_with_neither_field_is_byte_identical() -> None:
|
||||
"""A row that exists but carries neither name nor label (the owner
|
||||
saved only tags) leaves the block untouched — tags never render in the
|
||||
block (they are a retrieval input, D2/D3)."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
pre = build_high_prompt([doc])
|
||||
names = {"Homelab": _label_view(tags=("gitea", "forge"))}
|
||||
built = build_high_prompt([doc], names=names)
|
||||
assert built == pre
|
||||
assert "gitea" not in built
|
||||
|
||||
|
||||
def test_document_block_carries_both_attributes_in_the_locked_order() -> None:
|
||||
"""Both fields set → both attributes, in the fixed order
|
||||
``source`` → ``name`` → ``label`` → ``path`` → ``title`` → ``date``."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
names = {"Homelab": _label_view(display_name=GIT_SERVER, label=FORGE_LABEL)}
|
||||
head = _block_head(build_high_prompt([doc], names=names))
|
||||
order = ['source="', 'name="', 'label="', 'path="', 'title="', 'date="']
|
||||
positions = [head.index(attr) for attr in order]
|
||||
assert positions == sorted(positions)
|
||||
assert head == (
|
||||
'<document source="Homelab" name="the git server" '
|
||||
'label="self-hosted forge + Gitea" path="kubernetes.md" '
|
||||
'title="Kubernetes" date="2024-06-15">'
|
||||
)
|
||||
|
||||
|
||||
def test_document_block_name_and_label_are_xml_escaped() -> None:
|
||||
"""The escaping pin (task 05 work item 1): a ``"`` in owner text renders
|
||||
as ``"`` so the attribute cannot break; ``&``/``<`` fold too."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
names = {
|
||||
"Homelab": _label_view(
|
||||
display_name='Reese "forge" & <co>',
|
||||
label='say "hi"',
|
||||
)
|
||||
}
|
||||
prompt = build_high_prompt([doc], names=names)
|
||||
assert ' name="Reese "forge" & <co>"' in prompt
|
||||
assert ' label="say "hi""' in prompt
|
||||
# The unescaped form never reaches the wire: the attribute stays closed.
|
||||
assert ' name="Reese "forge"' not in prompt
|
||||
head = _block_head(prompt)
|
||||
assert head.count('"') % 2 == 0, "an unescaped quote would break the attribute"
|
||||
|
||||
|
||||
def test_attribute_escaping_helper_covers_the_xml_five() -> None:
|
||||
"""``_attr`` is the ONE escaper for the new attribute values: the XML
|
||||
specials go, the single quote stays (the delimiter is the double
|
||||
quote, and ``Reese's`` must stay readable to the model)."""
|
||||
assert _attr('a & b < c > d " e') == "a & b < c > d " e"
|
||||
assert _attr("Reese's forge") == "Reese's forge"
|
||||
|
||||
|
||||
def test_names_apply_per_document_source_and_never_touch_the_body() -> None:
|
||||
"""Only the blocks whose source has an entry gain the attribute; the
|
||||
summary body (and the other source's block) are untouched."""
|
||||
named = _doc("a.md", "CONTENT_A", "Title A", summary="SUMMARY A.")
|
||||
other = _doc("b.md", "CONTENT_B", "Title B", summary="SUMMARY B.")
|
||||
other.source = "Other"
|
||||
prompt = build_high_prompt(
|
||||
[named, other],
|
||||
names={"Homelab": _label_view(display_name=GIT_SERVER)},
|
||||
)
|
||||
assert 'source="Homelab" name="the git server" path="a.md"' in prompt
|
||||
assert 'source="Other" path="b.md"' in prompt
|
||||
assert prompt.count(GIT_SERVER) == 1, "the name rides the attribute, nothing else"
|
||||
assert "SUMMARY A." in prompt and "SUMMARY B." in prompt
|
||||
|
||||
|
||||
def test_names_on_an_empty_documents_build_change_nothing() -> None:
|
||||
"""No blocks → nothing to annotate: the honest "(no documents
|
||||
matched…)" build is byte-identical with a label row in hand."""
|
||||
names = {"Homelab": _label_view(display_name=GIT_SERVER, label=FORGE_LABEL)}
|
||||
assert build_high_prompt([], names=names) == build_high_prompt([])
|
||||
|
||||
|
||||
def test_names_compose_with_folder_lines_and_display_clauses() -> None:
|
||||
"""The additive attributes ride alongside the phase-119 folder lines and
|
||||
the phase-124/182 display clauses — the section order and the intro are
|
||||
untouched (the folder lines stay plain, no new markup)."""
|
||||
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
|
||||
names = {"Homelab": _label_view(display_name=GIT_SERVER)}
|
||||
prompt = build_high_prompt(
|
||||
[doc],
|
||||
folder_lines=["docs/gitea.md — Gitea install"],
|
||||
show_image=True,
|
||||
show_text=True,
|
||||
names=names,
|
||||
)
|
||||
assert 'source="Homelab" name="the git server" path="kubernetes.md"' in prompt
|
||||
assert prompt.index(SUGGEST_INTRO) < prompt.index("docs/gitea.md — Gitea install")
|
||||
assert prompt.index("docs/gitea.md — Gitea install") < prompt.index("<document ")
|
||||
assert prompt.endswith(TOOLS_SECTION[: -len("</tools>")]
|
||||
+ SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
|
||||
|
||||
def test_deflect_prompt_has_no_name_or_label_surface() -> None:
|
||||
"""D4 touches the suggested-tier block only: the deflected prompt is
|
||||
byte-identical (it carries no document blocks to annotate)."""
|
||||
prompt = build_deflect_prompt(["Kubernetes Homelab Cluster"])
|
||||
assert ' name="' not in prompt and ' label="' not in prompt
|
||||
assert GIT_SERVER not in prompt
|
||||
|
||||
@@ -1260,9 +1260,12 @@ def _bonus_rc(
|
||||
cosine: float,
|
||||
position: int = 0,
|
||||
name_hit: bool = False,
|
||||
source_name_hit: bool = False,
|
||||
) -> RetrievedChunk:
|
||||
"""One fused-list candidate (name-hit rows follow the D1 lexical
|
||||
convention: ``cosine=0.0``, ``fts_hit=True``)."""
|
||||
convention: ``cosine=0.0``, ``fts_hit=True``). The
|
||||
``source_name_hit`` flag (phase 191, D2) is set at the retrieval
|
||||
boundary in chat.py — here directly, on the fused row."""
|
||||
return RetrievedChunk(
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=position,
|
||||
@@ -1272,6 +1275,7 @@ def _bonus_rc(
|
||||
cosine=cosine,
|
||||
fts_hit=cosine == 0.0,
|
||||
name_hit=name_hit,
|
||||
source_name_hit=source_name_hit,
|
||||
)
|
||||
|
||||
|
||||
@@ -1479,24 +1483,185 @@ def test_weak_hit_titles_bonus_adjusted_order() -> None:
|
||||
|
||||
|
||||
def test_bonus_lives_in_the_selection_layer_only() -> None:
|
||||
"""LOCKED A3: the bonus never touches the chunk objects —
|
||||
"""LOCKED A3 (mirrored by phase 191 for the SECOND bonus): the
|
||||
bonuses never touch the chunk objects —
|
||||
``score``/``cosine``/``fts_hit`` (the A8 gate's inputs —
|
||||
``query_log.top_score`` is the best fused chunk score, the same
|
||||
values) are unchanged after every selection walk, even with the
|
||||
bonus lifting a document."""
|
||||
values) and the ``name_hit``/``source_name_hit`` flags themselves
|
||||
are unchanged after every selection walk, even with the bonuses
|
||||
lifting a document."""
|
||||
chunks = _bonus_chunks()
|
||||
chunks[-1].source_name_hit = True
|
||||
before = {
|
||||
rc.chunk_id: (rc.score, rc.cosine, rc.fts_hit, rc.name_hit) for rc in chunks
|
||||
rc.chunk_id: (
|
||||
rc.score,
|
||||
rc.cosine,
|
||||
rc.fts_hit,
|
||||
rc.name_hit,
|
||||
rc.source_name_hit,
|
||||
)
|
||||
for rc in chunks
|
||||
}
|
||||
select_suggested(chunks, n=5, bonus=0.005)
|
||||
select_related(chunks, set(), cap=5, bonus=0.005)
|
||||
weak_hit_titles(chunks, bonus=0.005)
|
||||
select_suggested(chunks, n=5, bonus=0.005, source_bonus=0.005)
|
||||
select_related(chunks, set(), cap=5, bonus=0.005, source_bonus=0.005)
|
||||
weak_hit_titles(chunks, bonus=0.005, source_bonus=0.005)
|
||||
after = {
|
||||
rc.chunk_id: (rc.score, rc.cosine, rc.fts_hit, rc.name_hit) for rc in chunks
|
||||
rc.chunk_id: (
|
||||
rc.score,
|
||||
rc.cosine,
|
||||
rc.fts_hit,
|
||||
rc.name_hit,
|
||||
rc.source_name_hit,
|
||||
)
|
||||
for rc in chunks
|
||||
}
|
||||
assert before == after
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 191, D3 — the bounded SOURCE-name bonus (the second, independent
|
||||
# knob; the phase-119 mirror: kill switch, bounded per-document apply,
|
||||
# the fuse OR-merge, the settings defaults)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_selection_order_source_bonus_zero_is_byte_identical_to_pre_phase() -> None:
|
||||
"""The kill switch (LOCKED A3, phase-119 mirror): with
|
||||
*source_bonus* ``0`` the walk is byte-identical to the
|
||||
pre-phase-191 walk even when EVERY chunk carries the flag — same
|
||||
order, same effective scores (the phase-119 name bonus alone). With
|
||||
BOTH knobs ``0`` the pre-phase golden walk returns byte-identical."""
|
||||
chunks = _bonus_chunks()
|
||||
for rc in chunks:
|
||||
rc.source_name_hit = True
|
||||
out = _selection_order(chunks, 0.005, 0.0)
|
||||
assert [d.path for d, _eff, _cos, _idx in out] == [
|
||||
"gitea/README.md", "a.md", "b.md", "c.md", "d.md"
|
||||
]
|
||||
# No +0.005 rides the effective scores — the name bonus alone.
|
||||
assert out[0][1] == pytest.approx(0.016 + 0.005)
|
||||
off = _selection_order(chunks, 0.0, 0.0)
|
||||
assert [d.path for d, *_ in off] == GOLDEN_PRE_PHASE_ORDER
|
||||
assert [eff for _d, eff, _cos, _idx in off] == [
|
||||
0.0200, 0.0160, 0.0150, 0.0150, 0.0100
|
||||
]
|
||||
|
||||
|
||||
def test_selection_order_source_bonus_inert_without_source_hits() -> None:
|
||||
"""No ``source_name_hit`` chunk present → the second bonus cannot
|
||||
fire: with both knobs on the walk equals the phase-119 name-bonus
|
||||
walk exactly (the flagged-signal presence, not just the knob, gates
|
||||
the re-sort)."""
|
||||
out = _selection_order(_bonus_chunks(), 0.005, 0.005)
|
||||
assert [d.path for d, _eff, _cos, _idx in out] == [
|
||||
"gitea/README.md", "a.md", "b.md", "c.md", "d.md"
|
||||
]
|
||||
assert out[0][1] == pytest.approx(0.016 + 0.005)
|
||||
|
||||
|
||||
def test_selection_order_source_bonus_lifts_named_doc_over_equal_fused() -> None:
|
||||
"""The boost (D3): a document whose source is a D2 hit
|
||||
(``source_name_hit=True``) ranks ABOVE an equal-fused-score
|
||||
non-hit — the fused tie normally resolves on cosine (a's 0.9 beats
|
||||
the hit's 0.0), the 0.005 head start decides instead."""
|
||||
gitserver = _doc("gitserver.md", "G" * 50)
|
||||
a = _doc("a.md", "A" * 50)
|
||||
chunks = [
|
||||
_bonus_rc(a, 0.0200, 0.9),
|
||||
_bonus_rc(gitserver, 0.0200, 0.0, source_name_hit=True),
|
||||
]
|
||||
assert [d.path for d, *_ in _selection_order(chunks, 0.0, 0.0)] == [
|
||||
"a.md", "gitserver.md"
|
||||
]
|
||||
out = _selection_order(chunks, 0.0, 0.005)
|
||||
assert [d.path for d, *_ in out] == ["gitserver.md", "a.md"]
|
||||
assert out[0][1] == pytest.approx(0.0200 + 0.005)
|
||||
|
||||
|
||||
def test_selection_order_two_bonuses_bounded_at_two_per_document() -> None:
|
||||
"""A document carrying BOTH flags gets EXACTLY +0.010 (the bounded
|
||||
two-bonus pin — no amplification): every one of the doc's THREE
|
||||
chunks is flagged for both signals, yet each bonus applies ONCE per
|
||||
document — best score + bonus + source_bonus, never more."""
|
||||
gitserver = _doc("gitserver.md", "G" * 50)
|
||||
a = _doc("a.md", "A" * 50)
|
||||
chunks = [
|
||||
_bonus_rc(a, 0.0300, 0.9),
|
||||
_bonus_rc(gitserver, 0.0260, 0.0, 0, name_hit=True, source_name_hit=True),
|
||||
_bonus_rc(gitserver, 0.0200, 0.0, 1, name_hit=True, source_name_hit=True),
|
||||
_bonus_rc(gitserver, 0.0100, 0.0, 2, name_hit=True, source_name_hit=True),
|
||||
]
|
||||
out = _selection_order(chunks, 0.005, 0.005)
|
||||
assert [d.path for d, *_ in out] == ["gitserver.md", "a.md"]
|
||||
assert out[0][1] == pytest.approx(0.0260 + 0.010)
|
||||
|
||||
|
||||
def test_selection_helpers_pull_the_source_bonus_from_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""All three selection helpers default *source_bonus* to the LIVE
|
||||
``BOR_SOURCE_NAME_HIT_BONUS`` setting (the phase-119 ``bonus``
|
||||
pattern): the settings default 0.005 lifts the flagged doc, a
|
||||
settings kill switch (``0``) and an explicit ``source_bonus=0``
|
||||
both reproduce the pre-phase-191 walk."""
|
||||
chunks = _bonus_chunks()
|
||||
chunks[-1].score = 0.0199 # d.md just UNDER a.md's 0.0200
|
||||
chunks[-1].source_name_hit = True
|
||||
on = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||
assert on.source_name_hit_bonus == 0.005 # the production default
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: on)
|
||||
lifted = ["d.md", "gitea/README.md", "a.md", "b.md", "c.md"]
|
||||
assert [d.path for d in select_suggested(chunks, n=5)] == lifted
|
||||
assert [d.path for d in select_related(chunks, set(), cap=5)] == lifted
|
||||
assert weak_hit_titles(chunks) == lifted
|
||||
# Explicit kill switch — the pre-phase-191 (name-bonus-only) walk.
|
||||
assert [d.path for d in select_suggested(chunks, n=5, source_bonus=0.0)] == [
|
||||
"gitea/README.md", "a.md", "d.md", "b.md", "c.md"
|
||||
]
|
||||
# Settings kill switch (BOR_SOURCE_NAME_HIT_BONUS=0) — the same walk.
|
||||
off = Settings(_env_file=None, source_name_hit_bonus=0.0) # pyright: ignore[reportCallIssue]
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: off)
|
||||
pre = ["gitea/README.md", "a.md", "d.md", "b.md", "c.md"]
|
||||
assert [d.path for d in select_suggested(chunks, n=5)] == pre
|
||||
assert [d.path for d in select_related(chunks, set(), cap=5)] == pre
|
||||
assert weak_hit_titles(chunks) == pre
|
||||
|
||||
|
||||
def test_fuse_or_s_source_name_hit_on_double_hit() -> None:
|
||||
"""A vector row merged with a flagged source-name row keeps
|
||||
``source_name_hit=True`` — the merge ORs the flag in (the
|
||||
``name_hit`` mirror), so the selection tier still sees the hit on
|
||||
the fused list."""
|
||||
v = _rc("gitserver.md", cosine=0.9)
|
||||
l1 = _rc("gitserver.md", cosine=0.1) # the lexical copy of the same chunk
|
||||
l1.chunk_id = v.chunk_id
|
||||
l1.source_name_hit = True
|
||||
out = fuse([v], [l1], k=60)
|
||||
assert len(out) == 1
|
||||
assert out[0].source_name_hit is True
|
||||
assert out[0].fts_hit is True
|
||||
assert out[0].score == pytest.approx(2 / 61)
|
||||
|
||||
|
||||
def test_fuse_keeps_source_name_hit_on_lexical_only_hit() -> None:
|
||||
"""A source-name-hit row with no vector rank keeps
|
||||
``source_name_hit=True`` through the fusion (the ``replace()``
|
||||
copy carries the field — the ``name_hit`` mirror)."""
|
||||
row = _rc("gitserver.md")
|
||||
row.source_name_hit = True
|
||||
out = fuse([], [row], k=60)
|
||||
assert len(out) == 1
|
||||
assert out[0].source_name_hit is True
|
||||
|
||||
|
||||
def test_fuse_default_source_name_hit_stays_false_for_ordinary_rows() -> None:
|
||||
"""Neither list flagged ⇒ fusion never invents a source-name hit —
|
||||
ordinary vector and FTS rows are ``source_name_hit=False``."""
|
||||
out = fuse([_rc("a.md", cosine=0.8)], [_rc("b.md", fts_hit=True)], k=60)
|
||||
assert len(out) == 2
|
||||
assert all(rc.source_name_hit is False for rc in out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 162, D1 — the accent-folded name normalization (LOCKED A1): the
|
||||
# 2026-10-07 live gap, the ASCII filename vs the accented content layer
|
||||
|
||||
@@ -253,6 +253,29 @@ def test_sync_session_bound_field_types() -> None:
|
||||
assert Settings.model_fields["sync_prune_batch"].default == 500
|
||||
|
||||
|
||||
def test_source_name_hit_bonus_row_is_a_plain_live_overridable_row() -> None:
|
||||
"""Phase 191 (task 04, D3): the second selection-bonus row sits in
|
||||
``Retrieval & honesty`` (right beside ``name_hit_bonus`` — the two
|
||||
independently kill-able knobs) with the ``Source name hit bonus``
|
||||
label and mirrors the ``name_hit_bonus`` entry's flags exactly:
|
||||
plain — not ``secret`` (a float), not ``restart_required`` (the
|
||||
selection walk reads ``get_settings()`` per turn, so a saved
|
||||
override applies on the next question), overridable (the overlay is
|
||||
generic over ``float`` fields). ``0`` is the byte-identical kill
|
||||
switch; a negative value fails startup, not the panel."""
|
||||
meta = CATALOG["source_name_hit_bonus"]
|
||||
assert meta.group == "Retrieval & honesty"
|
||||
assert meta.label == "Source name hit bonus"
|
||||
assert meta.secret is False
|
||||
assert meta.restart_required is False
|
||||
assert meta.overridable is True
|
||||
assert "source_name_hit_bonus" not in SECRETS
|
||||
assert "source_name_hit_bonus" not in RESTART_REQUIRED
|
||||
assert "source_name_hit_bonus" not in NON_OVERRIDABLE
|
||||
assert Settings.model_fields["source_name_hit_bonus"].annotation is float
|
||||
assert Settings.model_fields["source_name_hit_bonus"].default == 0.005
|
||||
|
||||
|
||||
def test_labels_nonempty_and_unique() -> None:
|
||||
labels = [meta.label for meta in CATALOG.values()]
|
||||
assert all(label.strip() for label in labels), "empty label in the catalogue"
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Unit: source name/label/tags helpers + the D2 matching rule (phase 191, task 01).
|
||||
|
||||
The pure half (``normalize_tags``, ``question_name_hits``) needs no DB;
|
||||
the session helpers run against the local compose Postgres
|
||||
(``podman compose up -d db``) — the house DB-test pattern of
|
||||
``test_sources_meta.py``: the helpers are thin session wrappers whose
|
||||
contract (upsert, partial-update shape, flush-not-commit, absent-row
|
||||
``None``/``False``) only holds against a real database. Skips with clear
|
||||
instructions when the stack is not up.
|
||||
|
||||
The D2 rule is the heart of the phase — every branch is pinned: the
|
||||
name/label/tag hit classes, the case-insensitive ``_normalize_name``
|
||||
form (the phase-162 accent fold included), the 4-char minimum on BOTH
|
||||
sides, the NO digit-class rule ("gitea" hits digitless), ``None`` →
|
||||
``False``, and the no-overlap baseline.
|
||||
|
||||
A fixture truncates ``source_labels`` before and after every test so
|
||||
the suite leaves the dev DB exactly as migration 0030 left it (empty).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.rag.source_labels import (
|
||||
TAGS_MAX_COUNT,
|
||||
SourceLabelView,
|
||||
all_labels,
|
||||
delete_label,
|
||||
get_label,
|
||||
label_for,
|
||||
normalize_tags,
|
||||
question_name_hit_sources,
|
||||
question_name_hits,
|
||||
set_label,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_source_labels(db: Session):
|
||||
"""Empty ``source_labels`` before and after every test (the
|
||||
migration-0030 state)."""
|
||||
db.execute(text("DELETE FROM source_labels"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM source_labels"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The model round-trip + the upsert / partial-update shape (D1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_label_creates_row_with_stamp(db: Session) -> None:
|
||||
"""All three fields set: the row is created verbatim and
|
||||
``updated_at`` carries the server stamp (the house style)."""
|
||||
row = set_label(
|
||||
db,
|
||||
"gitea-forge",
|
||||
display_name="the git server",
|
||||
label="self-hosted forge + Gitea",
|
||||
tags=["gitea", "forge"],
|
||||
)
|
||||
db.commit()
|
||||
assert row.source == "gitea-forge"
|
||||
stored = get_label(db, "gitea-forge")
|
||||
assert stored is not None
|
||||
assert stored.display_name == "the git server"
|
||||
assert stored.label == "self-hosted forge + Gitea"
|
||||
assert stored.tags == ["gitea", "forge"] # stored exactly as normalized
|
||||
assert stored.updated_at is not None, "updated_at must be server-stamped"
|
||||
|
||||
|
||||
def test_set_label_defaults_omitted_fields(db: Session) -> None:
|
||||
"""A brand-new row created with only ``display_name`` carries the
|
||||
D1 defaults: ``label`` NULL and ``tags`` ``[]`` (NOT NULL column)."""
|
||||
set_label(db, "uploads", display_name="the uploads")
|
||||
db.commit()
|
||||
stored = get_label(db, "uploads")
|
||||
assert stored is not None
|
||||
assert stored.display_name == "the uploads"
|
||||
assert stored.label is None
|
||||
assert stored.tags == []
|
||||
|
||||
|
||||
def test_partial_update_omitted_field_untouched(db: Session) -> None:
|
||||
"""The partial-update shape: an OMITTED field leaves the stored
|
||||
value untouched (the ``_UNSET`` sentinel owns the difference)."""
|
||||
set_label(
|
||||
db, "homelab", display_name="the lab", label="first", tags=["homelab"]
|
||||
)
|
||||
db.commit()
|
||||
set_label(db, "homelab", label="second")
|
||||
db.commit()
|
||||
stored = get_label(db, "homelab")
|
||||
assert stored is not None
|
||||
assert stored.label == "second", "the passed field overwrites"
|
||||
assert stored.display_name == "the lab", "the omitted field is untouched"
|
||||
assert stored.tags == ["homelab"], "the omitted tags are untouched"
|
||||
|
||||
|
||||
def test_partial_update_explicit_none_clears(db: Session) -> None:
|
||||
"""An explicit ``None`` CLEARS the field (the D5 shape); ``tags``
|
||||
clears to ``[]`` — the column stays NOT NULL."""
|
||||
set_label(
|
||||
db, "homelab", display_name="the lab", label="first", tags=["homelab"]
|
||||
)
|
||||
db.commit()
|
||||
set_label(db, "homelab", display_name=None, label=None, tags=None)
|
||||
db.commit()
|
||||
stored = get_label(db, "homelab")
|
||||
assert stored is not None
|
||||
assert stored.display_name is None
|
||||
assert stored.label is None
|
||||
assert stored.tags == []
|
||||
|
||||
|
||||
def test_set_label_flushes_without_committing(db: Session) -> None:
|
||||
"""Flush-only (the phase-53 session convention): a rolled-back
|
||||
session rolls the upsert back with it."""
|
||||
set_label(db, "homelab", display_name="the lab")
|
||||
db.rollback()
|
||||
other = SessionLocal()
|
||||
try:
|
||||
assert get_label(other, "homelab") is None, "the uncommitted row must roll back"
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
|
||||
def test_source_is_the_sole_pk(db: Session) -> None:
|
||||
"""One label row per source: the raw duplicate insert raises."""
|
||||
db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')"))
|
||||
db.commit()
|
||||
with pytest.raises(IntegrityError):
|
||||
db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')"))
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_tags_server_default_is_empty_list(db: Session) -> None:
|
||||
"""A row inserted without ``tags`` reads ``[]`` (the phase-89
|
||||
``ignore_paths`` server-default shape — no NULL ever surfaces)."""
|
||||
db.execute(text("INSERT INTO source_labels (source) VALUES ('nodefault')"))
|
||||
db.commit()
|
||||
stored = get_label(db, "nodefault")
|
||||
assert stored is not None
|
||||
assert stored.tags == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The read side: label_for / all_labels / delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_label_for_returns_frozen_view(db: Session) -> None:
|
||||
"""``label_for`` maps the row to the frozen view; ``tags`` arrives
|
||||
as a tuple."""
|
||||
set_label(db, "homelab", display_name="the lab", tags=["a", "b"])
|
||||
db.commit()
|
||||
view = label_for(db, "homelab")
|
||||
assert view is not None
|
||||
assert view == SourceLabelView(
|
||||
source="homelab", display_name="the lab", label=None, tags=("a", "b")
|
||||
)
|
||||
assert isinstance(view.tags, tuple)
|
||||
|
||||
|
||||
def test_label_for_absent_returns_none(db: Session) -> None:
|
||||
"""No row → ``None`` — the "no label row = pre-phase" contract."""
|
||||
assert label_for(db, "never-named") is None
|
||||
|
||||
|
||||
def test_all_labels_keyed_by_source(db: Session) -> None:
|
||||
"""One query, every row, keyed by ``source`` (the views the boost
|
||||
and the API iterate)."""
|
||||
set_label(db, "alpha", display_name="the alpha")
|
||||
set_label(db, "beta", label="the beta row")
|
||||
db.commit()
|
||||
labels = all_labels(db)
|
||||
assert set(labels) == {"alpha", "beta"}
|
||||
assert labels["alpha"].display_name == "the alpha"
|
||||
assert labels["beta"].label == "the beta row"
|
||||
assert labels["alpha"].tags == ()
|
||||
|
||||
|
||||
def test_all_labels_empty_table(db: Session) -> None:
|
||||
"""The pre-phase state: an empty table reads as an empty mapping."""
|
||||
assert all_labels(db) == {}
|
||||
|
||||
|
||||
def test_delete_label_removes_row(db: Session) -> None:
|
||||
"""The reset path: the row is gone, the helper reports ``True``."""
|
||||
set_label(db, "homelab", display_name="the lab")
|
||||
db.commit()
|
||||
assert delete_label(db, "homelab") is True
|
||||
db.commit()
|
||||
assert get_label(db, "homelab") is None
|
||||
|
||||
|
||||
def test_delete_label_absent_returns_false(db: Session) -> None:
|
||||
"""An absent row is ``False`` — the API maps that to 404 (D5)."""
|
||||
assert delete_label(db, "never-named") is False
|
||||
|
||||
|
||||
def test_delete_label_flushes_without_committing(db: Session) -> None:
|
||||
"""Flush-only both ways: a rolled-back delete keeps the row."""
|
||||
set_label(db, "homelab", display_name="the lab")
|
||||
db.commit()
|
||||
assert delete_label(db, "homelab") is True
|
||||
db.rollback()
|
||||
other = SessionLocal()
|
||||
try:
|
||||
assert get_label(other, "homelab") is not None, "the rollback must restore"
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_tags (the API's 422 source, D5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_tags_trims_and_drops_empties() -> None:
|
||||
"""Entries are trimmed; entries that are empty afterwards dropped;
|
||||
``[]`` is legal (it clears)."""
|
||||
assert normalize_tags([" gitea ", "", " ", "forge"]) == ["gitea", "forge"]
|
||||
assert normalize_tags([]) == []
|
||||
assert normalize_tags(["", " "]) == []
|
||||
|
||||
|
||||
def test_normalize_tags_dedupes_case_insensitively_first_case_wins() -> None:
|
||||
"""Case-insensitive de-dupe preserving the FIRST case seen, order
|
||||
otherwise kept."""
|
||||
assert normalize_tags(["Gitea", "gitea", "GITEA", "Forge"]) == ["Gitea", "Forge"]
|
||||
|
||||
|
||||
def test_normalize_tags_rejects_too_many_tags() -> None:
|
||||
"""> 10 entries raise (counted on the trimmed non-empty list)."""
|
||||
with pytest.raises(ValueError):
|
||||
normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT + 1)])
|
||||
# exactly 10 is legal
|
||||
assert len(normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT)])) == 10
|
||||
|
||||
|
||||
def test_normalize_tags_rejects_overlong_tag() -> None:
|
||||
"""A > 32-char entry raises (the cap is checked after the trim)."""
|
||||
with pytest.raises(ValueError):
|
||||
normalize_tags(["gitea", "x" * 33])
|
||||
assert normalize_tags(["x" * 32]) == ["x" * 32] # exactly 32 is legal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# question_name_hits — the D2 rule (every branch pinned)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VIEW = SourceLabelView(
|
||||
source="gitea-forge",
|
||||
display_name="the git server",
|
||||
label="self-hosted forge + Gitea",
|
||||
tags=("gitea", "forgejo"),
|
||||
)
|
||||
|
||||
|
||||
def test_hit_on_display_name_token() -> None:
|
||||
"""A question token equal to a display-name token hits — and the
|
||||
phase-119 digit-class rule does NOT apply ("server" is digitless)."""
|
||||
assert question_name_hits(_VIEW, "restart the SERVER properly") is True
|
||||
|
||||
|
||||
def test_hit_on_label_token() -> None:
|
||||
"""A token of the ``label`` field hits (``+Gitea`` normalizes to
|
||||
``gitea``)."""
|
||||
assert question_name_hits(_VIEW, "upgrade gitea today") is True
|
||||
|
||||
|
||||
def test_hit_on_tag_entry() -> None:
|
||||
"""A tag entry hits even when neither name nor label mentions it."""
|
||||
view = SourceLabelView(
|
||||
source="s", display_name=None, label=None, tags=("forgejo",)
|
||||
)
|
||||
assert question_name_hits(view, "mirror forgejo repos") is True
|
||||
|
||||
|
||||
def test_hit_is_accent_folded_both_sides() -> None:
|
||||
"""The phase-162 fold rides ``_normalize_name``: the accented source
|
||||
name answers to the ASCII question token (and vice versa)."""
|
||||
view = SourceLabelView(
|
||||
source="besta", display_name="BESTÅ", label=None, tags=()
|
||||
)
|
||||
assert question_name_hits(view, "how tall is the besta shelf") is True
|
||||
assert question_name_hits(view, "BESTÅ shelf height") is True
|
||||
|
||||
|
||||
def test_digitless_name_hits_without_digit_rule() -> None:
|
||||
"""The 2026-09-16 finding, name side: "gitea" must hit even though
|
||||
it carries no digit (the path digit rule is NOT reused here)."""
|
||||
view = SourceLabelView(
|
||||
source="gitea-forge", display_name="Gitea", label=None, tags=()
|
||||
)
|
||||
assert question_name_hits(view, "How do I deploy gitea?") is True
|
||||
|
||||
|
||||
def test_short_source_side_token_never_matches() -> None:
|
||||
"""The 4-char minimum applies to the SOURCE side: a 3-char tag can
|
||||
never match, even against an identical question token."""
|
||||
view = SourceLabelView(
|
||||
source="ops", display_name="gke", label=None, tags=("gke",)
|
||||
)
|
||||
assert question_name_hits(view, "set up gke today") is False
|
||||
|
||||
|
||||
def test_short_question_token_never_matches() -> None:
|
||||
"""The phase-119 ``NAME_TOKEN_MIN_LEN`` side: a 3-char question
|
||||
token is no candidate against a matching source token."""
|
||||
view = SourceLabelView(
|
||||
source="ops", display_name="Gke Cluster", label=None, tags=()
|
||||
)
|
||||
assert question_name_hits(view, "gke deploy") is False
|
||||
|
||||
|
||||
def test_none_view_never_hits() -> None:
|
||||
"""No label row → ``False`` — the byte-identical pre-phase contract."""
|
||||
assert question_name_hits(None, "restart the git server") is False
|
||||
|
||||
|
||||
def test_question_without_candidates_never_hits() -> None:
|
||||
"""A question with NO qualifying candidate (every token under the
|
||||
4-char floor) short-circuits to ``False`` without touching the
|
||||
source side."""
|
||||
assert question_name_hits(_VIEW, "ai ox up") is False
|
||||
|
||||
|
||||
def test_no_overlap_question_never_hits() -> None:
|
||||
"""The baseline: a question sharing no normalized token with any
|
||||
field is not a hit."""
|
||||
assert question_name_hits(_VIEW, "bake a birthday cake") is False
|
||||
|
||||
|
||||
def test_dotted_and_versioned_candidates_still_match() -> None:
|
||||
"""The reused phase-119 candidate list: a dotted token (kept whole,
|
||||
``llama.cpp`` → ``llamacpp``) matches the equal source token."""
|
||||
view = SourceLabelView(
|
||||
source="infer", display_name="llama.cpp", label=None, tags=()
|
||||
)
|
||||
assert question_name_hits(view, "tune llama.cpp flags") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# question_name_hit_sources — the per-turn entry point (one read)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hit_sources_matches_per_row(db: Session) -> None:
|
||||
"""The DB half: every labelled row is tested against the ONE
|
||||
``all_labels`` read; only the named source comes back."""
|
||||
set_label(db, "gitea-forge", display_name="the git server", tags=["gitea"])
|
||||
set_label(db, "media", display_name="the media box", tags=["jellyfin"])
|
||||
set_label(db, "plain") # a row with nothing set can never hit
|
||||
db.commit()
|
||||
assert question_name_hit_sources(db, "restart jellyfin tonight") == {"media"}
|
||||
assert question_name_hit_sources(db, "upgrade gitea") == {"gitea-forge"}
|
||||
assert question_name_hit_sources(db, "nothing relevant here") == set()
|
||||
|
||||
|
||||
def test_hit_sources_empty_table(db: Session) -> None:
|
||||
"""Pre-phase state: no rows → the empty set (the boost never fires)."""
|
||||
assert question_name_hit_sources(db, "restart the git server") == set()
|
||||
@@ -0,0 +1,746 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user