phase: 157_chat_chip_cap
All verification is green. Final report: **Phase 157 — final verification pass (all 3 tasks already complete in `complete/`)** - Verified the implemented work matches the design: `CHIP_CAP = 90` constant + display-only cap in `renderChips` (`slice(0,90).trimEnd() + "…"`), `title`/`aria-label`/click payload byte-unchanged; unit pins (89/90/91 letter & space, full-text invariant, short-string guard, Maybe-try single-path guard); phase-104 E2E updated to the cap contract - Full suite `uv run pytest --cov=app --cov-report=term-missing`: **3578 passed**, app/ coverage **99%** (>90% ✓) - `uv run ruff check .` clean; `uv run pyright`: 0 errors - Dedicated E2E `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov`: **4/4 passed** in isolation (DB up) - Cross-phase regression: phase-104 E2E 6/6, phase 05/80/103 chip suite 8/8 (seed chips <90 chars — cap is a no-op, assertions hold) - Criteria: ≤91-char capped display ✓ (byte-for-byte pin) · click submits FULL 120-char question (user bubble) ✓ · `title` + clipped-case `aria-label` carry full text ✓ (375px viewport) · <90-char byte-identical ✓ (unit + live DOM) · commit: deferred to the harness (per harness protocol, overrides AGENTS.md rule 8 for this pass) - Documentation: README.md reviewed in full — every `.env.example` var documented, all referenced scripts/pages/commands verified against the tree, the phase's chip-cap note is in the Chat section; AGENTS.md verified (commands, test files, layout-rule citations — phase 157 adds no new layout principle) → **verified consistent, no changes needed** - No live-infrastructure changes were required or made - Next pending phase: **158_thinking_timeline**
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
"""Phase 157 E2E (Playwright): the hero chip's DISPLAY cap (90 chars +
|
||||
``…``) with the phase-104 full-text contract extended (owner-locked
|
||||
A1, 2026-10-03 audit: "long opening questions get hard-truncated
|
||||
mid-word… the chips stretch near full container width before
|
||||
clipping").
|
||||
|
||||
The contract this suite pins end to end (the user-facing half of the
|
||||
phase — the unit pins in ``tests/unit/test_chip_sizing_question_cap.py``
|
||||
are the source-level half):
|
||||
|
||||
* **capped display** — a >90-char saved-chat opener renders a
|
||||
COMPACT chip: the displayed text is
|
||||
``slice(0, 90).trimEnd() + "…"`` — at most 91 chars, ending in
|
||||
``…`` (the phase-104 single-line CSS clip demoted to the secondary
|
||||
guard); the display is the EXACT pinned string, asserted
|
||||
byte-for-byte on the live DOM, not just by length.
|
||||
* **full text reachable** — the chip's ``title`` (the hover reveal)
|
||||
and, in the CLIPPED case (a 91-char display overflows the
|
||||
single-line box — the ≤640px viewport, where the chip's
|
||||
``max-width: 100%`` binds and ``text-overflow: ellipsis``
|
||||
engages), the ``aria-label`` accessible name both carry the FULL
|
||||
question (phase 104, extended by the cap).
|
||||
* **the click submits the FULL question** (the load-bearing
|
||||
assertion) — clicking a capped chip sends the UN-capped 120-char
|
||||
question: the user bubble is the full question, not the 91-char
|
||||
display (the cap is display-only — the phase-80 contract).
|
||||
* **short-chip regression guard** — a <90-char opener renders
|
||||
byte-identical to pre-phase on the live DOM: the
|
||||
``textContent`` is the full question with NO ``…`` appended, the
|
||||
``title`` is the full question, and (unclipped at the 1280px
|
||||
viewport) no ``aria-label`` — the cap is a no-op below 90.
|
||||
|
||||
Seeding idiom (mirrored from the phase-80/103 suite
|
||||
``tests/e2e/test_suggestion_chips.py``, not invented): the chips are
|
||||
the session openers read from ``saved_chats`` — each test saves its
|
||||
chat(s) via the signed-in admin's ``POST /api/chats`` and loads a
|
||||
FRESH page, whose boot fetch renders the chips. The mock LLM is the
|
||||
deterministic answer source (no ``E2E_REAL_LLM`` dependency); the
|
||||
seeded questions are FIXED strings (the pins, not generated
|
||||
lengths). The click test grounds on the fixture KB (the 120-char
|
||||
opener carries the "kubernetes" token — the mock-calibrated cosine
|
||||
clears the 0.30 gate, non-deflected). ``saved_chats`` is global state
|
||||
on the shared e2e Postgres AND the state this contract reads — the
|
||||
autouse fixture truncates it before and after EVERY test (including
|
||||
the click test's auto-saved row), so each test starts from — and
|
||||
leaves — an empty deployment.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, 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"
|
||||
|
||||
#: The FIXED 120-char opener (90+ — the cap engages deterministically;
|
||||
#: the suite's pin, not a generated length). It carries the
|
||||
#: "kubernetes" token (``tests/fixtures/docs/homelab/kubernetes.md``
|
||||
#: + the deployments fixture), so the click test's turn grounds on
|
||||
#: the mock-calibrated gate (0.30) — non-deflected, marker answer.
|
||||
LONG_Q = (
|
||||
"What are the correct arguments for running a kubernetes node pool "
|
||||
"with qwen 3.8 27b on llama.cpp in the homelab cluster?"
|
||||
)
|
||||
assert len(LONG_Q) == 120, "the pin is a FIXED 120-char string"
|
||||
#: The phase-157 display of LONG_Q (the exact-string pin — char 90 is
|
||||
#: the "a" of "llama", so the trimEnd is a no-op here; the
|
||||
#: space-at-char-90 trimEnd case is the unit pins' job). 91 chars.
|
||||
LONG_Q_CAPPED = LONG_Q[:90].rstrip() + "…"
|
||||
assert len(LONG_Q_CAPPED) == 91 and LONG_Q_CAPPED.endswith("…")
|
||||
#: The follow-up (phase-103 semantics: it must NEVER surface as a
|
||||
#: chip — a follow-up is meaningless as a conversation starter).
|
||||
LONG_FOLLOW_UP = "What about the node pool's GPU drivers?"
|
||||
#: Two <90-char openers for the three-chip state (the row fills to
|
||||
#: the cap of 3 with the long opener NEWEST — the first chip).
|
||||
SHORT_A = "Which provider is the primary DNS for reeseapps.com?"
|
||||
SHORT_A_FOLLOW_UP = "What about the secondary DNS for the LAN?"
|
||||
SHORT_B = "What cron schedule runs the restic prune?"
|
||||
SHORT_B_FOLLOW_UP = "And where do the restic lock files live?"
|
||||
#: The STANDALONE <90-char regression-guard question.
|
||||
SHORT_Q = "How do I rotate the WireGuard keys on the VPN node?"
|
||||
SHORT_Q_FOLLOW_UP = "What about the peers' allowed-ips?"
|
||||
|
||||
|
||||
@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 click test's turn auto-saves a row —
|
||||
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/104 autouse pattern."""
|
||||
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 click test's grounded answer).
|
||||
``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. ``updated_at`` is the API's stamp (server
|
||||
``now()`` at INSERT) — the save ORDER is what makes the chip-walk
|
||||
order deterministic in the multi-chat test."""
|
||||
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 _chips(page: Page) -> Any:
|
||||
return page.locator("#suggestions .suggestion-chip")
|
||||
|
||||
|
||||
def test_capped_display(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The >90-char opener renders a COMPACT chip: the displayed text
|
||||
is the EXACT ``slice(0, 90).trimEnd() + "…"`` pin (91 chars,
|
||||
ending in ``…``), and the row is the phase-80/103 three-chip
|
||||
contract — the three seeded openers, newest ``updated_at``
|
||||
first; the <90-char openers render byte-identical (no ``…``), and
|
||||
none of the chats' follow-ups surfaces."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# Save oldest→newest: the API stamps ``updated_at`` (server
|
||||
# now()), so save order IS walk order (newest first). The short
|
||||
# pauses keep the three stamps strictly apart.
|
||||
stamps: list[str] = []
|
||||
for opener, follow_up in (
|
||||
(SHORT_A, SHORT_A_FOLLOW_UP),
|
||||
(SHORT_B, SHORT_B_FOLLOW_UP),
|
||||
(LONG_Q, LONG_FOLLOW_UP),
|
||||
):
|
||||
body = _save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(opener), _brain(),
|
||||
_user(follow_up), _brain(),
|
||||
],
|
||||
)
|
||||
stamps.append(body["updated_at"])
|
||||
time.sleep(0.05)
|
||||
assert stamps == sorted(stamps) and len(set(stamps)) == 3, (
|
||||
"the three API-stamped updated_at values must be strictly increasing"
|
||||
)
|
||||
|
||||
# A FRESH page load (a new boot fetch): EXACTLY the three
|
||||
# openers, newest first — the long one capped, the shorts not.
|
||||
page.goto(app_url + "/")
|
||||
chips = _chips(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(3, timeout=15_000)
|
||||
|
||||
# The CAP (the phase-157 A1 contract, live DOM, byte-for-byte):
|
||||
# the 120-char opener shows as EXACTLY slice(0, 90).trimEnd() +
|
||||
# "…" — 91 chars, ending in the ellipsis, not the 120-char
|
||||
# pre-phase display (which stretched the chip near full row
|
||||
# width before the CSS clip engaged — the audit's observation).
|
||||
display = chips.nth(0).text_content()
|
||||
assert display is not None
|
||||
assert len(display) <= 91, (
|
||||
f"a >90-char opener must display ≤91 chars — the cap + the "
|
||||
f"… — got {len(display)}"
|
||||
)
|
||||
assert display.endswith("…"), "a capped chip ends with the ellipsis"
|
||||
assert display == LONG_Q_CAPPED, (
|
||||
"the display is the EXACT slice(0, 90).trimEnd() + '…' pin, "
|
||||
"not just a length"
|
||||
)
|
||||
# The row order (phase 80/103 — newest updated_at first, cap 3)
|
||||
# and the <90-char openers' byte-identity (no cap, no "…").
|
||||
assert chips.nth(1).text_content() == SHORT_B, "a <90-char opener is byte-identical (no …)"
|
||||
assert chips.nth(2).text_content() == SHORT_A, "a <90-char opener is byte-identical (no …)"
|
||||
for follow_up in (SHORT_A_FOLLOW_UP, SHORT_B_FOLLOW_UP, LONG_FOLLOW_UP):
|
||||
assert follow_up not in [
|
||||
chips.nth(i).text_content() for i in range(3)
|
||||
], "a chat's follow-up must never chip"
|
||||
|
||||
|
||||
def test_full_text_reachable(browser: Browser, app_url: str, db_ready: None) -> None:
|
||||
"""The full text stays one hover away AND accessible in the
|
||||
CLIPPED case: at the ≤640px viewport the 91-char display
|
||||
overflows the single-line box (the chip's ``max-width: 100%``
|
||||
binds, ``text-overflow: ellipsis`` engages — the phase-104 CSS,
|
||||
the secondary guard), so the phase-104 idiom applies: the
|
||||
``title`` carries the FULL question (universal — every chip) and
|
||||
the ``aria-label`` (set ONLY when visually clipped, the
|
||||
``scrollWidth > clientWidth`` check) carries the FULL question
|
||||
— never the 91-char display (phase 104, extended by the cap)."""
|
||||
page = browser.new_page(viewport={"width": 375, "height": 720})
|
||||
try:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# ONE multi-turn chat: the 120-char opener + the follow-up
|
||||
# (phase-103: the follow-up never surfaces).
|
||||
_save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(LONG_Q), _brain(),
|
||||
_user(LONG_FOLLOW_UP), _brain(),
|
||||
],
|
||||
)
|
||||
|
||||
# A FRESH page load: EXACTLY ONE chip — the opener.
|
||||
page.goto(app_url + "/")
|
||||
chips = _chips(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(1, timeout=15_000)
|
||||
chip = chips.first
|
||||
# Precondition: the CLIPPED case at this viewport (the 91-char
|
||||
# display overflows the single-line box — pin the state the
|
||||
# aria-label branch keys on, so a layout regression cannot
|
||||
# silently flip the attribute's contract).
|
||||
dims = chip.evaluate(
|
||||
"el => ({ sw: el.scrollWidth, cw: el.clientWidth })"
|
||||
)
|
||||
assert dims["sw"] > dims["cw"], (
|
||||
"at the ≤640px viewport the 91-char display must overflow "
|
||||
"the single-line box (the clipped case the aria-label "
|
||||
"idiom governs)"
|
||||
)
|
||||
# The hover reveal (phase 104, universal): the FULL question.
|
||||
assert chip.get_attribute("title") == LONG_Q, (
|
||||
"the title tooltip must carry the FULL question (phase 104)"
|
||||
)
|
||||
# The clipped-case accessible name (phase 104 idiom — the
|
||||
# scrollWidth > clientWidth check): the FULL question.
|
||||
assert chip.get_attribute("aria-label") == LONG_Q, (
|
||||
"a clipped chip's aria-label must carry the FULL "
|
||||
"question — never the 91-char display"
|
||||
)
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
|
||||
def test_click_submits_full(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""The load-bearing assertion: clicking a CAPPED chip submits the
|
||||
FULL (120-char) question — the cap is display-only. The user
|
||||
bubble is the EXACT full question (byte-for-byte, not the 91-char
|
||||
display), and the turn is the grounded mock's (the opener carries
|
||||
the "kubernetes" token — non-deflected, marker answer); the turn
|
||||
clears the input and the send button recovers (never stale)."""
|
||||
_seed_kb(mock_llm)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# ONE multi-turn chat: the 120-char opener + the follow-up.
|
||||
_save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(LONG_Q), _brain(),
|
||||
_user(LONG_FOLLOW_UP), _brain(),
|
||||
],
|
||||
)
|
||||
|
||||
# A FRESH page load: EXACTLY ONE chip — the capped opener.
|
||||
page.goto(app_url + "/")
|
||||
chips = _chips(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(1, timeout=15_000)
|
||||
chip = chips.first
|
||||
# Precondition: the chip that gets clicked IS the capped display
|
||||
# (the 91-char pin — the click test is about the CAPPED chip).
|
||||
assert chip.text_content() == LONG_Q_CAPPED
|
||||
|
||||
# One tap = one question: the click fills AND submits — with the
|
||||
# FULL text (the phase-80 contract, the load-bearing assertion).
|
||||
chip.click()
|
||||
expect(page.locator("#empty-state")).to_be_hidden()
|
||||
expect(page.locator("#message-input")).to_have_value("") # submitted, not queued
|
||||
bubble = page.locator(".msg.user .bubble")
|
||||
expect(bubble).to_have_count(1, timeout=30_000)
|
||||
# The user bubble is the EXACT FULL question — byte-for-byte, not
|
||||
# the 91-char capped display (the cap is display-only).
|
||||
assert bubble.inner_text() == LONG_Q, (
|
||||
"the capped chip must submit the FULL question — the user "
|
||||
"bubble is the un-capped 120-char text, not the display"
|
||||
)
|
||||
assert len(bubble.inner_text()) == 120
|
||||
|
||||
# The grounded mock reply follows (the seeded KB answers this
|
||||
# topic — the "kubernetes" token grounds, non-deflected).
|
||||
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)
|
||||
|
||||
# Never stale: the turn cleared the input and the send button
|
||||
# recovered.
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def test_short_chip_unchanged(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The regression guard on the live DOM: a <90-char opener renders
|
||||
byte-identical to pre-phase — the ``textContent`` is the full
|
||||
question with NO ``…`` appended (the cap is a no-op below 90),
|
||||
the ``title`` is the full question (phase 104, unchanged), and
|
||||
(unclipped at the 1280px viewport) no ``aria-label`` (the
|
||||
``scrollWidth > clientWidth`` check — the attribute is set only
|
||||
when visually clipped, phase 104)."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# ONE multi-turn chat: the 49-char opener + the follow-up.
|
||||
_save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(SHORT_Q), _brain(),
|
||||
_user(SHORT_Q_FOLLOW_UP), _brain(),
|
||||
],
|
||||
)
|
||||
|
||||
# A FRESH page load: EXACTLY ONE chip — the short opener.
|
||||
page.goto(app_url + "/")
|
||||
chips = _chips(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expect(chips).to_have_count(1, timeout=15_000)
|
||||
chip = chips.first
|
||||
# Byte-identical to pre-phase: the full question, no "…".
|
||||
assert chip.text_content() == SHORT_Q, (
|
||||
"a <90-char chip's display is byte-identical to pre-phase — "
|
||||
"the full question, no ellipsis appended"
|
||||
)
|
||||
assert "…" not in (chip.text_content() or "")
|
||||
# The hover reveal: the full question (phase 104, unchanged).
|
||||
assert chip.get_attribute("title") == SHORT_Q
|
||||
# Unclipped at the 1280px viewport (pin the state — the
|
||||
# pre-phase display fit, so the cap must not change the
|
||||
# aria-label contract either): no aria-label.
|
||||
dims = chip.evaluate(
|
||||
"el => ({ sw: el.scrollWidth, cw: el.clientWidth })"
|
||||
)
|
||||
assert dims["sw"] <= dims["cw"], (
|
||||
"a 49-char chip must NOT be clipped at the 1280px viewport "
|
||||
"(the pre-phase state — byte-identical)"
|
||||
)
|
||||
assert chip.get_attribute("aria-label") is None, (
|
||||
"an unclipped chip stays attribute-free (phase 104)"
|
||||
)
|
||||
# The follow-up never surfaces (phase 103 — the row is the
|
||||
# opener only).
|
||||
assert SHORT_Q_FOLLOW_UP not in [chip.text_content()]
|
||||
@@ -16,7 +16,11 @@ The contract (owner decisions A1–A6, recorded in 00_phase.md):
|
||||
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;
|
||||
actually clipped; phase 157 (owner 2026-10-03, A1) EXTENDS this:
|
||||
the chip's DISPLAYED text is capped at 90 characters
|
||||
(``slice(0, 90).trimEnd() + "…"``) — the cap is the PRIMARY length
|
||||
guard, the single-line CSS clip the secondary; the ``title`` /
|
||||
``aria-label`` full-text contract is unchanged;
|
||||
* **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);
|
||||
@@ -34,17 +38,21 @@ The contract (owner decisions A1–A6, recorded in 00_phase.md):
|
||||
|
||||
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;
|
||||
1. **capped-chip** (A1/A2 core + the phase-157 extension) — 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`` (the phase-104 secondary guard), the
|
||||
DISPLAY capped at 90 chars (``slice(0, 90).trimEnd() + "…"`` — the
|
||||
phase-157 primary guard), FITTING the 72rem column at the 1280px
|
||||
viewport (``scrollWidth <= clientWidth`` — the cap is what keeps
|
||||
the chip compact), 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), ``title`` == the FULL
|
||||
long text, and NO ``aria-label`` (not visually clipped — the
|
||||
attribute is absent by design);
|
||||
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
|
||||
@@ -231,22 +239,26 @@ def _wait_chat_booted(page: Page) -> None:
|
||||
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:
|
||||
"""A1/A2 core + the phase-157 extension: 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);
|
||||
``text-overflow: ellipsis`` (the phase-104 secondary guard);
|
||||
* display-capped — the visible text is
|
||||
``slice(0, 90).trimEnd() + "…"`` (the phase-157 PRIMARY guard —
|
||||
a 91-char display no longer stretches the chip to the row edge);
|
||||
* FITS the 72rem column at the 1280px viewport — NOT visually
|
||||
clipped (``scrollWidth <= clientWidth``);
|
||||
* 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.
|
||||
* no ``aria-label`` — the chip is not visually clipped, so the
|
||||
attribute stays absent (the phase-104 pattern).
|
||||
"""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
@@ -268,9 +280,11 @@ def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip(
|
||||
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
|
||||
# Phase 157 (A1): the DISPLAY is capped at 90 chars — the 720-char
|
||||
# opener shows as slice(0, 90).trimEnd() + "…" (91 chars max);
|
||||
# the full text stays in the title (asserted below).
|
||||
assert chips.first.inner_text().strip() == LONG_OPENER[:90].rstrip() + "…"
|
||||
assert len(chips.first.inner_text().strip()) <= 91
|
||||
|
||||
chip = chips.first
|
||||
# A1 — the single-line computed contract (the phase-104 CSS).
|
||||
@@ -291,22 +305,27 @@ def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip(
|
||||
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 dims["sw"] <= dims["cw"], (
|
||||
"the 91-char capped display must FIT the 72rem column at the "
|
||||
"1280px viewport — the cap is the PRIMARY guard, the CSS "
|
||||
"clip the secondary (a 720-char display is what used to "
|
||||
"stretch the chip to the row edge)"
|
||||
)
|
||||
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.
|
||||
# A2 — the full text is one hover away (the native tooltip).
|
||||
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)"
|
||||
# Not visually clipped (the capped display fits) → NO aria-label
|
||||
# (the phase-104 pattern: the attribute is set only when the
|
||||
# visible text is clipped).
|
||||
assert chip.get_attribute("aria-label") is None, (
|
||||
"a chip whose display FITS its pill must stay attribute-free "
|
||||
"— aria-label is set only when visually clipped (phase 104)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,13 +51,42 @@ row) carries:
|
||||
|
||||
Task 03 extends this module with the composer cap pins (``maxlength`` /
|
||||
the counter / the guard / the single-source cross-file pin).
|
||||
|
||||
Phase 157 (owner 2026-10-03 audit, A1) EXTENDS the task-02 visible-text
|
||||
contract: the chip's DISPLAYED text is capped at ``CHIP_CAP = 90``
|
||||
characters (``slice(0, 90).trimEnd() + "…"`` over the cap) — the cap is
|
||||
the PRIMARY length guard, the single-line CSS clip the secondary. The
|
||||
task-02 ``title`` = FULL-text pin and the ``aria-label``-when-clipped
|
||||
pin are UNCHANGED (the cap is display-only; the hover reveal and the
|
||||
accessible name still carry the full question).
|
||||
|
||||
Phase 157 task 02 pins the extension at the source level AND by
|
||||
executing the REAL ``renderChips`` under node with a stubbed DOM
|
||||
(the house node-execution idiom, test_frontend_doc_modal.py — the
|
||||
stubbed-state pattern): the 90-char boundary (89/90 byte-identical;
|
||||
91 → ``slice(0, 90).trimEnd() + "…"`` — the EXACT string, both the
|
||||
letter-at-90 and the space-at-90 fixtures — the ``trimEnd`` case is
|
||||
the artifact the cap avoids), the full-text invariant (``title`` / the
|
||||
``onSelect`` spy / the ``submitSuggestion`` spy all carry the FULL
|
||||
string for a >90-char item — a capped chip sends the full
|
||||
question), the short-string guard (<90 is byte-identical in display +
|
||||
title; the ``scrollWidth > clientWidth`` aria guard is unchanged — a
|
||||
clipped chip gets the full text, an unclipped one stays
|
||||
attribute-free), and the "Maybe try" single code path
|
||||
(``appendMaybeTry`` routes through the same ``renderChips`` — no
|
||||
second cap, no text re-treatment).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas import ChatRequest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
@@ -76,6 +105,15 @@ def _app_js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _index_html() -> str:
|
||||
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
@@ -256,24 +294,45 @@ def test_mobile_chip_row_keeps_the_phase_07_track() -> None:
|
||||
# ---------- task 02: the full-text tooltip + accessible name ----------
|
||||
|
||||
|
||||
def test_render_chips_display_is_capped_at_chip_cap() -> None:
|
||||
"""The chip's DISPLAYED text is the phase-157 cap (owner A1,
|
||||
2026-10-03 audit): over ``CHIP_CAP = 90`` characters the display
|
||||
is ``slice(0, 90).trimEnd() + "…"`` — the ``trimEnd`` avoids the
|
||||
trailing-space-before-``…`` artifact when char 90 lands mid-word —
|
||||
and at/below the cap the display is the (trimmed) text
|
||||
byte-identical to the pre-phase full question (the short-string
|
||||
guard). The cap is the PRIMARY length guard; the phase-104
|
||||
single-line CSS clip is the secondary."""
|
||||
body = _render_chips_body(_app_js())
|
||||
cap_line = re.search(
|
||||
r"btn\.textContent =\s*"
|
||||
r"text\.length > CHIP_CAP \? "
|
||||
r'text\.slice\(0, CHIP_CAP\)\.trimEnd\(\) \+ "…" : text;',
|
||||
body,
|
||||
)
|
||||
assert cap_line, (
|
||||
"the chip's displayed text must be the phase-157 cap: "
|
||||
"text.length > CHIP_CAP → slice(0, CHIP_CAP).trimEnd() + '…', "
|
||||
"otherwise the (trimmed) text itself"
|
||||
)
|
||||
|
||||
|
||||
def test_render_chips_sets_full_text_title_on_every_chip() -> None:
|
||||
"""Every chip ``renderChips`` builds (onboarding row AND "Maybe try"
|
||||
row — one shared component) carries ``title`` = the FULL text
|
||||
(owner A2): the task-01 pill clips a long question to one
|
||||
ellipsized line, and the native hover tooltip is the way the user
|
||||
(owner A2): a long question is capped in the DISPLAY (phase 157,
|
||||
the PRIMARY guard) or clipped by the single-line pill (phase 104,
|
||||
the secondary), and the native hover tooltip is the way the user
|
||||
reads the rest. The pin is the house source-chip precedent
|
||||
(``chip.title = label``) — and it is set to the SAME ``text``
|
||||
variable ``textContent`` gets, so the tooltip can never drift
|
||||
short of the full question."""
|
||||
(``chip.title = label``) — set to the SAME ``text`` variable the
|
||||
click payload submits, so the tooltip can never drift short of
|
||||
the full question."""
|
||||
body = _render_chips_body(_app_js())
|
||||
assert "btn.textContent = text;" in body, (
|
||||
"the chip's visible text is the (trimmed) full question"
|
||||
)
|
||||
assert "btn.title = text;" in body, (
|
||||
"every chip must carry the full text as its native title "
|
||||
"tooltip — hovering is the contract for the clipped text "
|
||||
"(owner A2: 'Hovering over the chips should still show the "
|
||||
"full message')"
|
||||
"tooltip — hovering is the contract for the capped/clipped "
|
||||
"text (owner A2: 'Hovering over the chips should still show "
|
||||
"the full message')"
|
||||
)
|
||||
|
||||
|
||||
@@ -548,3 +607,320 @@ def test_char_count_css_is_aa_on_the_app_background() -> None:
|
||||
"the --err-ink on --bg ratio must be recorded (verified "
|
||||
"10.4:1 ≥ 4.5:1, WCAG AA)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 157 (task 02): the 90-char boundary, the full-text
|
||||
# invariant, the short-string guard ----------
|
||||
|
||||
FIX_SHORT = "hello world" # <90: the no-cap case (byte-identical)
|
||||
FIX_89 = "a" * 89 # 89 chars — just under the cap
|
||||
FIX_90 = "b" * 90 # 90 chars — exactly at the cap (the cap is `>`)
|
||||
# 91 chars, char 90 (0-based 89) is a LETTER: trimEnd is a no-op.
|
||||
FIX_91_LETTER = "a" * 89 + "bb"
|
||||
# 91 chars, char 90 (0-based 89) is a SPACE: the trimEnd case — the
|
||||
# trailing-space-before-`…` artifact the cap avoids.
|
||||
FIX_91_SPACE = "b" * 89 + " " + "c"
|
||||
|
||||
NODE_STUB = """
|
||||
const calls = {};
|
||||
function submitSuggestion(t) { calls.submit = t; }
|
||||
let GEO = { clipped: false };
|
||||
function makeBtn() {
|
||||
const el = {
|
||||
type: null,
|
||||
className: "",
|
||||
title: null,
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_text: "",
|
||||
scrollWidth: GEO.clipped ? 500 : 100,
|
||||
clientWidth: GEO.clipped ? 100 : 100,
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
addEventListener(ev, fn) { this._listeners[ev] = fn; },
|
||||
remove() {},
|
||||
};
|
||||
Object.defineProperty(el, "textContent", {
|
||||
get() { return this._text; },
|
||||
set(v) { this._text = String(v); },
|
||||
});
|
||||
return el;
|
||||
}
|
||||
const document = { createElement: () => makeBtn() };
|
||||
function newContainer() {
|
||||
return {
|
||||
_children: [],
|
||||
querySelectorAll: () => [],
|
||||
appendChild(ch) { this._children.push(ch); },
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
NODE_DRIVER = """
|
||||
function run(name, text) {
|
||||
calls.submit = null;
|
||||
calls.select = null;
|
||||
const c = newContainer();
|
||||
renderChips(c, [text], { onSelect: (t, b) => { calls.select = t; } });
|
||||
const btn = c._children[0];
|
||||
const aria =
|
||||
Object.prototype.hasOwnProperty.call(btn._attrs, "aria-label")
|
||||
? btn._attrs["aria-label"]
|
||||
: null;
|
||||
btn._listeners["click"]();
|
||||
return {
|
||||
name: name,
|
||||
text: btn.textContent,
|
||||
title: btn.title,
|
||||
aria: aria,
|
||||
submit: calls.submit,
|
||||
select: calls.select,
|
||||
};
|
||||
}
|
||||
const CASES = __CASES__;
|
||||
const out = [];
|
||||
for (const [name, text, clipped] of CASES) {
|
||||
GEO.clipped = clipped;
|
||||
out.push(run(name, text));
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _chip_render_results(
|
||||
js: str, cases: list[tuple[str, str, bool]]
|
||||
) -> list[dict]:
|
||||
"""Execute the REAL ``renderChips`` (the brace-matched source text
|
||||
from app.js — not a re-implementation) under node with a stubbed
|
||||
DOM: a fresh chip-less container, a ``createElement`` button stub
|
||||
(``textContent`` get/set, ``title``, ``setAttribute``, one click
|
||||
listener, and the ``scrollWidth``/``clientWidth`` pair switched
|
||||
per case — 500>100 clipped vs 100=100 unclipped), a
|
||||
``submitSuggestion`` spy, and an ``onSelect`` spy. Each case gets
|
||||
a fresh chip that is clicked exactly once; the row carries the
|
||||
displayed text, the title, the ``aria-label`` (or ``null``), and
|
||||
the payloads the click delivered to ``submitSuggestion`` /
|
||||
``onSelect``."""
|
||||
cap = re.search(r"const CHIP_CAP = (\d+);", js)
|
||||
assert cap, "app.js must define const CHIP_CAP"
|
||||
script = (
|
||||
NODE_STUB
|
||||
+ f"const CHIP_CAP = {cap.group(1)};\n"
|
||||
+ _render_chips_body(js)
|
||||
+ "\n"
|
||||
+ NODE_DRIVER.replace("__CASES__", json.dumps(cases))
|
||||
)
|
||||
return json.loads(_run_node(script).strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_chip_cap_constant_is_90_once_directly_above_render_chips() -> None:
|
||||
"""``CHIP_CAP = 90`` (owner-locked A1) is defined EXACTLY ONCE, sits
|
||||
directly above ``renderChips`` (only comments/blank lines between
|
||||
— the established placement beside the other chip constants), and
|
||||
the comparison uses the NAMED constant (no hard-coded ``> 90``
|
||||
that could drift from it)."""
|
||||
js = _app_js()
|
||||
assert js.count("const CHIP_CAP") == 1, "exactly one CHIP_CAP definition"
|
||||
m = re.search(r"const CHIP_CAP = (\d+);", js)
|
||||
assert m is not None and m.group(1) == "90", "CHIP_CAP must be 90 (owner A1)"
|
||||
gap = js[m.end() : js.index("function renderChips(")]
|
||||
assert all(
|
||||
line.strip() == "" or line.lstrip().startswith("//")
|
||||
for line in gap.splitlines()
|
||||
), "the constant must sit directly above renderChips (comment-only gap)"
|
||||
body = _render_chips_body(js)
|
||||
assert not re.search(r"length\s*>=?\s*90\b", body), (
|
||||
"the cap comparison must use the named CHIP_CAP constant — a "
|
||||
"hard-coded literal could drift from it"
|
||||
)
|
||||
|
||||
|
||||
def test_click_handler_payloads_carry_the_untouched_full_text() -> None:
|
||||
"""The load-bearing invariant pinned on VARIABLE IDENTITY: the loop
|
||||
declares ONE ``text`` (the trimmed item — the full question) and
|
||||
nothing reassigns it, so the ``title``, the ``submitSuggestion``
|
||||
argument, and the ``onSelect`` argument are all the SAME full
|
||||
string (the cap may only ever touch ``textContent``). A capped
|
||||
chip therefore sends the FULL question (the phase-80 contract).
|
||||
The node-executed spy pins below prove it at runtime."""
|
||||
body = _render_chips_body(_app_js())
|
||||
assert 'const text = String(item || "").trim();' in body
|
||||
assigns = re.findall(r"\btext\s*=[^=]", body)
|
||||
assert len(assigns) == 1, (
|
||||
"exactly ONE `text` assignment in renderChips (the declaration) "
|
||||
"— the title / submitSuggestion / onSelect payloads must be the "
|
||||
"full (trimmed) item text, never a reassignment or a slice"
|
||||
)
|
||||
handler = re.search(
|
||||
r'btn\.addEventListener\("click", \(\) => \{([\s\S]*?)\}\);', body
|
||||
)
|
||||
assert handler, "the chip must keep its click handler"
|
||||
h = handler.group(1)
|
||||
assert "submitSuggestion(text);" in h, (
|
||||
"the click must submit the FULL text — a capped chip sends the "
|
||||
"full question, not the capped display"
|
||||
)
|
||||
assert "onSelect(text, btn);" in h, (
|
||||
"onSelect must receive the FULL text (the spy payload pin)"
|
||||
)
|
||||
|
||||
|
||||
def test_display_cap_is_the_only_length_treatment_in_render_chips() -> None:
|
||||
"""No second cap, no drift: the cap expression is the ONLY length
|
||||
treatment in ``renderChips`` — one slice, one trimEnd, one title,
|
||||
one submit; the click handler / title / aria-label lines never
|
||||
touch the display string (the cap is display-only)."""
|
||||
body = _render_chips_body(_app_js())
|
||||
assert body.count("text.slice(0, CHIP_CAP)") == 1
|
||||
assert body.count(".trimEnd()") == 1
|
||||
assert body.count("btn.title = text;") == 1
|
||||
assert body.count("submitSuggestion(text);") == 1
|
||||
|
||||
|
||||
def test_both_chip_rows_share_the_one_render_chips_path() -> None:
|
||||
"""ONE code path: the hero row (``loadSuggestions`` →
|
||||
``renderChips(suggestionsEl, …)``) and the "Maybe try" row
|
||||
(``appendMaybeTry`` → ``renderChips(group, …)``) both route the
|
||||
suggestions through the SAME ``renderChips`` — so the phase-157
|
||||
cap applies to both identically (a >90-char suggestion renders
|
||||
capped in either row; a short one is byte-identical).
|
||||
``appendMaybeTry`` itself must never re-treat the text (no
|
||||
second cap, no slice, no trimEnd, no ellipsis of its own) and
|
||||
keeps the accessible group (role=list + "Maybe try") it always
|
||||
had."""
|
||||
js = _app_js()
|
||||
assert "renderChips(suggestionsEl, suggestions);" in js, (
|
||||
"the hero row must use the shared renderChips component"
|
||||
)
|
||||
body = _function_body(js, "function appendMaybeTry(")
|
||||
assert "renderChips(group, suggestions);" in body, (
|
||||
"the Maybe-try row must route through the SAME renderChips — "
|
||||
"the cap applies identically (a single code path)"
|
||||
)
|
||||
assert 'wrap.querySelector(".msg-body")' in body
|
||||
assert "body.appendChild(group);" in body
|
||||
assert 'group.setAttribute("role", "list");' in body
|
||||
assert 'group.setAttribute("aria-label", "Maybe try");' in body
|
||||
for banned in ("CHIP_CAP", "slice", "trimEnd", "…"):
|
||||
assert banned not in body, (
|
||||
f"appendMaybeTry must not re-treat the suggestion text "
|
||||
f"({banned!r} found — no second cap / no drift)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_chip_display_boundary_executed_under_node() -> None:
|
||||
"""The 90-char BOUNDARY, executed under node with the REAL
|
||||
``renderChips`` (the actual ``btn.textContent`` expression — the
|
||||
extracted source text, not a re-implementation):
|
||||
|
||||
* an 89-char item → the display is BYTE-IDENTICAL to the input
|
||||
(the cap does not engage below the cap);
|
||||
* a 90-char item → BYTE-IDENTICAL (the cap is ``>``, not ``≥``);
|
||||
* a 91-char item whose char 90 is a LETTER → the EXACT
|
||||
``slice(0, 90) + "…"`` (91 chars — the ``trimEnd`` is a no-op);
|
||||
* a 91-char item whose char 90 is a SPACE → the EXACT
|
||||
``slice(0, 90).trimEnd() + "…"`` (90 chars — the
|
||||
trailing-space-before-``…`` artifact the ``trimEnd`` avoids;
|
||||
no space may sit before the ``…``).
|
||||
"""
|
||||
res = {
|
||||
r["name"]: r
|
||||
for r in _chip_render_results(
|
||||
_app_js(),
|
||||
[
|
||||
("c89", FIX_89, False),
|
||||
("c90", FIX_90, False),
|
||||
("c91l", FIX_91_LETTER, False),
|
||||
("c91s", FIX_91_SPACE, False),
|
||||
],
|
||||
)
|
||||
}
|
||||
assert res["c89"]["text"] == FIX_89, "89 chars: byte-identical (no cap)"
|
||||
assert res["c90"]["text"] == FIX_90, (
|
||||
"90 chars: byte-identical (the cap is >, not >=)"
|
||||
)
|
||||
assert res["c91l"]["text"] == FIX_91_LETTER[:90] + "…", (
|
||||
"91 chars (letter at 90): the exact slice(0,90) + '…'"
|
||||
)
|
||||
assert len(res["c91l"]["text"]) == 91
|
||||
assert res["c91s"]["text"] == FIX_91_SPACE[:90].rstrip() + "…", (
|
||||
"91 chars (space at 90): the exact slice(0,90).trimEnd() + '…' "
|
||||
"— the trimEnd case"
|
||||
)
|
||||
assert len(res["c91s"]["text"]) == 90
|
||||
assert res["c91s"]["text"].endswith("…")
|
||||
assert " …" not in res["c91s"]["text"], (
|
||||
"no space may sit before the … — the artifact the trimEnd avoids"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_capped_chip_submits_the_full_question_executed_under_node() -> None:
|
||||
"""The FULL-TEXT invariant (the load-bearing assertion), executed
|
||||
under node with the real ``renderChips`` + spies: for a >90-char
|
||||
item, the ``title`` hover reveal, the ``onSelect`` spy payload,
|
||||
and the ``submitSuggestion`` spy argument all carry the FULL
|
||||
string (asserted on the EXACT value — not a length). The capped
|
||||
chip sends the FULL question: the cap is display-only."""
|
||||
res = {
|
||||
r["name"]: r
|
||||
for r in _chip_render_results(
|
||||
_app_js(),
|
||||
[
|
||||
("c91l", FIX_91_LETTER, False),
|
||||
("c91s", FIX_91_SPACE, False),
|
||||
],
|
||||
)
|
||||
}
|
||||
for name, full in (("c91l", FIX_91_LETTER), ("c91s", FIX_91_SPACE)):
|
||||
assert res[name]["title"] == full, (
|
||||
f"{name}: the title must be the FULL text (the hover reveal)"
|
||||
)
|
||||
assert res[name]["submit"] == full, (
|
||||
f"{name}: the click must submit the FULL text — a capped chip "
|
||||
"sends the full question (the phase-80 contract)"
|
||||
)
|
||||
assert res[name]["select"] == full, (
|
||||
f"{name}: the onSelect spy must receive the FULL text"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_short_items_byte_identical_and_aria_guard_executed_under_node() -> None:
|
||||
"""The SHORT-STRING guard + the aria guard, executed under node
|
||||
with the real ``renderChips`` (the DOM-simulation idiom):
|
||||
|
||||
* a <90-char item → the display and the title are BYTE-IDENTICAL
|
||||
to the input (the no-cap case is unchanged by the phase);
|
||||
* the ``scrollWidth > clientWidth`` guard (the phase-104
|
||||
source-chip pattern) is unchanged: a CLIPPED chip (a 91-char
|
||||
item simulated as overflowing its single-line box) gets
|
||||
``aria-label`` = the FULL text; an UNCLIPPED chip stays
|
||||
attribute-free (its ``textContent`` already reads the full text
|
||||
to screen readers).
|
||||
"""
|
||||
res = {
|
||||
r["name"]: r
|
||||
for r in _chip_render_results(
|
||||
_app_js(),
|
||||
[
|
||||
("short", FIX_SHORT, False),
|
||||
("clipped91", FIX_91_LETTER, True),
|
||||
("unclipped91", FIX_91_LETTER, False),
|
||||
],
|
||||
)
|
||||
}
|
||||
assert res["short"]["text"] == FIX_SHORT, "short: byte-identical display"
|
||||
assert res["short"]["title"] == FIX_SHORT, "short: byte-identical title"
|
||||
assert res["short"]["aria"] is None, (
|
||||
"a short (unclipped) chip must stay attribute-free — its "
|
||||
"textContent already reads the full text"
|
||||
)
|
||||
assert res["clipped91"]["aria"] == FIX_91_LETTER, (
|
||||
"the CLIPPED chip's aria-label must be the FULL text under the "
|
||||
"scrollWidth > clientWidth guard (the source-chip pattern)"
|
||||
)
|
||||
assert res["unclipped91"]["aria"] is None, (
|
||||
"an unclipped chip must stay attribute-free (the guard is "
|
||||
"unchanged by the cap)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user