Files
brain-of-reese/tests/unit/test_chat_image_questions.py
T
ducoterra 4cb85e5baa
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 17s
phase: 132_image_upload_feedback
All verification complete. Final report:

**Phase 132 (image upload feedback) — final verification pass: 100% green**

- **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window.
- **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E).
- **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
- **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only).
- **Decisions**: none — all task work was already implemented and correct; no fixes required.
- **Next pending phase**: `133_pdf_screenshot_fallback`.
2026-09-28 13:45:31 -04:00

1608 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: image questions (phase 123, task 01 — attach an image to a
question; TODO L6).
The server-side contract pinned here:
* ``POST /api/chat-images`` (user-gated like the chat turn it feeds)
stores the question image as ``<uuid4().hex>.<ext>`` under
``chat_image_dir`` — the extension gate reuses the phase-122 image
frozenset (the extension is the source of truth, the Content-Type
header a hint), the bytes are streamed with the
``chat_image_max_mb`` cap (413, fixed detail naming the cap, never
echoing the filename) — and returns the served path, the ONLY value
``ChatRequest.image`` accepts (never a data URL).
* ``GET /api/chat-images/{filename}`` (PUBLIC — the saved chat's id is
already its credential, phase 55 A1; the uuid filename has no
enumeration value) serves the bytes with the phase-122 mime map +
``Cache-Control: private, max-age=3600``; a malformed name OR a
missing file is one fixed 404 (no traversal, no shape-hinting 422).
* ``ChatRequest.image`` (stored path, pattern-validated with a fixed
422 detail — no echo) and ``ChatMessage.image`` (on the USER record
only; omitted when ``None`` — a text-only saved payload stays
byte-identical to pre-phase, the phase-50 contract).
* The turn pipeline: image + toggle OFF → the phase-114 error frame
WITH the hint (no model call, no record); image + the stored file
deleted out-of-band → the same frame shape, no hint; a valid image →
the multimodal user content list (text part + image_url data URL) at
BOTH construction sites (this module's deflected-branch ``messages``
list and the grounded branch's ``run_agent`` — the flow pinned in
``app.rag.agent``); ``image=None`` → the plain-string content,
byte-identical to pre-phase.
Endpoint-level chat tests use the ``test_embed_question_length.py``
wiring (a recording fake LLM, a fake retriever, a fake DB session,
patched settings) so the whole validate → build contract runs without
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
directory (the raw-string ``expanduser`` convention).
Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the
same chat-frontend surface: the preview strip ships a HIDDEN status
part (the house loading arc — decorative, aria-hidden — + the pinned
"Uploading image…" text), and ``handleSend``'s attachedImage branch
enters it exactly once per upload (after the ``attachUpload`` guard,
before the ``await uploadAttachedImage``) and exits it on BOTH
resolution paths (the helper never throws) — strip class
``is-uploading`` + status unhidden + remove ✕ disabled + the Send
button disabled with "Uploading…" on entry; the mirror image on exit
with the idle label restored byte-for-byte. ``setUiState`` / ``uiState``
/ the turn loader stay untouched (the upload is pre-turn; ``uiState``
stays ``idle``).
"""
from __future__ import annotations
import base64
import io
import json
import re
import shutil
import subprocess
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api import chat as chat_api
from app.api import chat_images
from app.config import Settings
from app.main import app as fastapi_app
from app.models import Document
from app.rag import summarizer
from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk
from app.schemas import ChatMessage, ChatRequest
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the
#: server is content-agnostic (extension + size gates only), but a
#: well-formed fixture keeps the data-URL pin honest.
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
#: A well-formed stored path (the upload response's shape) for one hex
#: per test module — the validators key on the SHAPE, not a specific
#: uuid, so a fixed value pins the pattern deterministically.
GOOD_PATH = "/api/chat-images/" + "a" * 32 + ".png"
GOOD_PATHS = {
GOOD_PATH,
"/api/chat-images/" + "B" * 32 + ".jpeg", # uppercase hex is accepted too
"/api/chat-images/" + "0" * 32 + ".jpg",
"/api/chat-images/" + "1" * 32 + ".webp",
"/api/chat-images/" + "2" * 32 + ".gif",
"/api/chat-images/" + "3" * 32 + ".bmp",
}
BAD_PATHS = {
"data:image/png;base64,iVBORw0KGgo", # a data URL — the anti-pattern
"/api/chat-images/" + "a" * 31 + ".png", # 31 hex chars — not a uuid4().hex
"/api/chat-images/" + "a" * 33 + ".png", # 33 hex chars
"/api/chat-images/" + "g" * 32 + ".png", # non-hex chars
"/api/chat-images/" + "a" * 32 + ".txt", # a wrong extension
"/api/chat-images/" + "a" * 32 + ".giff", # near-miss extension
"/api/chat-images/" + "a" * 32, # no extension at all
"photo.png", # a bare filename — not a served path
"/api/chat-images/../etc/passwd", # a traversal
"/api/chat-images/" + "a" * 32 + ".png?x=1", # a query string is not part of a path
}
# ---------- settings (phase 123 task 01, work item 1) ----------
def test_chat_image_settings_defaults() -> None:
"""The ``image_dir`` convention: a sibling of phase 122's document-
image dir, and the A5 ~10 MB cap."""
assert Settings.model_fields["chat_image_dir"].default == (
"~/bor-sources/chat-images"
)
assert Settings.model_fields["chat_image_max_mb"].default == 10
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert s.chat_image_dir == "~/bor-sources/chat-images"
assert s.chat_image_max_mb == 10
@pytest.mark.parametrize("bad", [0, -1, -10])
def test_chat_image_max_mb_rejects_zero_and_negative(bad: int) -> None:
"""``<= 0`` would reject every question-image upload — a typo that
must fail loudly at startup (the ``upload_max_mb`` precedent)."""
with pytest.raises(ValidationError, match="chat_image_max_mb must be > 0"):
Settings(_env_file=None, chat_image_max_mb=bad) # pyright: ignore[reportCallIssue]
@pytest.mark.parametrize("good", [1, 10, 512])
def test_chat_image_max_mb_accepts_positive(good: int) -> None:
"""A deployment with a smaller-cap vision model lowers the cap via
``BOR_CHAT_IMAGE_MAX_MB`` — env-tunable, no code change."""
s = Settings(_env_file=None, chat_image_max_mb=good) # pyright: ignore[reportCallIssue]
assert s.chat_image_max_mb == good
def test_chat_image_settings_env_overrides(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ``BOR_``-prefixed env vars drive the pair (the house
convention): a deployment moves the store (a shared volume) or
lowers the cap through ``.env`` alone — no code change."""
monkeypatch.setenv("BOR_CHAT_IMAGE_DIR", "/srv/bor/chat-images")
monkeypatch.setenv("BOR_CHAT_IMAGE_MAX_MB", "42")
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert s.chat_image_dir == "/srv/bor/chat-images"
assert s.chat_image_max_mb == 42
# ---------- the shared data-URL helper (assert the import, not a copy)
def test_the_data_url_helper_is_imported_not_copied() -> None:
"""Phase 123 (task 04): the multimodal builder and the phase-122
``describe_image`` path share ONE data-URL helper —
``app.rag.summarizer.image_data_url``. The identity pin catches a
regression that would silently fork the construction (a local
copy is a different function object): one map, one truth, both
call sites."""
assert chat_api.image_data_url is summarizer.image_data_url
# and the describe path really calls it (the phase-122 call site
# inside summarizer.py's describe_image — a re-implementation would
# build the string inline and this line would fail).
src = (Path(__file__).resolve().parents[2] / "app" / "rag" / "summarizer.py").read_text(
encoding="utf-8"
)
assert re.search(r"data_url = image_data_url\(data, mime\)", src)
# ---------- ChatRequest.image (the stored-path pattern guard) ----------
def test_image_validator_accepts_well_formed_paths() -> None:
"""Every well-formed stored path parses — the six extensions,
lower- AND uppercase hex (``uuid4().hex`` is lowercase; the schema
pattern allows both, per the pinned design regex)."""
for path in GOOD_PATHS:
request = ChatRequest(message="What is in this image?", image=path)
assert request.image == path
@pytest.mark.parametrize("bad", sorted(BAD_PATHS))
def test_image_validator_rejects_wrong_shapes(bad: str) -> None:
"""A data URL, a wrong extension, a traversal, a mistyped uuid —
all 422 with ONE fixed detail (no echo of the input)."""
with pytest.raises(ValidationError, match="image must be an uploaded chat image path"):
ChatRequest(message="What is in this image?", image=bad) # type: ignore[call-arg]
def test_image_validator_defaults_to_none() -> None:
"""``image=None`` (every pre-phase / text-only request) stays
untouched — no validation, byte-identical request shape. An
EXPLICIT ``null`` (a client that always sends the key) passes the
validator as ``None`` too (the null-safe restore convention)."""
request = ChatRequest(message="How is my homelab set up?")
assert request.image is None
explicit = ChatRequest.model_validate({"message": "hi", "image": None})
assert explicit.image is None
def test_image_over_500_chars_is_rejected() -> None:
"""The path bound (500) — a data URL smuggled past the pattern
would be caught by it, but the bound is the size ceiling anyway."""
with pytest.raises(ValidationError, match="at most 500 characters"):
ChatRequest(message="hi", image="x" * 501) # type: ignore[call-arg]
def test_chat_endpoint_422s_a_non_path_image(client: TestClient) -> None:
"""The schema guard is the boundary: a data URL never reaches the
turn (422 JSON — not an SSE frame)."""
r = client.post(
"/api/chat",
json={"message": "What is this?", "image": "data:image/png;base64,AAAA"},
)
assert r.status_code == 422
# ---------- build_user_content (the multimodal builder, unit level) ----------
def _stored_image(
tmp_path: Path, name: str = GOOD_PATH.rsplit("/", 1)[-1], data: bytes = PNG_1X1
) -> Path:
"""One stored question image in a tmp ``chat_image_dir``."""
root = tmp_path / "chat-images"
root.mkdir(parents=True, exist_ok=True)
(root / name).write_bytes(data)
return root
def test_build_user_content_none_is_the_plain_string(tmp_path: Path) -> None:
"""``image=None`` → the plain question string (byte-identical to
pre-phase — the multimodal branch is inert)."""
settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert chat_api.build_user_content("How is my homelab set up?", None, settings) == (
"How is my homelab set up?"
)
def test_build_user_content_set_is_the_multimodal_list(tmp_path: Path) -> None:
"""A stored path → the OpenAI-compatible content list: the text
part + the image part (a data URL built server-side from the stored
bytes + the phase-122 mime map — one map, one truth)."""
root = _stored_image(tmp_path)
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
content = chat_api.build_user_content("What is in this image?", GOOD_PATH, settings)
expected_b64 = base64.b64encode(PNG_1X1).decode("ascii")
assert content == [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{expected_b64}"}},
]
@pytest.mark.parametrize(
("ext", "mime"),
[
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
(".bmp", "image/bmp"),
],
)
def test_build_user_content_mime_follows_the_phase_122_map(
tmp_path: Path, ext: str, mime: str
) -> None:
"""Every accepted extension rides its phase-122 data-URL mime (the
``IMAGE_MIMES`` map — the describe path's one map, one truth)."""
name = "c" * 32 + ext
root = _stored_image(tmp_path, name=name)
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
content = chat_api.build_user_content("q", f"/api/chat-images/{name}", settings)
assert isinstance(content, list)
url = content[1]["image_url"]["url"] # type: ignore[index]
assert url.startswith(f"data:{mime};base64,")
# ---------- POST /api/chat-images (the upload endpoint) ----------
@pytest.fixture()
def image_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the store at a tmp dir (the raw-string ``expanduser``
convention — the setting is patched, not the filesystem home; the
monkeypatch fixture reverts it)."""
root = tmp_path / "chat-images"
monkeypatch.setattr(
chat_images,
"get_settings",
lambda: Settings(_env_file=None, chat_image_dir=str(root)), # pyright: ignore[reportCallIssue]
)
return root
def _upload(client: TestClient, filename: str, data: bytes, mime: str = "image/png") -> Any:
return client.post(
"/api/chat-images",
files={"file": (filename, io.BytesIO(data), mime)},
)
def test_upload_stores_a_uuid_named_file_and_returns_its_path(
client: TestClient, image_dir: Path
) -> None:
"""The happy path: a well-formed upload lands as
``<uuid4().hex>.<ext>`` in the store (created on demand) and the
response is exactly the served path — the value the chat request
accepts."""
r = _upload(client, "my photo.png", PNG_1X1)
assert r.status_code == 200
path = r.json()["path"]
assert re.fullmatch(r"/api/chat-images/[0-9a-f]{32}\.png", path)
assert set(r.json()) == {"path"} # the response shape — nothing else
stored = image_dir / path.rsplit("/", 1)[-1]
assert stored.is_file()
assert stored.read_bytes() == PNG_1X1 # the exact bytes
# the store holds exactly the one file (no temp litter)
assert [p.name for p in image_dir.iterdir()] == [stored.name]
def test_upload_normalizes_an_uppercase_extension(
client: TestClient, image_dir: Path
) -> None:
"""The extension is lowercased into the stored name (``Photo.JPG``
→ ``.jpg``) — the serve route's mime map is lowercase-keyed."""
r = _upload(client, "Photo.JPG", PNG_1X1, mime="image/jpeg")
assert r.status_code == 200
assert r.json()["path"].endswith(".jpg")
assert (image_dir / r.json()["path"].rsplit("/", 1)[-1]).is_file()
def test_upload_rejects_a_non_image_extension_with_the_fixed_detail(
client: TestClient, image_dir: Path
) -> None:
"""The EXTENSION is the source of truth (the Content-Type header is
a hint): a non-image extension is a 422 naming the accepted set —
a fixed detail, no filename echo — and NOTHING is written (not
even the store dir)."""
r = _upload(client, "evil.exe", b"MZ", mime="application/octet-stream")
assert r.status_code == 422
assert r.json()["detail"] == (
"only .bmp, .gif, .jpeg, .jpg, .png, .webp images are accepted"
)
assert not image_dir.exists() # the dir is created on the accepted path only
def test_upload_rejects_a_name_without_an_extension(
client: TestClient, image_dir: Path
) -> None:
"""No extension → not in the phase-122 set → the same 422."""
r = _upload(client, "README", b"not an image")
assert r.status_code == 422
assert not image_dir.exists()
def test_upload_413s_over_cap_with_the_fixed_detail(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The byte cap (``chat_image_max_mb`` — 1 MiB here) is enforced
while streaming: over-cap is a 413 naming the cap (never the
filename), no file is stored, and no temp litter survives."""
root = tmp_path / "chat-images"
monkeypatch.setattr(
chat_images,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
chat_image_dir=str(root),
chat_image_max_mb=1,
),
)
r = _upload(client, "big.png", b"\x00" * (1024 * 1024 + 1))
assert r.status_code == 413
assert r.json()["detail"] == "the image exceeds the 1 MB limit"
if root.exists(): # nothing stored, no temp litter
assert list(root.iterdir()) == []
# at-or-under the cap passes (the boundary is inclusive)
r2 = _upload(client, "ok.png", b"\x00" * (1024 * 1024))
assert r2.status_code == 200
def test_upload_requires_a_user(client: TestClient, image_dir: Path) -> None:
"""The upload is user-gated exactly like the chat turn it feeds —
an anonymous caller gets the 401 before any byte is received."""
anon = TestClient(fastapi_app)
r = _upload(anon, "a.png", PNG_1X1)
assert r.status_code == 401
assert not image_dir.exists()
@pytest.mark.parametrize(
("ext", "mime"),
[
(".png", "image/png"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
(".bmp", "image/bmp"),
],
)
def test_upload_accepts_every_one_of_the_six_extensions(
client: TestClient, image_dir: Path, ext: str, mime: str
) -> None:
"""The FULL accepted set (the phase-122 frozenset — one set, one
truth with the serve route's mime map and the client pre-check):
every extension of the six lands stored + served, nothing in the
set is silently missing."""
r = _upload(client, f"photo{ext}", PNG_1X1, mime=mime)
assert r.status_code == 200, r.text
path = r.json()["path"]
assert path.endswith(ext)
assert (image_dir / path.rsplit("/", 1)[-1]).is_file()
# ---------- GET /api/chat-images/{filename} (the serve route) ----------
def test_serve_returns_the_exact_bytes_with_mime_and_cache(
client: TestClient, image_dir: Path
) -> None:
"""A stored file streams back byte-identical with the phase-122
mime map's Content-Type + the phase-122 serve-route cache header."""
r = _upload(client, "a.png", PNG_1X1)
path = r.json()["path"]
got = client.get(path)
assert got.status_code == 200
assert got.content == PNG_1X1
assert got.headers["content-type"].startswith("image/png")
assert got.headers["cache-control"] == "private, max-age=3600"
def test_serve_is_public_like_saved_chat_content(client: TestClient, image_dir: Path) -> None:
"""The image is part of a saved chat's content (phase 55 A1 — the
chat's id is already its credential): an anonymous visitor with the
path can render it (the shared page's fidelity)."""
r = _upload(client, "a.png", PNG_1X1)
anon = TestClient(fastapi_app)
got = anon.get(r.json()["path"])
assert got.status_code == 200
assert got.content == PNG_1X1
@pytest.mark.parametrize(
"bad",
[
"a" * 31 + ".png", # 31 hex chars — not the stored shape
"a" * 33 + ".png", # 33 hex chars
"a" * 32 + ".txt", # a wrong extension
"a" * 32, # no extension
"not-a-uuid.png", # not hex at all
"..png", # a traversal-shaped name
],
)
def test_serve_404s_a_malformed_filename(client: TestClient, image_dir: Path, bad: str) -> None:
"""The regex guard: anything that is not ``<uuid-hex>.<ext>`` 404s
with the ONE fixed detail — no 422 that would hint at accepted
shapes, no traversal by construction."""
r = client.get(f"/api/chat-images/{bad}")
assert r.status_code == 404
assert r.json() == {"detail": "chat image not found"}
def test_serve_404s_a_missing_file(client: TestClient, image_dir: Path) -> None:
"""Well-formed name, file deleted out-of-band (the stale-path
edge) → the same fixed 404 (the viewer's fallback renders the
"image unavailable" note — never a broken icon)."""
r = client.get(f"/api/chat-images/{'d' * 32}.png")
assert r.status_code == 404
assert r.json() == {"detail": "chat image not found"}
# ---------- ChatMessage.image (persistence shape) ----------
def test_chat_message_image_defaults_to_none_and_is_omitted() -> None:
"""A record without an image serializes WITHOUT the key (ABSENT,
never ``null``) — a text-only saved payload is byte-identical to
pre-phase-123 (the phase-50 round-trip contract)."""
m = ChatMessage(who="user", text="How is my homelab set up?")
assert m.image is None
dumped = m.model_dump()
assert "image" not in dumped # ABSENT, never ``null`` (the phase-50 contract)
# and every pre-phase key set is untouched
assert set(dumped) == {
"who",
"text",
"sources",
"related",
"deflected",
"suggestions",
"thinking",
"tools",
"stopped",
"failed",
"error",
}
def test_chat_message_carries_the_stored_path_on_the_user_record() -> None:
"""The user record carries the PATH (never base64, LOCKED A5) and
it round-trips losslessly through the saved/shared shape."""
m = ChatMessage(who="user", text="What is in this image?", image=GOOD_PATH)
dumped = m.model_dump()
assert dumped["image"] == GOOD_PATH
rt = ChatMessage.model_validate(dumped)
assert rt.image == GOOD_PATH
# an explicit ``null`` (a legacy/corrupted payload) parses too —
# the restore path is null-safe for every optional key
assert ChatMessage.model_validate({"who": "user", "text": "x", "image": None}).image is None
def test_chat_message_image_bound_is_500() -> None:
"""The path bound: 500 fits (the upload path is ~52 chars — the
bound is headroom, not a squeeze), 501 422s."""
assert ChatMessage(who="user", text="x", image="a" * 500).image == "a" * 500
with pytest.raises(ValidationError, match="at most 500 characters"):
ChatMessage(who="user", text="x", image="a" * 501)
def test_chat_message_extra_forbid_boundary_is_intact() -> None:
"""``extra="forbid"`` still rejects unknown keys (a corrupted or
HTML-shaped payload 422s at the boundary) — adding ``image``
changed nothing about the KEYS contract."""
with pytest.raises(ValidationError):
ChatMessage(who="user", text="x", bogus="<b>") # type: ignore[call-arg]
with pytest.raises(ValidationError):
ChatMessage(who="alien", text="x") # type: ignore[call-arg]
# ---------- the turn pipeline (validate → multimodal build) ----------
class _RecordingLLM:
"""Records every ``embed_one`` input and the messages of each
request (the ``test_embed_question_length.py`` fake) — streams a
canned answer, never emits tool calls (a grounded turn through the
agent loop ends after the single request)."""
def __init__(self, answer: str = "Here is what the image shows.") -> None:
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.embedded: list[str] = []
self.answer = answer
self.seen: list[list[dict[str, Any]]] = []
async def embed_one(self, text: str) -> list[float]:
self.embedded.append(text)
return [0.0] * 768
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
scaffolding: ScaffoldingFilter | None = None,
):
self.seen.append(messages)
for i in range(0, len(self.answer), 12):
yield StreamPiece("content", self.answer[i : i + 12])
class _FakeSteeringResult:
def all(self) -> list[Any]:
return []
class _FakeSession:
"""Stands in for the DB session (the ``test_embed_question_length``
fake) — records what was written (the error-path convention is a
NO-WRITE)."""
def __init__(self) -> None:
self.added: list[Any] = []
self.commits = 0
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *args: Any) -> None:
pass
def add(self, obj: Any) -> None:
self.added.append(obj)
def commit(self) -> None:
self.commits += 1
def scalars(self, _stmt: Any) -> _FakeSteeringResult:
return _FakeSteeringResult()
def get(self, model: Any, pk: Any) -> Any:
return None
def execute(self, *args: Any, **kwargs: Any) -> list[Any]:
return []
def _doc(title: str, content: str) -> Document:
return Document(
id=uuid.uuid4(),
source="Homelab",
path=f"{title.lower().replace(' ', '-')}.md",
full_path="/tmp/doc.md",
title=title,
content=content,
content_hash="0" * 64,
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
def _chunk(doc: Document, score: float) -> RetrievedChunk:
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content=doc.content[:32],
score=score,
document=doc,
cosine=score,
fts_hit=False,
is_summary=False,
)
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""``POST /api/chat`` and ``POST /api/chat-images`` are user-gated
— every test here runs as the signed-in ADMIN (the TestClient
cookie jar carries the session; ``require_user`` passes the admin
session without a DB lookup)."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
@pytest.fixture()
def turn_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[tuple[_FakeSession, _RecordingLLM, Path]]:
"""``POST /api/chat`` with the LLM, retriever, session, and
settings all faked — the images toggle ON by default here (each
test that needs it off patches the settings again), the store is a
tmp dir, and the code-default thresholds apply (0.9 chunk grounded,
0.29 chunk deflected, against the conftest-calibrated 0.30
floor... the gate threshold is patched to the mock's calibration)."""
session = _FakeSession()
llm = _RecordingLLM()
store = tmp_path / "chat-images"
store.mkdir()
(store / GOOD_PATH.rsplit("/", 1)[-1]).write_bytes(PNG_1X1)
settings = Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=0.30,
images=True,
chat_image_dir=str(store),
llm_retry_delay=0.01,
)
monkeypatch.setattr(chat_api, "db_available", lambda: True)
monkeypatch.setattr(chat_api, "SessionLocal", lambda: session)
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
yield session, llm, store
fastapi_app.dependency_overrides.clear()
def _retrieve_with(cosine: float) -> Any:
doc = _doc("Kubernetes", "k" * 80)
chunks = [_chunk(doc, cosine)]
def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]:
return chunks
return retrieve
def _ask(
client: TestClient, message: str, image: str | None = None
) -> list[dict[str, Any]]:
body: dict[str, Any] = {"message": message}
if image is not None:
body["image"] = image
with client.stream("POST", "/api/chat", json=body) as r:
assert r.status_code == 200
frames: list[dict[str, Any]] = []
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
frames.append(json.loads(frame.removeprefix("data:").strip()))
assert buf.strip() == ""
return frames
def test_image_with_toggle_off_settles_the_hinted_frame_without_any_model_call(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A5 gate: ``images`` false with an image set → the
phase-114 error frame with the EXACT detail + hint, ONE frame
(terminal — no ``done``), NO model call (zero embeds, zero
requests), NO record (the rejected turn saves nothing)."""
session, llm, _store = turn_env
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=0.30,
images=False,
chat_image_dir=str(_store),
),
)
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"detail": "Image support is turned off on this server.",
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
}
assert llm.embedded == [] # NO model call (the embed never ran)
assert llm.seen == []
assert session.added == [] # NO query_log row (the error-path convention)
def test_image_with_a_missing_stored_file_settles_the_stale_frame(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The stale-path edge (the file was deleted out-of-band): the
SAME frame shape, a different detail, NO hint (the banner's
default copy is the honest fallback) — and again NO model call,
NO record."""
session, llm, _store = turn_env
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", stale)
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"detail": "That image is no longer available.",
"hint": None,
}
assert llm.embedded == []
assert llm.seen == []
assert session.added == []
def test_text_only_question_is_byte_identical_with_images_enabled(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``image=None`` with the toggle ON: the user message is the PLAIN
STRING (not a list) — the multimodal branch is inert, and the turn
completes normally (the toggle only gates image turns)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
frames = _ask(client, "How is my Kubernetes cluster set up?")
assert [f["type"] for f in frames][-1] == "done"
assert llm.seen[0][-1] == {"role": "user", "content": "How is my Kubernetes cluster set up?"}
def test_deflected_image_turn_delivers_the_multimodal_list(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Construction site 1 (the deflected branch's ``messages`` list):
the mock client RECEIVES the content list — the text part + the
image_url part whose data URL decodes to the stored bytes (the
phase-122 mime map, the shared data-URL helper)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert [f["type"] for f in frames][-1] == "done"
assert len(llm.seen) == 1
user = llm.seen[0][-1]
assert user["role"] == "user"
assert user["content"] == [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
def test_grounded_image_turn_delivers_the_multimodal_list_through_run_agent(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Construction site 2 (the grounded branch): ``run_agent`` builds
its own ``[system, *history, user]`` from the value chat.py passes
(the pinned flow) — the mock receives the SAME multimodal list,
with the tools offered (the agent loop ran)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert len(llm.seen) == 1
user = llm.seen[0][-1]
assert user["role"] == "user"
assert user["content"] == [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
assert llm.seen[0][0]["role"] == "system"
# ---------------------------------------------------------------------------
# Frontend source pins (tasks 02 + 03) — the house-style source
# assertions (see tests/unit/test_pinned_composer.py): the composer's
# attach control (hidden by default, config-gated reveal, the A8
# upload-before-send ordering), the user record's `image` key (the
# stored PATH — A5: never base64), the ONE bubble-image renderer
# (live + restore + shared, the onerror degradation), and the redo's
# text-only re-ask (A7).
# ---------------------------------------------------------------------------
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
INDEX_HTML = FRONTEND / "index.html"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _index_html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _app_js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_the_attach_control_is_hidden_by_default_in_the_markup() -> None:
"""The default-off contract (A5): the paperclip button, the hidden
file input, and the preview strip all ship HIDDEN in the static
markup — with the flag off (the default) nothing is ever revealed,
so the rendered flag-off DOM is the pre-phase one. The accessible
names ride aria-label (the SVGs are decorative)."""
html = _index_html()
assert (
'<button type="button" class="attach-btn" id="attach-btn" hidden'
' aria-label="Attach an image">' in html
)
assert (
'<input type="file" id="attach-file"'
' accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>'
in html
)
assert '<div class="attach-preview" id="attach-preview" hidden>' in html
assert (
'class="attach-preview-remove" id="attach-remove"'
' aria-label="Remove the attached image">' in html
)
# the thumbnail is decorative — the filename beside it is the
# readable label (the alt="" ships in the markup).
assert '<img class="attach-preview-img" alt="">' in html
def test_the_attach_reveal_is_gated_on_the_config_flag() -> None:
"""The reveal reads `images` from the boot /api/config — the brand
boot's ONE config request (no second round-trip). The `!== true`
guard degrades quietly: off (the default), a missing key, or an
unanswered fetch all leave the button hidden for good, and the API
contract still enforces the toggle server-side."""
js = _app_js()
assert "const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());" in js
m = re.search(r"if \(attachBtn\) attachBtn\.hidden = bootConfig\?\.images !== true;", js)
assert m, "the attach reveal must be gated on the settled config's images flag"
def test_the_client_precheck_matches_the_servers_six_extensions() -> None:
"""ONE list of the six, on both sides: the client pre-check (the
file NAME's extension — the accept attribute is advisory) and the
server's phase-122 frozenset (the upload endpoint's authority).
The client list must stay the code default's six — a drift here
would either block a valid pick or waste a round-trip on a 422."""
js = _app_js()
m = re.search(r"const ATTACHABLE_IMAGE_EXTENSIONS = \[(.*?)\];", js)
assert m, "the attachable-extension pre-check list must exist"
client_set = {tok.strip().strip('"') for tok in m.group(1).split(",")}
assert client_set == {
"bmp",
"gif",
"jpeg",
"jpg",
"png",
"webp",
}
# and it equals the server's default set (dots stripped — the
# client list is dotless, the server's is dotted).
server_set = {
e.lstrip(".")
for e in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
}
assert client_set == server_set
# the file input's accept attribute names the same six (mime form).
m = re.search(r'<input type="file" id="attach-file"\s+accept="([^"]+)"', _index_html())
assert m, "the attach file input must carry the six-mime accept attribute"
accept_set = {mime.rsplit("/", 1)[-1] for mime in m.group(1).split(",")}
# image/jpeg appears twice (jpg + jpeg) — the extension SET is the six.
assert accept_set == {"png", "jpeg", "webp", "gif", "bmp"}
def test_the_upload_happens_before_the_send_and_blocks_on_failure() -> None:
"""LOCKED A8, ordered in ``handleSend``: the attached File goes to
``POST /api/chat-images`` as the FIRST await of the send — BEFORE
the input is cleared and BEFORE ``runTurn`` — and a null path (any
failure: 413 over-cap, 422 a bad extension, 5xx, network drop)
RETURNS immediately: the send is BLOCKED, the typed question stays
exactly where the user left it, and the attachment stays for the
retry (the banner says what failed)."""
js = _app_js()
start = js.index("async function handleSend")
end = js.index("async function runTurn")
body = js[start:end]
upload = body.index("await uploadAttachedImage(attachedImage.file)")
clear = body.index('input.value = ""')
turn = body.index("await runTurn(text, { reask: false, image });")
assert upload < clear < turn, (
"A8 ordering: the upload must precede the input clear and the turn"
)
# the block line sits between the upload and the clear — a null
# path never reaches the turn, and the double-fire guard means a
# second submit mid-upload is a no-op (the first owns the send).
block = body.index("if (path === null) return;")
assert upload < block < clear
assert "if (attachUpload) return;" in body
# the upload endpoint itself (multipart, the session cookie rides
# the browser) with the phase-114-style out-of-turn banner copy.
assert 'res = await fetch("/api/chat-images", { method: "POST", body: form });' in js
assert "Couldn't attach the image — " in js
assert "Couldn't attach the image — try again." in js
def test_the_user_record_carries_the_stored_path_never_base64() -> None:
"""A5 at the record level: save point 1 (the user append in
``runTurn``) joins the ``bor.chat.v1`` record with the `image` key
ONLY when an attachment exists — and the value is the upload
response's STORED PATH, never a data URL. The /api/chat body
follows the same omission rule (a text-only body is byte-identical
to pre-phase)."""
js = _app_js()
assert (
"conversation.push(\n"
" image ? { who: \"user\", text, image: image.path } : { who: \"user\", text }\n"
" );" in js
)
# the request body: the key is added ONLY when present.
m = re.search(
r"const payload = \{ message: text, history \};\s*\n"
r"\s*if \(image\) payload\.image = image\.path;",
js,
)
assert m, "the /api/chat body must gain `image` (the path) only when attached"
# the triple: the PATH for the record + body, the data URL for the
# LIVE bubble only, the filename for the alt.
m = re.search(
r"image = \{\s*\n\s*path,.*\n\s*src: attachedImage\.dataUrl,.*\n"
r"\s*alt: attachedImage\.name,",
js,
)
assert m, "the image triple must carry path / live data URL / filename"
def test_prior_turns_images_are_never_replayed_and_the_redo_is_text_only() -> None:
"""LOCKED A7: the `history` entries travel as {who, text,
thinking} ONLY — a prior turn's image is never re-sent (the text of
a prior turn stands alone; a 10 MB image per past turn would blow
every budget). And the redo (``retryLastTurn``) re-asks with
``reask: true`` and NO image argument — and the upload helper is
called from EXACTLY ONE site (the plain send's handleSend): a
re-ask of a question that had an image re-sends ``prev.text``
only."""
js = _app_js()
m = re.search(
r"const history = conversation\.slice\(0, -1\)\.map\(\(m\) => \(\{(.*?)\}\)\);",
js,
re.S,
)
assert m, "the history map (prior turns in the body) must exist"
assert "image" not in m.group(1), "A7: history entries must never carry an image"
assert "return runTurn(text, { reask: true });" in js
# the upload is reached ONLY from the plain send's handleSend —
# the def + the one call site, nowhere else (the redo, the
# stale-regen, and every other turn path are image-free).
assert js.count("uploadAttachedImage") == 2
def test_the_live_user_bubble_renders_the_attached_image() -> None:
"""The live user bubble carries the image through the ONE
renderer (``attachBubbleImage``): the img is created
createElement-style (no HTML strings — the house rule), prepends
the bubble (the attachment is part of the question — it sits
ABOVE the text), lazy-loads, alt = the filename, and the CSS caps
it (a tall portrait must not blow the 46rem chat column)."""
js = _app_js()
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in js
m = re.search(r"function attachBubbleImage\(bubble, src, alt\) \{(.*?)\n\}", js, re.S)
assert m, "the bubble-image renderer must exist"
body = m.group(1)
for needle in (
"img.className = \"msg-image\";",
"img.alt = alt || \"attached image\";",
"img.loading = \"lazy\";",
"bubble.prepend(img);",
"img.onerror",
):
assert needle in body, f"attachBubbleImage must keep: {needle}"
css_block = re.search(r"\.msg-image \{(.*?)\}", _css(), re.S)
assert css_block, ".msg-image must exist in styles.css"
assert "max-width: 100%;" in css_block.group(1)
assert "max-height: 240px;" in css_block.group(1)
def test_the_restore_renders_the_stored_path_through_the_same_helper() -> None:
"""Task 03: ``renderStoredMessage``'s USER branch reads ``m.image``
(the stored PATH — null-safe: pre-phase / text-only records have
no key at all and render byte-identically, no img) and routes it
through the SAME ``attachBubbleImage`` helper the live send uses
(ONE renderer for live + restore — the live bubble passes the data
URL, the restore passes the path). The load failure degrades IN
PLACE to the small "image unavailable" line — never a broken
image icon (the stored file was deleted out-of-band; the record
keeps its path, the render degrades)."""
js = _app_js()
m = re.search(
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
r"\s*attachBubbleImage\(wrap\.querySelector\(\"\.bubble\"\),"
r" m\.image, m\.text \|\| \"attached image\"\);",
js,
)
assert m, "the restore's user branch must render m.image through attachBubbleImage"
assert 'note.textContent = "image unavailable";' in js
assert "img.replaceWith(note);" in js
# the degradation's small note is styled (the italic one-liner).
assert re.search(r"\.msg-image-unavailable \{", _css())
def test_the_shared_page_renders_the_user_image_the_same_way() -> None:
"""Task 03: the shared page's ``renderSharedMessage`` user branch
carries the SAME image render (the per-page duplication house
style: shared.js keeps its own ``addBubbleImage`` copy — the
.msg-image treatment itself is ONE, styles.css is shared by both
pages, so the rule appears exactly once). The serve route is
public (the token is the shared chat's credential — the image is
part of that content), and the load-failure degradation is
identical to the chat page."""
js = _shared_js()
m = re.search(
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
r"\s*addBubbleImage\(wrap, m\.image, m\.text \|\| \"attached image\"\);",
js,
)
assert m, "the shared page's user branch must render m.image"
m = re.search(r"function addBubbleImage\(wrap, src, alt\) \{(.*?)\n\}", js, re.S)
assert m, "the shared page's bubble-image helper must exist"
body = m.group(1)
for needle in (
"img.className = \"msg-image\";",
"img.alt = alt || \"attached image\";",
"img.loading = \"lazy\";",
"bubble.prepend(img);",
):
assert needle in body, f"addBubbleImage must keep: {needle}"
assert 'note.textContent = "image unavailable";' in js
assert "img.replaceWith(note);" in js
# ONE .msg-image rule — both pages share it (no second copy).
assert len(re.findall(r"\.msg-image \{", _css())) == 1
def test_the_preview_strip_reveals_and_clears_with_the_attachment() -> None:
"""The preview strip (thumbnail ≤48px + the filename + the remove
✕): revealed with the data-URL thumbnail + the file NAME as the
readable label (the thumbnail is decorative, alt=""), and cleared
with the attachment (the remove button, a fresh pick, the send,
or a New chat — the strip must not linger into a turn)."""
js = _app_js()
m = re.search(r"function showAttachPreview\(\) \{(.*?)\n\}", js, re.S)
assert m, "the preview reveal helper must exist"
body = m.group(1)
for needle in (
'attachPreview.querySelector("img").src = attachedImage.dataUrl;',
'attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;',
"attachPreview.hidden = false;",
):
assert needle in body, f"showAttachPreview must keep: {needle}"
m = re.search(r"function clearAttachedImage\(\) \{(.*?)\n\}", js, re.S)
assert m, "the attachment-clear helper must exist (idempotent)"
body = m.group(1)
assert "attachedImage = null;" in body
assert "attachPreview.hidden = true;" in body
# the send clears the strip AFTER the user bubble is rendered (the
# bubble already holds the image; a failed turn keeps both).
run_start = js.index("async function runTurn")
region = js[run_start:run_start + 4000]
push = region.index("conversation.push(")
clear = region.index("clearAttachedImage();")
assert push < clear, "the strip must clear only after the user bubble lands"
# the 44px touch floor + the thumbnail box (styles.css is shared
# with the shared page — the rules need no second copy there).
btn_block = re.search(r"\.attach-btn \{(.*?)\}", _css(), re.S)
assert btn_block and "width: 44px;" in btn_block.group(1)
assert "min-height: 44px;" in btn_block.group(1)
thumb_block = re.search(r"\.attach-preview-img \{(.*?)\}", _css(), re.S)
assert thumb_block and "width: 48px;" in thumb_block.group(1)
assert "height: 48px;" in thumb_block.group(1)
def test_the_remove_button_clears_the_state_and_refocuses_the_trigger() -> None:
"""The remove ✕ (or a new selection) clears the state + hides the
strip, and focus returns to the paperclip trigger (the keyboard
path stays inside the composer — WCAG 2.1 AA)."""
js = _app_js()
m = re.search(
r'attachRemove\?\.addEventListener\("click", \(\) => \{(.*?)\}\);', js, re.S
)
assert m, "the remove button's click binding must exist"
body = m.group(1)
assert "clearAttachedImage();" in body
assert "attachBtn?.focus();" in body
# a fresh pick re-arms the input (the same file re-picked must
# fire change again).
m = re.search(r'attachFile\?\.addEventListener\("change", \(\) => \{(.*?)\n\}\);', js, re.S)
assert m, "the file input's change binding must exist"
assert 'attachFile.value = "";' in m.group(1)
def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
"""Client-side failure copy (the out-of-turn banner — no state
change: a previous attachment, if any, survives a bad pick): the
extension pre-check names the six in prose, and a file whose bytes
never became readable (evicted mid-pick) gets the same copy as an
upload failure."""
js = _app_js()
m = re.search(
r'showErrorBanner\(\s*\n\s*"Only PNG, JPEG, WebP, GIF, and BMP'
r' images can be attached."\s*\);',
js,
)
assert m, "the bad-pick banner copy must name the six in prose"
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
assert m, "the FileReader's onerror branch must exist"
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
# =====================================================================
# Phase 132 (task 01, TODO L8) — the PRE-turn uploading state
# =====================================================================
def _fn(js: str, name: str) -> str:
"""The source of the first `function <name>` in js (brace balanced
— the house pin pattern from tests/unit/test_sync_button.py)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def test_the_strip_ships_the_hidden_uploading_status_part() -> None:
"""The preview strip's markup gains ONE status part INSIDE the
strip — after the filename span, before the remove button —
shipping HIDDEN (the house "ships hidden" rule): the house
loading arc (the svg is decorative, aria-hidden — the arc is not
the meaning) + the pinned "Uploading image…" text (it carries the
meaning). The strip's existing parts (thumbnail, name, remove ✕
+ its aria-label) are untouched, and the thumbnail + filename stay
visible throughout the upload (the user watches THEIR image
uploading — only the status part's `hidden` moves)."""
html = _index_html()
start = html.find('<div class="attach-preview" id="attach-preview" hidden>')
assert start != -1
strip = html[start : html.find("</div>", start)]
# the status part: the exact shipped markup (hidden by default).
assert '<span class="attach-preview-status" hidden>' in strip
assert (
'<svg class="attach-preview-status-icon" aria-hidden="true"'
' viewBox="0 0 24 24" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round">'
'<path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>' in strip
), "the house loading arc (decorative, aria-hidden) must ship"
assert '<span class="attach-preview-status-text">Uploading image…</span>' in strip
# exactly ONE status part, in the pinned order: img → name →
# status → remove (inside the strip, before its closing tag).
assert strip.count('class="attach-preview-status"') == 1
i_img = strip.find('class="attach-preview-img"')
i_name = strip.find('class="attach-preview-name"')
i_status = strip.find('class="attach-preview-status" hidden')
i_remove = strip.find('id="attach-remove"')
assert -1 < i_img < i_name < i_status < i_remove, (
"the status part sits after the name, before the remove button"
)
# the pre-phase parts are byte-untouched (the phase-123 contract).
assert '<img class="attach-preview-img" alt="">' in strip
assert 'aria-label="Remove the attached image"' in strip
def test_enter_exit_upload_state_are_the_exact_affordance_writers() -> None:
"""The two tiny helpers next to showAttachPreview / clearAttachedImage
(A5, owner-confirmed 2026-09-27 — the double-spam block is the
BOTH-halves affordance). enterUploadState: the strip gains the
.is-uploading class (task 02's CSS hooks it), the status part
unhides, the remove ✕ disables (the attachment is mid-flight), and
the Send button disables with the "Uploading…" label. exitUploadState
is the mirror image — and its idle restore is BYTE-IDENTICAL to
setUiState's idle label ("Send"), so the success path hands the
button to runTurn's setUiState("thinking") as usual and the
failure path leaves a fully editable composer. The attach family
is null-guarded (the house pattern); the send refs are
unconditional (the composer is required). setUiState / uiState /
the turn loader are NEVER touched — the upload is PRE-turn.
The module-scope ref is the strip's status part (the text is
static — no attachStatusText)."""
js = _app_js()
enter = _fn(js, "enterUploadState")
exit_ = _fn(js, "exitUploadState")
# entry — the full affordance, in the pinned order.
i_preview = enter.find('attachPreview.classList.add("is-uploading")')
i_status = enter.find("attachStatus.hidden = false")
i_remove = enter.find("attachRemove.disabled = true")
i_send = enter.find("sendBtn.disabled = true")
i_label = enter.find('sendLabel.textContent = "Uploading…"')
assert -1 < i_preview < i_status < i_remove < i_send < i_label
# exit — the mirror image, ending on the exact idle label.
i_preview = exit_.find('attachPreview.classList.remove("is-uploading")')
i_status = exit_.find("attachStatus.hidden = true")
i_remove = exit_.find("attachRemove.disabled = false")
i_send = exit_.find("sendBtn.disabled = false")
i_label = exit_.find('sendLabel.textContent = "Send"')
assert -1 < i_preview < i_status < i_remove < i_send < i_label
# the idle restore is byte-identical to setUiState's idle label —
# the same literal "Send", so no stale "Uploading…" can survive.
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in js
# the house null-guard pattern on the attach family only (a New
# chat mid-upload hides the strip; the class on a hidden element
# is harmless and still cleared at resolution); the send refs are
# unconditional (the chat view cannot exist without the composer).
for fn_src in (enter, exit_):
for guarded in ("attachPreview", "attachStatus", "attachRemove"):
assert f"if ({guarded})" in fn_src, f"{guarded} must be null-guarded"
assert "if (sendBtn)" not in fn_src and "if (sendLabel)" not in fn_src
# the PRE-turn contract: neither helper may touch the turn's
# state owners (the turn loader spins only when the turn starts).
for fn_src in (enter, exit_):
assert "setUiState" not in fn_src
assert "uiState" not in fn_src
assert "turnLoader" not in fn_src
assert "innerHTML" not in fn_src, "the house rule: textContent only"
# the module-scope ref: the status part (no text ref — static).
assert 'const attachStatus = document.querySelector(".attach-preview-status");' in js
assert "attachStatusText" not in js
def test_handle_send_wraps_the_await_with_the_upload_state() -> None:
"""In ``handleSend``'s attachedImage branch: ``enterUploadState()``
lands EXACTLY ONCE — after the ``attachUpload = true;`` guard line
and BEFORE ``await uploadAttachedImage`` (the moment the upload
starts — never before, never twice); ``exitUploadState()`` lands
EXACTLY ONCE — after the ``attachUpload = false;`` line (the
helper never throws, so this is reached on BOTH success and
failure) and BEFORE the ``if (path === null) return;`` block
(the failure path exits the state, then returns with the banner +
the editable composer + the attachment for the retry). The
``attachUpload`` guard is byte-untouched, the upload helper is
still called from exactly one site (the def + the call), and
``handleSend`` itself never calls ``setUiState`` (the pre-turn
contract — the turn's own state takes over inside runTurn)."""
js = _app_js()
# the EXACT handleSend body (brace matched — the file-order
# neighbors of handleSend carry their own setUiState references in
# their docstrings; the pin is on this function alone).
body = _fn(js, "handleSend")
i_guard = body.index("if (attachUpload) return;")
i_arm = body.index("attachUpload = true;")
i_enter = body.index("enterUploadState();")
i_await = body.index("await uploadAttachedImage(attachedImage.file)")
i_release = body.index("attachUpload = false;")
i_exit = body.index("exitUploadState();")
i_block = body.index("if (path === null) return;")
assert -1 < i_guard < i_arm < i_enter < i_await < i_release < i_exit < i_block, (
"guard → arm → ENTER → await → release → EXIT → block, in order"
)
assert body.count("enterUploadState();") == 1, "entered exactly once per upload"
assert body.count("exitUploadState();") == 1, (
"exited exactly once — the helper never throws, one line covers both paths"
)
# the pre-phase contract is byte-untouched: the guard, the single
# call site of the upload helper (def + call = 2 occurrences),
# and the A8 block line.
assert (
"if (attachUpload) return; // a second submit mid-upload: "
"the first owns the send (never two uploads, never two turns)" in js
)
assert js.count("uploadAttachedImage") == 2
# handleSend itself never drives the turn's UI state — the upload
# is PRE-turn (uiState stays idle across the whole flight); the
# call is absent (the word survives only in the phase-48 comment).
assert "setUiState(" not in body
#: The node harness (the test-document-viewer pattern): the REAL
#: extracted enterUploadState / exitUploadState run against stub
#: element refs; every transition is snapshotted as a JSON row —
#: [tag, isUploading, statusHidden, removeDisabled, sendDisabled,
#: label]. Plain strings only (the script is concatenation-assembled).
_UPLOAD_NODE_HEAD = """
const _classes = new Set();
const attachPreview = {
classList: {
add: (c) => _classes.add(c),
remove: (c) => _classes.delete(c),
contains: (c) => _classes.has(c),
},
};
const attachStatus = { hidden: true };
const attachRemove = { disabled: false };
const sendBtn = { disabled: false };
const sendLabel = { textContent: "Send" };
const show = (tag) => {
console.log(JSON.stringify([
tag,
_classes.has("is-uploading"),
attachStatus.hidden,
attachRemove.disabled,
sendBtn.disabled,
sendLabel.textContent,
]));
};
"""
_UPLOAD_NODE_TAIL = """
show("initial");
enterUploadState();
show("enter");
exitUploadState();
show("exit-success");
enterUploadState();
show("re-enter");
exitUploadState();
show("exit-failure");
"""
#: node availability (the test-document-viewer pattern).
HAVE_NODE = shutil.which("node") is not None
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_upload_state_transitions_under_node() -> None:
"""Behavioral check (node) of the REAL enterUploadState /
exitUploadState against stub element refs (the test-document-
viewer pattern), across the pinned transition set:
* entry: the strip gains .is-uploading, the status part unhides,
the remove ✕ disables, the Send disables with "Uploading…";
* exit (success path): the mirror image — class removed, status
re-hidden, remove re-enabled, Send re-enabled with "Send";
* exit (failure path — the same call, banner the failure's
signal, not the strip's): byte-identical to the success exit —
the state is fully cleared either way (the user can retry or
remove).
"""
js = _app_js()
fns = "\n".join(_fn(js, name) for name in ("enterUploadState", "exitUploadState"))
rows = [
json.loads(line)
for line in _run_node(_UPLOAD_NODE_HEAD + fns + _UPLOAD_NODE_TAIL).splitlines()
if line
]
by_tag = {row[0]: row for row in rows}
# row = [tag, isUploading, statusHidden, removeDisabled, sendDisabled, label]
assert by_tag["initial"] == ["initial", False, True, False, False, "Send"]
assert by_tag["enter"] == ["enter", True, False, True, True, "Uploading…"], (
"entry is the full affordance — strip + both halves of the button"
)
# success and failure exits are byte-identical: fully cleared, the
# idle label restored exactly.
for tag in ("exit-success", "exit-failure"):
assert by_tag[tag] == [tag, False, True, False, False, "Send"], tag
# a re-entry (the retry after a failed upload) re-arms the full
# affordance — the helpers are idempotent in both directions.
assert by_tag["re-enter"] == ["re-enter", True, False, True, True, "Uploading…"]
# =====================================================================
# Phase 132 (task 02, TODO L8) — the strip's uploading CSS (the visual
# half of the affordance: the dimmed thumbnail, the hidden name slot,
# the spinning --ink-soft status, the dimmed remove ✕, the reduced-
# motion still, and the verified house :disabled language on the Send)
# =====================================================================
def _phase132_css() -> str:
"""The Phase 132 CSS block in styles.css — from its comment marker
to the next top-level rule (the ``.composer`` that follows the
``.attach-preview*`` family) — with the comments stripped, so the
raw-color audit sees only declarations."""
css = _css()
start = css.find("/* Phase 132 (task 02, TODO L8):")
assert start != -1, "the Phase 132 CSS block must exist in styles.css"
end = css.find("\n.composer {", start)
assert end != -1, "the Phase 132 block must sit before the .composer rule"
return re.sub(r"/\*.*?\*/", "", css[start:end], flags=re.S)
def test_the_uploading_strip_dims_the_thumbnail_and_keeps_the_name_slot() -> None:
"""The in-flight strip: the thumbnail DIMS (opacity 0.5 — it is
decorative, no contrast duty), and the filename HIDES with
visibility (NOT display/width — the flex: 1 slot keeps its measured
space, so the status takes the row's right hand without reflowing
the pinned 44px remove button; the strip height is stable)."""
css = _css()
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-img \{(.*?)\}", css, re.S)
assert m, "the uploading thumbnail must dim"
assert "opacity: 0.5;" in m.group(1)
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-name \{(.*?)\}", css, re.S)
assert m, "the uploading name must hide"
body = m.group(1)
assert "visibility: hidden;" in body
assert "display: none" not in body and "width: 0" not in body, (
"the name slot must keep its space (visibility, not removal)"
)
def test_the_uploading_status_row_is_the_ink_soft_small_text() -> None:
"""The status part's row: the house inline-flex language (the icon
+ the text as one unit), --ink-soft at the strip's small text size
(the name's 0.85rem), and min-width 0 (the row's clamp — at 320px
the text wraps rather than clipping; verified in the task's browser
pass: one line at 320px, no clip)."""
m = re.search(r"\.attach-preview-status \{(.*?)\}", _css(), re.S)
assert m, ".attach-preview-status must be styled"
body = m.group(1)
for needle in (
"display: inline-flex;",
"align-items: center;",
"gap: 0.35rem;",
"color: var(--ink-soft);",
"font-size: 0.85rem;",
"min-width: 0;",
):
assert needle in body, f".attach-preview-status must keep: {needle}"
def test_the_status_icon_is_the_14px_house_spin_at_the_sync_pace() -> None:
"""The arc: 14px (the strip's small-text scale), flex 0 0 auto (the
.sync-icon house pattern), animated with the HOUSE `spin`
keyframes at 1s linear — .sync-icon.is-spinning's in-flight pace
(the .spinner's 0.8s is the busy-turn arc's). The keyframes must
EXIST — a dangling animation-name would spin nothing and the
"in motion" state would be invisible."""
m = re.search(r"\.attach-preview-status-icon \{(.*?)\}", _css(), re.S)
assert m, ".attach-preview-status-icon must be styled"
body = m.group(1)
for needle in ("width: 14px;", "height: 14px;", "flex: 0 0 auto;"):
assert needle in body, f".attach-preview-status-icon must keep: {needle}"
assert "animation: spin 1s linear infinite;" in body
assert re.search(r"@keyframes spin \{\s*to \{ transform: rotate\(360deg\); \}\s*\}", _css()), (
"the house `spin` keyframes must exist (the arc's animation-name)"
)
def test_reduced_motion_stills_the_uploading_arc() -> None:
"""The house reduced-motion block: with prefers-reduced-motion the
arc STILLS (animation none) — the "Uploading image…" text + the
dimmed thumbnail then carry the state without motion (B5: never
motion alone; the state is text + dimming, not the spin)."""
m = re.search(
r"@media \(prefers-reduced-motion: reduce\) \{\s*"
r"\.attach-preview-status-icon \{ animation: none; \}\s*\}",
_css(),
)
assert m, "the house reduced-motion block must still the uploading arc"
def test_the_disabled_remove_x_dims_with_the_disabled_affordance() -> None:
"""The remove ✕ (task 01 disables it mid-flight via the attribute)
dims to 0.5 while uploading — the attribute is the state, the
opacity is its visual half (the same 0.5 as the dimmed thumbnail,
the house dim language)."""
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-remove \{(.*?)\}", _css(), re.S)
assert m, "the uploading remove button must dim"
assert "opacity: 0.5;" in m.group(1)
def test_the_disabled_send_rule_is_the_existing_house_rule_not_a_new_visual() -> None:
"""Task 02's verification step: the Send is disabled via the
`disabled` attribute (task 01) and the EXISTING house rule
``.send-btn:disabled`` already reads as disabled — the
--brand-busy background (the brand brightened 40% toward white,
visibly lighter than the enabled --brand fill, and a derived
variable that follows every saved theme) + the not-allowed
cursor. The pin says the rule is INTACT: the upload state must
not invent a second disabled look (no new color, no new weight).
(Verified in the task's browser pass: the rule applies to
#send-btn, which rides .send-btn.)"""
m = re.search(r"\.send-btn:disabled \{(.*?)\}", _css(), re.S)
assert m, "the house .send-btn:disabled rule must exist"
body = m.group(1)
assert "background: var(--brand-busy);" in body
assert "cursor: not-allowed;" in body
def test_the_phase132_css_ships_no_raw_color_and_holds_the_aa_pair() -> None:
"""The theme audit (the B4 byte-identical contract): every value
in the Phase 132 block is a theme variable or a neutral number —
NO raw hex/rgb/hsl (a custom theme repaints everything through the
variables). And the status text's --ink-soft on the strip's
--surface holds the AA floor: computed here against WCAG 2.1 from
the built-in :root literals (the theme phases' method) — 8.2:1,
≥ 4.5:1."""
block = _phase132_css()
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no raw hex in the Phase 132 block"
assert not re.search(r"\b(?:rgb|rgba|hsl|hsla)\(|\bsrgb\b", block), (
"no raw rgb/hsl in the Phase 132 block"
)
ratio = _wcag_contrast(_root_color("--ink-soft"), _root_color("--surface"))
assert ratio >= 4.5, f"--ink-soft on --surface = {ratio:.2f}:1 (< 4.5:1 AA floor)"
def _root_color(name: str) -> str:
"""A built-in :root literal color (the theme phases audit the
built-in palette this way; a saved theme repaints through the
variables and is checked by the theme E2E)."""
m = re.search(rf"(?m)^\s*{name}:\s*(#[0-9a-fA-F]{{6}})", _css())
assert m, f"{name} must ship a plain built-in color in :root"
return m.group(1)
def _hex_rgb(value: str) -> tuple[int, int, int]:
h = value.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
"""WCAG 2.1 relative luminance (the house helper's math — the
test_responsive_polish / test_dark_tech_theme contrast helper)."""
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _wcag_contrast(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_hex_rgb(fg)), _rel_luminance(_hex_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)