Files
brain-of-reese/tests/unit/test_frontend_cluster_css.py
ducoterra c88c65641a
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Successful in 12s
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/).
2026-09-28 07:49:37 -04:00

246 lines
10 KiB
Python

"""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