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
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
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
`/document.html`) carries a **Copy path** button: one click copies the
+13 -2
View File
@@ -1289,6 +1289,14 @@ function submitSuggestion(text) {
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 } = {}) {
// Replace only previous chips; keep any other children (e.g. the
// visually-hidden group label inside a "maybe-try" row).
@@ -1300,10 +1308,13 @@ function renderChips(container, items, { onSelect } = {}) {
btn.type = "button";
btn.className = "suggestion-chip";
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
// 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.addEventListener("click", () => {
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
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)"
)
+387 -11
View File
@@ -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)"
)