**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
1643 lines
70 KiB
Python
1643 lines
70 KiB
Python
"""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.api.chat_images import _VECTOR_IMAGE_EXTENSIONS
|
||
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_vector_svg_even_though_it_indexes(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""Phase 181: ``svg`` joins the DEFAULT image set for the DOCUMENT
|
||
walk, and question attachments stay RASTER-only — the upload 422s
|
||
with the six-extension detail and writes nothing.
|
||
|
||
Without the subtraction this would be a silent trap: the stored
|
||
``<uuid>.svg`` is refused by the serve route's filename regex (a
|
||
broken figure in the user's own bubble) and an OpenAI-compatible
|
||
vision endpoint rejects ``image/svg+xml``, so the turn could never
|
||
be answered. The vector family belongs to INDEXING, not to
|
||
attachments."""
|
||
assert ".svg" in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
|
||
assert frozenset({".svg"}) == _VECTOR_IMAGE_EXTENSIONS
|
||
r = _upload(
|
||
client, "diagram.svg", b'<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||
mime="image/svg+xml",
|
||
)
|
||
assert r.status_code == 422
|
||
assert r.json()["detail"] == (
|
||
"only .bmp, .gif, .jpeg, .jpg, .png, .webp images are accepted"
|
||
)
|
||
assert not image_dir.exists()
|
||
|
||
|
||
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 (the phase-160 additive
|
||
# ``budget_exhausted`` marker joins as an explicit null — the
|
||
# phase-120 ``failed`` / phase-48 ``stopped`` style).
|
||
assert set(dumped) == {
|
||
"who",
|
||
"text",
|
||
"sources",
|
||
"related",
|
||
"deflected",
|
||
"suggestions",
|
||
"thinking",
|
||
"tools",
|
||
"stopped",
|
||
"failed",
|
||
"error",
|
||
"budget_exhausted",
|
||
}
|
||
|
||
|
||
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 MINUS the vector members (the upload
|
||
endpoint's authority — phase 181: question images stay RASTER-only,
|
||
a vision endpoint rejects ``image/svg+xml`` and the serve route's
|
||
filename regex would never serve one).
|
||
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 raster set (dots stripped — the
|
||
# client list is dotless, the server's is dotted). ``svg`` is in the
|
||
# DEFAULT image set for the document walk (phase 181) and
|
||
# deliberately NOT attachable here (see
|
||
# ``chat_images._VECTOR_IMAGE_EXTENSIONS``).
|
||
raw_default = Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
|
||
assert ".svg" in raw_default # the vector member (phase 181)
|
||
server_set = {e.lstrip(".") for e in raw_default - _VECTOR_IMAGE_EXTENSIONS}
|
||
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)
|