phase: 157_chat_chip_cap
Build and Push Containers / build-and-push-app (push) Successful in 2m19s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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:
2026-10-04 22:18:31 -04:00
parent 1513981c1c
commit 3f752fb77f
21 changed files with 1423 additions and 45 deletions
@@ -0,0 +1,13 @@
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**
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
All gates green. Task 01 complete.
**Report — phase 157, task 01 (chip cap)**
- Implemented `CHIP_CAP = 90` constant (with phase-157 provenance comment, placed just above `renderChips`) + the display-only cap line in `renderChips` exactly per the A1 shape; extended the phase-104 comment block with the cap sentence (cap = PRIMARY guard, CSS clip = secondary) in the same voice
- `title` / `aria-label` / click-handler lines verified byte-unchanged via `git diff -U0 frontend/assets/app.js` (they don't appear in the diff)
- Fixed stale phase-104 work superseded by the cap (step 8): the unit pin `btn.textContent = text;` (replaced with `test_render_chips_display_is_capped_at_chip_cap` + updated title pin/docstrings) and the E2E's `inner_text() == LONG_OPENER` / visually-clipped / `aria-label` assertions (a 91-char display now fits the 72rem column at the 1280px E2E viewport — updated to the extended contract, empirically verified)
- Tests: `uv run pytest` → **3571 passed** (unit+integration); `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; `tests/e2e/test_suggestion_chips.py` (phase-80 regression guard) → **8 passed**
- Coverage: `uv run pytest --cov=app` → **99%** on `app/` (gate >90%; no `app/` code touched this phase)
- Lint: `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
- Docs: README.md — named the 90-char display cap in the onboarding-chips description (cap, `…`, full text in hover tooltip, click submits the full question); AGENTS.md — no chip-related content, no change
- Decisions: kept the phase-104 E2E test name (contract still single-line/ellipsized/full-text-tooltip); aria-label now asserted absent for the capped chip at 1280px (not visually clipped — the phase-104 `scrollWidth > clientWidth` check still governs, per the phase design)
- Next pending task: `.agents/phases/todo/157_chat_chip_cap/02_unit_pins.md`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
Task 02 complete — all gates green.
**Implemented** (in `tests/unit/test_chip_sizing_question_cap.py`, the chip pin module, additions only):
- `CHIP_CAP = 90` source pins: defined exactly once, value 90, placed directly above `renderChips` (comment-only gap), comparison uses the named constant (no hard-coded `> 90`)
- Full-text invariant pins: single `text` declaration with zero reassignments in `renderChips`; handler carries `submitSuggestion(text)` + `onSelect(text, btn)`
- No-second-cap guard: exactly one `slice`/`trimEnd`/`title`/`submit` treatment in `renderChips`; `appendMaybeTry` + hero row both route through the one `renderChips` (banned: `CHIP_CAP`/`slice`/`trimEnd`/`…` in the wrapper)
- Node-executed pins (house idiom, real `renderChips` source under a stubbed DOM with `submitSuggestion`/`onSelect` spies and per-case scroll geometry): 89/90 byte-identical; 91-char letter-at-90 → exact `slice(0,90) + "…"`; 91-char space-at-90 → exact `trimEnd` case (no space before `…`); capped chip's `title`/`onSelect`/`submitSuggestion` all === the FULL string (exact value, not length); <90 item byte-identical; `scrollWidth > clientWidth` aria guard unchanged (clipped → full text, unclipped → attribute-free)
- `/api/suggestions` payload pin (phase-80 integration suite): untouched, green as regression guard (no server change this phase)
**Results:**
- `uv run pytest` — 3578 passed, exit 0 (100% green)
- `uv run pytest --cov=app` — TOTAL coverage 99% (>90% ✓)
- `uv run pytest tests/unit -q` — exit 0
- `uv run ruff check .` — clean; `uv run pyright` — clean
**Docs:** no change needed — task 01 already named the cap in README.md's onboarding-chips paragraph (exact contract); AGENTS.md doesn't describe chips.
**Decisions:** extended the existing phase-104 pin module (the task's named precedent) rather than a new file; used the repo's established `node -e` execution idiom (`test_frontend_doc_modal.py`) with `skipif(not HAVE_NODE)` for the runtime pins.
**Next pending task:** `.agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,21 @@
All green. The working tree contains my one new file (`tests/e2e/test_chat_chip_cap.py`); everything else predates this task and I touched no git index.
## Report — phase 157, task 03 (dedicated Playwright suite)
**Implemented**
- `tests/e2e/test_chat_chip_cap.py` — 4-test isolated suite, mirroring the phase-80 saved-chat seeding idiom (`POST /api/chats` via the signed-in admin + fresh page load), deterministic mock LLM, fixed strings (120-char pin, 91-char capped pin computed as `slice(0,90).rstrip() + "…"`):
- `test_capped_display` — 3-chip contract (newest first); the 120-char opener renders the **exact** 91-char `…` pin (byte-for-byte, not just length); short openers byte-identical; follow-ups never chip
- `test_full_text_reachable` — ≤640px viewport (the clipped case, precondition pinned `scrollWidth > clientWidth`): `title` == full AND `aria-label` == full seeded question (phase-104 idiom)
- `test_click_submits_full` (load-bearing) — clicking the capped chip submits the **full 120-char question** (user bubble byte-for-byte == full text, not the display); grounded non-deflected mock turn, input cleared, send recovered
- `test_short_chip_unchanged` — 49-char opener byte-identical pre-phase: no `…`, `title` == full, unclipped (pinned) → no `aria-label`
**Test / lint / coverage**
- `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` → **4 passed** (in isolation)
- `uv run pytest --cov=app --cov-report=term-missing` → **3578 passed, 99% on `app/`** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Docs** — no doc impact: README L90-92 already names the phase-157 cap accurately (hover tooltip + full submit); AGENTS.md describes no chip machinery.
**Decisions** — the task's "aria-label at the E2E viewport" premise can't hold at the 1280px viewport (phase-104 proved the 91-char display *fits* there → no aria-label); the test forces the genuine clipped case at 375px (chip `max-width: 100%` binds + `flex: 0 0 auto`), pinning the precondition so a layout regression fails loudly. Question grounded deterministically (mock bag-of-words cosine 0.354 ≥ 0.30 gate; verified no mock trigger-phrase collision).
**Next pending task** — none in `todo/` for phase 157 (task 03 was the last); phase 157 is ready for the harness's final pass.
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+4 -1
View File
@@ -87,7 +87,10 @@ the viewer's back link to Chat, one opened from Sources returns to Sources
onboarding suggestion chips onboarding suggestion chips
follow the opening questions of the 3 most recent saved chats (each follow the opening questions of the 3 most recent saved chats (each
chat's first question — a follow-up never chips, phase 103) — on a chat's first question — a follow-up never chips, phase 103) — on a
fresh deployment they seed from `BOR_SUGGESTIONS`. fresh deployment they seed from `BOR_SUGGESTIONS`. A long opening
question caps at 90 displayed characters with a `…` (phase 157) — the
full text stays in the hover tooltip, and clicking the chip submits
the full question.
The document's meta row (the chat modal **and** the standalone viewer at The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the `/document.html`) carries a **Copy path** button: one click copies the
+13 -2
View File
@@ -1289,6 +1289,14 @@ function submitSuggestion(text) {
composer.requestSubmit(); composer.requestSubmit();
} }
/* Phase 157 (owner-locked 2026-10-03, A1): the chip display cap — a
* chip's DISPLAYED text never exceeds CHIP_CAP characters (the `…`
* brings a capped chip to at most CAP+1). The cap is the PRIMARY
* length guard; the phase-104 single-line CSS clip stays the
* secondary. Display-only: the title / aria-label / click payload
* keep the FULL text (the phase-104 contract, extended). */
const CHIP_CAP = 90;
function renderChips(container, items, { onSelect } = {}) { function renderChips(container, items, { onSelect } = {}) {
// Replace only previous chips; keep any other children (e.g. the // Replace only previous chips; keep any other children (e.g. the
// visually-hidden group label inside a "maybe-try" row). // visually-hidden group label inside a "maybe-try" row).
@@ -1300,10 +1308,13 @@ function renderChips(container, items, { onSelect } = {}) {
btn.type = "button"; btn.type = "button";
btn.className = "suggestion-chip"; btn.className = "suggestion-chip";
btn.setAttribute("role", "listitem"); btn.setAttribute("role", "listitem");
btn.textContent = text; btn.textContent =
text.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : text;
// phase 104 (owner 2026-09-12): the single-line chip clips long // phase 104 (owner 2026-09-12): the single-line chip clips long
// questions — `title` is the hover reveal, `aria-label` the // questions — `title` is the hover reveal, `aria-label` the
// clipped-case accessible name (the source-chip pattern). // clipped-case accessible name (the source-chip pattern);
// phase 157 (owner 2026-10-03, A1): the CHIP_CAP display cap is
// the PRIMARY guard — the CSS clip is the secondary.
btn.title = text; btn.title = text;
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
submitSuggestion(text); submitSuggestion(text);
+418
View File
@@ -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()]
+50 -31
View File
@@ -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 multi-line "chonk") — long text ellipsizes at the row edge, and the
FULL text is recoverable via the native ``title`` tooltip on every FULL text is recoverable via the native ``title`` tooltip on every
chip plus the ``aria-label`` accessible name when the visible text is 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 * **A3 — the hard cap through the input path:** the composer textarea
carries ``maxlength="4000"`` mirroring the pre-existing server cap carries ``maxlength="4000"`` mirroring the pre-existing server cap
(``ChatRequest.message max_length=4000`` — the server 422s beyond); (``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): The states pinned here (six tests):
1. **truncated-chip** (A1/A2 core) — a saved chat whose FIRST user 1. **capped-chip** (A1/A2 core + the phase-157 extension) — a saved
question is LONG (720 chars — a readable repeated phrase, 300+ per chat whose FIRST user question is LONG (720 chars — a readable
the phase) with a short follow-up → a fresh page load shows EXACTLY repeated phrase, 300+ per the phase) with a short follow-up → a
ONE onboarding chip (the phase-103 opener semantics — the follow-up fresh page load shows EXACTLY ONE onboarding chip (the phase-103
never surfaces): computed ``white-space: nowrap`` / opener semantics — the follow-up never surfaces): computed
``overflow: hidden`` / ``text-overflow: ellipsis``, visually clipped ``white-space: nowrap`` / ``overflow: hidden`` /
(``scrollWidth > clientWidth``), single line (``44 <= ``text-overflow: ellipsis`` (the phase-104 secondary guard), the
offsetHeight <= 60`` — a one-line pill sits at the 44px DISPLAY capped at 90 chars (``slice(0, 90).trimEnd() + "…"`` — the
``min-height`` floor, border-box; a wrapped two-liner is ~65px — phase-157 primary guard), FITTING the 72rem column at the 1280px
the chonk), and viewport (``scrollWidth <= clientWidth`` — the cap is what keeps
``title`` + ``aria-label`` == the FULL long text; 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 2. **seed-contrast** — a fresh deployment's (seed) short chips have
``title`` set AND NO ``aria-label`` (not truncated — the attribute ``title`` set AND NO ``aria-label`` (not truncated — the attribute
is absent by design; the ``textContent`` already carries the full 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( def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip(
page: Page, app_url: str, db_ready: None page: Page, app_url: str, db_ready: None
) -> None: ) -> None:
"""A1/A2 core: a saved chat whose FIRST user question is 720 chars """A1/A2 core + the phase-157 extension: a saved chat whose FIRST
(a readable repeated phrase) with a short follow-up → a fresh page user question is 720 chars (a readable repeated phrase) with a
load shows EXACTLY ONE onboarding chip (the phase-103 opener short follow-up → a fresh page load shows EXACTLY ONE onboarding
semantics — the follow-up never surfaces), and it is: chip (the phase-103 opener semantics — the follow-up never
surfaces), and it is:
* ONE line — computed ``white-space: nowrap``, ``overflow: hidden``, * ONE line — computed ``white-space: nowrap``, ``overflow: hidden``,
``text-overflow: ellipsis``; ``text-overflow: ellipsis`` (the phase-104 secondary guard);
* visually clipped — ``scrollWidth > clientWidth`` (720 chars of * display-capped — the visible text is
~7px/char far exceeds the 46rem column); ``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 * the pill, not the chonk — ``44 <= offsetHeight <= 60`` (a
one-line pill sits at the 44px ``min-height`` floor, border-box one-line pill sits at the 44px ``min-height`` floor, border-box
— the global ``* { box-sizing: border-box }`` makes the 44px — the global ``* { box-sizing: border-box }`` makes the 44px
the OUTER height; a wrapped two-liner is ~65px); the OUTER height; a wrapped two-liner is ~65px);
* the FULL text one hover away — ``title`` == the full long text; * the FULL text one hover away — ``title`` == the full long text;
* the clipped-case accessible name — ``aria-label`` == the full * no ``aria-label`` — the chip is not visually clipped, so the
long text. attribute stays absent (the phase-104 pattern).
""" """
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
login(page, app_url, next="/") 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") chips = page.locator("#suggestions .suggestion-chip")
expect(chips.first).to_be_visible(timeout=30_000) expect(chips.first).to_be_visible(timeout=30_000)
expect(chips).to_have_count(1, timeout=15_000) expect(chips).to_have_count(1, timeout=15_000)
# The chip carries the EXACT full opener in the DOM (the clipping # Phase 157 (A1): the DISPLAY is capped at 90 chars — the 720-char
# is visual — textContent is never truncated). # opener shows as slice(0, 90).trimEnd() + "…" (91 chars max);
assert chips.first.inner_text().strip() == LONG_OPENER # 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 chip = chips.first
# A1 — the single-line computed contract (the phase-104 CSS). # 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( dims = chip.evaluate(
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, oh: el.offsetHeight })" "el => ({ sw: el.scrollWidth, cw: el.clientWidth, oh: el.offsetHeight })"
) )
assert dims["sw"] > dims["cw"], ( assert dims["sw"] <= dims["cw"], (
"a 720-char question must be visually clipped in the 46rem column" "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, ( assert 44 <= dims["oh"] <= 60, (
f"one-line pill: 44px min-height floor (border-box), a wrapped " f"one-line pill: 44px min-height floor (border-box), a wrapped "
f"two-liner is ~65px — got {dims['oh']}px" f"two-liner is ~65px — got {dims['oh']}px"
) )
# A2 — the full text is one hover away (the native tooltip), and # A2 — the full text is one hover away (the native tooltip).
# the accessible name of the CLIPPED chip is the full text.
assert chip.get_attribute("title") == LONG_OPENER, ( assert chip.get_attribute("title") == LONG_OPENER, (
"the title tooltip must carry the FULL question (owner A2)" "the title tooltip must carry the FULL question (owner A2)"
) )
assert chip.get_attribute("aria-label") == LONG_OPENER, ( # Not visually clipped (the capped display fits) → NO aria-label
"a clipped chip's aria-label must be the full text (the " # (the phase-104 pattern: the attribute is set only when the
"source-chip pattern)" # 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)"
) )
+387 -11
View File
@@ -51,13 +51,42 @@ row) carries:
Task 03 extends this module with the composer cap pins (``maxlength`` / Task 03 extends this module with the composer cap pins (``maxlength`` /
the counter / the guard / the single-source cross-file pin). 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 from __future__ import annotations
import json
import re import re
import shutil
import subprocess
from pathlib import Path from pathlib import Path
import pytest
from app.schemas import ChatRequest from app.schemas import ChatRequest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend" FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
@@ -76,6 +105,15 @@ def _app_js() -> str:
return APP_JS.read_text(encoding="utf-8") 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: def _index_html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}" assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8") 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 ---------- # ---------- 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: def test_render_chips_sets_full_text_title_on_every_chip() -> None:
"""Every chip ``renderChips`` builds (onboarding row AND "Maybe try" """Every chip ``renderChips`` builds (onboarding row AND "Maybe try"
row — one shared component) carries ``title`` = the FULL text row — one shared component) carries ``title`` = the FULL text
(owner A2): the task-01 pill clips a long question to one (owner A2): a long question is capped in the DISPLAY (phase 157,
ellipsized line, and the native hover tooltip is the way the user 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 reads the rest. The pin is the house source-chip precedent
(``chip.title = label``) — and it is set to the SAME ``text`` (``chip.title = label``) — set to the SAME ``text`` variable the
variable ``textContent`` gets, so the tooltip can never drift click payload submits, so the tooltip can never drift short of
short of the full question.""" the full question."""
body = _render_chips_body(_app_js()) 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, ( assert "btn.title = text;" in body, (
"every chip must carry the full text as its native title " "every chip must carry the full text as its native title "
"tooltip — hovering is the contract for the clipped text " "tooltip — hovering is the contract for the capped/clipped "
"(owner A2: 'Hovering over the chips should still show the " "text (owner A2: 'Hovering over the chips should still show "
"full message')" "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 " "the --err-ink on --bg ratio must be recorded (verified "
"10.4:1 ≥ 4.5:1, WCAG AA)" "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)"
)