phase: 130_chat_answer_clusters
**Phase 130 — final verification pass: 100% green, complete.** - **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail. - **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅ - **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors. - **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100). - **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession. - **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
@@ -19,16 +19,18 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
|
||||
turn, the shape that makes the chip assertion meaningful). The
|
||||
brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>``
|
||||
from the bytes route, ``loading="lazy"``, below the text and above
|
||||
the chip row per A4, alt/caption settling to the summary), the
|
||||
answer TEXT is still streamed in full (the tail echo of the
|
||||
the source clusters per A4, alt/caption settling to the summary),
|
||||
the answer TEXT is still streamed in full (the tail echo of the
|
||||
``<documents>`` seed proves the description was in the prompt), the
|
||||
source-chip row carries the image doc's chip + its phase-122
|
||||
figure (the AUTO-CITE proof — the mock made zero ``read`` calls,
|
||||
asserted on the mock's request capture), and the "Nearby docs" row
|
||||
does not repeat the doc. The wire pin: the first model request
|
||||
offered FOUR tools (the three base + ``show_image``) and the turn
|
||||
executed exactly one ``show_image`` — the saved brain record
|
||||
persists the ``answer_image`` ref.
|
||||
sources block carries the image doc's citation as the compact
|
||||
figure in the images row (``.msg-meta.msg-images`` — phase 130
|
||||
task 01: NO chip for an image doc, the figure IS the citation;
|
||||
the AUTO-CITE proof — the mock made zero ``read`` calls, asserted
|
||||
on the mock's request capture), and the "Nearby docs" row does not
|
||||
repeat the doc. The wire pin: the first model request offered
|
||||
FOUR tools (the three base + ``show_image``) and the turn executed
|
||||
exactly one ``show_image`` — the saved brain record persists the
|
||||
``answer_image`` ref.
|
||||
- **Restore + share** — a second feature turn, then a page RELOAD:
|
||||
the restored brain bubble shows the figure again (the
|
||||
``answer_image`` persistence through the ``bor.chat.v1`` restore
|
||||
@@ -36,11 +38,12 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
|
||||
fresh anonymous context) renders the figure on the shared page.
|
||||
- **Bug-fix-only flow** — the ``which diagram is in your notes``
|
||||
trigger (the mock answers straight from the seed with ZERO tool
|
||||
calls — no ``show_image``, no ``read``): the source-chip row STILL
|
||||
carries the image doc's chip + figure (the bug fix on the pure
|
||||
zero-read path — the assertion the owner's report describes), the
|
||||
answer text quotes the seeded description's tail, and NO answer
|
||||
figure renders (the model never called the tool).
|
||||
calls — no ``show_image``, no ``read``): the sources block STILL
|
||||
carries the image doc's citation figure (no chip — phase 130;
|
||||
the bug fix on the pure zero-read path — the assertion the owner's
|
||||
report describes), the answer text quotes the seeded
|
||||
description's tail, and NO answer figure renders (the model never
|
||||
called the tool).
|
||||
* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
|
||||
pinned to the code default ``false`` (EXPLICITLY — process env ranks
|
||||
above an operator's local gitignored ``.env``, so the default-off
|
||||
@@ -51,9 +54,10 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
|
||||
``show_image`` among the offered tools (the request capture carries
|
||||
exactly the THREE base tools), the brain bubble has NO answer
|
||||
figure, and the saved brain record carries NO ``answer_image`` key
|
||||
(the byte-identical-off contract). The bug-fix trigger STILL chips
|
||||
the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` —
|
||||
it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design).
|
||||
(the byte-identical-off contract). The bug-fix trigger STILL cites
|
||||
the image doc (the figure, no chip — phase 130; the auto-cite fix is
|
||||
NOT gated by ``BOR_ANSWER_IMAGES`` — it belongs to the
|
||||
``BOR_IMAGES``-indexed docs, per the phase design).
|
||||
|
||||
DB isolation: the suites share one Postgres; ``saved_chats`` survives
|
||||
the truncation (the house pattern), so every question text here is
|
||||
@@ -593,15 +597,18 @@ def _assert_answer_figure(
|
||||
def _assert_image_chip(
|
||||
page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
|
||||
) -> None:
|
||||
"""The citation chip the AUTO-CITE earns (phase 124 task 05): the
|
||||
image doc's text chip + its phase-122 compact figure (``<img>``
|
||||
from the bytes route, alt settling to the summary — the admin
|
||||
surface; the shared page's chip is plain text and asserted
|
||||
separately)."""
|
||||
"""The AUTO-CITE (phase 124 task 05) in the phase-130 cluster
|
||||
shape (task 01, TODO L6): an image doc gets NO chip — its figure
|
||||
IS the citation — the compact figure (``<img>`` from the bytes
|
||||
route, alt settling to the summary — the admin surface; the
|
||||
shared page's plain-text chip is asserted separately) sits in the
|
||||
images row (``.msg-meta.msg-images``)."""
|
||||
chip = page.locator(f"{last_brain} .source-chip")
|
||||
expect(chip).to_have_count(1)
|
||||
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||||
fig = page.locator(f"{last_brain} .source-image")
|
||||
expect(chip).to_have_count(0)
|
||||
row = page.locator(f"{last_brain} .msg-meta.msg-images")
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_have_attribute("aria-label", "Source images")
|
||||
fig = row.locator(".source-image")
|
||||
expect(fig).to_have_count(1)
|
||||
img = fig.locator(".source-image-img")
|
||||
expect(img).to_be_visible(timeout=15_000)
|
||||
@@ -612,7 +619,7 @@ def _assert_image_chip(
|
||||
def _body_child_classes(page: Page) -> list[str]:
|
||||
"""The LAST brain bubble's ``.msg-body`` direct children's class
|
||||
names, in DOM order — the A4 position pin (bubble < answer-image
|
||||
< msg-meta)."""
|
||||
< the first .msg-meta-family source row)."""
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const body = [...document.querySelectorAll(".msg.brain")].pop()
|
||||
@@ -635,15 +642,18 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
|
||||
a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
|
||||
(1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
|
||||
an ``<img>`` from the bytes route (200, image Content-Type) and
|
||||
``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta``
|
||||
source-chip row (A4); (3) the answer TEXT is non-empty and quotes
|
||||
the seed's tail (the "summaries still provided" contract + the
|
||||
echo proving the seed was in the prompt); (4) the source-chip row
|
||||
carries the image doc's chip + its figure (the AUTO-CITE proof —
|
||||
the mock made ZERO ``read`` calls: only ``show_image`` — asserted
|
||||
on the wire); (5) the "Nearby docs" row does NOT repeat the doc.
|
||||
Plus the persistence pin: the saved brain record carries the
|
||||
``answer_image`` ref (the task-04 frame field, round-tripped)."""
|
||||
``loading="lazy"``; (2) the figure sits BELOW the answer text and
|
||||
ABOVE the source clusters (A4 — the image-only KB renders the
|
||||
images row, ``.msg-meta.msg-images`` — phase 130 task 01: no chip
|
||||
row); (3) the answer TEXT is non-empty and quotes the seed's tail
|
||||
(the "summaries still provided" contract + the echo proving the
|
||||
seed was in the prompt); (4) the images row carries the image
|
||||
doc's figure — no chip (phase 130: the figure IS the citation) —
|
||||
the AUTO-CITE proof (the mock made ZERO ``read`` calls: only
|
||||
``show_image`` — asserted on the wire); (5) the "Nearby docs" row
|
||||
does NOT repeat the doc. Plus the persistence pin: the saved
|
||||
brain record carries the ``answer_image`` ref (the task-04 frame
|
||||
field, round-tripped)."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
@@ -664,20 +674,25 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
|
||||
# summary (the fetchContentSummary pattern settles both).
|
||||
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
|
||||
|
||||
# (2) A4 position: below the answer text, above the chip row.
|
||||
# (2) A4 position: below the answer text, above the source
|
||||
# clusters — the image-only KB renders the images row
|
||||
# (.msg-meta.msg-images, phase 130 task 01: no chip row).
|
||||
classes = _body_child_classes(page)
|
||||
assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}"
|
||||
assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}"
|
||||
assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}"
|
||||
meta_idx = next(
|
||||
(i for i, c in enumerate(classes) if c.startswith("msg-meta")), None
|
||||
)
|
||||
assert meta_idx is not None, f"no source row in .msg-body: {classes}"
|
||||
assert (
|
||||
classes.index("bubble")
|
||||
< classes.index("answer-image")
|
||||
< classes.index("msg-meta")
|
||||
), f"A4 order violated (bubble < answer-image < msg-meta): {classes}"
|
||||
< meta_idx
|
||||
), f"A4 order violated (bubble < answer-image < source row): {classes}"
|
||||
|
||||
# (4) The chip row carries the image doc's chip + its figure —
|
||||
# the AUTO-CITE (the model never read it; the seed IS its
|
||||
# entire content, so suggested == read for it).
|
||||
# (4) The images row carries the image doc's figure (no chip —
|
||||
# phase 130) — the AUTO-CITE (the model never read it; the
|
||||
# seed IS its entire content, so suggested == read for it).
|
||||
_assert_image_chip(page, seeded)
|
||||
|
||||
# (5) The "Nearby docs" row does NOT repeat the image doc — the
|
||||
@@ -817,8 +832,8 @@ def test_reload_restores_figure_and_shared_page_shows_it(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL
|
||||
# appears (the pure zero-read path — the owner's report)
|
||||
# 3. App A — the bug-fix-only flow: ZERO tool calls, the citation
|
||||
# STILL renders (the pure zero-read path — the owner's report)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -828,11 +843,12 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips(
|
||||
"""The ``which diagram is in your notes`` trigger: the mock
|
||||
answers straight from the seed with ZERO tool calls (no
|
||||
``show_image`` — the clause's flow is not triggered — and no
|
||||
``read``): the source-chip row STILL carries the image doc's chip
|
||||
+ figure (the bug fix on the pure zero-read path — the assertion
|
||||
the owner's report describes), the answer text quotes the seeded
|
||||
description's tail (the description WAS in the prompt), and NO
|
||||
answer figure renders (the model never called the tool)."""
|
||||
``read``): the sources block STILL carries the image doc's
|
||||
citation figure (no chip — phase 130; the bug fix on the pure
|
||||
zero-read path — the assertion the owner's report describes), the
|
||||
answer text quotes the seeded description's tail (the description
|
||||
WAS in the prompt), and NO answer figure renders (the model never
|
||||
called the tool)."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
@@ -860,8 +876,9 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips(
|
||||
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
|
||||
|
||||
# NO answer figure (the model never called show_image) — but the
|
||||
# chip row STILL carries the image doc's chip + figure (the auto-
|
||||
# cite: suggested == read for an image doc, no read performed).
|
||||
# sources block STILL carries the image doc's citation figure
|
||||
# (no chip — phase 130; the auto-cite: suggested == read for an
|
||||
# image doc, no read performed).
|
||||
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
|
||||
_assert_image_chip(page, seeded)
|
||||
|
||||
@@ -936,8 +953,8 @@ def test_toggle_off_flow_no_tool_no_answer_image_key(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. App B (default off) — the bug-fix trigger STILL chips the image
|
||||
# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
|
||||
# 5. App B (default off) — the bug-fix trigger STILL cites the image
|
||||
# doc (the auto-cite fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -946,10 +963,11 @@ def test_toggle_off_bugfix_trigger_still_chips(
|
||||
) -> None:
|
||||
"""The bug-fix trigger on the default-OFF app (the clause is
|
||||
absent — the trigger does NOT require it): ZERO tool calls, the
|
||||
answer quotes the seeded description's tail, and the source-chip
|
||||
row STILL carries the image doc's chip + figure — the chips fix
|
||||
is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the
|
||||
``BOR_IMAGES``-indexed docs, per the phase design)."""
|
||||
answer quotes the seeded description's tail, and the sources
|
||||
block STILL carries the image doc's citation figure (no chip —
|
||||
phase 130) — the auto-cite fix is NOT gated by
|
||||
``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
|
||||
docs, per the phase design)."""
|
||||
login(page, default_app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
|
||||
@@ -0,0 +1,939 @@
|
||||
"""Phase 130 task 04 E2E (Playwright, mock-only): the chat answer renders
|
||||
as organized clusters — chips row, images row, bottom-right actions row.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's gate
|
||||
is the deterministic cluster DOM of the rendered answer, which needs the
|
||||
``done`` frame to cite ONE text doc AND ONE image doc in the same
|
||||
answer. That mixed citation is driven by the house scripted turn in
|
||||
``tests/e2e/mock_llm.py`` (``DRILL_TRIGGER``: the user message contains
|
||||
``drill down the tree`` — the question carries its own tool call after
|
||||
the colon, ``drill down the tree: read e2e-clusters/note.md`` — **and**
|
||||
the system prompt carries the ``<tools>`` section of the HIGH prompt).
|
||||
The scripted ``read`` cites the text doc (the phase-119 read-docs-only
|
||||
rule) while the image doc joins the citation tier auto-cited (its
|
||||
suggested-tier seed block IS its entire content — phase 124): the
|
||||
``done`` frame's ``sources`` carries exactly the two refs — the text
|
||||
ref WITHOUT ``image_url`` (the phase-122 omission rule) and the image
|
||||
ref WITH it — the shape phase 130 renders as two clusters (chips row
|
||||
text-only, images row figures-only, NO chip for the image doc) + the
|
||||
actions row last.
|
||||
|
||||
KB fixture — ONE local source (a ``tmp_path_factory`` dir, registered
|
||||
through the authenticated API + the real in-process ``POST /api/sync``
|
||||
— the ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown.
|
||||
py`` pattern):
|
||||
|
||||
* ``e2e-clusters/note.md`` — a markdown doc whose body carries the
|
||||
trigger phrase's words + the diagram vocabulary: the mock's
|
||||
token-overlap cosine against the scripted question clears the
|
||||
mock-calibrated 0.30 gate (the turn grounds, the ``<tools>`` section
|
||||
is present — the flow's precondition);
|
||||
* ``e2e-clusters/pic.png`` — a standalone 1×1 PNG: the module app runs
|
||||
``BOR_IMAGES=true`` (the phase-122 pattern — the mock's
|
||||
``IMAGE_DESCRIPTION_MODE`` writes the byte-stable
|
||||
``IMAGE_DESCRIPTION_ANSWER`` as the doc's content + summary).
|
||||
|
||||
The module app ALSO pins ``BOR_DOCS_REPO`` to a non-empty scratch path
|
||||
(the ``docs_repo_configured`` gate is ``bool(docs_repo.strip())`` — a
|
||||
configured repo reveals the Save-as-doc button for the admin; the push
|
||||
endpoint is never exercised — the button is never clicked) and
|
||||
``BOR_ANSWER_IMAGES=false`` explicitly (the leak-guard convention: the
|
||||
owner's local gitignored ``.env`` cannot leak the answer-image toggle
|
||||
into the premise — the scripted turn makes NO ``show_image`` call, so
|
||||
the bubble carries no ``.answer-image``).
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_answer_clusters_render_in_order`` — the LIVE turn: the wire
|
||||
carries the scripted ``read`` once, and the ``done`` frame cites
|
||||
BOTH docs — the read text doc first (read order), then the
|
||||
auto-cited image ref (``image_url`` present; the text ref's key is
|
||||
ABSENT); the last brain bubble's ``.msg-body`` direct children are
|
||||
exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta
|
||||
msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the
|
||||
text chip — NO chip for the image doc, phase 130 task 01) before
|
||||
the images row (the figure, ``<img>`` loaded from the bytes route,
|
||||
alt + caption settling to the canned description), no related row
|
||||
(the 2-doc KB has no related tier), and the actions row LAST
|
||||
(the "bottom" of the TODO's "bottom right") holding ALL THREE gated
|
||||
buttons (admin + configured docs repo — the phase-16/59 gates).
|
||||
Geometry (bounding boxes, not pixels): the actions row's box is
|
||||
BELOW the images row's, and the rightmost button's right edge sits
|
||||
at the row's right edge minus the row's 0.25rem padding (the
|
||||
per-button auto margins are gone — the row's
|
||||
``justify-content: flex-end`` does the right-align, phase 130
|
||||
task 03).
|
||||
2. ``test_image_without_chip_keeps_its_citation_via_figure`` — the
|
||||
same bubble: the ONLY ``.source-chip`` is the text doc's (the
|
||||
owner's exact ask — no chip whose label is the image's
|
||||
``source/path``), while the image doc is fully cited: the figure's
|
||||
``href`` is its ``documentUrl`` and a left-click opens the same-
|
||||
page modal (the phase-26 affordance — the modal shows the image +
|
||||
the description). The text chip keeps its own phase-26 affordance
|
||||
(left-click → the same modal) — the no-behavior-change half.
|
||||
3. ``test_restore_renders_the_same_clusters`` — the turn auto-saves
|
||||
(phase 66); a page reload restores it through the phase-14 restore
|
||||
path (the same appenders): the restored last brain bubble carries
|
||||
the IDENTICAL ``.msg-body`` child sequence (chips row text-only,
|
||||
images row, actions row LAST with the three gated buttons) and the
|
||||
same geometry — the DOM order assertion pinned for BOTH the live
|
||||
and the restored bubble.
|
||||
4. ``test_non_admin_sees_retry_only_actions`` — a FRESH non-admin
|
||||
browser context (no admin cookie — the real in-app token gate,
|
||||
``login_with_token``, the phase-79 pattern) on a FRESH chat: the
|
||||
same scripted turn, and the ``.msg-actions`` row holds ONLY the
|
||||
``.retry-btn`` (Tune admin-gated phase 16, Save-as-doc admin-
|
||||
gated phase 59 — the gates unchanged) while the cluster rows
|
||||
render identically (the render is not identity-gated).
|
||||
|
||||
DB isolation: the suites share one Postgres. ``saved_chats`` survives
|
||||
the truncation (the house pattern) — every question text here is
|
||||
UNIQUE (its auto-title therefore is) and the module boundary cleans
|
||||
this suite's rows by title. The non-admin turn's auto-save 403s
|
||||
(``/api/chats`` is admin-only — the quiet A2 status note, no row) and
|
||||
its token is revoked at the end of the test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Locator, Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document
|
||||
from e2e.auth_helpers import login, login_with_token
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
# port in a combined run — this module app binds its own port instead
|
||||
# (a same-port second uvicorn dies on bind and would drive the wrong
|
||||
# server). Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_CLUSTERS", "8170"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
SETTLE_TIMEOUT_MS = 30_000
|
||||
FIGURE_TIMEOUT_MS = 15_000
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The fixture (one local source: ONE text doc + ONE image doc)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SOURCE_NAME = "e2e-clusters" # the directory basename = the source name
|
||||
TEXT_PATH = "note.md"
|
||||
IMG_PATH = "pic.png"
|
||||
TEXT_SP = f"{SOURCE_NAME}/{TEXT_PATH}" # the chip's label (source/path)
|
||||
IMG_SP = f"{SOURCE_NAME}/{IMG_PATH}"
|
||||
TEXT_TITLE = "Homelab Network Note" # the first H1 (extract_title)
|
||||
IMG_TITLE = "pic" # the image doc's title = the file stem (phase 122)
|
||||
#: The chip/figure ``href`` (the phase-26 same-page-modal affordance —
|
||||
#: ``documentUrl(source, path, "/")`` — the /document.html escape hatch).
|
||||
TEXT_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={TEXT_PATH}&back=%2F"
|
||||
IMG_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={IMG_PATH}&back=%2F"
|
||||
|
||||
#: The text doc's body carries the trigger phrase's words + the diagram
|
||||
#: vocabulary, so the scripted questions (which repeat them) clear the
|
||||
#: mock's token-overlap cosine gate deterministically (~0.5 vs the
|
||||
#: mock-calibrated 0.30).
|
||||
TEXT_CONTENT = (
|
||||
"# Homelab Network Note\n"
|
||||
"\n"
|
||||
"The drill down the tree fixture note for the e2e clusters suite: this\n"
|
||||
"homelab note describes the network diagram — the core router, the core\n"
|
||||
"switch, and the three VLAN subnets the homelab network diagram shows.\n"
|
||||
)
|
||||
|
||||
# The scripted turn's answer (the phase-37 single-read shape): the mock
|
||||
# quotes the read document's FIRST 80 chars — the H1 line (renders as a
|
||||
# heading) + 56 body chars (the assertable prefix below, one text node).
|
||||
READ_ANSWER_PREFIX = f"Read {TEXT_SP}."
|
||||
READ_BODY_QUOTE = "The drill down the tree fixture note for the e2e cluster"
|
||||
|
||||
# A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||||
# the pipeline is content-agnostic, the well-formed bytes keep the
|
||||
# upload + serve + render path honest).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The scripted questions (the mock's ``DRILL_TRIGGER`` — the regex
|
||||
# ``drill down the tree:\s*read\s+[a-z0-9_./-]+`` parses the read
|
||||
# target from the RAW message, so the trigger line stays lowercase and
|
||||
# the path stays in its character set). Each tail is unique — the
|
||||
# auto-titles are unique on the shared e2e Postgres (the row-lookup
|
||||
# convention of test_answer_images).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Q_ORDER = (
|
||||
"drill down the tree: read e2e-clusters/note.md — "
|
||||
"what does the homelab note say about the diagram?"
|
||||
)
|
||||
Q_FIGURE = (
|
||||
"drill down the tree: read e2e-clusters/note.md — "
|
||||
"which diagram does the note describe?"
|
||||
)
|
||||
Q_RESTORE = (
|
||||
"drill down the tree: read e2e-clusters/note.md — "
|
||||
"summarize the homelab network note (restore)"
|
||||
)
|
||||
Q_NONADMIN = (
|
||||
"drill down the tree: read e2e-clusters/note.md — "
|
||||
"what is the note's diagram? (non-admin)"
|
||||
)
|
||||
assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), (
|
||||
"the auto-title cap is 120 chars — the questions must stay whole"
|
||||
)
|
||||
|
||||
#: The .msg-body child sequence of a completed scripted-read bubble
|
||||
#: (phase 130's fix, DOM-asserted): the tool record (the scripted read),
|
||||
#: the answer bubble, the chips row (the bare .msg-meta), the images
|
||||
#: row, the actions row LAST. The live and restore paths share it.
|
||||
EXPECTED_CHILD_CLASSES = [
|
||||
"tool-calls-disclosure",
|
||||
"bubble",
|
||||
"msg-meta",
|
||||
"msg-meta msg-images",
|
||||
"msg-meta msg-actions",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app: images ON + a configured docs repo (the Save-as-doc gate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cluster_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The one-source fixture tree (see the module docstring): the app
|
||||
server runs on the same host, so the paths are visible to it. The
|
||||
directory NAME is the source name (``kind=local`` → the directory's
|
||||
basename, phase 38)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_clusters")
|
||||
src = scratch / SOURCE_NAME
|
||||
src.mkdir()
|
||||
(src / TEXT_PATH).write_text(TEXT_CONTENT, encoding="utf-8")
|
||||
(src / IMG_PATH).write_bytes(PNG_1X1)
|
||||
assert (src / TEXT_PATH).is_file() and (src / IMG_PATH).is_file()
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, cluster_dir: Path) -> Iterator[str]:
|
||||
"""The real app under test — per-module app (the conftest pattern):
|
||||
NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the source
|
||||
here is a DB-registered local directory), ``BOR_IMAGES=true`` (the
|
||||
PNG must become an image document), a configured ``BOR_DOCS_REPO``
|
||||
(the Save-as-doc reveal gate), and the leak-guarded code defaults.
|
||||
The session app is never started in this isolated run, so no port
|
||||
clash."""
|
||||
scratch = cluster_dir.parent # the tmp dir holding the source tree
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (conftest pattern): the scripted questions
|
||||
# ground on these values (see TEXT_CONTENT).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value: the registry
|
||||
# must hold EXACTLY this suite's local directory.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||
# .env cannot leak corpus-specific settings into the app under test.
|
||||
env["BOR_DOCS_REPO"] = str(scratch / "docs") # non-empty → configured
|
||||
env["BOR_DOCS_WORK_DIR"] = str(scratch / "docs-work")
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# Phase 122: the PNG must become an image document; the image/upload
|
||||
# homes are in the suite's scratch (never the owner's real
|
||||
# ~/bor-sources). The answer-image toggle is pinned OFF explicitly —
|
||||
# the scripted turn makes no show_image call, so no .answer-image.
|
||||
env["BOR_IMAGES"] = "true"
|
||||
env["BOR_ANSWER_IMAGES"] = "false"
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB + admin-HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern): the suites share
|
||||
one Postgres, and a leftover row would corrupt the 2-doc premise.
|
||||
``saved_chats`` is never touched — the unique question texts keep
|
||||
the row lookups unambiguous, and :func:`_cleanup_saved_rows` owns
|
||||
this suite's rows (re-runnable on the shared DB)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, sources_meta, folder_summaries"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _health_db(url: str) -> bool:
|
||||
try:
|
||||
return httpx.get(f"{url}/api/health", timeout=5).json()["db"] == "up"
|
||||
except Exception: # noqa: BLE001 — unreachable is the skip case
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admin_client(app_url: str) -> Iterator[httpx.Client]:
|
||||
"""An httpx client holding a fresh admin session (the form login's
|
||||
API twin — the house httpx pattern of test_ls_tree_drilldown's
|
||||
``synced_kb``), closed on exit."""
|
||||
client = httpx.Client(base_url=app_url, timeout=30.0)
|
||||
try:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state (the test_local_directory_sources pattern)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
#: This suite's auto-titles (the shared e2e Postgres keeps
|
||||
#: ``saved_chats`` across runs — the test_answer_images cleanup
|
||||
#: concern): a re-run must start clean.
|
||||
_SUITE_TITLES = tuple(
|
||||
_auto_title(q) for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_saved_rows() -> None:
|
||||
"""Delete THIS suite's saved rows by title (best-effort: a failed
|
||||
delete (DB unreachable — the skip case) never masks the skip)."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("DELETE FROM saved_chats WHERE title = ANY(:titles)"),
|
||||
{"titles": list(_SUITE_TITLES)},
|
||||
)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
|
||||
pass
|
||||
|
||||
|
||||
def _wait_saved_row(
|
||||
client: httpx.Client, title: str, messages: int = 2
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for the auto-saved row (phase 55/66: auto-saves are SILENT
|
||||
— so there is no status line to wait on)."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/chats", timeout=10)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["chats"]
|
||||
last = next((c for c in rows if c["title"] == title), None)
|
||||
if last is not None and last["message_count"] >= messages:
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||
|
||||
|
||||
def _create_token(client: httpx.Client, label: str) -> tuple[str, str]:
|
||||
"""Admin-issued token (``POST /api/tokens`` — the house pattern of
|
||||
test_api_tokens): returns (row id, plaintext)."""
|
||||
r = client.post("/api/tokens", json={"label": label}, timeout=10)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["label"] == label
|
||||
return body["id"], body["token"]
|
||||
|
||||
|
||||
def _revoke_token(client: httpx.Client, token_id: str) -> None:
|
||||
"""Revoke the token (a revoked token is dead NOW — A10): the shared
|
||||
DB keeps no live credential behind the suite."""
|
||||
r = client.post(f"/api/tokens/{token_id}/revoke", timeout=10)
|
||||
assert r.status_code == 204, r.text
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded(app_url: str, cluster_dir: Path) -> Iterator[dict[str, str]]:
|
||||
"""The story's precondition: the one-source KB (ONE text doc + ONE
|
||||
image doc) synced under the deterministic mock — registered through
|
||||
the authenticated API, scanned by the REAL in-process
|
||||
``POST /api/sync`` pipeline (walk → chunk → embed → the mock's
|
||||
vision description for the PNG → overview → version bump). Yields
|
||||
the seeded image doc's identity (the bytes route + the
|
||||
description) for the render assertions."""
|
||||
if not _health_db(app_url):
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
_cleanup_saved_rows() # a previous run's rows would be stale (new doc id)
|
||||
_truncate_all()
|
||||
with _admin_client(app_url) as client:
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(cluster_dir)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
body = _wait_sync_done_http(client)
|
||||
assert body["state"] == "success", body
|
||||
detail = body["detail"]
|
||||
assert detail["added"] == 2, detail # the text doc + the image doc
|
||||
with SessionLocal() as db:
|
||||
text_doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE_NAME, Document.path == TEXT_PATH
|
||||
)
|
||||
)
|
||||
img_doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == SOURCE_NAME, Document.path == IMG_PATH
|
||||
)
|
||||
)
|
||||
assert text_doc is not None, "the text doc must be indexed"
|
||||
assert img_doc is not None, "the PNG must become a document"
|
||||
assert img_doc.is_image is True and img_doc.image_path is not None
|
||||
# content == summary == the mock's description (the ONLY embedded
|
||||
# text of the image doc — the phase-122 pipeline contract).
|
||||
assert img_doc.content == IMAGE_DESCRIPTION_ANSWER
|
||||
assert img_doc.summary == IMAGE_DESCRIPTION_ANSWER
|
||||
assert text_doc.title == TEXT_TITLE # the first H1 (extract_title)
|
||||
assert img_doc.title == IMG_TITLE # the file stem (phase 122)
|
||||
yield {
|
||||
"image_url": f"/api/documents/{img_doc.id}/image",
|
||||
"description": IMAGE_DESCRIPTION_ANSWER,
|
||||
}
|
||||
_cleanup_saved_rows()
|
||||
_truncate_all()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db_ready: None) -> Iterator[None]:
|
||||
"""Per-test query_log isolation (the KB itself is module-scoped —
|
||||
the scripted turns never change it, so the registry and the docs
|
||||
persist across the tests of this module)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE query_log"))
|
||||
db.commit()
|
||||
yield
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE query_log"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers (the test_ls_tree_drilldown house pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
|
||||
#: (a response clone read in the background) — wire-level assertions
|
||||
#: for the ``done`` frame's mixed citation, independent of the UI.
|
||||
SSE_HOOK = """
|
||||
() => {
|
||||
if (window.__sseInstalled) return;
|
||||
window.__sseInstalled = true;
|
||||
window.__sseFrames = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await origFetch.apply(this, args);
|
||||
try {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
|
||||
if (url.includes('/api/chat')) {
|
||||
res.clone().text().then((bodyText) => {
|
||||
for (const block of bodyText.split('\\n\\n')) {
|
||||
const line = block.trim();
|
||||
if (line.startsWith('data: ')) {
|
||||
window.__sseFrames.push(line.slice(6));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { /* non-clonable responses: ignored */ }
|
||||
return res;
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _install_page_hooks(page: Page) -> None:
|
||||
page.evaluate(SSE_HOOK)
|
||||
|
||||
|
||||
def _frames(page: Page) -> list[dict[str, Any]]:
|
||||
"""The SSE frames captured since the last submit (``_submit``
|
||||
clears the buffer), once the hook's background read settles."""
|
||||
deadline = time.monotonic() + 30.0
|
||||
while True:
|
||||
raw = page.evaluate("() => window.__sseFrames || []")
|
||||
parsed = [json.loads(line) for line in raw if line]
|
||||
if any(f.get("type") == "done" for f in parsed):
|
||||
return parsed
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError(
|
||||
f"SSE hook captured no `done` frame (frames so far: "
|
||||
f"{len(parsed)}) — hook install failed?"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def _tool_frames(frames: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [f for f in frames if f.get("type") == "tool"]
|
||||
|
||||
|
||||
def _submit(page: Page, question: str) -> None:
|
||||
page.evaluate("window.__sseFrames = []")
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
# The user bubble lands synchronously with the submit handler.
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
|
||||
|
||||
def _wait_settled(page: Page) -> None:
|
||||
"""The turn is complete: answer text in the bubble, button recovered
|
||||
(the phase-48 settle wait, the test_agent_document_tools helper)."""
|
||||
expect(
|
||||
page.locator(".msg.brain .bubble").last
|
||||
).not_to_have_text("", timeout=SETTLE_TIMEOUT_MS)
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=SETTLE_TIMEOUT_MS)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
|
||||
def _wait_answer(page: Page) -> None:
|
||||
"""The scripted read's answer landed (the settle's answer-side half
|
||||
— used when the turn's wire is not asserted separately)."""
|
||||
expect(
|
||||
page.locator(".msg.brain .bubble").last
|
||||
).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
|
||||
_wait_settled(page)
|
||||
|
||||
|
||||
def _last_brain(page: Page) -> Locator:
|
||||
return page.locator(".msg.brain").last
|
||||
|
||||
|
||||
def _body_child_classes(page: Page) -> list[str]:
|
||||
"""The LAST brain bubble's ``.msg-body`` direct children's class
|
||||
names, in DOM order — the cluster-order pin (the phase-130 fix,
|
||||
asserted exactly: pre-phase markup — buttons inside the chips row,
|
||||
figures interleaved — fails this sequence)."""
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const body = [...document.querySelectorAll(".msg.brain")].pop()
|
||||
.querySelector(".msg-body");
|
||||
return [...body.children].map((el) => el.className);
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def _assert_cluster_rows(
|
||||
page: Page,
|
||||
seeded: dict[str, str],
|
||||
*,
|
||||
action_buttons: list[str],
|
||||
) -> None:
|
||||
"""The phase-130 cluster contract on the LAST brain bubble (the
|
||||
live and restore paths share it): the chips row holds EXACTLY the
|
||||
text doc's chip (NO chip for the image doc — the figure IS the
|
||||
citation, phase 130 task 01), the images row holds the one figure
|
||||
(the ``<img>`` loaded from the bytes route, alt + the visible
|
||||
caption settling to the document's summary), and the actions row
|
||||
— the bubble's LAST row — holds exactly the gated buttons
|
||||
(*action_buttons*; the row-level right-align is
|
||||
``_assert_actions_geometry``'s job)."""
|
||||
bubble = _last_brain(page)
|
||||
# The three rows exist exactly once each (the bare .msg-meta is the
|
||||
# chips row — the images/actions/related rows carry their own
|
||||
# classes).
|
||||
chips_row = bubble.locator(
|
||||
".msg-meta:not(.msg-images):not(.msg-actions):not(.related-docs)"
|
||||
)
|
||||
images_row = bubble.locator(".msg-meta.msg-images")
|
||||
actions_row = bubble.locator(".msg-meta.msg-actions")
|
||||
expect(chips_row).to_have_count(1)
|
||||
expect(images_row).to_have_count(1)
|
||||
expect(actions_row).to_have_count(1)
|
||||
# ARIA is kept (the house meta-row convention, unchanged).
|
||||
expect(chips_row).to_have_attribute("role", "list")
|
||||
expect(chips_row).to_have_attribute("aria-label", "Sources")
|
||||
expect(images_row).to_have_attribute("role", "list")
|
||||
expect(images_row).to_have_attribute("aria-label", "Source images")
|
||||
expect(actions_row).to_have_attribute("role", "list")
|
||||
expect(actions_row).to_have_attribute("aria-label", "Answer actions")
|
||||
|
||||
# The chips row: EXACTLY the text doc's chip — and the bubble
|
||||
# carries NO other chip anywhere (the image row has none — the
|
||||
# owner's exact ask, TODO L6).
|
||||
chips = chips_row.locator(".source-chip")
|
||||
expect(chips).to_have_count(1)
|
||||
expect(chips.first).to_have_text(TEXT_SP)
|
||||
expect(chips.first).to_have_attribute("href", TEXT_DOC_URL)
|
||||
expect(bubble.locator(".source-chip")).to_have_count(1)
|
||||
|
||||
# The images row: the one figure — the citation of the image doc.
|
||||
# Its href is the documentUrl (the phase-26 escape hatch), the
|
||||
# <img> loaded from the bytes route, alt + caption settling to the
|
||||
# document's summary (the fetchContentSummary pattern).
|
||||
fig = images_row.locator(".source-image")
|
||||
expect(fig).to_have_count(1)
|
||||
expect(fig).to_have_attribute("href", IMG_DOC_URL)
|
||||
img = fig.locator(".source-image-img")
|
||||
expect(img).to_be_visible(timeout=FIGURE_TIMEOUT_MS)
|
||||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||
expect(img).to_have_attribute("alt", seeded["description"], timeout=FIGURE_TIMEOUT_MS)
|
||||
expect(fig.locator(".source-image-caption")).to_have_text(
|
||||
seeded["description"], timeout=FIGURE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
# The actions row: exactly the gated buttons (the cluster — nothing
|
||||
# else joins it: the notes are absent on a completed turn), and
|
||||
# NOTHING after it — it is the bubble's LAST row (the "bottom" of
|
||||
# "bottom right").
|
||||
for cls in action_buttons:
|
||||
expect(actions_row.locator(f".{cls}")).to_have_count(1)
|
||||
expect(actions_row.locator("button")).to_have_count(len(action_buttons))
|
||||
expect(actions_row.locator("~ *")).to_have_count(0)
|
||||
|
||||
|
||||
def _assert_actions_geometry(page: Page) -> None:
|
||||
"""The TODO's "bottom right" geometry (bounding boxes, not pixels):
|
||||
the actions row is BELOW the images row, and the rightmost
|
||||
button's right edge sits at the row's right edge minus the row's
|
||||
0.25rem inline padding (≤ 5px — the per-button auto margins are
|
||||
gone: the row's ``justify-content: flex-end`` does the
|
||||
right-align, phase 130 task 03)."""
|
||||
bubble = _last_brain(page)
|
||||
a_box = bubble.locator(".msg-meta.msg-actions").bounding_box()
|
||||
i_box = bubble.locator(".msg-meta.msg-images").bounding_box()
|
||||
assert a_box and i_box, "the cluster rows must be laid out (visible)"
|
||||
assert a_box["y"] >= i_box["y"] + i_box["height"] - 1, (
|
||||
f"the actions row must be BELOW the images row: actions.y="
|
||||
f"{a_box['y']} vs images bottom {i_box['y'] + i_box['height']}"
|
||||
)
|
||||
btn_box = (
|
||||
bubble.locator(".msg-meta.msg-actions button").last.bounding_box()
|
||||
)
|
||||
assert btn_box, "the rightmost button must be laid out (visible)"
|
||||
gap = (a_box["x"] + a_box["width"]) - (btn_box["x"] + btn_box["width"])
|
||||
assert 0 <= gap <= 5, (
|
||||
f"the rightmost button must sit at the row's right edge (the "
|
||||
f"only slack is the row's 0.25rem padding): gap={gap}px"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The live turn: chips row → images row → actions row LAST (the
|
||||
# owner-visible fix, DOM-asserted), the wire cites both docs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_answer_clusters_render_in_order(
|
||||
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
|
||||
) -> None:
|
||||
"""The scripted mixed-citation turn (the mock's ``DRILL_TRIGGER``
|
||||
scripted read of the text doc; the image doc auto-cited — see the
|
||||
module docstring): the ``done`` frame cites BOTH docs, and the
|
||||
last brain bubble's ``.msg-body`` reads as organized clusters top
|
||||
to bottom — the answer bubble, the chips row (EXACTLY the text
|
||||
chip — NO chip for the image doc), the images row (the figure,
|
||||
loaded, caption settled to the canned description), and the
|
||||
actions row LAST with ALL THREE gated buttons (admin + configured
|
||||
docs repo) — right-aligned and below the images row (the "bottom
|
||||
right" geometry)."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
_install_page_hooks(page)
|
||||
|
||||
_submit(page, Q_ORDER)
|
||||
_wait_settled(page)
|
||||
|
||||
# The WIRE: exactly the scripted read (one tool frame), and the
|
||||
# done frame carries the mixed citation — the read text doc FIRST
|
||||
# (the phase-119 read order), then the auto-cited image ref:
|
||||
# the text ref's ``image_url`` key is ABSENT (the phase-122
|
||||
# omission rule), the image ref carries it. No related tier (the
|
||||
# 2-doc KB), no answer figure (no show_image call).
|
||||
frames = _frames(page)
|
||||
assert _tool_frames(frames) == [
|
||||
{"type": "tool", "name": "read", "argument": TEXT_SP}
|
||||
], _tool_frames(frames)
|
||||
done = next(f for f in frames if f.get("type") == "done")
|
||||
assert done["deflected"] is False, done
|
||||
assert done["sources"] == [
|
||||
{"source": SOURCE_NAME, "path": TEXT_PATH, "title": TEXT_TITLE},
|
||||
{
|
||||
"source": SOURCE_NAME,
|
||||
"path": IMG_PATH,
|
||||
"title": IMG_TITLE,
|
||||
"image_url": seeded["image_url"],
|
||||
},
|
||||
], done["sources"]
|
||||
assert done["related"] == []
|
||||
assert done["suggestions"] == []
|
||||
assert "answer_image" not in done
|
||||
|
||||
# The scripted read reached the model and landed in the answer
|
||||
# (the phase-37 citation shape: the prefix + the document's first
|
||||
# 80 chars — the H1 renders as a heading, the body quote stays in
|
||||
# its paragraph).
|
||||
bubble = _last_brain(page).locator(".bubble")
|
||||
expect(bubble).to_contain_text(READ_ANSWER_PREFIX)
|
||||
expect(bubble).to_contain_text(TEXT_TITLE)
|
||||
expect(bubble).to_contain_text(READ_BODY_QUOTE)
|
||||
|
||||
# The DOM ORDER (the fix): tool record, answer, chips row, images
|
||||
# row, actions row LAST — asserted exactly (this sequence fails
|
||||
# against the pre-phase soup div, where the buttons shared the
|
||||
# chips row and the figure interleaved with it).
|
||||
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, (
|
||||
_body_child_classes(page)
|
||||
)
|
||||
|
||||
# The clusters + the actions row's contents (all three buttons —
|
||||
# admin, docs repo configured) + nothing after the actions row.
|
||||
_assert_cluster_rows(
|
||||
page, seeded,
|
||||
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
|
||||
)
|
||||
_assert_actions_geometry(page)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. No chip for the image doc — the figure is its full citation
|
||||
# (documentUrl href + the phase-26 same-page modal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_image_without_chip_keeps_its_citation_via_figure(
|
||||
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
|
||||
) -> None:
|
||||
"""The owner's exact ask in one bubble: NO ``.source-chip`` whose
|
||||
label is the image's ``source/path`` (the ONLY chip is the text
|
||||
doc's), while the image doc is fully cited — the figure's ``href``
|
||||
is its ``documentUrl`` and a LEFT-CLICK opens the same-page modal
|
||||
(the phase-26 affordance, the modal shows the image + the
|
||||
description). The text chip keeps its own phase-26 affordance
|
||||
(left-click → the same modal) — the no-behavior-change half."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
_submit(page, Q_FIGURE)
|
||||
_wait_answer(page)
|
||||
|
||||
bubble = _last_brain(page)
|
||||
# The ONLY chip is the text doc's — there is NO chip for the image
|
||||
# doc (the figure is the citation — TODO L6).
|
||||
chips = bubble.locator(".source-chip")
|
||||
expect(chips).to_have_count(1)
|
||||
expect(chips.first).to_have_text(TEXT_SP)
|
||||
|
||||
# The figure is the image doc's full citation: the documentUrl
|
||||
# href, and a left-click opens the same-page modal (phase 26).
|
||||
fig = bubble.locator(".msg-meta.msg-images .source-image")
|
||||
expect(fig).to_have_count(1)
|
||||
expect(fig).to_have_attribute("href", IMG_DOC_URL)
|
||||
fig.click()
|
||||
expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
|
||||
expect(page.locator("#doc-modal-title")).to_have_text(IMG_TITLE)
|
||||
img = page.locator("#doc-modal .doc-image-img")
|
||||
expect(img).to_have_count(1)
|
||||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||
expect(page.locator("#doc-modal pre.doc-raw")).to_have_text(
|
||||
seeded["description"]
|
||||
)
|
||||
page.keyboard.press("Escape") # the phase-26 close affordance
|
||||
expect(page.locator("#doc-modal")).to_be_hidden()
|
||||
|
||||
# The text chip keeps its own phase-26 affordance (the no behavior
|
||||
# change half of the phase): left-click → the same modal, the
|
||||
# text doc's title + content.
|
||||
chips.first.click()
|
||||
expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
|
||||
expect(page.locator("#doc-modal-title")).to_have_text(TEXT_TITLE)
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator("#doc-modal")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reload: the phase-14 restore path renders the IDENTICAL clusters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_renders_the_same_clusters(
|
||||
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
|
||||
) -> None:
|
||||
"""The turn auto-saves (phase 66); a page RELOAD restores the
|
||||
conversation through the phase-14 restore path (the same
|
||||
appenders — ``renderStoredMessage`` + the after-the-loop
|
||||
``markLastRetryable``): the restored last brain bubble carries the
|
||||
IDENTICAL ``.msg-body`` child sequence (chips row text-only,
|
||||
images row, ``.msg-actions`` LAST with the three gated buttons)
|
||||
and the same "bottom right" geometry — the DOM order assertion
|
||||
pinned for the restored bubble too."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
_submit(page, Q_RESTORE)
|
||||
_wait_answer(page)
|
||||
_assert_cluster_rows(
|
||||
page, seeded,
|
||||
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
|
||||
)
|
||||
_assert_actions_geometry(page)
|
||||
live_children = _body_child_classes(page)
|
||||
|
||||
# The auto-save settled (the row exists with both messages) before
|
||||
# the reload (phase 55/66 — the silent upsert).
|
||||
with _admin_client(app_url) as client:
|
||||
_wait_saved_row(client, _auto_title(Q_RESTORE))
|
||||
|
||||
# RELOAD: the localStorage restore (the done handler's save point
|
||||
# ran synchronously, so the record is durable before we reload).
|
||||
page.reload()
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
restored = page.locator(".msg.brain .bubble").last
|
||||
expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
# The IDENTICAL structure (the restore renders through the same
|
||||
# appenders — the child sequence is the live one, exactly).
|
||||
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, (
|
||||
_body_child_classes(page)
|
||||
)
|
||||
_assert_cluster_rows(
|
||||
page, seeded,
|
||||
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
|
||||
)
|
||||
_assert_actions_geometry(page)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. A non-admin (token user) sees ONLY the Retry in the actions row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_admin_sees_retry_only_actions(
|
||||
browser: Browser,
|
||||
app_url: str,
|
||||
seeded: dict[str, str],
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
"""A FRESH non-admin context (a new browser context — no admin
|
||||
cookie, no cached token, no localStorage → a FRESH chat) signs in
|
||||
through the REAL in-app token gate (``login_with_token``, phase
|
||||
79) with an admin-issued token, and sends the same scripted turn:
|
||||
the cluster rows render identically (the render is not
|
||||
identity-gated — chips + images + the ARIA names), and the
|
||||
``.msg-actions`` row holds ONLY the ``.retry-btn`` (Tune admin-
|
||||
gated phase 16, Save-as-doc admin-gated phase 59 — the gates
|
||||
unchanged). The token is revoked at the end."""
|
||||
with _admin_client(app_url) as client:
|
||||
token_id, token = _create_token(client, "e2e-clusters-guest")
|
||||
try:
|
||||
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
anon = ctx.new_page()
|
||||
anon.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login_with_token(anon, app_url, token, next="/")
|
||||
expect(anon.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
_submit(anon, Q_NONADMIN)
|
||||
_wait_answer(anon)
|
||||
|
||||
bubble = _last_brain(anon)
|
||||
# The cluster rows are identical for the token user
|
||||
# (the actions row carries the retry-only cluster).
|
||||
_assert_cluster_rows(anon, seeded, action_buttons=["retry-btn"])
|
||||
# The gates, unchanged: no Tune (phase 16), no
|
||||
# Save-as-doc (phase 59) — the ONE button in the row
|
||||
# is the Retry.
|
||||
actions = bubble.locator(".msg-meta.msg-actions")
|
||||
expect(actions.locator(".tune-btn")).to_have_count(0)
|
||||
expect(actions.locator(".save-as-doc-btn")).to_have_count(0)
|
||||
expect(actions.locator("button")).to_have_count(1)
|
||||
expect(actions.locator(".retry-btn")).to_have_count(1)
|
||||
finally:
|
||||
ctx.close()
|
||||
finally:
|
||||
_revoke_token(client, token_id)
|
||||
@@ -413,12 +413,13 @@ def test_chat_sources_block_shows_the_inline_image(
|
||||
turn chips nothing): the mock's echo carries the agent ``read``
|
||||
result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line
|
||||
(the model saw the description, not raw bytes) — and the answer's
|
||||
sources block carries the citation chip (the READ doc clears the
|
||||
usefulness floor) AND the compact inline figure — the ``<img>``
|
||||
from the bytes route, the visible caption + ``alt`` settling to
|
||||
the document's summary (the figure fetches it from the content
|
||||
endpoint — the frame carries no summary), the "shown in the chat
|
||||
nicely" contract."""
|
||||
sources block carries the citation (the READ doc clears the
|
||||
usefulness floor) as the compact figure in the images row
|
||||
(phase 130 task 01: NO chip for an image doc — the figure IS the
|
||||
citation) — the ``<img>`` from the bytes route, the visible
|
||||
caption + ``alt`` settling to the document's summary (the figure
|
||||
fetches it from the content endpoint — the frame carries no
|
||||
summary), the "shown in the chat nicely" contract."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
@@ -433,15 +434,17 @@ def test_chat_sources_block_shows_the_inline_image(
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
# The citation chip (the text affordance stays — the figure is
|
||||
# additive, not a replacement).
|
||||
chip = page.locator(".msg.brain .source-chip")
|
||||
expect(chip).to_have_count(1)
|
||||
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||||
# Phase 130 (task 01, TODO L6): NO chip for the image doc — its
|
||||
# figure is the citation — and the figure sits in its own images
|
||||
# cluster (.msg-meta.msg-images), not interleaved with a chip.
|
||||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||||
row = page.locator(".msg.brain .msg-meta.msg-images")
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_have_attribute("aria-label", "Source images")
|
||||
|
||||
# The inline figure: image + caption, both settling to the summary
|
||||
# (the content fetch resolves the alt/caption after the title).
|
||||
fig = page.locator(".msg.brain .source-image")
|
||||
fig = row.locator(".source-image")
|
||||
expect(fig).to_have_count(1)
|
||||
img = fig.locator(".source-image-img")
|
||||
expect(img).to_be_visible(timeout=15_000)
|
||||
|
||||
@@ -541,10 +541,13 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image(
|
||||
PDF (phase 119 A1): the echo carries the read result VERBATIM —
|
||||
the document's FULL extracted text (LOCKED A4 — the page markers
|
||||
and both page texts reach the model and land in the answer), and
|
||||
the sources block carries the PDF's citation chip (the READ doc)
|
||||
PLUS the embedded image's AUTO-cited chip (phase 124 — the
|
||||
suggested-tier image doc) with its compact inline figure (the
|
||||
bytes route, caption/alt settling to the description)."""
|
||||
the sources block renders the mixed citation as phase-130
|
||||
clusters (task 01): the chips row carries the PDF's citation chip
|
||||
(the READ doc) and NO chip for the image doc (its figure IS the
|
||||
citation), while the embedded image's AUTO-cite (phase 124 — the
|
||||
suggested-tier image doc) sits in the images row as its compact
|
||||
figure (the bytes route, caption/alt settling to the
|
||||
description)."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
@@ -563,17 +566,21 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image(
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
# The citation chips: the READ PDF first (read order), then the
|
||||
# suggested image doc's auto-cite (phase 124).
|
||||
# Phase 130 (task 01) — the mixed citation: the chips row carries
|
||||
# the READ PDF ONLY (no chip for the image doc — the figure IS
|
||||
# the citation), and the image doc's auto-cite (phase 124) is the
|
||||
# figure in its own images row.
|
||||
chip = page.locator(".msg.brain .source-chip")
|
||||
expect(chip).to_have_count(2)
|
||||
expect(chip).to_have_count(1)
|
||||
expect(chip.nth(0)).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||||
expect(chip.nth(1)).to_have_text(f"{SOURCE_NAME}/{IMAGE_REL}")
|
||||
row = page.locator(".msg.brain .msg-meta.msg-images")
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_have_attribute("aria-label", "Source images")
|
||||
|
||||
# The inline figure on the image chip: image + caption, both
|
||||
# The inline figure in the images row: image + caption, both
|
||||
# settling to the description (the content fetch resolves the
|
||||
# summary after the title).
|
||||
fig = page.locator(".msg.brain .source-image")
|
||||
fig = row.locator(".source-image")
|
||||
expect(fig).to_have_count(1)
|
||||
img = fig.locator(".source-image-img")
|
||||
expect(img).to_be_visible(timeout=15_000)
|
||||
|
||||
@@ -429,7 +429,10 @@ def test_a11y_landmarks_and_labels(
|
||||
f"skip link must move focus to #main ({path})"
|
||||
)
|
||||
|
||||
# Every button has an accessible name; every img has alt text.
|
||||
# Every button has an accessible name; every img carries an alt
|
||||
# ATTRIBUTE (the WCAG contract — an EMPTY alt is the legitimate
|
||||
# decorative marker, e.g. the phase-123 attach-preview
|
||||
# thumbnail, pinned in tests/unit/test_chat_image_questions.py).
|
||||
unnamed = page.evaluate(
|
||||
"""() => [...document.querySelectorAll("button")]
|
||||
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim()))
|
||||
@@ -437,9 +440,10 @@ def test_a11y_landmarks_and_labels(
|
||||
)
|
||||
assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}"
|
||||
bad_imgs = page.evaluate(
|
||||
"() => [...document.querySelectorAll('img')].filter((i) => !i.alt).length"
|
||||
"""() => [...document.querySelectorAll('img')]
|
||||
.filter((i) => !i.hasAttribute('alt')).length"""
|
||||
)
|
||||
assert bad_imgs == 0, f"{bad_imgs} <img> without alt on {path}"
|
||||
assert bad_imgs == 0, f"{bad_imgs} <img> without an alt attribute on {path}"
|
||||
|
||||
# A real keyboard Tab walk: every focused element shows a visible
|
||||
# :focus-visible outline (solid, >= 2px). Start from the top of the
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster.
|
||||
|
||||
The three answer actions (Tune / Retry / Save-as-doc) and the
|
||||
answer-status notes (Stopped / Failed) stop joining whatever the first
|
||||
``.msg-meta`` row happens to be (usually the source chips row). They
|
||||
get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's
|
||||
LAST row, right-aligned by CSS (task 03) — so chips and images can
|
||||
never push the buttons around.
|
||||
|
||||
Pins (the house source-pin pattern — no browser):
|
||||
|
||||
* ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to
|
||||
the ``msg-actions`` class so it NEVER grabs the chips row (or any
|
||||
other meta row), ``role=list`` + ``aria-label="Answer actions"``,
|
||||
null without a ``.msg-body``;
|
||||
* all FIVE appenders/notes route through ``getActionsRow(wrap)`` —
|
||||
none of them reuses the first ``.msg-meta`` row anymore;
|
||||
* ``markLastRetryable`` re-appends the Retry button into the actions
|
||||
row and the phase-59 save-button re-append hack is GONE (the row's
|
||||
row-level right-align replaces the per-button auto margins);
|
||||
* the call-order audit (task 02 item 5): at every completed-bubble
|
||||
render site the order is answer figure → sources → related → the
|
||||
action appenders, so ``.msg-actions`` is the bubble's LAST row, and
|
||||
the done branch's insertion order inside the row is Tune → Retry →
|
||||
Save-as-doc.
|
||||
|
||||
The browser-level assertions (the right-aligned cluster, the row
|
||||
order in the rendered DOM, the restore parity) are E2E-gated by
|
||||
tests/e2e/test_chat_answer_clusters.py (task 04).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn_body(js: str, header: str) -> str:
|
||||
"""The full source of the function whose header is *header* —
|
||||
brace-matched from the first ``{`` OUTSIDE the parameter list
|
||||
(paren depth 0, the house convention), so destructured parameters
|
||||
are skipped (the pinned functions carry no string literal with a
|
||||
stray brace)."""
|
||||
start = js.index(header)
|
||||
i = start
|
||||
paren = 0
|
||||
while i < len(js):
|
||||
c = js[i]
|
||||
if c == "(":
|
||||
paren += 1
|
||||
elif c == ")":
|
||||
paren -= 1
|
||||
elif c == "{" and paren == 0:
|
||||
break
|
||||
i += 1
|
||||
depth = 0
|
||||
for j in range(i, len(js)):
|
||||
c = js[j]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : j + 1]
|
||||
raise AssertionError(f"unbalanced braces in {header!r}")
|
||||
|
||||
|
||||
def _done_branch(js: str) -> str:
|
||||
"""The SSE ``done`` branch — from the branch test to the next
|
||||
``else if`` (flat block, the slice is exact)."""
|
||||
start = js.index('ev.type === "done"')
|
||||
return js[start : js.index('ev.type === "tool_result"', start)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# getActionsRow — the shared get-or-create helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None:
|
||||
"""``getActionsRow(wrap)`` find-or-creates the bubble's
|
||||
``.msg-meta.msg-actions`` row: the query is scoped to the
|
||||
``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that
|
||||
would grab the chips row), the created row is ``role=list`` +
|
||||
``aria-label="Answer actions"`` (the ARIA convention of the other
|
||||
meta rows), and it lands in ``.msg-body``. A missing ``.msg-body``
|
||||
(the defensive pre-phase convention) returns null — the appenders
|
||||
no-op."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "function getActionsRow(wrap) {")
|
||||
assert 'body.querySelector(".msg-meta.msg-actions")' in body, (
|
||||
"the reuse query is scoped to the msg-actions class — the "
|
||||
"helper must NEVER grab the chips row (or any other meta row)"
|
||||
)
|
||||
assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body
|
||||
assert 'row.className = "msg-meta msg-actions";' in body
|
||||
assert 'row.setAttribute("role", "list");' in body, (
|
||||
"the row is an accessible list (the house meta-row convention)"
|
||||
)
|
||||
assert 'row.setAttribute("aria-label", "Answer actions");' in body, (
|
||||
"the row's accessible name (WCAG — the row is never unnamed)"
|
||||
)
|
||||
assert "body.appendChild(row)" in body, "a new row lands in .msg-body"
|
||||
assert 'wrap?.querySelector?.(".msg-body")' in body, (
|
||||
"defensive .msg-body lookup (the stopped-note convention)"
|
||||
)
|
||||
assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)"
|
||||
assert "return row" in body
|
||||
|
||||
|
||||
def test_get_actions_row_is_the_shared_helper() -> None:
|
||||
"""EXACTLY ONE getActionsRow (no per-appender copies of the
|
||||
get-or-create logic), and the created row's class string appears
|
||||
nowhere else in the file (the appenders only ever LOOK it up —
|
||||
they never build a second actions row)."""
|
||||
js = _js()
|
||||
assert js.count("function getActionsRow(wrap) {") == 1, (
|
||||
"exactly ONE getActionsRow — the shared helper"
|
||||
)
|
||||
assert js.count('row.className = "msg-meta msg-actions";') == 1, (
|
||||
"the row is created in exactly ONE place — the helper; the "
|
||||
"appenders never build a row themselves"
|
||||
)
|
||||
assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, (
|
||||
"the reuse query lives in exactly ONE place — the helper"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the five appenders/notes — all through getActionsRow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: (function header, the one-per-bubble guard's class, the child class)
|
||||
APPENDERS = [
|
||||
("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"),
|
||||
("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"),
|
||||
(
|
||||
"function appendSaveAsDocButton(wrap, markdown) {",
|
||||
".save-as-doc-btn",
|
||||
"save-as-doc-btn",
|
||||
),
|
||||
("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"),
|
||||
("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"),
|
||||
]
|
||||
|
||||
|
||||
def test_all_action_appenders_join_the_actions_row() -> None:
|
||||
"""All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped,
|
||||
Failed) route through ``getActionsRow(wrap)`` and no-op on its
|
||||
null (no ``.msg-body``) — none of them reuses the FIRST
|
||||
``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is
|
||||
gone from every appender)."""
|
||||
js = _js()
|
||||
for header, guard, child in APPENDERS:
|
||||
body = _fn_body(js, header)
|
||||
assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow"
|
||||
assert 'querySelector(".msg-meta")' not in body, (
|
||||
f"{header} must not query the bare first .msg-meta row"
|
||||
)
|
||||
assert "if (!meta) return" in body, (
|
||||
f"{header} must no-op when getActionsRow returns null"
|
||||
)
|
||||
assert f'querySelector("{guard}")' in body, (
|
||||
f"{header} keeps its one-per-bubble guard"
|
||||
)
|
||||
assert f'"{child}"' in body, f"{header} keeps its child class"
|
||||
|
||||
|
||||
def test_action_appenders_keep_the_listitem_aria() -> None:
|
||||
"""ARIA is kept (the existing convention): the role=list check
|
||||
stays on every appender and the child joins the row as a
|
||||
listitem (the row is ALWAYS role=list — getActionsRow sets it —
|
||||
so the check always applies)."""
|
||||
js = _js()
|
||||
for header, _, _ in APPENDERS:
|
||||
body = _fn_body(js, header)
|
||||
assert 'meta.getAttribute("role") === "list"' in body, (
|
||||
f"{header} keeps the role=list check"
|
||||
)
|
||||
assert 'setAttribute("role", "listitem")' in body, (
|
||||
f"{header} keeps the listitem join"
|
||||
)
|
||||
|
||||
|
||||
def test_tune_and_save_gates_are_unchanged() -> None:
|
||||
"""The gates are untouched (no behavior change in completed work):
|
||||
Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND
|
||||
docs-repo-gated (phase 59) — both BEFORE any row work. Retry is
|
||||
NOT admin-gated (owner-locked: every visitor)."""
|
||||
js = _js()
|
||||
tune = _fn_body(js, "function appendTuneButton(wrap) {")
|
||||
assert 'if (!isAdmin) return' in tune
|
||||
save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {")
|
||||
assert "if (!isAdmin || !docsRepoConfigured) return" in save
|
||||
retry = _fn_body(js, "function appendRetryButton(wrap) {")
|
||||
assert "isAdmin" not in retry, "Retry stays NOT admin-gated"
|
||||
|
||||
|
||||
def test_failed_note_detail_is_textcontent_only() -> None:
|
||||
"""The ``failed-detail`` contract survives (task 02 item 3): the
|
||||
error detail goes through ``textContent`` — no HTML from error
|
||||
strings, ever."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "function appendFailedNote(wrap, detail) {")
|
||||
assert 'd.className = "failed-detail"' in body
|
||||
assert "d.textContent = detail" in body, (
|
||||
"the error string is NEVER innerHTML"
|
||||
)
|
||||
# The icon constant is the ONLY innerHTML (the static aria-hidden
|
||||
# SVG) — the detail path never builds HTML (comments stripped —
|
||||
# the "NEVER innerHTML" remark is not code).
|
||||
code = "\n".join(
|
||||
line for line in body.splitlines() if "//" not in line
|
||||
)
|
||||
assert code.count("innerHTML") == 1, (
|
||||
"only the static FAILED_ICON innerHTML remains in the note"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# markLastRetryable — the rework (task 02 item 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None:
|
||||
"""``markLastRetryable`` keeps the phase-49 remove-then-append
|
||||
shape (remove every rendered .retry-btn FIRST, then re-append on
|
||||
the last brain bubble — only when its preceding user record
|
||||
exists), but the re-append now lands in the bubble's .msg-actions
|
||||
row (via appendRetryButton → getActionsRow). The phase-59
|
||||
save-button re-append hack (the ``saveDocBtn`` block) is DELETED:
|
||||
the row's row-level right-align (task 03) right-aligns the whole
|
||||
cluster, and the DOM order inside the row is the insertion order.
|
||||
The 'auto margins split the free space' rationale is gone with it."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "function markLastRetryable() {")
|
||||
assert 'querySelectorAll(".retry-btn")' in body
|
||||
assert ".remove()" in body
|
||||
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
|
||||
"the existing buttons are removed before the new one is appended"
|
||||
)
|
||||
assert "appendRetryButton(lastBrainWrap)" in body, (
|
||||
"the re-append targets the last brain wrap (via getActionsRow)"
|
||||
)
|
||||
assert 'who !== "user"' in body, "the preceding-user-record guard stays"
|
||||
assert "saveDocBtn" not in body, (
|
||||
"the phase-59 save-button re-append hack is deleted"
|
||||
)
|
||||
assert "parentElement" not in body, (
|
||||
"no DOM re-parenting of the save button anymore"
|
||||
)
|
||||
assert "auto margins" not in body, (
|
||||
"the 'auto margins split the free space' rationale is gone"
|
||||
)
|
||||
# The hack had no other home in the file.
|
||||
assert "saveDocBtn" not in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the call-order audit (task 02 item 5) — .msg-actions is the LAST row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_done_branch_appends_actions_last_in_insertion_order() -> None:
|
||||
"""The live ``done`` branch (a completed brain bubble): the row
|
||||
order is the DOM order — the answer figure, then the source
|
||||
clusters (appendSources), then the related row, then the action
|
||||
appenders — so the .msg-actions row is the bubble's LAST row.
|
||||
Inside the row the insertion order is Tune → Retry (the
|
||||
markLastRetryable re-append) → Save-as-doc."""
|
||||
js = _js()
|
||||
branch = _done_branch(js)
|
||||
for marker in (
|
||||
"appendSources(wrap, ev.sources);",
|
||||
"appendRelated(wrap, ev.related);",
|
||||
"appendTuneButton(wrap);",
|
||||
"markLastRetryable();",
|
||||
'appendSaveAsDocButton(wrap, finalText || acc || "…");',
|
||||
):
|
||||
assert marker in branch, f"the done branch must keep {marker!r}"
|
||||
order = [
|
||||
branch.index("appendSources(wrap, ev.sources);"),
|
||||
branch.index("appendRelated(wrap, ev.related);"),
|
||||
branch.index("appendTuneButton(wrap);"),
|
||||
branch.index("markLastRetryable();"),
|
||||
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
|
||||
]
|
||||
assert order == sorted(order), (
|
||||
"done branch: sources → related → Tune → Retry → Save-as-doc "
|
||||
"(the .msg-actions row is the bubble's LAST row — phase 130 "
|
||||
"task 02; the row's inner order is the insertion order)"
|
||||
)
|
||||
# lastBrainWrap is set before the Retry re-append reads it.
|
||||
assert branch.index("lastBrainWrap = wrap") < branch.index(
|
||||
"markLastRetryable();"
|
||||
)
|
||||
|
||||
|
||||
def test_zero_frame_fallback_appends_actions_last() -> None:
|
||||
"""The zero-frame-but-completed fallback bubble (the stream
|
||||
settled with no events): a failed turn — Tune + the Failed note +
|
||||
Retry (markLastRetryable) + Save-as-doc, all in the bubble's
|
||||
.msg-actions row (the only row it has), the note BEFORE the Retry
|
||||
re-append (the 'note + Retry' reading), the save button LAST in
|
||||
the row (the phase-59 parity with the done path)."""
|
||||
js = _js()
|
||||
zero = js.index("if (!aborted && !wrap) {")
|
||||
block = js[zero : js.index("} catch (err) {", zero)]
|
||||
for marker in (
|
||||
"appendTuneButton(fwrap);",
|
||||
"appendFailedNote(fwrap, nothing);",
|
||||
"markLastRetryable();",
|
||||
"appendSaveAsDocButton(fwrap, fallback);",
|
||||
):
|
||||
assert marker in block, f"the fallback block must keep {marker!r}"
|
||||
order = [
|
||||
block.index("appendTuneButton(fwrap);"),
|
||||
block.index("appendFailedNote(fwrap, nothing);"),
|
||||
block.index("markLastRetryable();"),
|
||||
block.index("appendSaveAsDocButton(fwrap, fallback);"),
|
||||
]
|
||||
assert order == sorted(order), (
|
||||
"fallback: Tune → Failed note → Retry → Save-as-doc — the "
|
||||
"actions row holds the whole cluster (phase 130 task 02)"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_finalize_appends_actions_last() -> None:
|
||||
"""The stop finalize (a stopped partial — the prime retry
|
||||
candidate): Tune + the Stopped note + Retry (markLastRetryable),
|
||||
all in the actions row — the stop path renders no sources or
|
||||
related rows, so the actions row IS the bubble's only (and last)
|
||||
row. The order is Tune → note → Retry (the 'note + Retry'
|
||||
reading), unchanged from pre-phase (no save button — a stopped
|
||||
partial is a note, not an answer)."""
|
||||
js = _js()
|
||||
catch = js.index("} catch (err) {")
|
||||
stop = js.index('stoppedByUser || err?.name === "AbortError"', catch)
|
||||
branch = js[stop : js.index("} else {", stop)]
|
||||
for marker in (
|
||||
"appendTuneButton(wrap);",
|
||||
"appendStoppedNote(wrap);",
|
||||
"markLastRetryable();",
|
||||
):
|
||||
assert marker in branch, f"the stop branch must keep {marker!r}"
|
||||
order = [
|
||||
branch.index("appendTuneButton(wrap);"),
|
||||
branch.index("appendStoppedNote(wrap);"),
|
||||
branch.index("markLastRetryable();"),
|
||||
]
|
||||
assert order == sorted(order), (
|
||||
"stop: Tune → Stopped note → Retry (phase 130 task 02 — the "
|
||||
"note + Retry in the actions row)"
|
||||
)
|
||||
assert "appendSaveAsDocButton" not in branch, (
|
||||
"the stopped partial (note, not answer) still gets no save button"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
|
||||
note is appended and the funnel ENDS with markLastRetryable — the
|
||||
note + Retry in the failed bubble's actions row (the partial
|
||||
keeps its streamed text; the zero-frame shape gets a plain
|
||||
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
|
||||
js = _js()
|
||||
header = (
|
||||
"function finalizeFailedTurn(detail, "
|
||||
"{ acc, thinking, tools, wrap, leavePartialIndex }) {"
|
||||
)
|
||||
body = _fn_body(js, header)
|
||||
assert body.count("appendFailedNote(") == 2, (
|
||||
"both funnel shapes add the in-bubble error note"
|
||||
)
|
||||
lines = [line for line in body.splitlines() if line.strip()]
|
||||
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
|
||||
"markLastRetryable();"
|
||||
), (
|
||||
"the funnel still ends with the markLastRetryable() call — the "
|
||||
"Retry button lands on the failed bubble's actions row"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_path_appends_actions_last() -> None:
|
||||
"""The phase-14 restore path (renderStoredMessage): the answer
|
||||
figure → sources → related → the action appenders (Tune /
|
||||
Save-as-doc / the Stopped + Failed notes) — so the .msg-actions
|
||||
row a restored bubble gets is its LAST row too, identical to the
|
||||
live done path. The restore loop's after-the-loop
|
||||
markLastRetryable() then lands the Retry button in that same row
|
||||
(on the LAST restored brain bubble)."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "function renderStoredMessage(m) {")
|
||||
for marker in (
|
||||
"appendSources(wrap, m.sources);",
|
||||
"appendRelated(wrap, m.related);",
|
||||
"appendTuneButton(wrap)",
|
||||
"appendSaveAsDocButton(wrap, m.text);",
|
||||
"appendStoppedNote(wrap);",
|
||||
"appendFailedNote(wrap, m.error);",
|
||||
"lastBrainWrap = wrap",
|
||||
):
|
||||
assert marker in body, f"the restore path must keep {marker!r}"
|
||||
order = [
|
||||
body.index("appendSources(wrap, m.sources);"),
|
||||
body.index("appendRelated(wrap, m.related);"),
|
||||
body.index("appendTuneButton(wrap)"),
|
||||
body.index("appendSaveAsDocButton(wrap, m.text);"),
|
||||
body.index("lastBrainWrap = wrap"),
|
||||
]
|
||||
assert order == sorted(order), (
|
||||
"restore: sources → related → the action appenders → "
|
||||
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
|
||||
"Retry in the same actions row — phase 130 task 02)"
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Unit: the phase-130 (task 03, TODO L6) chat cluster rows — CSS.
|
||||
|
||||
The bubble's metadata reads as organized rows, top to bottom: the
|
||||
answer text → the answer figure (phase 124, unchanged) → the source
|
||||
chips row (text refs only) → the source images row (`.msg-images`,
|
||||
phase 130 task 01) → the "Nearby docs" row (phase 113, unchanged) →
|
||||
the actions row (`.msg-actions`, phase 130 task 02) — never one soup
|
||||
div. The DOM is built by app.js (pinned by test_frontend_actions_cluster
|
||||
and test_source_chip_quality); THIS module pins the row-level layout
|
||||
that makes the clusters read as clusters:
|
||||
|
||||
* the actions row right-aligns its buttons as a cluster — the "bottom
|
||||
right" is the row's ``justify-content: flex-end``, and the
|
||||
per-button auto margins (the phase-49/59 ``.tune-btn`` /
|
||||
``.retry-btn`` / ``.save-as-doc-btn`` margin mechanisms) are GONE;
|
||||
* the images row wraps its figures with the chips row's rhythm (the
|
||||
base ``.msg-meta`` wrap + 0.35rem gap + center align — pinned
|
||||
explicitly so the two clusters cannot drift apart);
|
||||
* the audit (task 03 item 3): the chips row and the related row are
|
||||
untouched, the ``.source-image`` figure styles are untouched, and
|
||||
the mobile squeeze pins nothing about the actions row left — the
|
||||
cluster stays right-aligned at every width.
|
||||
|
||||
House source-pin pattern (no browser); the browser-level bounding-box
|
||||
assertions are E2E-gated by tests/e2e/test_chat_answer_clusters.py
|
||||
(task 04).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
"""The first ``<selector> { … }`` rule body — the house pin
|
||||
pattern (the cluster selectors are unique in the sheet)."""
|
||||
m = re.search(re.escape(selector) + r" \{([^}]*)\}", css)
|
||||
assert m, f"styles.css must style {selector}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _media_block(css: str, query: str) -> str:
|
||||
"""The body of the ``@media (<query>)`` block — the house non-
|
||||
greedy pattern (the sheet indents the block's nested closing
|
||||
braces, so the FIRST column-0 ``}`` is the block's own end)."""
|
||||
pattern = "@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}"
|
||||
m = re.search(pattern, css)
|
||||
assert m, f"{query} media query missing"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the actions cluster — right-aligned by the ROW, no per-button margins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_actions_row_rule_right_aligns_the_cluster() -> None:
|
||||
"""``.msg-meta.msg-actions`` right-aligns its contents — the TODO's
|
||||
"bottom right" is the bubble's LAST row (the DOM order, phase 130
|
||||
task 02) + this row-level justify. No positioning tricks: the row
|
||||
keeps the base .msg-meta flex/wrap/rhythm, only the justify moves."""
|
||||
css = _css()
|
||||
assert re.search(r"\.msg-meta\.msg-actions \{", css), (
|
||||
"the .msg-meta.msg-actions row rule is missing"
|
||||
)
|
||||
body = _block(css, ".msg-meta.msg-actions")
|
||||
assert "justify-content: flex-end" in body, (
|
||||
"the row's justify does the 'bottom right' (right edge)"
|
||||
)
|
||||
# Row-level only — no absolute/float/transform tricks.
|
||||
for prop in ("position", "float", "transform", "margin", "padding"):
|
||||
assert prop not in body, (
|
||||
f"the actions row is a plain flex row ({prop} is a "
|
||||
"positioning trick — the row order is DOM order)"
|
||||
)
|
||||
|
||||
|
||||
def test_no_per_button_auto_margins_remain() -> None:
|
||||
"""The per-button auto margins (the pre-phase-130 mechanism the
|
||||
markLastRetryable comment named) are gone from ALL THREE action
|
||||
buttons — grep-confirmed: the buttons are plain flex items of the
|
||||
actions row, and the row's justify does the alignment. (Completion
|
||||
criterion: 'right-aligned by .msg-actions alone'.)"""
|
||||
css = _css()
|
||||
for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"):
|
||||
body = _block(css, selector)
|
||||
assert "margin-left: auto" not in body, (
|
||||
f"{selector}: the per-button auto margin must be gone — "
|
||||
"the .msg-actions row's justify right-aligns the cluster"
|
||||
)
|
||||
assert "margin-inline-start: auto" not in body, (
|
||||
f"{selector}: the per-button auto margin must be gone"
|
||||
)
|
||||
assert "margin-right: auto" not in body and "margin-inline-end: auto" not in body
|
||||
|
||||
|
||||
def test_button_family_is_untouched() -> None:
|
||||
"""Removing the auto margins is the ONLY change to the button
|
||||
rules — the phase-49/59 visual family (pill, ≥44px, line border,
|
||||
ink-soft palette, brand/neutral hovers, 14px glyph) is intact."""
|
||||
css = _css()
|
||||
for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"):
|
||||
body = _block(css, selector)
|
||||
for prop in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"padding: 0.35rem 0.8rem",
|
||||
"border-radius: 999px",
|
||||
"border: 1px solid var(--line)",
|
||||
"color: var(--ink-soft)",
|
||||
"font-size: 0.82rem",
|
||||
"cursor: pointer",
|
||||
):
|
||||
assert prop in body, f"{selector} must keep the family ({prop})"
|
||||
assert f"{selector} svg {{ width: 14px; height: 14px" in css
|
||||
assert f"{selector}:hover" in css
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the images cluster — wraps like the chips row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_images_row_rule_wraps_like_the_chips_row() -> None:
|
||||
"""``.msg-meta.msg-images`` pins the chips row's rhythm EXPLICITLY
|
||||
(wrap + the base .msg-meta 0.35rem gap + center align) — no new
|
||||
spacing values (task 03: 'match the EXISTING .msg-meta gap/align
|
||||
values'). A future base-rule change cannot drift the two clusters
|
||||
apart."""
|
||||
css = _css()
|
||||
assert re.search(r"\.msg-meta\.msg-images \{", css), (
|
||||
"the .msg-meta.msg-images row rule is missing"
|
||||
)
|
||||
body = _block(css, ".msg-meta.msg-images")
|
||||
assert "flex-wrap: wrap" in body, "the figures wrap (many image refs)"
|
||||
assert "gap: 0.35rem" in body, (
|
||||
"the images row's gap MUST equal the chips row's gap "
|
||||
"(the base .msg-meta 0.35rem — no invented spacing)"
|
||||
)
|
||||
assert "align-items: center" in body, "the row aligns like the chips row"
|
||||
|
||||
|
||||
def test_base_msg_meta_rhythm_is_unchanged() -> None:
|
||||
"""The base ``.msg-meta`` rule (the chips row, the related row, and
|
||||
every meta row's rhythm) is untouched: flex + wrap + 0.35rem gap +
|
||||
center align + the soft-ink 0.75rem text."""
|
||||
css = _css()
|
||||
body = _block(css, ".msg-meta")
|
||||
for prop in (
|
||||
"font-size: 0.75rem",
|
||||
"color: var(--ink-soft)",
|
||||
"display: flex",
|
||||
"flex-wrap: wrap",
|
||||
"gap: 0.35rem",
|
||||
"align-items: center",
|
||||
):
|
||||
assert prop in body, f"the base .msg-meta rhythm must keep {prop}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the audit (task 03 item 3) — neighbors untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chips_row_is_untouched() -> None:
|
||||
"""The chips row's look is the completed phase-26/113 surface —
|
||||
mono pill, brand-soft fill, line border, nowrap ellipsis (the
|
||||
min-width: 0 escape). Phase 130 only removed the buttons from this
|
||||
row's neighbors, never the chip's styling."""
|
||||
css = _css()
|
||||
body = _block(css, ".source-chip")
|
||||
for prop in (
|
||||
"font-family: var(--mono)",
|
||||
"background: var(--brand-soft)",
|
||||
"border: 1px solid var(--line)",
|
||||
"border-radius: 999px",
|
||||
"white-space: nowrap",
|
||||
"min-width: 0",
|
||||
"text-overflow: ellipsis",
|
||||
):
|
||||
assert prop in body, f".source-chip must keep {prop}"
|
||||
assert ".source-chip:hover" in css
|
||||
|
||||
|
||||
def test_related_row_is_untouched() -> None:
|
||||
"""The phase-113 "Nearby docs" row keeps its de-emphasized look
|
||||
(dashed border, transparent fill, muted mono links — never a
|
||||
citation) and still joins the .msg-meta family."""
|
||||
css = _css()
|
||||
body = _block(css, ".related-doc")
|
||||
for prop in (
|
||||
"border: 1px dashed var(--line)",
|
||||
"background: transparent",
|
||||
"font-family: var(--mono)",
|
||||
"white-space: nowrap",
|
||||
):
|
||||
assert prop in body, f".related-doc must keep {prop}"
|
||||
assert ".related-docs-label" in css
|
||||
assert "Nearby docs" not in css # the label text lives in app.js, not CSS
|
||||
|
||||
|
||||
def test_source_image_figure_is_untouched() -> None:
|
||||
"""The ``.source-image`` figure (phase 122) was already sized for a
|
||||
row context — the 96px contain img on the theme's surface, the
|
||||
clamped muted caption. Phase 130 moves it to its OWN row, not to a
|
||||
new scale."""
|
||||
css = _css()
|
||||
assert "max-height: 96px" in _block(css, ".source-image-img"), (
|
||||
"the compact source figure keeps its 96px row-scale cap"
|
||||
)
|
||||
assert "object-fit: contain" in _block(css, ".source-image-img")
|
||||
assert "-webkit-line-clamp: 2" in _block(css, ".source-image-caption")
|
||||
# The answer figure (phase 124) is a DIFFERENT scale — untouched.
|
||||
assert "max-height: 320px" in _block(css, ".answer-image-img")
|
||||
|
||||
|
||||
def test_mobile_pins_the_actions_row_left_nowhere() -> None:
|
||||
"""The mobile squeeze (≤640px) may keep the ≥44px touch floor but
|
||||
must NOT pin the actions row left or restyle it: the cluster stays
|
||||
right-aligned at every width (the row's justify is width-agnostic,
|
||||
the .msg-body flex gap stacks the rows)."""
|
||||
css = _css()
|
||||
mobile = _media_block(css, "max-width: 640px")
|
||||
for selector in (".tune-btn", ".retry-btn", ".msg-actions", ".msg-images"):
|
||||
for m in re.finditer(re.escape(selector) + r" \{([^}]*)\}", mobile):
|
||||
body = m.group(1)
|
||||
assert "justify-content" not in body, (
|
||||
f"{selector}: the mobile block must not re-justify the "
|
||||
"cluster (the actions row stays right-aligned)"
|
||||
)
|
||||
assert "margin-left" not in body and "margin-inline" not in body, (
|
||||
f"{selector}: the mobile block must not re-margin the "
|
||||
"cluster (no left pin, no margin reset)"
|
||||
)
|
||||
assert "position" not in body
|
||||
# The mobile squeeze's only tune/retry rules are the 44px floors
|
||||
# (pre-existing, untouched by this phase).
|
||||
assert ".tune-btn { min-height: 44px; }" in mobile
|
||||
assert ".retry-btn { min-height: 44px; }" in mobile
|
||||
@@ -84,12 +84,16 @@ def _fn_body(js: str, name: str) -> str:
|
||||
|
||||
def test_append_failed_note_exists_and_guards_dedup() -> None:
|
||||
"""``appendFailedNote(wrap, detail)`` exists and mirrors
|
||||
``appendStoppedNote``: it reuses the ``.msg-meta`` row and adds at
|
||||
``appendStoppedNote``: it joins the bubble's ``.msg-actions`` row
|
||||
(getActionsRow — phase 130 task 02, ASSUMPTION A7: the notes are
|
||||
answer-status markers, the actions row is their home) and adds at
|
||||
most ONE ``.failed-note`` per bubble (the duplicate guard)."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "appendFailedNote")
|
||||
assert '.msg-body' in body, "the note must land in the bubble's .msg-body"
|
||||
assert ".msg-meta" in body, "the note rides the existing .msg-meta row"
|
||||
assert "getActionsRow(wrap)" in body, (
|
||||
"the note joins the dedicated .msg-actions row (getActionsRow — "
|
||||
"phase 130 task 02), never the first .msg-meta row"
|
||||
)
|
||||
assert '.failed-note' in body
|
||||
assert (
|
||||
'if (meta.querySelector(".failed-note")) return;' in body
|
||||
|
||||
@@ -313,18 +313,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
|
||||
|
||||
def test_stopped_note_helper_and_restore_path() -> None:
|
||||
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
|
||||
appendTuneButton (role=list → the span joins as a listitem), one
|
||||
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
|
||||
"Stopped" text (the accessible meaning). The restore path renders it
|
||||
for records with `m.stopped` (phase-14 optional-field convention —
|
||||
no version bump)."""
|
||||
"""appendStoppedNote: joins the bubble's ACTIONS row (getActionsRow
|
||||
— phase 130 task 02, ASSUMPTION A7: the notes are answer-status
|
||||
markers, the actions row is their home; the role=list row joins the
|
||||
span as a listitem so ARIA stays valid), one .stopped-note per
|
||||
bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the
|
||||
accessible meaning). The restore path renders it for records with
|
||||
`m.stopped` (phase-14 optional-field convention — no version
|
||||
bump)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendStoppedNote")
|
||||
assert fn != -1, "appendStoppedNote must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert "getActionsRow(wrap)" in body, (
|
||||
"the note joins the dedicated .msg-actions row (getActionsRow — "
|
||||
"phase 130 task 02), never the first .msg-meta row"
|
||||
)
|
||||
assert 'className = "stopped-note"' in body
|
||||
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
|
||||
assert 'note.setAttribute("role", "listitem")' in body
|
||||
@@ -443,19 +447,22 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||
|
||||
|
||||
def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
|
||||
"""appendRetryButton: the house appendTuneButton pattern — reuses the
|
||||
.msg-meta row when it exists (role=list → the button joins as a
|
||||
listitem so ARIA stays valid), creates it otherwise, one .retry-btn
|
||||
per bubble, the aria-hidden redo glyph + the "Retry" text (the
|
||||
accessible name). NOT admin-gated — unlike appendTuneButton, chat is
|
||||
public and every visitor gets the redo (owner-locked)."""
|
||||
"""appendRetryButton: the house pattern through getActionsRow
|
||||
(phase 130 task 02 — the button joins the bubble's dedicated
|
||||
.msg-actions row; the role=list row joins it as a listitem so ARIA
|
||||
stays valid), one .retry-btn per bubble, the aria-hidden redo glyph
|
||||
+ the "Retry" text (the accessible name). NOT admin-gated — unlike
|
||||
appendTuneButton, chat is public and every visitor gets the redo
|
||||
(owner-locked)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendRetryButton")
|
||||
assert fn != -1, "appendRetryButton must exist"
|
||||
body = js[fn: js.find("\n}\n", fn)]
|
||||
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)"
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert "getActionsRow(wrap)" in body, (
|
||||
"the button joins the dedicated .msg-actions row (getActionsRow — "
|
||||
"phase 130 task 02), never the first .msg-meta row"
|
||||
)
|
||||
assert 'className = "retry-btn"' in body
|
||||
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
|
||||
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem")
|
||||
@@ -584,12 +591,14 @@ def test_last_brain_wrap_tracking_and_restore() -> None:
|
||||
|
||||
def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn
|
||||
visual family (same size/spacing/min-height, right-aligned after
|
||||
the source chips, 14px glyph) so the two meta actions read as a
|
||||
pair — with the neutral ink-soft → ink hover (Tune keeps the brand
|
||||
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on
|
||||
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px
|
||||
floor."""
|
||||
visual family (same size/spacing/min-height, 14px glyph) so the
|
||||
meta actions read as a pair — with the neutral ink-soft → ink
|
||||
hover (Tune keeps the brand pair). The right-align moved to the
|
||||
ROW in phase 130 (the .msg-meta.msg-actions justify — the button
|
||||
is a plain flex item with NO per-button auto margin; the
|
||||
cluster-level pin lives in test_frontend_cluster_css). Contrast:
|
||||
ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
|
||||
~15.7:1 — all AA. The mobile squeeze keeps the >=44px floor."""
|
||||
css = _css()
|
||||
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .retry-btn"
|
||||
@@ -597,7 +606,6 @@ def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
for prop in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"margin-left: auto",
|
||||
"padding: 0.35rem 0.8rem",
|
||||
"border-radius: 999px",
|
||||
"border: 1px solid var(--line)",
|
||||
@@ -606,6 +614,10 @@ def test_retry_btn_css_is_the_tune_family() -> None:
|
||||
"cursor: pointer",
|
||||
):
|
||||
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})"
|
||||
assert "margin-left: auto" not in body and "margin-inline-start: auto" not in body, (
|
||||
"phase 130: the per-button auto margin is gone — the row's "
|
||||
".msg-actions justify right-aligns the cluster"
|
||||
)
|
||||
assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
|
||||
"the redo glyph rides the 14px meta-row size"
|
||||
)
|
||||
|
||||
@@ -51,12 +51,19 @@ image doc prefacing the description with the pinned
|
||||
``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the
|
||||
cap-truncation case marker-before-cut, a text doc's result
|
||||
byte-identical); and the house-style frontend source contracts — the
|
||||
chat sources block's inline image figure (both the citation chips and
|
||||
the related row, gated on ``s.image_url``) reads ``image_url``, sets
|
||||
chat sources block's inline image figure reads ``image_url``, sets
|
||||
``alt`` + the visible caption to the document's summary (fetched from
|
||||
the content endpoint the chip's modal already uses — the frame
|
||||
carries no summary), and collapses to the plain chip on an image
|
||||
load error (never a broken-image icon). The SSE-level byte pin sits in
|
||||
carries no summary), and becomes the IN-PLACE CHIP FALLBACK on an
|
||||
image load error (phase 130 task 01: the figure is replaced, in
|
||||
place, with a plain chip for the same document — never a broken-image
|
||||
icon; the pre-phase-130 rule collapsed by removing the figure, the
|
||||
chip staying). Phase 130 (task 01) also re-partitions the citation
|
||||
block: the chips row holds the NON-image refs only (no chip for an
|
||||
image doc — its figure IS the citation) and a second
|
||||
``.msg-meta.msg-images`` row holds the figures in ref order (the
|
||||
related row keeps its own interleave, unchanged — ASSUMPTION A4).
|
||||
The SSE-level byte pin sits in
|
||||
``tests/integration/test_chat_api.py``; the E2E rendering scenario is
|
||||
task 06's isolated story suite.
|
||||
"""
|
||||
@@ -1342,16 +1349,23 @@ def test_read_of_image_doc_over_the_cap_truncates_after_the_marker(db) -> None:
|
||||
# --- the chat sources block: the inline image (house-style frontend)
|
||||
|
||||
|
||||
def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -> None:
|
||||
def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place() -> None:
|
||||
"""House-style source contract (app.js appendSourceImageFigure —
|
||||
task 05's "shown in the chat nicely" renderer): the figure's
|
||||
``<img>`` comes from the ref's ``image_url``; ``alt`` is set
|
||||
(title first, then the SUMMARY once the content fetch settles —
|
||||
the frame carries no summary, image_url is its only new field)
|
||||
and so is the VISIBLE caption; a FAILED image load removes the
|
||||
whole figure — it collapses to the plain chip, never a
|
||||
broken-image icon. Properties only (src/alt/textContent) — the
|
||||
figure builds no innerHTML from document-derived data."""
|
||||
and so is the VISIBLE caption. Phase 130 (task 01, ASSUMPTION A3)
|
||||
REPLACES the old collapse-to-chip rule (``fig.remove()`` — the
|
||||
chip stayed) with the IN-PLACE CHIP FALLBACK: a FAILED image load
|
||||
replaces the figure, in place (``fig.replaceWith(chip)``), with a
|
||||
plain ``.source-chip`` anchor for the same document (the same
|
||||
``documentUrl`` href, the same left-click → modal, the label as
|
||||
text) — there is no chip for image docs anymore, so the figure
|
||||
itself becomes the citation; never a broken-image icon. The fetch
|
||||
summary swap is untouched (its ``!fig.isConnected`` guard makes it
|
||||
a no-op after the swap). Properties only (src/alt/textContent) —
|
||||
the figure builds no innerHTML from document-derived data."""
|
||||
js = APP_JS.read_text(encoding="utf-8")
|
||||
body = _function_body(js, "function appendSourceImageFigure(meta, s) {")
|
||||
assert "img.src = s.image_url" in body, "the <img> must come from s.image_url"
|
||||
@@ -1360,10 +1374,34 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
|
||||
assert "caption.textContent = s.title || label" in body # visible caption: title
|
||||
assert "caption.textContent = summary" in body # …then the summary
|
||||
assert 'img.addEventListener("error"' in body, "the error handler must exist"
|
||||
assert "fig.remove();" in body, "a failed load collapses to the plain chip"
|
||||
assert "fig.remove()" not in body, (
|
||||
"the phase-122 collapse rule is RETIRED — the figure is never "
|
||||
"simply removed (there is no chip left behind to stay)"
|
||||
)
|
||||
# Phase 130 (task 01): the in-place chip fallback — the figure is
|
||||
# replaced with a plain .source-chip anchor for the same document.
|
||||
assert "fig.replaceWith(chip);" in body, (
|
||||
"a failed load replaces the figure IN PLACE with its chip"
|
||||
)
|
||||
assert 'chip.className = "source-chip";' in body, (
|
||||
"the fallback is a PLAIN citation chip"
|
||||
)
|
||||
assert 'chip.setAttribute("role", "listitem");' in body # ARIA stays valid
|
||||
assert 'chip.href = documentUrl(s.source, s.path, "/");' in body, (
|
||||
"the fallback chip keeps the chip's /document.html href"
|
||||
)
|
||||
assert "openDocumentModal(s.source, s.path, chip);" in body, (
|
||||
"the fallback chip keeps the left-click → same-page modal"
|
||||
)
|
||||
assert "e.preventDefault();" in body # no new tab (phase 26)
|
||||
assert "chip.textContent = label;" in body, ("the label is the chip's text")
|
||||
assert "chip.title = label;" in body # full path as the native tooltip
|
||||
assert "fetchContentSummary(s.source, s.path)" in body, (
|
||||
"the frame carries no summary — the figure fetches it"
|
||||
)
|
||||
assert "if (!fig.isConnected) return;" in body, (
|
||||
"the summary swap is a no-op once the figure was replaced"
|
||||
)
|
||||
code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S))
|
||||
assert "innerHTML" not in code, "no innerHTML from document-derived data"
|
||||
# The figure reuses the chip's navigation (phase 26 contract):
|
||||
@@ -1371,20 +1409,55 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
|
||||
assert "openDocumentModal(s.source, s.path, fig);" in body
|
||||
|
||||
|
||||
def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None:
|
||||
"""Both chat source surfaces (the citation chips AND the related
|
||||
row — the frame's two ref tiers, built by the same server helper)
|
||||
render the figure ONLY when the ref carries ``image_url``: text
|
||||
refs (and every pre-phase saved ref) add zero new DOM — the
|
||||
renderer stays byte-identical for them. The chip's text +
|
||||
affordance stay (the figure is additive, not a replacement), and
|
||||
the summary fetch targets the SAME content endpoint the chip's
|
||||
modal already uses (failing soft to null — the title stands)."""
|
||||
def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
|
||||
"""Phase 130 (task 01, TODO L6): ``appendSources`` stops
|
||||
interleaving figures after chips — it PARTITIONS the refs on the
|
||||
existing image-doc signal (the ``image_url`` presence, phase 122)
|
||||
and builds two clusters: the chips row from the NON-image refs
|
||||
ONLY (no chip for an image doc — the figure IS the citation) and
|
||||
a second ``.msg-meta.msg-images`` row (``role=list`` +
|
||||
``aria-label="Source images"``) holding one figure per image ref
|
||||
in ref order. Each row is built only when its refs are non-empty
|
||||
(image-only citations get no chips row; text-only turns render
|
||||
the pre-phase-130 single row). The clipped-chip ``aria-label``
|
||||
pass stays, over the chips row only. The related row is a
|
||||
separate tier and is NOT reorganized (ASSUMPTION A4 — its own
|
||||
chip/figure interleave is untouched), and the summary fetch
|
||||
targets the SAME content endpoint the chip's modal already uses
|
||||
(failing soft to null — the title stands)."""
|
||||
js = APP_JS.read_text(encoding="utf-8")
|
||||
src = _function_body(js, "function appendSources(wrap, sources) {")
|
||||
assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src
|
||||
assert 'chip.className = "source-chip";' in src, "the chip stays (additive)"
|
||||
# The partition (the `image_url` presence is the image-doc signal):
|
||||
assert "const imageRefs = sources.filter((s) => s.image_url);" in src
|
||||
assert "const textRefs = sources.filter((s) => !s.image_url);" in src
|
||||
# Cluster 1 — the chips row, text refs only, skipped when empty:
|
||||
assert "if (textRefs.length) {" in src, (
|
||||
"the chips row is skipped when there are no text refs "
|
||||
"(an image-only citation still gets its images row)"
|
||||
)
|
||||
assert "for (const s of textRefs) {" in src, (
|
||||
"one chip per NON-image ref — an image ref contributes NO chip"
|
||||
)
|
||||
assert 'meta.className = "msg-meta";' in src
|
||||
assert 'meta.setAttribute("aria-label", "Sources");' in src # ARIA name kept
|
||||
assert 'chip.className = "source-chip";' in src
|
||||
assert "chip.textContent = label" in src
|
||||
# The clipped-chip aria-label pass stays, over the chips row only:
|
||||
assert "chip.scrollWidth > chip.clientWidth" in src
|
||||
# Cluster 2 — the images row, image refs only, absent when empty:
|
||||
assert "if (imageRefs.length) {" in src, (
|
||||
"the images row is built only when image refs exist"
|
||||
)
|
||||
assert 'images.className = "msg-meta msg-images";' in src
|
||||
assert 'images.setAttribute("role", "list");' in src
|
||||
assert 'images.setAttribute("aria-label", "Source images");' in src
|
||||
assert "for (const s of imageRefs) appendSourceImageFigure(images, s);" in src, (
|
||||
"one figure per image ref, in ref order, no chips in the row"
|
||||
)
|
||||
# The phase-122 interleave is gone from appendSources (the figure
|
||||
# no longer rides right after a chip in the chips row):
|
||||
assert "if (s.image_url) appendSourceImageFigure(meta, s);" not in src
|
||||
# The related tier is NOT reorganized (ASSUMPTION A4):
|
||||
rel = _function_body(js, "function appendRelated(wrap, related) {")
|
||||
assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel
|
||||
fetch = _function_body(js, "function fetchContentSummary(source, path) {")
|
||||
|
||||
@@ -139,8 +139,9 @@ def test_app_js_button_gates_on_admin_and_configured() -> None:
|
||||
"""The single guard: admin (the whoami gate Tune uses) AND
|
||||
docs_repo_configured — otherwise the function injects NOTHING
|
||||
(anonymous, or unconfigured admin, or deflected scope — same as
|
||||
Tune). One button per bubble; the .msg-meta row is reused (or
|
||||
created plain) and a role=list row gets a listitem button (ARIA)."""
|
||||
Tune). One button per bubble; the button joins the bubble's
|
||||
.msg-actions row (getActionsRow — phase 130 task 02; the role=list
|
||||
row gets a listitem button, ARIA)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
||||
assert fn_idx != -1, "appendSaveAsDocButton missing"
|
||||
@@ -354,23 +355,36 @@ def test_app_js_post_payload_and_navigation() -> None:
|
||||
assert "Couldn't save the answer as a doc" in fn_body
|
||||
|
||||
|
||||
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
|
||||
"""markLastRetryable re-appends the save button AFTER the Retry
|
||||
button lands on the same (last) bubble — the auto-margined buttons
|
||||
split the row's free space between them, so DOM order decides the
|
||||
right edge: "Save as doc" stays the bottom-right action even on the
|
||||
last bubble (which also carries Retry)."""
|
||||
def test_app_js_retry_landing_has_no_save_reappend_hack() -> None:
|
||||
"""Phase 130 (task 02): markLastRetryable re-appends the Retry
|
||||
button into the last bubble's .msg-actions row (appendRetryButton →
|
||||
getActionsRow) and NO LONGER re-appends the save button after it —
|
||||
the row's row-level right-align (phase 130 task 03) right-aligns
|
||||
the WHOLE cluster, and the DOM order inside the row is the
|
||||
insertion order (the live call sites append Tune → Retry →
|
||||
Save-as-doc per their gates). The phase-59 "auto margins split the
|
||||
free space" hack is gone with the per-button auto margins it worked
|
||||
around."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function markLastRetryable() {")
|
||||
assert fn_idx != -1
|
||||
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert "appendRetryButton(lastBrainWrap);" in fn_body
|
||||
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
|
||||
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
|
||||
# identifier gone from app.js (substring), so the local stays distinct.
|
||||
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
|
||||
assert "saveBtn" not in _text(APP_JS), (
|
||||
assert "appendRetryButton(lastBrainWrap);" in fn_body, (
|
||||
"the Retry button is re-appended to the last brain bubble"
|
||||
)
|
||||
assert "saveDocBtn" not in fn_body, (
|
||||
"the phase-59 save-button re-append hack is gone — the actions "
|
||||
"row's right-align (phase 130 task 03) replaces it"
|
||||
)
|
||||
assert "parentElement" not in fn_body, (
|
||||
"no DOM re-parenting of the save button anymore"
|
||||
)
|
||||
# "saveDocBtn" is gone from the WHOLE file (the hack had no other
|
||||
# home); "saveBtn" (the phase-55 retired Save pill's identifier)
|
||||
# stays absent too.
|
||||
assert "saveDocBtn" not in js
|
||||
assert "saveBtn" not in js, (
|
||||
"the phase-55 pin: no saveBtn identifier in app.js"
|
||||
)
|
||||
|
||||
@@ -381,16 +395,24 @@ def test_app_js_retry_landing_keeps_save_rightmost() -> None:
|
||||
|
||||
|
||||
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
|
||||
""".save-as-doc-btn exists, carries the bottom-right declaration
|
||||
(margin-inline-start: auto) and the .tune-btn visual family (pill,
|
||||
>=44px target, line border, ink-soft palette); :focus-visible is
|
||||
the global rule, the hover rule is per-class."""
|
||||
""".save-as-doc-btn exists in the .tune-btn visual family (pill,
|
||||
>=44px target, line border, ink-soft palette) with NO per-button
|
||||
auto margin — phase 130 moved the bottom-right requirement to the
|
||||
ROW (the .msg-meta.msg-actions justify right-aligns the whole
|
||||
actions cluster; the cluster-level pin lives in
|
||||
test_frontend_cluster_css). :focus-visible is the global rule, the
|
||||
hover rule is per-class."""
|
||||
css = _text(STYLES_CSS)
|
||||
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
|
||||
assert m, "the .save-as-doc-btn rule is missing"
|
||||
block = m.group(0)
|
||||
assert "margin-inline-start: auto;" in block, (
|
||||
"the bottom-right requirement lives on the button's class"
|
||||
assert "margin-inline-start: auto" not in block and "margin-left: auto" not in block, (
|
||||
"phase 130: the per-button auto margin is gone — the row's "
|
||||
".msg-actions justify does the bottom right"
|
||||
)
|
||||
row = re.search(r"\.msg-meta\.msg-actions \{[^}]*\}", css)
|
||||
assert row and "justify-content: flex-end" in row.group(0), (
|
||||
"the bottom-right requirement now lives on the .msg-actions row"
|
||||
)
|
||||
assert "min-height: 44px;" in block # WCAG touch target (the family)
|
||||
assert "border-radius: 999px;" in block
|
||||
|
||||
@@ -223,26 +223,35 @@ def test_append_related_row_contract() -> None:
|
||||
# ---------- task 02: wiring — the done frame and the restore path ----------
|
||||
|
||||
|
||||
def test_done_handler_calls_append_related_last() -> None:
|
||||
def test_done_handler_appends_related_above_the_actions_row() -> None:
|
||||
"""The done-frame handler calls ``appendRelated(wrap, ev.related)``
|
||||
— and appends the row LAST among the bubble's meta rows: AFTER
|
||||
``markLastRetryable()`` (the appendTuneButton / appendSaveAsDoc /
|
||||
appendRetryButton claimers all take the FIRST ``.msg-meta`` row,
|
||||
so a deflected turn with related docs — no citation row — gets its
|
||||
OWN meta row for the buttons instead of actions joining the
|
||||
related row). The call sits after the Retry claim in the branch."""
|
||||
— and appends the row BELOW the citation clusters and ABOVE the
|
||||
actions row (phase 130 task 02): the action appenders (appendTune
|
||||
Button → markLastRetryable's Retry → appendSaveAsDocButton) FOLLOW
|
||||
it in the branch, so the get-or-created ``.msg-actions`` row is the
|
||||
bubble's LAST row (the bottom-right cluster — DOM order is visual
|
||||
order). The getActionsRow scoping keeps the buttons out of the
|
||||
related row either way: a deflected turn with related docs gets its
|
||||
OWN actions row, never a button in the related row."""
|
||||
branch = _done_branch(_app_js())
|
||||
assert "appendRelated(wrap, ev.related);" in branch, (
|
||||
"the done handler must render the related tier (it arrives in "
|
||||
"ev.related — on a deflected turn ev.sources is empty and the "
|
||||
"weak hits live here, row only, zero chips)"
|
||||
)
|
||||
assert branch.index("appendRelated(wrap, ev.related);") > branch.index(
|
||||
"markLastRetryable();"
|
||||
assert branch.index("appendRelated(wrap, ev.related);") < branch.index(
|
||||
"appendTuneButton(wrap);"
|
||||
), (
|
||||
"appendRelated must run AFTER the meta-action claimers (last "
|
||||
"meta row) — a deflected turn's tune/retry buttons must land in "
|
||||
"their own row, never in the related row"
|
||||
"appendRelated must run BEFORE the action appenders — the "
|
||||
".msg-actions row is the bubble's LAST row (phase 130 task 02)"
|
||||
)
|
||||
# The insertion order inside the row: Tune → Retry → Save-as-doc
|
||||
# (markLastRetryable re-appends the Retry button between them).
|
||||
assert branch.index("appendTuneButton(wrap);") < branch.index(
|
||||
"markLastRetryable();"
|
||||
) < branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), (
|
||||
"the actions row's DOM order is the insertion order: Tune, "
|
||||
"Retry, Save-as-doc (phase 130 task 02)"
|
||||
)
|
||||
|
||||
|
||||
@@ -266,25 +275,30 @@ def test_done_handler_persists_related_with_the_turn() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_restore_path_calls_append_related() -> None:
|
||||
def test_restore_path_appends_related_above_the_actions_row() -> None:
|
||||
"""The phase-14 restore path (``renderStoredMessage`` — used by
|
||||
BOTH the localStorage restore and the /?chat=<id> saved-chat boot
|
||||
load) re-renders the related row from the stored payload when it
|
||||
carries ``related``; a pre-phase record without the field
|
||||
restores exactly as today (appendRelated no-ops — no row). The
|
||||
call sits after the meta-row claimers, exactly like the live
|
||||
done path, so restored buttons never join the related row."""
|
||||
call sits BEFORE the action appenders (Tune / Save-as-doc / notes —
|
||||
plus the restore loop's after-the-loop markLastRetryable Retry),
|
||||
so the .msg-actions row is the restored bubble's LAST row too —
|
||||
identical order to the live done path (phase 130 task 02); the
|
||||
getActionsRow scoping keeps restored buttons out of the related
|
||||
row."""
|
||||
body = _function_body(_app_js(), "function renderStoredMessage(m) {")
|
||||
assert "appendRelated(wrap, m.related);" in body, (
|
||||
"the restore path must render the related tier from the stored "
|
||||
"payload (pre-phase records carry no `related` → no row, "
|
||||
"graceful)"
|
||||
)
|
||||
assert body.index("appendRelated(wrap, m.related);") > body.index(
|
||||
"appendStoppedNote(wrap);"
|
||||
assert body.index("appendRelated(wrap, m.related);") < body.index(
|
||||
"appendTuneButton(wrap)"
|
||||
), (
|
||||
"the related row appends LAST (after the claimers) on the "
|
||||
"restore path too — identical order to the live done path"
|
||||
"the related row appends BEFORE the action appenders on the "
|
||||
"restore path too (the actions row is the bubble's LAST row) — "
|
||||
"identical order to the live done path (phase 130 task 02)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user