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