"""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']*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 ``

`` 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'

([\s\S]*?)\s*
', html) assert unit, "the .chat-bottom sticky unit must exist (phase 65)" inner = unit.group(1) el = re.search(r'

]*>

', 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("") # 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"", _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)" )