Files
brain-of-reese/tests/unit/test_frontend_tool_states.py
T
ducoterra fe55be0c35
Build and Push Containers / build-and-push (push) Successful in 1m50s
feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer
One env var (BOR_APP_NAME, default "Brain of Reese") now drives the app's
display name everywhere (TODO.md L12 — owner ask: "a way to customize the
name for 'Brain of'. Should be an env var."). The existing app_name setting
is the source of truth (phase locked decision — no new variable, no rename);
with the variable unset the app is byte-identical to before.

Endpoint (A10 public/stateless, no secrets):
  GET /api/config → exactly {app_name, version} (app/api/config.py, the
  health.py pattern; registered before the static mount). Integration tests:
  anonymous 200, default values, a Settings override follows, key set is
  exactly two keys — no other setting may leak in later.

Frontend brand layer (A11 — runtime fetch, static templates stay static):
  assets/brand.js — a CLASSIC script, first on all six pages, so its top
  level runs at parse time: window.BOR_BRAND = "Brain of Reese"
  synchronously (the default renders immediately, no blank flash), then a
  no-store fetch of /api/config applies the name — document.title (global
  replace), every .brand-text (a name starting "Brain of " keeps the bold
  split Brain of <strong>rest</strong>, any other name renders plain; the
  operator-controlled name is HTML-escaped before innerHTML), a TreeWalker
  over text nodes (script/style rejected — page source never rewritten),
  and the aria-label/placeholder/meta-content attributes. Fetch failure
  keeps the default + console.warn (the loadHealth house style).
  app.js (status labels, typing label, elapsed-hint aria, tool labels) and
  document.js (viewer titles) read window.BOR_BRAND at CALL time via
  brand() — a label set after the fetch lands carries the configured name.
  Containerfile: esbuild minify line for brand.js (classic, like markdown.js);
  the phase-33 ?v= cache-busting picks the new asset ref up automatically.

E2E (A16 — one story, one file, isolated): test_configurable_brand.py boots
a SECOND app instance (same DB/mock-LLM/admin-auth env block, port APP_PORT+1,
BOR_APP_NAME="Brain of Testy") — the shared conftest server keeps the
default name so every other suite's title/label assertions stay untouched —
and asserts /api/config on both instances, the index title/brand/greeting/
#messages aria-label, the sources + login page titles, and one pre-token
chat turn (think out loud marker) whose #send-status reads "Brain of Testy
is thinking"; the no-op regression pins the shared server's default bytes.

Docs: .env.example App section + README configuration reference — what it
affects (titles, header brand, status labels, aria text), the default, the
bold-split rendering rule.

Gates: 695 unit+integration passed, app/ coverage 99% (>90%), story E2E
green in isolation (two consecutive runs), brand-string suites (smoke,
shared header, header consistency, chat persistence) green, ruff + pyright
clean.
2026-08-27 02:24:16 -04:00

203 lines
8.7 KiB
Python

"""Unit: the phase-37 "calling tool" frontend contract (task 05).
No new Python app logic exists for this task — the behavior lives in
``frontend/assets/app.js`` + ``styles.css`` and is E2E-gated by the story
suite (task 06). Like the other frontend-adjacent unit files, this module
pins the JS/CSS markers the story depends on, so a silent regression in
the tool branch, the persistence shape, or the tool-line styling is
caught without a browser.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_tool_branch_is_a_first_class_turn_branch() -> None:
"""The turn handler must branch on `tool` frames BETWEEN the
thinking and delta branches: the stream stays alive (guard clears),
the brain wrap is created on demand, and the label state is
applied only while the UI state is still "thinking" (a late frame
after the first delta just appends the line — never a crash)."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < thinking_idx < tool_idx < delta_idx, (
"the turn handler must branch on tool frames"
)
branch = js[tool_idx:delta_idx]
assert "toolAcc.push" in branch, "every tool frame is recorded for persistence"
assert "clearTurnTimeout()" in branch, "a tool frame proves the stream is alive"
assert 'addMessage("brain", "")' in branch, "first frame creates the brain wrap"
assert "uiState === UI_STATE.thinking" in branch, (
"label updates only while the state is still thinking"
)
assert "appendToolLine(wrap, name, argument)" in branch
# No setUiState in the branch: the state stays "thinking" (never stale).
assert "setUiState" not in branch, (
"the tool branch must keep uiState=thinking (button stays disabled)"
)
def test_calling_tool_label_strings() -> None:
"""The 'calling tool' label strings the story keys off: the button
text and the status/typing-indicator labels. Phase 39 centralizes
the brand prefix: the name resolves from window.BOR_BRAND at call
time via brand() (the default name renders the same bytes)."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert '"Calling tool…"' in branch, "the button carries the calling-tool text"
assert "`${brand()} is listing documents`" in branch
assert "`${brand()} is reading ${argument}`" in branch
assert "sendStatus.textContent = toolStatus" in branch, (
"the #send-status live region announces what Brain is doing"
)
assert 'setAttribute("aria-label", toolStatus)' in branch, (
"the typing indicator label follows the tool state"
)
# The elapsed-seconds hint keeps running through tool frames: the
# branch must not stop/restart the clock.
assert "stopThinkingClock" not in branch
assert "startThinkingClock" not in branch
def test_tool_lines_render_into_the_bubble_wrap() -> None:
"""appendToolLine: first frame creates the .tool-calls list (role=list
+ accessible name) BEFORE the bubble — below an existing Thinking
block — and each line is a .tool-call listitem with the exact marks:
🔎 Listing documents / 📄 Reading <code>path</code>. The path goes
through textContent (storage can never inject HTML)."""
js = _js()
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".tool-calls")' in body, "idempotent per wrap"
assert 'className = "tool-calls"' in body
assert 'setAttribute("role", "list")' in body
assert 'setAttribute("aria-label", "Tool calls")' in body
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
"the list sits ABOVE the answer"
)
assert 'className = "tool-call"' in body
assert 'setAttribute("role", "listitem")' in body
assert 'line.textContent = "📄 Reading "' in body
assert 'line.textContent = "🔎 Listing documents"' in body
assert "code.textContent = argument" in body, (
"the path is data — textContent, never innerHTML"
)
assert "name === \"read_document\" && argument" in body
def test_tool_branch_is_append_only_and_interleaving_safe() -> None:
"""Append-only, the same rule as thinking: multiple calls append
multiple lines in order, and a tool frame after the first delta
(should not happen in v1) still appends — the branch has no early
return gated on acc/delta state."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "if (aborted) return" not in branch, (
"the aborted guard lives at the dispatch top, not per branch"
)
assert "acc" not in branch.split("appendToolLine")[0].replace("toolAcc", ""), (
"the tool branch must not depend on accumulated answer text"
)
def test_tool_frames_persist_next_to_thinking() -> None:
"""The `done` save point gains an optional `tools` key next to
`thinking` (empty turns persist exactly as before — `undefined`
drops the key from the JSON), and the accumulator is turn-scoped
in handleSend."""
js = _js()
assert "let toolAcc = []" in js
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx
done_block = js[done_idx:error_idx]
assert "thinking: thinkingAcc || undefined" in done_block
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block, (
"tools persisted next to thinking, optional like it"
)
def test_tool_lines_re_render_on_restore() -> None:
"""Phase 14 convention: a stored brain record with `tools` re-renders
the lines on load through the SAME append helper (after the thinking
re-render, before the deflected/sources additions)."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "Array.isArray(m.tools)" in body
assert "appendToolLine(wrap, t.name, arg)" in body
thinking_restore = body.find("if (m.thinking)")
tools_restore = body.find("Array.isArray(m.tools)")
assert -1 < thinking_restore < tools_restore, (
"tools restore sits after the thinking restore (same order as live)"
)
assert "typeof t.name !== \"string\"" in body, "malformed entries skipped"
def test_thinking_block_stays_on_top_of_tool_lines() -> None:
"""If a tool frame precedes the first thinking frame, the Thinking
block is still created ABOVE the tool lines (scratchpad on top),
not below them."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".tool-calls")' in body, (
"the thinking anchor must account for existing tool lines"
)
assert 'querySelector(".bubble")' in body
assert "insertBefore" in body
assert "block.open = true" in body
def test_tool_call_style_is_accent_and_contrast_safe() -> None:
"""styles.css: .tool-call is an inline row with the accent palette
(distinct from the brand-ink Thinking block) and mono `code` styling
for the path; the wrapper stacks lines without shifting the column."""
css = _css()
assert ".tool-calls" in css
assert ".tool-call" in css
m = re.search(r"\.tool-call \{([^}]*)\}", css)
assert m, "the .tool-call rule must exist"
row = m.group(1)
assert "display: flex" in row, "inline row: icon + text"
assert "var(--accent-ink)" in row, (
"accent color distinguishes it from the thinking block (≈10.4:1 on surface)"
)
assert "var(--accent-line)" in row, "accent left border"
code = re.search(r"\.tool-call code \{([^}]*)\}", css)
assert code, "the path `code` must be styled"
assert "var(--mono)" in code.group(1)
assert "var(--ink)" in code.group(1) # ≈11.5:1 on --brand-soft
assert "gap" in css.split(".tool-calls {")[1].split("}")[0], (
"lines stack with a gap — append-only, no reflow"
)
def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the tool state adds no external script/link."""
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
assert 'src="http' not in index and 'href="http' not in index