phase: 104_chip_sizing_question_cap
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`.
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
"""Phase 104 E2E (Playwright): single-line suggestion chips (hover reveals
|
||||
the full text) + the visible 4,000-char question cap.
|
||||
|
||||
Source: 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,
|
||||
to prevent ultra-long context overflowing queries. Users should still be
|
||||
able to paste code example of a few dozen lines, but nothing much longer
|
||||
than that."
|
||||
|
||||
The contract (owner decisions A1–A6, recorded in 00_phase.md):
|
||||
|
||||
* **A1/A2 — single-line chips, full text one hover away:** every
|
||||
suggestion chip is ONE line at every viewport width (never a wrapped
|
||||
multi-line "chonk") — long text ellipsizes at the row edge, and the
|
||||
FULL text is recoverable via the native ``title`` tooltip on every
|
||||
chip plus the ``aria-label`` accessible name when the visible text is
|
||||
actually clipped;
|
||||
* **A3 — the hard cap through the input path:** the composer textarea
|
||||
carries ``maxlength="4000"`` mirroring the pre-existing server cap
|
||||
(``ChatRequest.message max_length=4000`` — the server 422s beyond);
|
||||
a paste of more than 4,000 chars lands as EXACTLY 4,000, and
|
||||
submitting at the cap is a clean turn (no 422 error state);
|
||||
* **A4 — the counter:** ``#char-count`` is hidden while the RAW length
|
||||
is below 80% of the cap (3,200 — no noise on normal use), shows
|
||||
``len/4000`` from there, and reads ``len/4000 — character limit`` +
|
||||
the ``.is-max`` (err family + copy change, B3) at/over the cap;
|
||||
* **A5 — the over-cap guard:** a programmatic fill (the chip one-tap
|
||||
path) bypasses ``maxlength`` — ``handleSend`` refuses trimmed text
|
||||
over the cap with 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 states pinned here (six tests):
|
||||
|
||||
1. **truncated-chip** (A1/A2 core) — a saved chat whose FIRST user
|
||||
question is LONG (720 chars — a readable repeated phrase, 300+ per
|
||||
the phase) with a short follow-up → a fresh page load shows EXACTLY
|
||||
ONE onboarding chip (the phase-103 opener semantics — the follow-up
|
||||
never surfaces): computed ``white-space: nowrap`` /
|
||||
``overflow: hidden`` / ``text-overflow: ellipsis``, visually clipped
|
||||
(``scrollWidth > clientWidth``), single line (``44 <=
|
||||
offsetHeight <= 60`` — a one-line pill sits at the 44px
|
||||
``min-height`` floor, border-box; a wrapped two-liner is ~65px —
|
||||
the chonk), and
|
||||
``title`` + ``aria-label`` == the FULL long text;
|
||||
2. **seed-contrast** — a fresh deployment's (seed) short chips have
|
||||
``title`` set AND NO ``aria-label`` (not truncated — the attribute
|
||||
is absent by design; the ``textContent`` already carries the full
|
||||
text);
|
||||
3. **counter-threshold** (A4) — ``#char-count`` hidden at boot; 100
|
||||
chars typed → still hidden; exactly 3,500 chars (a dispatched
|
||||
``input`` event — ``locator.fill`` does this) → visible, text
|
||||
``3500/4000``, NO ``.is-max``;
|
||||
4. **paste-cap** (A3) — ``keyboard.insert_text`` of 6,500 chars (CDP
|
||||
``Input.insertText`` = the paste path — ``maxlength`` applies) →
|
||||
the textarea holds EXACTLY 4,000 chars; the counter reads
|
||||
``4000/4000 — character limit`` + ``.is-max``; Send → NO 422 error
|
||||
state → the mock answer streams to ``done`` (the brain bubble with
|
||||
the ``MOCK_ANSWER_MARKER``) → the input is cleared and the counter
|
||||
hidden again;
|
||||
5. **over-cap-guard** (A5) — ``page.evaluate`` sets
|
||||
``#message-input.value`` to 5,000 chars + dispatches an ``input``
|
||||
event (the programmatic path ``maxlength`` cannot stop) → counter
|
||||
``5000/4000 — character limit`` + ``.is-max`` → Send → the error
|
||||
banner shows the "4,000 characters" cap copy, NO bubble is
|
||||
appended, the input STILL holds the 5,000 chars (kept for
|
||||
trimming — never stale);
|
||||
6. **short-flow-regression** — a short question submits cleanly and
|
||||
the counter NEVER becomes visible during the turn (a
|
||||
MutationObserver flags any hidden→visible transition of
|
||||
``#char-count`` for the whole turn).
|
||||
|
||||
The endpoint and the chat are authed (phase 79, ``require_user``), so
|
||||
every test signs in as admin first (``auth_helpers.login``). The
|
||||
onboarding chips are the phase-103 session openers read from
|
||||
``saved_chats`` — AND the send tests auto-save a row per turn — so the
|
||||
autouse fixture truncates that table before and after EVERY test,
|
||||
which keeps each test on an empty deployment (the seed state) and
|
||||
stops cross-test leakage.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
#: A 720-char opener (300+ per the phase) — a readable repeated phrase,
|
||||
#: far wider than the 46rem chat column, so the single-line pill MUST
|
||||
#: clip. The phase-103 opener semantics surface exactly this text as
|
||||
#: the chat's one onboarding chip.
|
||||
LONG_OPENER = "What are the correct arguments for " * 20 + "qwen on llama.cpp?"
|
||||
SHORT_FOLLOW_UP = "And which of the two needs the most VRAM?"
|
||||
|
||||
#: A 6,500-char paste (4,000+ = over the cap) built from a word the
|
||||
#: fixture KB indexes ("kubernetes" — ``tests/fixtures/docs/homelab/
|
||||
#: kubernetes.md``): after ``maxlength`` trims it to EXACTLY 4,000 the
|
||||
#: question still carries the token, so the FTS leg of the hybrid
|
||||
#: retrieval hits and the mock answers the grounded (non-deflected)
|
||||
#: path with the marker — a clean turn at the cap, no 422.
|
||||
PASTE_QUESTION = "kubernetes " * 500
|
||||
PASTE_AT_CAP = PASTE_QUESTION[:4000] # what maxlength=4000 keeps
|
||||
|
||||
#: The over-cap programmatic fill (A5 — the chip one-tap path
|
||||
#: ``maxlength`` cannot stop): 5,000 chars, trimmed to 5,000.
|
||||
OVER_CAP_FILL = "x" * 5000
|
||||
|
||||
#: The short-flow question (well under the 3,200 counter threshold and
|
||||
#: grounded in the fixture KB — the mock answers it with the marker).
|
||||
SHORT_QUESTION = "How do I deploy a new service on the homelab node?"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats(db_ready: None) -> Iterator[None]:
|
||||
"""``saved_chats`` is the state the onboarding chips read (the
|
||||
phase-103 openers) AND the send tests auto-save a row per turn —
|
||||
truncate it before and after EVERY test so each starts from (and
|
||||
leaves) an empty deployment (the seed-chip state), exactly the
|
||||
phase-80/103 autouse pattern in test_suggestion_chips.py."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return await import_sources([FIXTURES], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _seed_kb(mock_port: int) -> ImportSummary:
|
||||
"""Deterministic KB: truncate the KB tables, import the fixture
|
||||
docs (needed by the send tests' grounded answers). ``saved_chats``
|
||||
is the autouse fixture's job."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||||
return summary
|
||||
|
||||
|
||||
def _user(q: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": q}
|
||||
|
||||
|
||||
def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]:
|
||||
return {"who": "brain", "text": text}
|
||||
|
||||
|
||||
def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Save one conversation as the signed-in admin (``POST /api/chats``)
|
||||
and return the 201 body."""
|
||||
r = page.request.post(
|
||||
f"{app_url}/api/chats",
|
||||
data=json.dumps({"messages": messages}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10_000,
|
||||
)
|
||||
assert r.status == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _chip(page: Page, index: int = 0) -> Any:
|
||||
return page.locator("#suggestions .suggestion-chip").nth(index)
|
||||
|
||||
|
||||
def _wait_chat_booted(page: Page) -> None:
|
||||
"""Wait until app.js has FINISHED booting the chat page. The login
|
||||
helper returns on the URL change (navigation commit) — the page's
|
||||
module script may still be executing, and an ``input`` event
|
||||
dispatched before its top-level listener registrations land on a
|
||||
page whose listeners do not exist yet (the event is simply lost).
|
||||
``#view-chat.chat-booted`` is added two frames after the boot
|
||||
settles (AFTER every top-level listener), so it is the "the app's
|
||||
JS is live" sentinel (house pattern: test_mobile_chat_hamburger_
|
||||
boot.py's post-login wait)."""
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('view-chat')?."
|
||||
"classList.contains('chat-booted')",
|
||||
timeout=15_000,
|
||||
)
|
||||
expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
|
||||
|
||||
|
||||
def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""A1/A2 core: a saved chat whose FIRST user question is 720 chars
|
||||
(a readable repeated phrase) with a short follow-up → a fresh page
|
||||
load shows EXACTLY ONE onboarding chip (the phase-103 opener
|
||||
semantics — the follow-up never surfaces), and it is:
|
||||
|
||||
* ONE line — computed ``white-space: nowrap``, ``overflow: hidden``,
|
||||
``text-overflow: ellipsis``;
|
||||
* visually clipped — ``scrollWidth > clientWidth`` (720 chars of
|
||||
~7px/char far exceeds the 46rem column);
|
||||
* the pill, not the chonk — ``44 <= offsetHeight <= 60`` (a
|
||||
one-line pill sits at the 44px ``min-height`` floor, border-box
|
||||
— the global ``* { box-sizing: border-box }`` makes the 44px
|
||||
the OUTER height; a wrapped two-liner is ~65px);
|
||||
* the FULL text one hover away — ``title`` == the full long text;
|
||||
* the clipped-case accessible name — ``aria-label`` == the full
|
||||
long text.
|
||||
"""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# ONE multi-turn chat: the LONG opener + one short follow-up
|
||||
# (the follow-up must never surface — phase-103 semantics).
|
||||
_save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(LONG_OPENER), _brain(),
|
||||
_user(SHORT_FOLLOW_UP), _brain(),
|
||||
],
|
||||
)
|
||||
|
||||
# A FRESH page load (a new boot fetch): EXACTLY ONE chip — the
|
||||
# opener, and nothing else.
|
||||
page.goto(app_url + "/")
|
||||
chips = page.locator("#suggestions .suggestion-chip")
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(1, timeout=15_000)
|
||||
# The chip carries the EXACT full opener in the DOM (the clipping
|
||||
# is visual — textContent is never truncated).
|
||||
assert chips.first.inner_text().strip() == LONG_OPENER
|
||||
|
||||
chip = chips.first
|
||||
# A1 — the single-line computed contract (the phase-104 CSS).
|
||||
style = chip.evaluate("el => getComputedStyle(el)")
|
||||
assert style["whiteSpace"] == "nowrap", "the chip must never wrap (owner A1)"
|
||||
assert style["overflow"] in {"hidden", "hidden hidden"}, (
|
||||
"`overflow: hidden` is what zeroes the flex auto-min so the "
|
||||
"ellipsis clip at the row edge binds"
|
||||
)
|
||||
assert style["textOverflow"] == "ellipsis", "the clipped text shows the ellipsis"
|
||||
|
||||
# Clipped + single line: the text overflows the pill (ellipsis) but
|
||||
# the pill itself stays ONE 44px-min line tall — never the chonk.
|
||||
# offsetHeight (border-box): the global `* { box-sizing: border-box }`
|
||||
# makes the 44px `min-height` the OUTER floor — a one-line pill is
|
||||
# exactly 44px, a wrapped two-liner is ~65px (63px content + 2px
|
||||
# border), so the 44..60 band is single-line only.
|
||||
dims = chip.evaluate(
|
||||
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, oh: el.offsetHeight })"
|
||||
)
|
||||
assert dims["sw"] > dims["cw"], (
|
||||
"a 720-char question must be visually clipped in the 46rem column"
|
||||
)
|
||||
assert 44 <= dims["oh"] <= 60, (
|
||||
f"one-line pill: 44px min-height floor (border-box), a wrapped "
|
||||
f"two-liner is ~65px — got {dims['oh']}px"
|
||||
)
|
||||
|
||||
# A2 — the full text is one hover away (the native tooltip), and
|
||||
# the accessible name of the CLIPPED chip is the full text.
|
||||
assert chip.get_attribute("title") == LONG_OPENER, (
|
||||
"the title tooltip must carry the FULL question (owner A2)"
|
||||
)
|
||||
assert chip.get_attribute("aria-label") == LONG_OPENER, (
|
||||
"a clipped chip's aria-label must be the full text (the "
|
||||
"source-chip pattern)"
|
||||
)
|
||||
|
||||
|
||||
def test_short_seed_chip_has_tooltip_but_no_aria_label(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The contrast pin: a fresh deployment (seed chips — the autouse
|
||||
fixture guarantees the empty state) renders four SHORT seed chips,
|
||||
none of them clipped in the 1280px desktop column. Every chip still
|
||||
has ``title`` = its full text (A2 is universal), but NO ``aria-label``
|
||||
— the attribute is absent by design when the visible text is not
|
||||
clipped (the ``textContent`` already carries the full text)."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
chips = page.locator("#suggestions .suggestion-chip")
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(4, timeout=15_000) # the built-in seed (4 entries)
|
||||
|
||||
for i in range(4):
|
||||
chip = chips.nth(i)
|
||||
full_text = chip.inner_text().strip()
|
||||
assert full_text, "every seed chip needs non-empty label text"
|
||||
# Not clipped: the pill fits its text in the desktop column.
|
||||
dims = chip.evaluate(
|
||||
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, h: el.clientHeight })"
|
||||
)
|
||||
assert dims["sw"] <= dims["cw"], f"seed chip {i!r} must not be clipped"
|
||||
# A2 universal: the tooltip is set on EVERY chip…
|
||||
assert chip.get_attribute("title") == full_text
|
||||
# …but the accessible name is added ONLY when clipped.
|
||||
assert chip.get_attribute("aria-label") is None, (
|
||||
f"an unclipped chip must NOT carry an aria-label (chip {i!r}) — "
|
||||
"the textContent already reads the full text"
|
||||
)
|
||||
|
||||
|
||||
def test_counter_hidden_below_threshold_and_visible_above(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""A4: the counter is hidden at boot, stays hidden at 100 chars
|
||||
(below the 3,200 = 80% threshold — no noise on normal use), and at
|
||||
exactly 3,500 chars (a dispatched ``input`` event — ``locator.fill``
|
||||
fires it) it is visible reading ``3500/4000`` with NO ``.is-max``
|
||||
(that state is for at/over the cap)."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_wait_chat_booted(page)
|
||||
|
||||
counter = page.locator("#char-count")
|
||||
expect(counter).to_be_hidden() # boot state (hidden by default in the HTML)
|
||||
|
||||
page.fill("#message-input", "a" * 100)
|
||||
expect(counter).to_be_hidden() # 100 < 3,200 — still no noise
|
||||
|
||||
page.fill("#message-input", "b" * 3500)
|
||||
expect(counter).to_be_visible(timeout=5_000)
|
||||
expect(counter).to_have_text("3500/4000")
|
||||
expect(counter).not_to_have_class(re.compile("is-max"))
|
||||
|
||||
|
||||
def test_paste_path_hard_caps_at_the_cap_and_sends(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""A3: the paste path (CDP ``Input.insertText`` — the browser
|
||||
applies ``maxlength`` to it, like a paste) hard-caps at EXACTLY
|
||||
4,000: a 6,500-char paste lands as 4,000, the counter reads
|
||||
``4000/4000 — character limit`` + ``.is-max``, and submitting a
|
||||
4,000-char question PASSES the server cap (no 422 error state) —
|
||||
the mock answer streams to ``done`` and the turn clears the input
|
||||
+ the counter."""
|
||||
_seed_kb(mock_llm)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_wait_chat_booted(page)
|
||||
|
||||
counter = page.locator("#char-count")
|
||||
input_el = page.locator("#message-input")
|
||||
expect(counter).to_be_hidden()
|
||||
|
||||
# The paste path: one CDP insert of 6,500 chars — maxlength trims
|
||||
# the OVERFLOW, the textarea keeps EXACTLY the first 4,000.
|
||||
input_el.click()
|
||||
page.keyboard.insert_text(PASTE_QUESTION)
|
||||
expect(input_el).to_have_value(PASTE_AT_CAP) # EXACTLY 4,000 chars
|
||||
|
||||
# At the cap: the honest reading + the B3 state (copy + err color).
|
||||
expect(counter).to_be_visible(timeout=5_000)
|
||||
expect(counter).to_have_text("4000/4000 — character limit")
|
||||
expect(counter).to_have_class(re.compile("is-max"))
|
||||
|
||||
# Submit at the cap: the 4,000-char question passes the server cap
|
||||
# (no 422 error state — a 422 would raise the error banner).
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_be_hidden()
|
||||
page.click("#send-btn")
|
||||
|
||||
# A clean turn: the user bubble is the exact 4,000-char question and
|
||||
# the grounded mock answer streams to done (the KB carries
|
||||
# "kubernetes" — the FTS leg hits, non-deflected).
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(PASTE_AT_CAP)
|
||||
brain = page.locator(".msg.brain .bubble").first
|
||||
expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||
expect(banner).to_be_hidden() # NO 422 error state at the cap
|
||||
|
||||
# Never stale: the turn cleared the input AND the counter, and the
|
||||
# send button recovered.
|
||||
expect(input_el).to_have_value("")
|
||||
expect(counter).to_be_hidden()
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
|
||||
|
||||
def test_over_cap_programmatic_fill_hits_the_guard(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""A5: the one path ``maxlength`` cannot stop — a programmatic
|
||||
``value`` assignment (the chip one-tap fill). A 5,000-char fill +
|
||||
a dispatched ``input`` event → the counter reads the honest
|
||||
``5000/4000 — character limit`` + ``.is-max``; Send → the over-cap
|
||||
guard fires: the error banner shows the "4,000 characters" cap
|
||||
copy, NO turn (no user or brain bubble), and the input STILL holds
|
||||
the 5,000 chars (kept for trimming — never stale, PLAN §7.4)."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_wait_chat_booted(page)
|
||||
|
||||
counter = page.locator("#char-count")
|
||||
expect(counter).to_be_hidden()
|
||||
|
||||
# The programmatic path: assign + dispatch (maxlength never sees it).
|
||||
page.evaluate(
|
||||
"""(value) => {
|
||||
const el = document.querySelector("#message-input");
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}""",
|
||||
OVER_CAP_FILL,
|
||||
)
|
||||
expect(counter).to_be_visible(timeout=5_000)
|
||||
expect(counter).to_have_text("5000/4000 — character limit")
|
||||
expect(counter).to_have_class(re.compile("is-max"))
|
||||
|
||||
# Send over the cap: the guard refuses BEFORE the request.
|
||||
page.click("#send-btn")
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_be_visible(timeout=5_000)
|
||||
expect(banner).to_have_attribute("role", "alert")
|
||||
expect(page.locator("#kb-banner-text")).to_contain_text("4,000 characters")
|
||||
|
||||
# NO turn: nothing was appended, and the input keeps the text the
|
||||
# user trims (a cleared input would be stale — PLAN §7.4).
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(0)
|
||||
expect(page.locator("#message-input")).to_have_value(OVER_CAP_FILL)
|
||||
# The counter (which mirrors the untouched input) and the send
|
||||
# button (never mid-turn) are consistent with the kept text.
|
||||
expect(counter).to_have_text("5000/4000 — character limit")
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
|
||||
|
||||
def test_short_flow_never_shows_the_counter(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Short-flow regression: a 49-char question submits cleanly (the
|
||||
grounded mock answer lands) and ``#char-count`` NEVER becomes
|
||||
visible during the whole turn — a MutationObserver flags ANY
|
||||
hidden→visible transition of the counter (with ``oldValue``, so
|
||||
even a same-task set/unset is caught)."""
|
||||
_seed_kb(mock_llm)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_wait_chat_booted(page)
|
||||
|
||||
# Arm the never-visible tripwire for the rest of this page's life.
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.__char_count_ever_visible = false;
|
||||
const el = document.querySelector("#char-count");
|
||||
new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.attributeName === "hidden" && r.oldValue === "true") {
|
||||
window.__char_count_ever_visible = true;
|
||||
}
|
||||
}
|
||||
}).observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
}"""
|
||||
)
|
||||
|
||||
counter = page.locator("#char-count")
|
||||
expect(counter).to_be_hidden()
|
||||
page.fill("#message-input", SHORT_QUESTION) # 49 chars << 3,200
|
||||
expect(counter).to_be_hidden()
|
||||
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(SHORT_QUESTION)
|
||||
brain = page.locator(".msg.brain .bubble").first
|
||||
expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
expect(page.locator("#message-input")).to_have_value("")
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
|
||||
# The whole turn: the counter never once became visible.
|
||||
assert page.evaluate("() => window.__char_count_ever_visible") is False, (
|
||||
"a short question must never surface the counter — not at the "
|
||||
"keystrokes, not at the send, not during the turn"
|
||||
)
|
||||
@@ -186,6 +186,38 @@ def scroll_state(page: Page) -> dict[str, float]:
|
||||
)
|
||||
|
||||
|
||||
def wait_reveal_scroll_done(page: Page, target: int, timeout: int = 10_000) -> None:
|
||||
"""Block until the submit's ``scrollReveal`` smooth scroll has ARRIVED
|
||||
at its target (the document bottom captured right after the click,
|
||||
``target`` — the first streamed delta lands tens of ms later and
|
||||
would inflate a later read). Needed before a test scroll: Chromium's
|
||||
smooth-scroll duration is distance- and environment-dependent (roughly
|
||||
half a second to a second for a full page), and a ``window.scrollTo``
|
||||
issued mid-animation does not settle the measured position (the
|
||||
animation keeps moving the page after the test's scroll, so a
|
||||
position read lands mid-flight, e.g. ``y=71`` instead of ``0``).
|
||||
|
||||
The wait is an ARRIVAL wait (``scrollY`` reached the target bottom),
|
||||
not a "looks still" wait: mid-animation frame stalls under load make
|
||||
a short stillness window pass while the animation is still pending
|
||||
(observed: a 120 ms window passed mid-scroll; 800 ms is the fallback
|
||||
for the vanishingly-rare late-target read, where the arrival
|
||||
threshold is inflated by an already-landed delta). Phase 104 (task
|
||||
03, 2026-09-12): pinned deterministically when the phase-104 E2E
|
||||
regression exposed the race (it reproduces on the pre-phase-104 tree
|
||||
— a pre-existing flake, not a phase-104 regression)."""
|
||||
page.wait_for_function(
|
||||
"""(target) => {
|
||||
if (window.scrollY >= target - window.innerHeight - 2) return true;
|
||||
const y0 = window.scrollY;
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(window.scrollY === y0), 800));
|
||||
}""",
|
||||
arg=target,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def wait_settled(page: Page, timeout: int = 30_000) -> None:
|
||||
"""The turn is over: the label is back to "Send" (phase 48 — the
|
||||
in-flight state is the enabled Stop control, so the label carries the
|
||||
@@ -194,11 +226,18 @@ def wait_settled(page: Page, timeout: int = 30_000) -> None:
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
|
||||
|
||||
|
||||
def submit(page: Page, question: str) -> None:
|
||||
"""Submit through the composer (the real-user flow)."""
|
||||
def submit(page: Page, question: str) -> int:
|
||||
"""Submit through the composer (the real-user flow). Returns the
|
||||
document ``scrollHeight`` read right after the click — the submit's
|
||||
``scrollReveal`` target (``window.scrollTo({top: scrollHeight})``
|
||||
runs in the click's own event dispatch, and the first streamed delta
|
||||
lands tens of ms later and would inflate a later read)."""
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
# The bubble exists by now and the first streamed delta is still tens
|
||||
# of ms away — the read is the scrollReveal's own target.
|
||||
return page.evaluate("() => document.documentElement.scrollHeight")
|
||||
|
||||
|
||||
def build_conversation(page: Page, n: int = 6) -> None:
|
||||
@@ -371,7 +410,7 @@ def test_stop_is_reachable_from_scrolled_up(
|
||||
# SECOND answer bubble exists and carries deltas (`.last` alone would
|
||||
# still resolve to turn 1's finished bubble: the typing indicator is
|
||||
# `.bubble.typing` and excluded from ANSWER).
|
||||
submit(page, LONG_QUESTION)
|
||||
reveal_target = submit(page, LONG_QUESTION)
|
||||
page.wait_for_function(
|
||||
"() => { const els = document.querySelectorAll('.msg.brain .bubble:not(.typing)');"
|
||||
" return els.length >= 2 && els[els.length - 1].innerText.length > 80; }",
|
||||
@@ -380,6 +419,12 @@ def test_stop_is_reachable_from_scrolled_up(
|
||||
answer = page.locator(ANSWER).nth(1)
|
||||
expect(page.locator("#send-label")).to_have_text("Stop", timeout=10_000)
|
||||
|
||||
# The submit's own smooth scrollReveal (to the document bottom) must
|
||||
# ARRIVE before the test scrolls up — mid-animation, the browser
|
||||
# keeps moving the page after the test's scrollTo and the position
|
||||
# read lands mid-flight (the race wait_reveal_scroll_done documents).
|
||||
wait_reveal_scroll_done(page, reveal_target)
|
||||
|
||||
# The user scrolls UP to read earlier content. Phase 42 leaves them
|
||||
# there — the app never follows the stream.
|
||||
page.evaluate("() => window.scrollTo(0, 0)")
|
||||
|
||||
@@ -130,8 +130,24 @@ def _assert_no_doc_overflow(page: Page, label: str) -> None:
|
||||
|
||||
|
||||
def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
|
||||
"""Real keyboard Tab walk; returns each focused element's outline."""
|
||||
first_key: str | None = None
|
||||
"""Real keyboard Tab walk; returns each focused element's outline.
|
||||
|
||||
The walk covers a FULL focus cycle (it starts wherever Chromium
|
||||
resumes after the skip-link check — mid-document, right after
|
||||
#main — and ends when focus wraps back to the FIRST element it
|
||||
visited). The wrap is detected by TRUE element identity: each
|
||||
newly focused element is stamped with a unique ``data-tabwalk-id``
|
||||
and the cycle ends when a STAMPED element is focused again.
|
||||
Phase 104 (task 04, 2026-09-12): the pre-104 heuristic keyed on
|
||||
the first 24 chars of the element's text — two chips sharing that
|
||||
prefix (e.g. the two identical "How is my Kubernetes cluster set
|
||||
up? write a long answer" opener chips left in ``saved_chats`` by
|
||||
test_pinned_composer.py, which runs suites in sequence on the
|
||||
shared e2e DB) collided and cut the walk short at two entries,
|
||||
flaking ``len(seen) >= 3``. Verified pre-existing on the
|
||||
pre-phase-104 tree (the mid-document Tab start is Chromium
|
||||
behavior, not a phase-104 change)."""
|
||||
first_id: str | None = None
|
||||
seen: list[dict[str, str]] = []
|
||||
for _ in range(max_tabs):
|
||||
page.keyboard.press("Tab")
|
||||
@@ -142,7 +158,13 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
|
||||
const cls = String(el.className).split(" ")[0];
|
||||
const label = (el.getAttribute("aria-label")
|
||||
|| el.textContent || "").trim().slice(0, 24);
|
||||
let id = el.getAttribute("data-tabwalk-id");
|
||||
if (!id) {
|
||||
id = "tw" + ((window.__twSeq = (window.__twSeq || 0) + 1));
|
||||
el.setAttribute("data-tabwalk-id", id);
|
||||
}
|
||||
return {
|
||||
id: id,
|
||||
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
|
||||
outline_style: cs.outlineStyle,
|
||||
outline_width: cs.outlineWidth,
|
||||
@@ -151,11 +173,11 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
|
||||
)
|
||||
if info["key"].startswith("BODY"):
|
||||
continue # focus has not entered the document yet
|
||||
if first_key is None:
|
||||
first_key = info["key"]
|
||||
if first_id is None:
|
||||
first_id = info["id"]
|
||||
seen.append(info)
|
||||
if len(seen) > 1 and info["key"] == first_key:
|
||||
break # wrapped back to the first focusable
|
||||
if len(seen) > 1 and info["id"] == first_id:
|
||||
break # wrapped back to the first focusable (same ELEMENT)
|
||||
return seen
|
||||
|
||||
|
||||
|
||||
@@ -181,7 +181,12 @@ def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict
|
||||
def _api_suggestions(page: Page, app_url: str) -> list[str]:
|
||||
# Phase 79: the endpoint is require_user-gated — the request rides
|
||||
# the page's signed-in context (each test signs in above).
|
||||
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
|
||||
# Phase 104 (task 03, 2026-09-12): the original 10 ms timeout sat
|
||||
# BELOW a normal localhost round trip (~12 ms steady-state) and
|
||||
# flaked the whole suite on a loaded box — the sibling _save_chat
|
||||
# helper's 10 s is the house pattern; the server is up by the time
|
||||
# this probe runs (the chips it mirrors already rendered).
|
||||
r = page.request.get(f"{app_url}/api/suggestions", timeout=10_000)
|
||||
assert r.status == 200, r.text
|
||||
return r.json()["suggestions"]
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"""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)"
|
||||
)
|
||||
@@ -312,12 +312,13 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||
"""Phase 65 (task 02, owner-locked A1): the LAST element child of
|
||||
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
|
||||
binds it; the bindings live on the inner elements, the move is pure
|
||||
HTML/CSS) — holding exactly the `.chat-actions` row and then the
|
||||
`#composer` form, in that order: the row + composer are ONE sticky
|
||||
unit, and the wrapper owns the shell's bottom slot, so the sticky
|
||||
shift range is still that column's box (a sibling after it would
|
||||
carve the range away and re-break the pin). The composer form keeps
|
||||
`novalidate` and its contract ids."""
|
||||
HTML/CSS) — holding the `.chat-actions` row, the phase-104
|
||||
`#char-count` counter, and the `#composer` form, in that order: the
|
||||
row + counter + composer are ONE sticky unit, and the wrapper owns
|
||||
the shell's bottom slot, so the sticky shift range is still that
|
||||
column's box (a sibling after it would carve the range away and
|
||||
re-break the pin). The composer form keeps `novalidate` and its
|
||||
contract ids."""
|
||||
shell = _tree().find("chat-shell")
|
||||
last = shell["children"][-1]
|
||||
assert last["tag"] == "div" and (
|
||||
@@ -330,17 +331,29 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||
"elements"
|
||||
)
|
||||
kids = last["children"]
|
||||
assert len(kids) == 2, (
|
||||
"the unit holds exactly two element children: .chat-actions, then "
|
||||
"#composer"
|
||||
assert len(kids) == 3, (
|
||||
"the unit holds exactly three element children: .chat-actions, "
|
||||
"then #char-count (phase 104), then #composer"
|
||||
)
|
||||
row, form = kids
|
||||
row, counter, form = kids
|
||||
assert row["tag"] == "div" and (
|
||||
row["attrs"].get("class") or ""
|
||||
).split() == ["chat-actions"], (
|
||||
"the first child is the .chat-actions row (the New chat → Share "
|
||||
"DOM order is pinned by test_save_chat_ui.py)"
|
||||
)
|
||||
# Phase 104 (owner 2026-09-12): the question-length counter — a
|
||||
# hidden-by-default <p> between the row and the composer (zero
|
||||
# height while hidden; a new child INSIDE the unit breaks no pin —
|
||||
# the sticky geometry below is untouched).
|
||||
assert counter["tag"] == "p" and counter["attrs"].get("id") == "char-count", (
|
||||
"the second child is the phase-104 #char-count counter"
|
||||
)
|
||||
assert (counter["attrs"].get("class") or "") == "char-count"
|
||||
assert "hidden" in counter["attrs"], (
|
||||
"the counter ships hidden — it appears only from 80% of the "
|
||||
"4,000-char cap (app.js updateCharCount)"
|
||||
)
|
||||
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
|
||||
assert "novalidate" in form["attrs"], (
|
||||
"phase 48: the composer form stays `novalidate` (a `required` "
|
||||
|
||||
@@ -695,8 +695,10 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it
|
||||
from the top of the column to the bottom: below the ``#messages``
|
||||
section, directly above the composer; nothing but the row's own
|
||||
comment lands between ``#messages`` and the row, and nothing but
|
||||
the composer comment lands between the row and the composer. No
|
||||
comment lands between ``#messages`` and the row, and nothing but the
|
||||
phase-104 ``#char-count`` counter + the composer comment lands
|
||||
between the row and the composer (the counter is hidden by default —
|
||||
zero height, the pinned-cluster geometry untouched). No
|
||||
other page carries ``.chat-actions`` (chat-page only, like the
|
||||
pills)."""
|
||||
html = _index()
|
||||
@@ -730,12 +732,23 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
and "<form" not in between
|
||||
), ("nothing but the row's comment lands between #messages and the row")
|
||||
after = html[end:composer_idx]
|
||||
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
|
||||
# row and the composer is the hidden-by-default question-length
|
||||
# counter — everything else (ids, buttons, sections, forms) is
|
||||
# still excluded from the gap.
|
||||
after_minus_counter = after.replace(
|
||||
'<p class="char-count" id="char-count" hidden></p>', ""
|
||||
)
|
||||
assert (
|
||||
"id=" not in after
|
||||
and "<button" not in after
|
||||
and "<section" not in after
|
||||
and "<form" not in after
|
||||
), ("nothing but the composer comment lands between the row and the composer")
|
||||
after.count("<p") == 1
|
||||
and "id=" not in after_minus_counter
|
||||
and "<button" not in after_minus_counter
|
||||
and "<section" not in after_minus_counter
|
||||
and "<form" not in after_minus_counter
|
||||
), (
|
||||
"nothing but the phase-104 counter + the composer comment lands "
|
||||
"between the row and the composer"
|
||||
)
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the row — pinned above);
|
||||
# the standalone pages carry none (task 03 dropped the last folded
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import ValidationError
|
||||
|
||||
from app.schemas import (
|
||||
ChatMessage,
|
||||
ChatRequest,
|
||||
SavedChatCreate,
|
||||
SavedChatUpdate,
|
||||
SourceRef,
|
||||
@@ -34,6 +35,7 @@ TITLE_CAP = 500 # documents.title String(500)
|
||||
NAME_CAP = 100 # ToolCall.name
|
||||
ARGUMENT_CAP = 2000 # ToolCall.argument
|
||||
MESSAGES_CAP = 200 # SavedChatCreate/Update.messages
|
||||
QUESTION_CAP = 4_000 # ChatRequest.message — the cap the composer mirrors (phase 104)
|
||||
|
||||
|
||||
def _source_ref() -> dict:
|
||||
@@ -174,6 +176,30 @@ def test_chat_message_tools_one_over_cap_rejects() -> None:
|
||||
_failed_loc(exc.value, "tools")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatRequest.message (the 4,000-char question cap — phase 104, A3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_request_message_at_cap_validates() -> None:
|
||||
"""EXACTLY 4,000 chars passes — the cap admits "a code example of a
|
||||
few dozen lines" (owner A3) and the composer's maxlength + counter
|
||||
mirror this exact boundary (the 4,000/4,000 E2E submit must not
|
||||
422)."""
|
||||
req = ChatRequest.model_validate({"message": "a" * QUESTION_CAP})
|
||||
assert len(req.message) == QUESTION_CAP
|
||||
|
||||
|
||||
def test_chat_request_message_one_over_cap_rejects() -> None:
|
||||
"""4,001 chars is the 422 the UI now prevents blind: the server cap
|
||||
stays the backstop (pre-existing, untouched — this phase only pins
|
||||
it at the boundary, phase-83 pattern), naming ``message`` in the
|
||||
error loc."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatRequest.model_validate({"message": "a" * (QUESTION_CAP + 1)})
|
||||
_failed_loc(exc.value, "message")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SourceRef (documents column-length mirrors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user