phase: 157_chat_chip_cap
Build and Push Containers / build-and-push-app (push) Successful in 2m19s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification is green. Final report:

**Phase 157 — final verification pass (all 3 tasks already complete in `complete/`)**

- Verified the implemented work matches the design: `CHIP_CAP = 90` constant + display-only cap in `renderChips` (`slice(0,90).trimEnd() + "…"`), `title`/`aria-label`/click payload byte-unchanged; unit pins (89/90/91 letter & space, full-text invariant, short-string guard, Maybe-try single-path guard); phase-104 E2E updated to the cap contract
- Full suite `uv run pytest --cov=app --cov-report=term-missing`: **3578 passed**, app/ coverage **99%** (>90% ✓)
- `uv run ruff check .` clean; `uv run pyright`: 0 errors
- Dedicated E2E `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov`: **4/4 passed** in isolation (DB up)
- Cross-phase regression: phase-104 E2E 6/6, phase 05/80/103 chip suite 8/8 (seed chips <90 chars — cap is a no-op, assertions hold)
- Criteria: ≤91-char capped display ✓ (byte-for-byte pin) · click submits FULL 120-char question (user bubble) ✓ · `title` + clipped-case `aria-label` carry full text ✓ (375px viewport) · <90-char byte-identical ✓ (unit + live DOM) · commit: deferred to the harness (per harness protocol, overrides AGENTS.md rule 8 for this pass)
- Documentation: README.md reviewed in full — every `.env.example` var documented, all referenced scripts/pages/commands verified against the tree, the phase's chip-cap note is in the Chat section; AGENTS.md verified (commands, test files, layout-rule citations — phase 157 adds no new layout principle) → **verified consistent, no changes needed**
- No live-infrastructure changes were required or made
- Next pending phase: **158_thinking_timeline**
This commit is contained in:
2026-10-04 22:18:31 -04:00
parent 1513981c1c
commit 3f752fb77f
21 changed files with 1423 additions and 45 deletions
+387 -11
View File
@@ -51,13 +51,42 @@ row) carries:
Task 03 extends this module with the composer cap pins (``maxlength`` /
the counter / the guard / the single-source cross-file pin).
Phase 157 (owner 2026-10-03 audit, A1) EXTENDS the task-02 visible-text
contract: the chip's DISPLAYED text is capped at ``CHIP_CAP = 90``
characters (``slice(0, 90).trimEnd() + "…"`` over the cap) — the cap is
the PRIMARY length guard, the single-line CSS clip the secondary. The
task-02 ``title`` = FULL-text pin and the ``aria-label``-when-clipped
pin are UNCHANGED (the cap is display-only; the hover reveal and the
accessible name still carry the full question).
Phase 157 task 02 pins the extension at the source level AND by
executing the REAL ``renderChips`` under node with a stubbed DOM
(the house node-execution idiom, test_frontend_doc_modal.py — the
stubbed-state pattern): the 90-char boundary (89/90 byte-identical;
91 → ``slice(0, 90).trimEnd() + "…"`` — the EXACT string, both the
letter-at-90 and the space-at-90 fixtures — the ``trimEnd`` case is
the artifact the cap avoids), the full-text invariant (``title`` / the
``onSelect`` spy / the ``submitSuggestion`` spy all carry the FULL
string for a >90-char item — a capped chip sends the full
question), the short-string guard (<90 is byte-identical in display +
title; the ``scrollWidth > clientWidth`` aria guard is unchanged — a
clipped chip gets the full text, an unclipped one stays
attribute-free), and the "Maybe try" single code path
(``appendMaybeTry`` routes through the same ``renderChips`` — no
second cap, no text re-treatment).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from app.schemas import ChatRequest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
@@ -76,6 +105,15 @@ def _app_js() -> str:
return APP_JS.read_text(encoding="utf-8")
HAVE_NODE = shutil.which("node") is not None
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
@@ -256,24 +294,45 @@ def test_mobile_chip_row_keeps_the_phase_07_track() -> None:
# ---------- task 02: the full-text tooltip + accessible name ----------
def test_render_chips_display_is_capped_at_chip_cap() -> None:
"""The chip's DISPLAYED text is the phase-157 cap (owner A1,
2026-10-03 audit): over ``CHIP_CAP = 90`` characters the display
is ``slice(0, 90).trimEnd() + "…"`` — the ``trimEnd`` avoids the
trailing-space-before-``…`` artifact when char 90 lands mid-word —
and at/below the cap the display is the (trimmed) text
byte-identical to the pre-phase full question (the short-string
guard). The cap is the PRIMARY length guard; the phase-104
single-line CSS clip is the secondary."""
body = _render_chips_body(_app_js())
cap_line = re.search(
r"btn\.textContent =\s*"
r"text\.length > CHIP_CAP \? "
r'text\.slice\(0, CHIP_CAP\)\.trimEnd\(\) \+ "…" : text;',
body,
)
assert cap_line, (
"the chip's displayed text must be the phase-157 cap: "
"text.length > CHIP_CAP → slice(0, CHIP_CAP).trimEnd() + '…', "
"otherwise the (trimmed) text itself"
)
def test_render_chips_sets_full_text_title_on_every_chip() -> None:
"""Every chip ``renderChips`` builds (onboarding row AND "Maybe try"
row — one shared component) carries ``title`` = the FULL text
(owner A2): the task-01 pill clips a long question to one
ellipsized line, and the native hover tooltip is the way the user
(owner A2): a long question is capped in the DISPLAY (phase 157,
the PRIMARY guard) or clipped by the single-line pill (phase 104,
the secondary), and the native hover tooltip is the way the user
reads the rest. The pin is the house source-chip precedent
(``chip.title = label``) — and it is set to the SAME ``text``
variable ``textContent`` gets, so the tooltip can never drift
short of the full question."""
(``chip.title = label``) — set to the SAME ``text`` variable the
click payload submits, so the tooltip can never drift short of
the full question."""
body = _render_chips_body(_app_js())
assert "btn.textContent = text;" in body, (
"the chip's visible text is the (trimmed) full question"
)
assert "btn.title = text;" in body, (
"every chip must carry the full text as its native title "
"tooltip — hovering is the contract for the clipped text "
"(owner A2: 'Hovering over the chips should still show the "
"full message')"
"tooltip — hovering is the contract for the capped/clipped "
"text (owner A2: 'Hovering over the chips should still show "
"the full message')"
)
@@ -548,3 +607,320 @@ def test_char_count_css_is_aa_on_the_app_background() -> None:
"the --err-ink on --bg ratio must be recorded (verified "
"10.4:1 ≥ 4.5:1, WCAG AA)"
)
# ---------- phase 157 (task 02): the 90-char boundary, the full-text
# invariant, the short-string guard ----------
FIX_SHORT = "hello world" # <90: the no-cap case (byte-identical)
FIX_89 = "a" * 89 # 89 chars — just under the cap
FIX_90 = "b" * 90 # 90 chars — exactly at the cap (the cap is `>`)
# 91 chars, char 90 (0-based 89) is a LETTER: trimEnd is a no-op.
FIX_91_LETTER = "a" * 89 + "bb"
# 91 chars, char 90 (0-based 89) is a SPACE: the trimEnd case — the
# trailing-space-before-`…` artifact the cap avoids.
FIX_91_SPACE = "b" * 89 + " " + "c"
NODE_STUB = """
const calls = {};
function submitSuggestion(t) { calls.submit = t; }
let GEO = { clipped: false };
function makeBtn() {
const el = {
type: null,
className: "",
title: null,
_attrs: {},
_listeners: {},
_text: "",
scrollWidth: GEO.clipped ? 500 : 100,
clientWidth: GEO.clipped ? 100 : 100,
setAttribute(k, v) { this._attrs[k] = v; },
addEventListener(ev, fn) { this._listeners[ev] = fn; },
remove() {},
};
Object.defineProperty(el, "textContent", {
get() { return this._text; },
set(v) { this._text = String(v); },
});
return el;
}
const document = { createElement: () => makeBtn() };
function newContainer() {
return {
_children: [],
querySelectorAll: () => [],
appendChild(ch) { this._children.push(ch); },
};
}
"""
NODE_DRIVER = """
function run(name, text) {
calls.submit = null;
calls.select = null;
const c = newContainer();
renderChips(c, [text], { onSelect: (t, b) => { calls.select = t; } });
const btn = c._children[0];
const aria =
Object.prototype.hasOwnProperty.call(btn._attrs, "aria-label")
? btn._attrs["aria-label"]
: null;
btn._listeners["click"]();
return {
name: name,
text: btn.textContent,
title: btn.title,
aria: aria,
submit: calls.submit,
select: calls.select,
};
}
const CASES = __CASES__;
const out = [];
for (const [name, text, clipped] of CASES) {
GEO.clipped = clipped;
out.push(run(name, text));
}
console.log(JSON.stringify(out));
"""
def _chip_render_results(
js: str, cases: list[tuple[str, str, bool]]
) -> list[dict]:
"""Execute the REAL ``renderChips`` (the brace-matched source text
from app.js — not a re-implementation) under node with a stubbed
DOM: a fresh chip-less container, a ``createElement`` button stub
(``textContent`` get/set, ``title``, ``setAttribute``, one click
listener, and the ``scrollWidth``/``clientWidth`` pair switched
per case — 500>100 clipped vs 100=100 unclipped), a
``submitSuggestion`` spy, and an ``onSelect`` spy. Each case gets
a fresh chip that is clicked exactly once; the row carries the
displayed text, the title, the ``aria-label`` (or ``null``), and
the payloads the click delivered to ``submitSuggestion`` /
``onSelect``."""
cap = re.search(r"const CHIP_CAP = (\d+);", js)
assert cap, "app.js must define const CHIP_CAP"
script = (
NODE_STUB
+ f"const CHIP_CAP = {cap.group(1)};\n"
+ _render_chips_body(js)
+ "\n"
+ NODE_DRIVER.replace("__CASES__", json.dumps(cases))
)
return json.loads(_run_node(script).strip().splitlines()[-1])
def test_chip_cap_constant_is_90_once_directly_above_render_chips() -> None:
"""``CHIP_CAP = 90`` (owner-locked A1) is defined EXACTLY ONCE, sits
directly above ``renderChips`` (only comments/blank lines between
— the established placement beside the other chip constants), and
the comparison uses the NAMED constant (no hard-coded ``> 90``
that could drift from it)."""
js = _app_js()
assert js.count("const CHIP_CAP") == 1, "exactly one CHIP_CAP definition"
m = re.search(r"const CHIP_CAP = (\d+);", js)
assert m is not None and m.group(1) == "90", "CHIP_CAP must be 90 (owner A1)"
gap = js[m.end() : js.index("function renderChips(")]
assert all(
line.strip() == "" or line.lstrip().startswith("//")
for line in gap.splitlines()
), "the constant must sit directly above renderChips (comment-only gap)"
body = _render_chips_body(js)
assert not re.search(r"length\s*>=?\s*90\b", body), (
"the cap comparison must use the named CHIP_CAP constant — a "
"hard-coded literal could drift from it"
)
def test_click_handler_payloads_carry_the_untouched_full_text() -> None:
"""The load-bearing invariant pinned on VARIABLE IDENTITY: the loop
declares ONE ``text`` (the trimmed item — the full question) and
nothing reassigns it, so the ``title``, the ``submitSuggestion``
argument, and the ``onSelect`` argument are all the SAME full
string (the cap may only ever touch ``textContent``). A capped
chip therefore sends the FULL question (the phase-80 contract).
The node-executed spy pins below prove it at runtime."""
body = _render_chips_body(_app_js())
assert 'const text = String(item || "").trim();' in body
assigns = re.findall(r"\btext\s*=[^=]", body)
assert len(assigns) == 1, (
"exactly ONE `text` assignment in renderChips (the declaration) "
"— the title / submitSuggestion / onSelect payloads must be the "
"full (trimmed) item text, never a reassignment or a slice"
)
handler = re.search(
r'btn\.addEventListener\("click", \(\) => \{([\s\S]*?)\}\);', body
)
assert handler, "the chip must keep its click handler"
h = handler.group(1)
assert "submitSuggestion(text);" in h, (
"the click must submit the FULL text — a capped chip sends the "
"full question, not the capped display"
)
assert "onSelect(text, btn);" in h, (
"onSelect must receive the FULL text (the spy payload pin)"
)
def test_display_cap_is_the_only_length_treatment_in_render_chips() -> None:
"""No second cap, no drift: the cap expression is the ONLY length
treatment in ``renderChips`` — one slice, one trimEnd, one title,
one submit; the click handler / title / aria-label lines never
touch the display string (the cap is display-only)."""
body = _render_chips_body(_app_js())
assert body.count("text.slice(0, CHIP_CAP)") == 1
assert body.count(".trimEnd()") == 1
assert body.count("btn.title = text;") == 1
assert body.count("submitSuggestion(text);") == 1
def test_both_chip_rows_share_the_one_render_chips_path() -> None:
"""ONE code path: the hero row (``loadSuggestions`` →
``renderChips(suggestionsEl, …)``) and the "Maybe try" row
(``appendMaybeTry`` → ``renderChips(group, …)``) both route the
suggestions through the SAME ``renderChips`` — so the phase-157
cap applies to both identically (a >90-char suggestion renders
capped in either row; a short one is byte-identical).
``appendMaybeTry`` itself must never re-treat the text (no
second cap, no slice, no trimEnd, no ellipsis of its own) and
keeps the accessible group (role=list + "Maybe try") it always
had."""
js = _app_js()
assert "renderChips(suggestionsEl, suggestions);" in js, (
"the hero row must use the shared renderChips component"
)
body = _function_body(js, "function appendMaybeTry(")
assert "renderChips(group, suggestions);" in body, (
"the Maybe-try row must route through the SAME renderChips — "
"the cap applies identically (a single code path)"
)
assert 'wrap.querySelector(".msg-body")' in body
assert "body.appendChild(group);" in body
assert 'group.setAttribute("role", "list");' in body
assert 'group.setAttribute("aria-label", "Maybe try");' in body
for banned in ("CHIP_CAP", "slice", "trimEnd", "…"):
assert banned not in body, (
f"appendMaybeTry must not re-treat the suggestion text "
f"({banned!r} found — no second cap / no drift)"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_chip_display_boundary_executed_under_node() -> None:
"""The 90-char BOUNDARY, executed under node with the REAL
``renderChips`` (the actual ``btn.textContent`` expression — the
extracted source text, not a re-implementation):
* an 89-char item → the display is BYTE-IDENTICAL to the input
(the cap does not engage below the cap);
* a 90-char item → BYTE-IDENTICAL (the cap is ``>``, not ``≥``);
* a 91-char item whose char 90 is a LETTER → the EXACT
``slice(0, 90) + "…"`` (91 chars — the ``trimEnd`` is a no-op);
* a 91-char item whose char 90 is a SPACE → the EXACT
``slice(0, 90).trimEnd() + "…"`` (90 chars — the
trailing-space-before-``…`` artifact the ``trimEnd`` avoids;
no space may sit before the ``…``).
"""
res = {
r["name"]: r
for r in _chip_render_results(
_app_js(),
[
("c89", FIX_89, False),
("c90", FIX_90, False),
("c91l", FIX_91_LETTER, False),
("c91s", FIX_91_SPACE, False),
],
)
}
assert res["c89"]["text"] == FIX_89, "89 chars: byte-identical (no cap)"
assert res["c90"]["text"] == FIX_90, (
"90 chars: byte-identical (the cap is >, not >=)"
)
assert res["c91l"]["text"] == FIX_91_LETTER[:90] + "…", (
"91 chars (letter at 90): the exact slice(0,90) + '…'"
)
assert len(res["c91l"]["text"]) == 91
assert res["c91s"]["text"] == FIX_91_SPACE[:90].rstrip() + "…", (
"91 chars (space at 90): the exact slice(0,90).trimEnd() + '…' "
"— the trimEnd case"
)
assert len(res["c91s"]["text"]) == 90
assert res["c91s"]["text"].endswith("…")
assert " …" not in res["c91s"]["text"], (
"no space may sit before the … — the artifact the trimEnd avoids"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_capped_chip_submits_the_full_question_executed_under_node() -> None:
"""The FULL-TEXT invariant (the load-bearing assertion), executed
under node with the real ``renderChips`` + spies: for a >90-char
item, the ``title`` hover reveal, the ``onSelect`` spy payload,
and the ``submitSuggestion`` spy argument all carry the FULL
string (asserted on the EXACT value — not a length). The capped
chip sends the FULL question: the cap is display-only."""
res = {
r["name"]: r
for r in _chip_render_results(
_app_js(),
[
("c91l", FIX_91_LETTER, False),
("c91s", FIX_91_SPACE, False),
],
)
}
for name, full in (("c91l", FIX_91_LETTER), ("c91s", FIX_91_SPACE)):
assert res[name]["title"] == full, (
f"{name}: the title must be the FULL text (the hover reveal)"
)
assert res[name]["submit"] == full, (
f"{name}: the click must submit the FULL text — a capped chip "
"sends the full question (the phase-80 contract)"
)
assert res[name]["select"] == full, (
f"{name}: the onSelect spy must receive the FULL text"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_short_items_byte_identical_and_aria_guard_executed_under_node() -> None:
"""The SHORT-STRING guard + the aria guard, executed under node
with the real ``renderChips`` (the DOM-simulation idiom):
* a <90-char item → the display and the title are BYTE-IDENTICAL
to the input (the no-cap case is unchanged by the phase);
* the ``scrollWidth > clientWidth`` guard (the phase-104
source-chip pattern) is unchanged: a CLIPPED chip (a 91-char
item simulated as overflowing its single-line box) gets
``aria-label`` = the FULL text; an UNCLIPPED chip stays
attribute-free (its ``textContent`` already reads the full text
to screen readers).
"""
res = {
r["name"]: r
for r in _chip_render_results(
_app_js(),
[
("short", FIX_SHORT, False),
("clipped91", FIX_91_LETTER, True),
("unclipped91", FIX_91_LETTER, False),
],
)
}
assert res["short"]["text"] == FIX_SHORT, "short: byte-identical display"
assert res["short"]["title"] == FIX_SHORT, "short: byte-identical title"
assert res["short"]["aria"] is None, (
"a short (unclipped) chip must stay attribute-free — its "
"textContent already reads the full text"
)
assert res["clipped91"]["aria"] == FIX_91_LETTER, (
"the CLIPPED chip's aria-label must be the FULL text under the "
"scrollWidth > clientWidth guard (the source-chip pattern)"
)
assert res["unclipped91"]["aria"] is None, (
"an unclipped chip must stay attribute-free (the guard is "
"unchanged by the cap)"
)