phase: 157_chat_chip_cap
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:
@@ -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)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user