From 3f752fb77f44237435776cb5cb9ba0cbd5bdd975 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 4 Oct 2026 22:18:31 -0400 Subject: [PATCH] phase: 157_chat_chip_cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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** --- .../157_chat_chip_cap/00_phase.md | 0 .../157_chat_chip_cap/01_chip_cap.md | 0 .../157_chat_chip_cap/02_unit_pins.md | 0 .../157_chat_chip_cap/03_e2e_chip_cap.md | 0 .../157_chat_chip_cap__00_phase.a1.err | 0 .../157_chat_chip_cap__00_phase.a1.md | 13 + .../157_chat_chip_cap__00_phase.a1.validate | 121 +++++ .../157_chat_chip_cap__01_chip_cap.a1.err | 0 .../157_chat_chip_cap__01_chip_cap.a1.md | 13 + ...157_chat_chip_cap__01_chip_cap.a1.validate | 121 +++++ .../157_chat_chip_cap__02_unit_pins.a1.err | 0 .../157_chat_chip_cap__02_unit_pins.a1.md | 20 + ...57_chat_chip_cap__02_unit_pins.a1.validate | 121 +++++ .../157_chat_chip_cap__03_e2e_chip_cap.a1.err | 0 .../157_chat_chip_cap__03_e2e_chip_cap.a1.md | 21 + ...chat_chip_cap__03_e2e_chip_cap.a1.validate | 121 +++++ README.md | 5 +- frontend/assets/app.js | 15 +- tests/e2e/test_chat_chip_cap.py | 418 ++++++++++++++++++ tests/e2e/test_chip_sizing_question_cap.py | 81 ++-- tests/unit/test_chip_sizing_question_cap.py | 398 ++++++++++++++++- 21 files changed, 1423 insertions(+), 45 deletions(-) rename .agents/phases/{todo => complete}/157_chat_chip_cap/00_phase.md (100%) rename .agents/phases/{todo => complete}/157_chat_chip_cap/01_chip_cap.md (100%) rename .agents/phases/{todo => complete}/157_chat_chip_cap/02_unit_pins.md (100%) rename .agents/phases/{todo => complete}/157_chat_chip_cap/03_e2e_chip_cap.md (100%) create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.err create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.md create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.validate create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.err create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.md create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.validate create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.err create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.md create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.validate create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.err create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.md create mode 100644 .agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.validate create mode 100644 tests/e2e/test_chat_chip_cap.py diff --git a/.agents/phases/todo/157_chat_chip_cap/00_phase.md b/.agents/phases/complete/157_chat_chip_cap/00_phase.md similarity index 100% rename from .agents/phases/todo/157_chat_chip_cap/00_phase.md rename to .agents/phases/complete/157_chat_chip_cap/00_phase.md diff --git a/.agents/phases/todo/157_chat_chip_cap/01_chip_cap.md b/.agents/phases/complete/157_chat_chip_cap/01_chip_cap.md similarity index 100% rename from .agents/phases/todo/157_chat_chip_cap/01_chip_cap.md rename to .agents/phases/complete/157_chat_chip_cap/01_chip_cap.md diff --git a/.agents/phases/todo/157_chat_chip_cap/02_unit_pins.md b/.agents/phases/complete/157_chat_chip_cap/02_unit_pins.md similarity index 100% rename from .agents/phases/todo/157_chat_chip_cap/02_unit_pins.md rename to .agents/phases/complete/157_chat_chip_cap/02_unit_pins.md diff --git a/.agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md b/.agents/phases/complete/157_chat_chip_cap/03_e2e_chip_cap.md similarity index 100% rename from .agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md rename to .agents/phases/complete/157_chat_chip_cap/03_e2e_chip_cap.md diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.err b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.md b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.md new file mode 100644 index 0000000..94eb7b9 --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.md @@ -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** diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.validate b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.validate new file mode 100644 index 0000000..4a8192e --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.err b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.md b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.md new file mode 100644 index 0000000..bb6a683 --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.md @@ -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` diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.validate b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.validate new file mode 100644 index 0000000..c91f388 --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__01_chip_cap.a1.validate @@ -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 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.err b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.md b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.md new file mode 100644 index 0000000..5afedf1 --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.md @@ -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` diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.validate b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.validate new file mode 100644 index 0000000..4a8192e --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__02_unit_pins.a1.validate @@ -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 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.err b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.md b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.md new file mode 100644 index 0000000..f319803 --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.md @@ -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. diff --git a/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.validate b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.validate new file mode 100644 index 0000000..4a8192e --- /dev/null +++ b/.agents/reports/157_chat_chip_cap/157_chat_chip_cap__03_e2e_chip_cap.a1.validate @@ -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 diff --git a/README.md b/README.md index 577992b..5e4adc2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index d613103..3145a5e 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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); diff --git a/tests/e2e/test_chat_chip_cap.py b/tests/e2e/test_chat_chip_cap.py new file mode 100644 index 0000000..5cf74f6 --- /dev/null +++ b/tests/e2e/test_chat_chip_cap.py @@ -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()] diff --git a/tests/e2e/test_chip_sizing_question_cap.py b/tests/e2e/test_chip_sizing_question_cap.py index cbc4367..0ddfe00 100644 --- a/tests/e2e/test_chip_sizing_question_cap.py +++ b/tests/e2e/test_chip_sizing_question_cap.py @@ -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)" ) diff --git a/tests/unit/test_chip_sizing_question_cap.py b/tests/unit/test_chip_sizing_question_cap.py index dcec31f..78b8ba5 100644 --- a/tests/unit/test_chip_sizing_question_cap.py +++ b/tests/unit/test_chip_sizing_question_cap.py @@ -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)" + )