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**
927 lines
40 KiB
Python
927 lines
40 KiB
Python
"""Unit: the phase-104 chip-sizing contract in the static frontend
|
|
(owner request 2026-09-12 — "tweak the size of the suggestion chips on
|
|
the chat page. Some users submit truly massive queries and the 'chips'
|
|
become more like 'chonks'. Hovering over the chips should still show
|
|
the full message … put a character cap on the chat submission box").
|
|
|
|
The browser geometry (the measured clientHeight / scrollWidth / the
|
|
title + aria-label / the counter / the guard) is E2E-gated by the
|
|
phase's dedicated suite (tests/e2e/test_chip_sizing_question_cap.py,
|
|
task 04); this module pins the static sources the contract stands on,
|
|
in the house source-pin pattern (the test_pinned_composer.py
|
|
``_rule`` style).
|
|
|
|
Task 01 — single-line chips, never chonks (owner A1): a suggestion
|
|
chip is ONE line at every viewport width — long text ellipsizes at the
|
|
row edge instead of wrapping the pill into a multi-line "chonk". The
|
|
pin is the CSS contract:
|
|
|
|
* the base ``.suggestion-chip`` rule carries the single-line/ellipsis
|
|
set — ``white-space: nowrap``, ``overflow: hidden`` (zeroes the flex
|
|
item's automatic minimum size so ``max-width: 100%`` actually binds),
|
|
``text-overflow: ellipsis``, ``max-width: 100%`` — plus
|
|
``min-width: 0``, and KEEPS the pill it always was (the 44px
|
|
``min-height`` floor, the 999px radius);
|
|
* the phase-07 override ``.maybe-try .suggestion-chip``
|
|
(``min-width: 0; max-width: 100%``) is fully subsumed by the base
|
|
rule and DELETED — zero occurrences of the selector remain in the
|
|
file (its phase-07 provenance was folded into the base rule's
|
|
comment, so the history lives with the contract); the ``.maybe-try``
|
|
GROUP rule itself stays (the deflection row still wraps at every
|
|
width — only the chip override is gone);
|
|
* the ≤640px block keeps its phase-07 contract untouched: the
|
|
``.suggestions`` row is the single horizontal-scroll track
|
|
(``flex-wrap: nowrap; overflow-x: auto``) and
|
|
``.suggestion-chip { flex: 0 0 auto; }`` — with the base rule's
|
|
``max-width: 100%`` a long chip now clips at the VISIBLE width while
|
|
the row scrolls, instead of letting the chip outgrow the viewport.
|
|
|
|
Task 02 — the full text is always one hover away (owner A2): the
|
|
single-line pill from task 01 CLIPS long questions, so every chip the
|
|
shared ``renderChips`` component builds (onboarding row AND "Maybe try"
|
|
row) carries:
|
|
|
|
* ``btn.title = text`` — the native tooltip set to the FULL text,
|
|
always (the house source-chip precedent, ``chip.title = label``);
|
|
* the truncation-aware accessible name — ONLY when the visible text is
|
|
clipped (``btn.scrollWidth > btn.clientWidth``) does the chip get
|
|
``aria-label`` = the full text (the source-chip pattern); when the
|
|
pill is not clipped the attribute stays absent, because
|
|
``textContent`` already reads the full text to screen readers.
|
|
|
|
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"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
INDEX_HTML = FRONTEND / "index.html"
|
|
|
|
|
|
def _css() -> str:
|
|
assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}"
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _app_js() -> str:
|
|
assert APP_JS.is_file(), f"missing {APP_JS}"
|
|
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")
|
|
|
|
|
|
def _function_body(js: str, header: str) -> str:
|
|
"""The full text of the function whose header is ``header`` — from
|
|
the header to its brace-matched closing ``}``. The naive brace count
|
|
is safe for the pinned functions: their template literals carry
|
|
balanced ``${…}`` pairs and no string literal holds a stray brace."""
|
|
start = js.index(header)
|
|
body_open = js.index("{", start)
|
|
depth = 0
|
|
for j in range(body_open, len(js)):
|
|
if js[j] == "{":
|
|
depth += 1
|
|
elif js[j] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return js[start : j + 1]
|
|
raise AssertionError(f"unbalanced braces in {header!r}")
|
|
|
|
|
|
def _single_line_rule(css: str, selector: str) -> str:
|
|
"""The body of a ONE-LINE rule (``selector { … }`` on a single
|
|
line) — the .char-count rules are written house one-liners."""
|
|
block = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.MULTILINE)
|
|
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
|
|
return block.group(1)
|
|
|
|
|
|
def _message_input_tag(html: str) -> str:
|
|
"""The FULL opening tag of the ``#message-input`` textarea (the
|
|
composer's — index.html carries other textareas too)."""
|
|
tag = re.search(r'<textarea\b[^>]*id="message-input"[^>]*>', html)
|
|
assert tag, "index.html must carry the #message-input textarea"
|
|
return tag.group(0)
|
|
|
|
|
|
def _schema_question_cap() -> int:
|
|
"""The server cap the UI mirrors — ``ChatRequest.message``'s
|
|
``max_length`` (the single conceptual source of the 4,000)."""
|
|
for meta in ChatRequest.model_fields["message"].metadata:
|
|
if getattr(meta, "max_length", None):
|
|
return int(meta.max_length)
|
|
raise AssertionError("ChatRequest.message must keep its max_length")
|
|
|
|
|
|
def _render_chips_body(js: str) -> str:
|
|
"""The full text of the ``renderChips`` function — from its
|
|
signature to its brace-matched closing ``}`` (brace counting, so
|
|
the nested click-handler braces are exact). The body's opening
|
|
brace is the LAST ``{`` on the signature line — the ``{ onSelect }
|
|
= {}`` destructuring pair sits before it."""
|
|
start = js.index("function renderChips(")
|
|
line_end = js.index("\n", start)
|
|
body_open = js.rindex("{", start, line_end)
|
|
depth = 0
|
|
for j in range(body_open, len(js)):
|
|
if js[j] == "{":
|
|
depth += 1
|
|
elif js[j] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return js[start : j + 1]
|
|
raise AssertionError("unbalanced braces in renderChips")
|
|
|
|
|
|
def _rule(css: str, selector: str) -> str:
|
|
"""The body of the rule whose selector line is exactly `selector`
|
|
(multi-line block) — same slicing style as test_pinned_composer.py."""
|
|
block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE)
|
|
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
|
|
return block.group(1)
|
|
|
|
|
|
def _mobile_block(css: str) -> str:
|
|
"""The ≤640px media query body (the phase-07 responsive block)."""
|
|
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
|
|
assert mobile, "the mobile media query must exist"
|
|
return mobile.group(1)
|
|
|
|
|
|
# ---------- task 01: the single-line chip contract ----------
|
|
|
|
|
|
def test_base_chip_rule_is_single_line_and_ellipsized() -> None:
|
|
"""The base ``.suggestion-chip`` rule carries the phase-104
|
|
single-line/ellipsis contract (owner A1) AND keeps the pill it
|
|
always was — a chip is a one-line 44px pill at every width, never
|
|
a wrapped multi-line block:
|
|
|
|
* ``white-space: nowrap`` — the text never wraps inside the pill;
|
|
* ``overflow: hidden`` (≠ visible) — zeroes the flex item's
|
|
automatic minimum size, which is what makes ``max-width: 100%``
|
|
bind at all;
|
|
* ``text-overflow: ellipsis`` + ``max-width: 100%`` — the text
|
|
clips at the row edge (desktop wrap row: the chat column;
|
|
≤640px row: the visible width) instead of the pill outgrowing it;
|
|
* ``min-width: 0`` — the same auto-min zeroing, pinned for clarity.
|
|
"""
|
|
body = _rule(_css(), ".suggestion-chip")
|
|
assert "white-space: nowrap;" in body, (
|
|
"the chip must never wrap — a 400-char question may not turn the "
|
|
"pill into a multi-line 'chonk' (owner A1)"
|
|
)
|
|
assert "overflow: hidden;" in body, (
|
|
"`overflow: hidden` zeroes the flex item's automatic minimum size "
|
|
"so `max-width: 100%` actually binds (visible overflow would let "
|
|
"the pill push past the row edge)"
|
|
)
|
|
assert "text-overflow: ellipsis;" in body, (
|
|
"the clipped text must show the ellipsis — the full text stays "
|
|
"one hover away (the title tooltip, task 02)"
|
|
)
|
|
assert "max-width: 100%;" in body, (
|
|
"the chip clips at the row edge — 100% of the row it sits in"
|
|
)
|
|
assert "min-width: 0;" in body, (
|
|
"the flex item's automatic minimum size must be zero — the "
|
|
"phase-07 shrink allowance, now on every chip row"
|
|
)
|
|
# The pill contract survives the sizing change (the 44px touch
|
|
# floor + the round pill — the E2E's 44 <= clientHeight <= 60 pin
|
|
# stands on this min-height).
|
|
assert "min-height: 44px;" in body, (
|
|
"the chip keeps its ≥44px touch target (WCAG 2.1 AA, phase 07)"
|
|
)
|
|
assert "border-radius: 999px;" in body, "the chip stays the round pill"
|
|
|
|
|
|
def test_subsumed_maybe_try_chip_override_is_gone() -> None:
|
|
"""The phase-07 ``.maybe-try .suggestion-chip { min-width: 0;
|
|
max-width: 100%; }`` override is fully subsumed by the new base
|
|
rule and DELETED — zero occurrences of the selector remain anywhere
|
|
in the file (comments included: the provenance was folded into the
|
|
base rule's comment WITHOUT the selector string, so this pin
|
|
doubles as the "no second cap / no drift" guard). The ``.maybe-try``
|
|
GROUP rule itself stays exactly once — the deflection row still
|
|
wraps at every width (phase 04), only the chip override is gone."""
|
|
css = _css()
|
|
assert css.count(".maybe-try .suggestion-chip") == 0, (
|
|
"the subsumed phase-07 override must be deleted — the base rule "
|
|
"carries min-width:0 + max-width:100% now, and a second cap "
|
|
"would be dead weight / a drift hazard"
|
|
)
|
|
assert css.count(".maybe-try {") == 1, (
|
|
"the .maybe-try group rule stays (the deflection row's wrap "
|
|
"contract) — only the chip override was removed"
|
|
)
|
|
|
|
|
|
def test_mobile_chip_row_keeps_the_phase_07_track() -> None:
|
|
"""The ≤640px block is UNTOUCHED by task 01: the ``.suggestions``
|
|
row keeps the phase-07 single horizontal-scroll track
|
|
(``flex-wrap: nowrap; overflow-x: auto``) and
|
|
``.suggestion-chip { flex: 0 0 auto; }``. With the base rule's
|
|
``max-width: 100%`` + ``overflow: hidden``, a long chip on the
|
|
phone clips at the VISIBLE width and the row scrolls — the
|
|
phase-07 overflow contract now lives in the base rule (task 01
|
|
folded the provenance there), so nothing in the mobile block may
|
|
have changed."""
|
|
mobile = _mobile_block(_css())
|
|
mobile_chip = re.search(r"\.suggestion-chip \{([^}]*)\}", mobile)
|
|
assert mobile_chip, "the ≤640px block must keep the .suggestion-chip rule"
|
|
assert ".suggestions { flex-wrap: nowrap; overflow-x: auto;" in mobile, (
|
|
"the mobile row keeps the phase-07 single horizontal-scroll "
|
|
"track (nowrap + overflow-x: auto)"
|
|
)
|
|
assert mobile_chip.group(1).strip() == "flex: 0 0 auto;", (
|
|
"the mobile chip must stay EXACTLY the phase-07 fixed flex item "
|
|
"(flex: 0 0 auto) on the scroll track — the chip does not shrink "
|
|
"below its content on the phone, the ROW scrolls; the long-chip "
|
|
"clip is the base rule's max-width: 100%, not a mobile-local one"
|
|
)
|
|
|
|
|
|
# ---------- 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): 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``) — 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.title = text;" in body, (
|
|
"every chip must carry the full text as its native title "
|
|
"tooltip — hovering is the contract for the capped/clipped "
|
|
"text (owner A2: 'Hovering over the chips should still show "
|
|
"the full message')"
|
|
)
|
|
|
|
|
|
def test_render_chips_sets_aria_label_only_when_clipped() -> None:
|
|
"""The accessible name is the full text ONLY when the visible text
|
|
is actually clipped (the source-chip truncation pattern, the
|
|
``chip.scrollWidth > chip.clientWidth`` → ``aria-label`` loop): a
|
|
chip whose text fits carries NO ``aria-label`` — its ``textContent``
|
|
already reads the full text to screen readers — while the clipped
|
|
chip (the ellipsized one) announces the full question. The pin is
|
|
the guard plus the ``setAttribute`` call inside it."""
|
|
body = _render_chips_body(_app_js())
|
|
guard = re.search(
|
|
r'if \(btn\.scrollWidth > btn\.clientWidth\) '
|
|
r'btn\.setAttribute\("aria-label", text\);',
|
|
body,
|
|
)
|
|
assert guard, (
|
|
"the clipped chip must set aria-label to the FULL text under "
|
|
"the scrollWidth > clientWidth guard (the source-chip pattern) "
|
|
"— a clipped chip's accessible name must be the full question, "
|
|
"and an unclipped chip must stay attribute-free"
|
|
)
|
|
|
|
|
|
# ---------- task 03: the visible 4,000-char question cap ----------
|
|
|
|
|
|
def test_message_input_carries_the_4000_maxlength() -> None:
|
|
"""The composer's textarea hard-caps the input path (typing AND
|
|
paste — the browser enforces maxlength on both, E2E task 04) at
|
|
EXACTLY the server's cap, so a user can never meet the 422 blind
|
|
through the input path (owner A3). The provenance comment lives
|
|
with the attribute — the house pattern (the theme inputs' "
|
|
maxlength=300 mirrors the server's 300-char")."""
|
|
html = _index_html()
|
|
tag = _message_input_tag(html)
|
|
assert 'maxlength="4000"' in tag, (
|
|
"#message-input must carry maxlength=4000 — the server already "
|
|
"rejects >4,000 (ChatRequest.message) and the counter makes it "
|
|
"visible; without it the input path 422s with zero feedback"
|
|
)
|
|
assert "maxlength=4000 mirrors ChatRequest.message max_length=4000" in html, (
|
|
"the provenance comment must live with the attribute (house "
|
|
"pattern) — the schema is the source of truth the HTML mirrors"
|
|
)
|
|
|
|
|
|
def test_char_count_sits_in_chat_bottom_above_the_composer() -> None:
|
|
"""The counter element is a child of the .chat-bottom sticky unit —
|
|
between the actions row and the composer form (source order) and
|
|
``hidden`` by default (it only appears from 80% of the cap). A
|
|
hidden ``<p>`` adds zero height, so the pinned-cluster geometry
|
|
(tests/unit/test_pinned_composer.py, the .chat-bottom-last-child
|
|
pin) is untouched by the new child."""
|
|
html = _index_html()
|
|
unit = re.search(r'<div class="chat-bottom">([\s\S]*?)</form>\s*</div>', html)
|
|
assert unit, "the .chat-bottom sticky unit must exist (phase 65)"
|
|
inner = unit.group(1)
|
|
el = re.search(r'<p class="char-count" id="char-count"[^>]*></p>', inner)
|
|
assert el, "the #char-count counter element must exist in .chat-bottom"
|
|
assert "hidden" in el.group(0), (
|
|
"the counter ships HIDDEN — it is feedback near the cap, not a "
|
|
"permanent chrome line (owner A4: no noise on normal use)"
|
|
)
|
|
# Source order: AFTER the actions row closes, BEFORE the composer —
|
|
# the counter sits between the row and the form inside the unit.
|
|
actions_close = inner.index("</div>") # the .chat-actions wrapper closes first
|
|
assert actions_close < inner.index('id="char-count"') < inner.index('id="composer"'), (
|
|
"the counter must sit inside .chat-bottom between the chat-actions "
|
|
"row and the composer form (its flow position above the box)"
|
|
)
|
|
|
|
|
|
def test_counter_comment_records_the_not_live_region_decision() -> None:
|
|
"""The phase-104 decision record on the element: the .is-max state
|
|
is --err-* PLUS a copy change (B3 — text + color, never color
|
|
alone) and the counter is NOT a live region (per-keystroke
|
|
feedback is decorative; the over-cap failure announces through the
|
|
role=alert error banner)."""
|
|
comment = re.search(
|
|
r"<!--\s*Phase 104\s*\(owner 2026-09-12\): the question-length counter[\s\S]*?-->",
|
|
_index_html(),
|
|
)
|
|
assert comment, "the phase-104 provenance comment must sit on the counter"
|
|
text = comment.group(0)
|
|
assert "NOT" in text and "live region" in text.lower(), (
|
|
"the counter must stay out of the aria-live contract — the "
|
|
"over-cap failure path announces through the error banner"
|
|
)
|
|
assert "B3" in text, (
|
|
"the .is-max state must be recorded as text + color (B3), "
|
|
"never color alone"
|
|
)
|
|
|
|
|
|
def test_cap_constants_mirror_the_server_cap() -> None:
|
|
"""The JS cap constants: ``MAX_QUESTION_CHARS = 4000`` (mirrors the
|
|
schema — the executor must NOT change app/schemas.py) and
|
|
``CHAR_COUNT_SHOW_AT = 3200`` (80% of the cap, owner A4). The
|
|
single-source pin below proves the three copies (HTML / JS / schema)
|
|
cannot drift."""
|
|
js = _app_js()
|
|
m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", js)
|
|
assert m, "app.js must define the MAX_QUESTION_CHARS constant"
|
|
assert int(m.group(1)) == _schema_question_cap(), (
|
|
"MAX_QUESTION_CHARS must mirror ChatRequest.message's "
|
|
"max_length — the cap lives in one place conceptually"
|
|
)
|
|
s = re.search(r"const CHAR_COUNT_SHOW_AT = (\d+);", js)
|
|
assert s, "app.js must define the CHAR_COUNT_SHOW_AT constant"
|
|
assert int(s.group(1)) == 0.8 * int(m.group(1)), (
|
|
"the counter appears at exactly 80% of the cap (owner A4) — "
|
|
"no noise on normal use, visible when it matters"
|
|
)
|
|
|
|
|
|
def test_html_maxlength_equals_js_constant_cross_file() -> None:
|
|
"""THE single-source cross-file pin: the HTML ``maxlength`` on
|
|
#message-input, the JS ``MAX_QUESTION_CHARS`` constant, and the
|
|
server's ``ChatRequest.message`` cap are the SAME number (regex-
|
|
parsed from both frontend files + the schema). Any one drifting is
|
|
a blind 422 (or a counter that lies about the cap)."""
|
|
html_m = re.search(r'maxlength="(\d+)"', _message_input_tag(_index_html()))
|
|
js_m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", _app_js())
|
|
assert html_m and js_m, "both the HTML maxlength and the JS constant must exist"
|
|
html_len = int(html_m.group(1))
|
|
js_len = int(js_m.group(1))
|
|
assert html_len == js_len == _schema_question_cap() == 4000, (
|
|
f"HTML maxlength={html_len}, JS MAX_QUESTION_CHARS={js_len} and the "
|
|
"schema cap must all be the one number (4,000)"
|
|
)
|
|
|
|
|
|
def test_update_char_count_contract() -> None:
|
|
"""The counter's state machine (owner A4): RAW length (no trim —
|
|
raw ≤ cap ⟹ trimmed ≤ cap, a safe superset of what the server
|
|
validates), hidden below the 80% threshold, plain ``len/4000`` at
|
|
and above it, and ``len/4000 — character limit`` + the .is-max
|
|
(err family) treatment at/over the cap — the over-cap reading keeps
|
|
the HONEST length (the chip-fill path exceeds maxlength, e.g.
|
|
``5123/4000 — character limit``)."""
|
|
body = _function_body(_app_js(), "function updateCharCount() {")
|
|
assert "const len = input.value.length;" in body, (
|
|
"the count is the RAW value (no trim) — a raw count is a safe "
|
|
"superset of what the server validates"
|
|
)
|
|
assert "len < CHAR_COUNT_SHOW_AT" in body, (
|
|
"below the 80% threshold the counter stays hidden"
|
|
)
|
|
assert 'charCountEl.hidden = true;' in body
|
|
assert 'charCountEl.classList.remove("is-max");' in body, (
|
|
"hiding the counter must also drop the .is-max state — a short "
|
|
"question after a maxed one must not keep the error color"
|
|
)
|
|
assert "len >= MAX_QUESTION_CHARS" in body, (
|
|
"the at/over-cap branch is >= (4,000 itself is already at the "
|
|
"limit — the server accepts exactly 4,000, so the counter says "
|
|
"so at 4,000)"
|
|
)
|
|
assert 'charCountEl.classList.toggle("is-max", atMax);' in body
|
|
assert "— character limit" in body, (
|
|
"the .is-max state must CHANGE THE COPY (B3) — color alone is "
|
|
"not the state; the words carry it"
|
|
)
|
|
assert "`${len}/${MAX_QUESTION_CHARS}`" in body, (
|
|
"the counter text is len/cap from the SAME constants — the "
|
|
"displayed cap can never drift from MAX_QUESTION_CHARS"
|
|
)
|
|
grabber = re.search(
|
|
r'const charCountEl = document\.querySelector\("#char-count"\);', _app_js()
|
|
)
|
|
assert grabber, "app.js must grab #char-count alongside the other controls"
|
|
|
|
|
|
def test_handle_send_guard_blocks_over_cap_questions() -> None:
|
|
"""The over-cap guard (owner A5): the one reachable path that
|
|
bypasses maxlength is the programmatic chip fill, and it is
|
|
blocked in handleSend — trimmed text over the cap gets the
|
|
out-of-turn error banner (the saveAsDoc precedent), NO turn, and
|
|
the input KEEPS the text (the user trims it — never stale, PLAN
|
|
§7.4). The guard sits AFTER the !text guard and BEFORE the clear.
|
|
"""
|
|
body = _function_body(_app_js(), "async function handleSend(e) {")
|
|
guard = re.search(
|
|
r"if \(text\.length > MAX_QUESTION_CHARS\) \{\s*"
|
|
r"showErrorBanner\(\"Questions are limited to 4,000 characters"
|
|
r" — trim the question and try again\.\"\);\s*"
|
|
r"return;\s*\}",
|
|
body,
|
|
)
|
|
assert guard, (
|
|
"handleSend must refuse a trimmed question over the cap with the "
|
|
"4,000-characters banner copy (the UI makes the server's 422 "
|
|
"visible BEFORE the request)"
|
|
)
|
|
assert "if (!text || sendBtn.disabled) return;" in body
|
|
assert body.index("text.length > MAX_QUESTION_CHARS") < body.index(
|
|
'input.value = "";'
|
|
), (
|
|
"the guard must run BEFORE the clear — the input keeps the kept "
|
|
"text so the user can trim it (a cleared input would be stale)"
|
|
)
|
|
|
|
|
|
def test_all_four_mutation_sites_run_update_char_count() -> None:
|
|
"""``updateCharCount()`` runs at the EXACT four ``input.value``
|
|
mutation sites (each already ran ``autoGrow()`` there) — the
|
|
counter can never show a stale length:
|
|
|
|
* the ``input`` listener — keystrokes + pastes (maxlength caps
|
|
both); the listener body now runs autoGrow AND updateCharCount;
|
|
* ``submitSuggestion`` — the chip one-tap fill (the programmatic
|
|
path maxlength cannot stop);
|
|
* the ``handleSend`` post-send clear — the sent question hides the
|
|
counter again;
|
|
* the ``startNewChat`` clear — same.
|
|
"""
|
|
js = _app_js()
|
|
# (a) the input listener — both calls inside the one listener body
|
|
listener = re.search(
|
|
r'input\.addEventListener\("input", \(\) => \{[\s\S]*?autoGrow\(\);'
|
|
r"[\s\S]*?updateCharCount\(\);[\s\S]*?\}\);",
|
|
js,
|
|
)
|
|
assert listener, (
|
|
"the input listener must run autoGrow AND updateCharCount — the "
|
|
"counter follows every input-path change"
|
|
)
|
|
# (b) the chip one-tap fill
|
|
sub = _function_body(js, "function submitSuggestion(text) {")
|
|
assert sub.index("input.value = text;") < sub.index("autoGrow();") < sub.index(
|
|
"updateCharCount();"
|
|
), "submitSuggestion must count the (possibly over-cap) filled text"
|
|
# (c) the post-send clear
|
|
send = _function_body(js, "async function handleSend(e) {")
|
|
assert send.index('input.value = "";') < send.index("autoGrow();") < send.index(
|
|
"updateCharCount();"
|
|
), "the post-send clear must hide the counter with the input"
|
|
# (d) the new-chat clear
|
|
newchat = _function_body(js, "function startNewChat() {")
|
|
assert newchat.index('input.value = "";') < newchat.index("autoGrow();") < newchat.index(
|
|
"updateCharCount();"
|
|
), "the new-chat clear must hide the counter with the input"
|
|
|
|
|
|
def test_char_count_css_is_aa_on_the_app_background() -> None:
|
|
"""The counter's two color pairings are WCAG-AA-verified against
|
|
the background it actually sits on — the APP background (--bg)
|
|
behind the transparent .chat-bottom unit — with each ratio recorded
|
|
in the rule's comment (house style): --ink-soft on --bg = 8.6:1,
|
|
--err-ink on --bg = 10.4:1 (both ≥4.5:1). The .is-max state pairs
|
|
the color with the "— character limit" copy change (B3)."""
|
|
css = _css()
|
|
body = _single_line_rule(css, ".char-count")
|
|
assert "text-align: right;" in body, (
|
|
"the counter right-aligns above the composer (house layout)"
|
|
)
|
|
assert "color: var(--ink-soft);" in body
|
|
is_max = _single_line_rule(css, ".char-count.is-max")
|
|
assert "color: var(--err-ink);" in is_max, (
|
|
"the at/over-cap state must use the --err-* semantic family (B3)"
|
|
)
|
|
comment = re.search(r"(/\*[^*]*?\*/)\s*\.char-count \{", css)
|
|
assert comment, "the .char-count rule must carry its provenance comment"
|
|
note = comment.group(1)
|
|
assert "8.6:1" in note, (
|
|
"the --ink-soft on --bg ratio must be recorded (verified "
|
|
"8.6:1 ≥ 4.5:1, WCAG AA)"
|
|
)
|
|
assert "10.4:1" in note, (
|
|
"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)"
|
|
)
|