All completion criteria verified. Final report: **Phase 104 — final verification pass: all green** - Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite. - E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed. - `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors. - Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅. - Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**. - Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree. - No commit made (harness commits per the execution protocol override). - Next pending phase: `98_sync_summary_visibility`.
551 lines
25 KiB
Python
551 lines
25 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).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
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")
|
|
|
|
|
|
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_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
|
|
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."""
|
|
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')"
|
|
)
|
|
|
|
|
|
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)"
|
|
)
|