phase: 124_image_chips_and_answers
All green. Final report: **Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)** - Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design). - `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate) - `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright) - `uv run ruff check . && uv run pyright` → clean, 0 errors - `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed** - Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4 **Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made). **Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
This commit is contained in:
@@ -0,0 +1,971 @@
|
||||
"""Unit tests: phase 124 — image chips and answers.
|
||||
|
||||
Task 01 covers the ``BOR_ANSWER_IMAGES`` feature toggle
|
||||
(``Settings.answer_images``). Later tasks in this phase extend this
|
||||
file (the ``show_image`` tool, the prompt clause, the ``answer_image``
|
||||
frame key, the auto-cite merge).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
"""Build Settings without reading a .env file (deterministic tests).
|
||||
|
||||
Mirrors ``tests/unit/test_config.py::_settings``.
|
||||
"""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
|
||||
|
||||
def test_answer_images_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""LOCKED A2: ``BOR_ANSWER_IMAGES`` gates the whole answer-image
|
||||
feature and is OFF by default — unset, ``=0``, and ``=false`` all
|
||||
parse to ``False`` (off = byte-identical behavior everywhere)."""
|
||||
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
|
||||
assert _settings().answer_images is False
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "0")
|
||||
assert _settings().answer_images is False
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "false")
|
||||
assert _settings().answer_images is False
|
||||
|
||||
|
||||
def test_answer_images_truthy_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The settings loader's accepted truthy forms (the
|
||||
``stream_thinking``/``images`` bool-test precedent): ``1`` and
|
||||
``true`` enable the toggle."""
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
|
||||
assert _settings().answer_images is True
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
|
||||
assert _settings().answer_images is True
|
||||
|
||||
|
||||
def test_answer_images_independent_of_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""LOCKED A2: the answer-side toggle is a SEPARATE knob from
|
||||
``images``/``BOR_IMAGES`` — indexing image docs and showing them in
|
||||
answers are independent owner decisions. Setting one never moves
|
||||
the other, in either direction."""
|
||||
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
|
||||
monkeypatch.delenv("BOR_IMAGES", raising=False)
|
||||
|
||||
# Default: both off.
|
||||
s = _settings()
|
||||
assert s.answer_images is False
|
||||
assert s.images is False
|
||||
|
||||
# Enabling the answer toggle alone leaves indexing off — a
|
||||
# coherent (if image-less) state: the tool is offered, finds no
|
||||
# image docs, and always refuses.
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
|
||||
s = _settings()
|
||||
assert s.answer_images is True
|
||||
assert s.images is False
|
||||
|
||||
# Enabling indexing alone leaves the answer side off (byte-
|
||||
# identical pre-phase-124 behavior).
|
||||
monkeypatch.delenv("BOR_ANSWER_IMAGES")
|
||||
monkeypatch.setenv("BOR_IMAGES", "1")
|
||||
s = _settings()
|
||||
assert s.answer_images is False
|
||||
assert s.images is True
|
||||
|
||||
# Both on — the full feature state.
|
||||
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
|
||||
s = _settings()
|
||||
assert s.answer_images is True
|
||||
assert s.images is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 02 — the ``show_image`` agent tool (toggle-gated)
|
||||
|
||||
import asyncio # noqa: E402
|
||||
import uuid # noqa: E402
|
||||
from collections.abc import AsyncIterator # noqa: E402
|
||||
from copy import deepcopy # noqa: E402
|
||||
from datetime import UTC, datetime # noqa: E402
|
||||
from typing import cast # noqa: E402
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app.models import Document # noqa: E402
|
||||
from app.rag.agent import ( # noqa: E402
|
||||
AGENT_TOOLS,
|
||||
ALREADY_SHOWING,
|
||||
IMAGE_DOC_MARKER,
|
||||
MISSING_SHOW_ARGS,
|
||||
NOT_AN_IMAGE_DOC,
|
||||
SHOW_IMAGE_TOOL,
|
||||
AgentHolder,
|
||||
_execute_tool,
|
||||
_no_document_refusal,
|
||||
agent_tools,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece # noqa: E402
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
#: ``show_image`` result formats ``doc.created_at`` on its date line —
|
||||
#: a pinned value keeps the result-string assertions exact.
|
||||
_SHOW_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
# --- the offer list: toggle-gated, the base three byte-identical ---
|
||||
|
||||
|
||||
def test_agent_tools_off_is_the_three_base_tools() -> None:
|
||||
"""LOCKED A2: toggle off (the default) → exactly the three base
|
||||
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
|
||||
list (not the constant), so a caller mutating the offer can never
|
||||
mutate the constant."""
|
||||
off = agent_tools(_settings())
|
||||
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
|
||||
assert off == AGENT_TOOLS # element-for-element
|
||||
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
|
||||
off.append({"junk": True}) # mutating the offer must not touch the constant
|
||||
assert len(AGENT_TOOLS) == 3
|
||||
|
||||
|
||||
def test_agent_tools_on_appends_show_image() -> None:
|
||||
"""Toggle on → the three base tools (byte-identical) plus the 4th
|
||||
tool, ``show_image`` — the offer list the grounded turn passes to
|
||||
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
|
||||
offer; the base three are unchanged)."""
|
||||
on = agent_tools(_settings(answer_images=True))
|
||||
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
|
||||
assert on[:3] == AGENT_TOOLS # the base three byte-identical
|
||||
assert on[3] == SHOW_IMAGE_TOOL
|
||||
|
||||
|
||||
def test_show_image_tool_definition_carries_the_relevance_gate() -> None:
|
||||
"""The tool definition is the same OpenAI function shape as the
|
||||
other three (one required string arg ``path``, the combined
|
||||
``source/path`` identity — the bare-path-will-not-resolve teaching
|
||||
mirrored from ``read``), and its description IS the relevance gate
|
||||
(LOCKED A5: never a merely related image, at most ONE image, the
|
||||
full text answer always stands)."""
|
||||
fn = SHOW_IMAGE_TOOL["function"]
|
||||
assert SHOW_IMAGE_TOOL["type"] == "function"
|
||||
assert fn["name"] == "show_image"
|
||||
assert fn["parameters"]["required"] == ["path"]
|
||||
assert fn["parameters"]["properties"]["path"]["type"] == "string"
|
||||
desc = fn["description"]
|
||||
assert "at most one image" in desc
|
||||
assert "never for a merely related" in desc
|
||||
assert "answer fully in text either way" in desc
|
||||
assert "bare document path will not resolve" in desc
|
||||
|
||||
|
||||
def test_run_agent_gates_the_show_image_offer() -> None:
|
||||
"""The wiring pin (completion criteria): a grounded turn's offer
|
||||
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
|
||||
three base tools when the toggle is off — element-for-element the
|
||||
pre-phase-124 list — and gains ``show_image`` only when it is on."""
|
||||
|
||||
class _ScriptedLLM:
|
||||
"""One canned content-only round; records the ``tools`` every
|
||||
request carried (the DB is never touched — no tool calls)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
|
||||
] = []
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
scaffolding: object | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
self.requests.append((deepcopy(messages), tools))
|
||||
yield StreamPiece("content", "Done!")
|
||||
|
||||
def _mock_session() -> Session:
|
||||
mock = cast("Session", MagicMock())
|
||||
mock.__enter__ = MagicMock(return_value=mock)
|
||||
mock.__exit__ = MagicMock(return_value=False)
|
||||
mock.scalar = MagicMock(return_value=None)
|
||||
mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
|
||||
return mock
|
||||
|
||||
async def _consume(llm: _ScriptedLLM, settings: Settings) -> None:
|
||||
async for _piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
_mock_session,
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=settings,
|
||||
holder=AgentHolder(),
|
||||
):
|
||||
pass
|
||||
|
||||
llm_off = _ScriptedLLM()
|
||||
asyncio.run(_consume(llm_off, _settings()))
|
||||
assert len(llm_off.requests) == 1
|
||||
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
|
||||
|
||||
llm_on = _ScriptedLLM()
|
||||
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
|
||||
sent = llm_on.requests[0][1]
|
||||
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
|
||||
assert [t["function"]["name"] for t in sent] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
]
|
||||
|
||||
|
||||
# --- the handler: the refusal matrix + the confirming success ---
|
||||
|
||||
|
||||
def test_show_image_refusal_texts_are_pinned() -> None:
|
||||
"""The refusal texts (the ``MISSING_READ_ARGS`` /
|
||||
``ALREADY_IN_CONTEXT`` shapes) are pinned — the model's
|
||||
self-correction relies on them being stable."""
|
||||
assert MISSING_SHOW_ARGS == "show_image requires a string argument 'path'."
|
||||
assert ALREADY_SHOWING == (
|
||||
"Already showing an image this answer — do not show another one. "
|
||||
"Answer from what you have."
|
||||
)
|
||||
assert NOT_AN_IMAGE_DOC.format(arg="s/p.png") == (
|
||||
"'s/p.png' is not an image document — only standalone image "
|
||||
"documents can be shown as answers."
|
||||
)
|
||||
|
||||
|
||||
def _show_call(arguments: dict[str, Any]) -> ToolCallPiece:
|
||||
return ToolCallPiece(id="call_show", name="show_image", arguments=arguments)
|
||||
|
||||
|
||||
def _seed_show_doc(
|
||||
db: Session, *, is_image: bool, content: str, path: str
|
||||
) -> Document:
|
||||
"""One real documents row ``show_image`` can resolve (source
|
||||
``showsrc`` — no fixture collision); cleaned up by the caller."""
|
||||
doc = Document(
|
||||
id=uuid.uuid4(),
|
||||
source="showsrc",
|
||||
path=path,
|
||||
full_path=f"/showsrc/{path}",
|
||||
title="diagram" if is_image else "Notes",
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||
is_image=is_image,
|
||||
)
|
||||
if is_image:
|
||||
doc.image_path = f"/tmp/{doc.id}.png"
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _show(db: Session, settings: Settings, holder: AgentHolder, arguments: dict[str, Any]) -> str:
|
||||
return _execute_tool(db, _show_call(arguments), [], holder, settings)
|
||||
|
||||
|
||||
def test_show_image_missing_or_empty_path_is_refused(db: Session) -> None:
|
||||
"""(a) missing/empty/whitespace/non-string ``path`` →
|
||||
``MISSING_SHOW_ARGS`` — counts in nothing, nothing shown."""
|
||||
holder = AgentHolder()
|
||||
for arguments in ({}, {"path": " "}, {"path": None}):
|
||||
assert _show(db, _settings(), holder, arguments) == MISSING_SHOW_ARGS
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_images == []
|
||||
|
||||
|
||||
def test_show_image_unresolvable_arg_gets_the_no_document_teaching(
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""(b) an unresolvable argument → the phase-72
|
||||
``_no_document_refusal`` text (the bare-argument line, and the
|
||||
did-you-mean combined-identity for a path-like argument) — counts
|
||||
in nothing, nothing shown."""
|
||||
holder = AgentHolder()
|
||||
# A bare argument (no ``/``) can never be a document — the
|
||||
# no-DB-lookup refusal, byte-identical to the read branch's line.
|
||||
assert _show(db, _settings(), holder, {"path": "nosuchdoc"}) == (
|
||||
"No document at 'nosuchdoc' — check the ls output."
|
||||
)
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_images == []
|
||||
# A path-like argument matching exactly ONE indexed document's path
|
||||
# gets the combined-identity teaching.
|
||||
doc = _seed_show_doc(db, is_image=True, content="A red square.", path="phase124/diagram.png")
|
||||
try:
|
||||
result = _show(db, _settings(), holder, {"path": "phase124/diagram.png"})
|
||||
assert result == _no_document_refusal(db, "phase124/diagram.png")
|
||||
assert "showsrc/phase124/diagram.png" in result # the combined identity named
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_images == []
|
||||
finally:
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_show_image_of_a_text_doc_is_refused_not_converted(db: Session) -> None:
|
||||
"""(c) a resolved TEXT document → ``NOT_AN_IMAGE_DOC`` (the argument
|
||||
echoed) — a text doc is refused, NEVER silently converted to a
|
||||
read; counts in nothing, nothing shown or read."""
|
||||
doc = _seed_show_doc(
|
||||
db, is_image=False, content="just text.", path="phase124/notes.md"
|
||||
)
|
||||
try:
|
||||
holder = AgentHolder()
|
||||
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/notes.md"})
|
||||
assert result == NOT_AN_IMAGE_DOC.format(arg="showsrc/phase124/notes.md")
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_images == []
|
||||
assert holder.read_docs == [] # never converted to a read
|
||||
finally:
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_show_image_of_an_image_doc_confirms_and_records(db: Session) -> None:
|
||||
"""(d) a resolved IMAGE document → the confirming result — the NEW
|
||||
first line (absent from ``read``) followed by the exact
|
||||
read-style document block (the ``Document …`` header + date line +
|
||||
:data:`IMAGE_DOC_MARKER` + the description), ``shown_images ==
|
||||
[doc]``, and ``tool_calls`` bumped (an executed call — it feeds the
|
||||
per-turn ``tool_calls=N``)."""
|
||||
doc = _seed_show_doc(
|
||||
db,
|
||||
is_image=True,
|
||||
content="A red square on a white background.",
|
||||
path="phase124/diagram.png",
|
||||
)
|
||||
try:
|
||||
holder = AgentHolder()
|
||||
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram.png"})
|
||||
assert result == (
|
||||
"Image shown to the user — it is now part of your answer.\n"
|
||||
"Document showsrc/phase124/diagram.png:\n"
|
||||
"date: 2024-06-15\n"
|
||||
f"{IMAGE_DOC_MARKER}\n"
|
||||
"A red square on a white background."
|
||||
)
|
||||
assert holder.shown_images == [doc]
|
||||
assert holder.tool_calls == 1
|
||||
finally:
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_show_image_second_call_is_refused(db: Session) -> None:
|
||||
"""(e) a second ``show_image`` in the same turn →
|
||||
``ALREADY_SHOWING`` (LOCKED A3, server-enforced) — appends nothing,
|
||||
counts in nothing; the first document stays the one shown."""
|
||||
doc = _seed_show_doc(
|
||||
db, is_image=True, content="A red square.", path="phase124/diagram2.png"
|
||||
)
|
||||
try:
|
||||
holder = AgentHolder()
|
||||
first = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
|
||||
assert first.startswith("Image shown to the user — it is now part of your answer.")
|
||||
second = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
|
||||
assert second == ALREADY_SHOWING
|
||||
assert holder.shown_images == [doc] # still exactly one shown
|
||||
assert holder.tool_calls == 1 # the refusal counts in nothing
|
||||
finally:
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 03 — the ``show_image`` prompt clause (toggle-gated)
|
||||
#
|
||||
# The clause body itself is pinned in ``tests/unit/test_prompts.py``
|
||||
# (``SHOW_IMAGE_CLAUSE`` + the ``build_high_prompt`` builds). This
|
||||
# section pins the chat-side wiring: the grounded plan carries the
|
||||
# clause EXACTLY when ``settings.answer_images`` is true, and a
|
||||
# deflected plan never does.
|
||||
|
||||
from app.api.chat import plan_turn # noqa: E402
|
||||
from app.rag.prompts import SHOW_IMAGE_CLAUSE, build_high_prompt # noqa: E402
|
||||
from app.rag.retriever import RetrievedChunk # noqa: E402
|
||||
|
||||
#: A detached document row + its chunk — ``plan_turn`` is pure (no DB),
|
||||
#: so the wiring is pinned without a database (the cosine 0.9 clears
|
||||
#: the 0.62 default ``relevance_threshold`` → HIGH/grounded; the
|
||||
#: 0.2 / no-fts chunk stays LOW/deflected — the gate is strict).
|
||||
_P3_DOC = Document(
|
||||
id=uuid.uuid4(),
|
||||
source="Homelab",
|
||||
path="kubernetes.md",
|
||||
full_path="/tmp/kubernetes.md",
|
||||
title="Kubernetes Homelab Cluster",
|
||||
content="Talos Linux on three nodes.",
|
||||
content_hash="0" * 64,
|
||||
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||
summary="A Talos Linux cluster on three nodes.",
|
||||
)
|
||||
|
||||
|
||||
def _plan3(cosine: float, settings: Settings):
|
||||
chunk = RetrievedChunk(
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=0,
|
||||
content="kubernetes",
|
||||
score=1.0,
|
||||
document=_P3_DOC,
|
||||
cosine=cosine,
|
||||
)
|
||||
return plan_turn([chunk], settings)
|
||||
|
||||
|
||||
def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
|
||||
"""Completion pin: the grounded plan's system prompt is BYTE-
|
||||
IDENTICAL to the pre-task build when ``answer_images`` is off (the
|
||||
default — the pre-phase pins pass untouched) and gains the clause
|
||||
(inside ``<tools>``) exactly when it is on."""
|
||||
off = _plan3(0.9, _settings())
|
||||
assert off.deflected is False
|
||||
assert off.system_prompt == build_high_prompt([_P3_DOC])
|
||||
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||
|
||||
on = _plan3(0.9, _settings(answer_images=True))
|
||||
assert on.deflected is False
|
||||
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
|
||||
assert SHOW_IMAGE_CLAUSE in on.system_prompt
|
||||
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
|
||||
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
|
||||
|
||||
|
||||
def test_plan_turn_deflected_never_carries_the_clause() -> None:
|
||||
"""A deflected turn has no tools and never shows an image (the
|
||||
phase's locked design): the deflection prompt takes no such flag,
|
||||
so its build is identical — and clause-free — whether the toggle
|
||||
is off or on."""
|
||||
off = _plan3(0.2, _settings())
|
||||
assert off.deflected is True
|
||||
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||
assert "<tools>" not in off.system_prompt
|
||||
|
||||
on = _plan3(0.2, _settings(answer_images=True))
|
||||
assert on.deflected is True
|
||||
assert on.system_prompt == off.system_prompt # toggle has no effect at all
|
||||
assert SHOW_IMAGE_CLAUSE not in on.system_prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 04 — the ``answer_image`` done frame + saved-chat persistence
|
||||
#
|
||||
# The wire contract: ``ChatDoneEvent.answer_image`` (the shown doc's
|
||||
# :class:`SourceRef`, OMITTED from the frame when absent — every
|
||||
# toggle-off / image-less turn stays byte-identical) and
|
||||
# ``ChatMessage.answer_image`` (the brain-record key the done-time
|
||||
# auto-save persists).
|
||||
#
|
||||
from json import dumps as _json_dumps # noqa: E402
|
||||
|
||||
from pydantic import ValidationError # noqa: E402
|
||||
|
||||
from app.schemas import ChatDoneEvent, ChatMessage, SourceRef # noqa: E402
|
||||
|
||||
#: The shown image doc's full ref (all four fields — a shown doc is an
|
||||
#: image doc, so its ref always carries the ``image_url`` bytes route).
|
||||
_SHOWN_REF = {
|
||||
"source": "docs",
|
||||
"path": "pic.png",
|
||||
"title": "pic",
|
||||
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
|
||||
}
|
||||
|
||||
|
||||
def test_done_event_without_answer_image_drops_the_key() -> None:
|
||||
"""``answer_image=None`` (every deflected / image-less / pre-phase
|
||||
turn) → ``model_dump()`` has NO ``answer_image`` key — ABSENT, never
|
||||
``null`` — and the dump is byte-identical to the pre-phase frame
|
||||
shape (the byte-identical-off contract, task 01). Checked on the
|
||||
dict AND on the raw JSON (no ``"answer_image"`` token, no
|
||||
``null`` field)."""
|
||||
event = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[SourceRef(source="docs", path="a.md", title="A")],
|
||||
related=[SourceRef(source="docs", path="b.md", title="B")],
|
||||
suggestions=["Maybe try X?"],
|
||||
)
|
||||
dump = event.model_dump()
|
||||
assert "answer_image" not in dump
|
||||
# The pre-phase-124 key set, exactly.
|
||||
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
|
||||
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
|
||||
{
|
||||
"type": "done",
|
||||
"deflected": False,
|
||||
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
|
||||
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
|
||||
"suggestions": ["Maybe try X?"],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
raw = _json_dumps(dump)
|
||||
assert "answer_image" not in raw
|
||||
|
||||
|
||||
def test_done_event_without_answer_image_parses_a_pre_phase_frame() -> None:
|
||||
"""Back-compat: a pre-phase-124 done frame (no ``answer_image``
|
||||
key) parses with the default ``None`` and re-dumps byte-identically
|
||||
(PLAN §4 — the field is additive in both directions)."""
|
||||
old_payload = {
|
||||
"type": "done",
|
||||
"deflected": True,
|
||||
"sources": [],
|
||||
"related": [],
|
||||
"suggestions": [],
|
||||
}
|
||||
event = ChatDoneEvent(**old_payload)
|
||||
assert event.answer_image is None
|
||||
assert event.model_dump() == old_payload
|
||||
|
||||
|
||||
def test_done_event_with_answer_image_carries_the_full_ref() -> None:
|
||||
"""``answer_image`` set (the shown doc) → the key is present with
|
||||
ALL FOUR ref fields (source/path/title + the ``image_url`` bytes
|
||||
route), and the existing ``sources``/``related``/``suggestions``
|
||||
serialization is untouched."""
|
||||
event = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[SourceRef(source="docs", path="pic.png", title="pic")],
|
||||
related=[],
|
||||
suggestions=[],
|
||||
answer_image=SourceRef(**_SHOWN_REF),
|
||||
)
|
||||
dump = event.model_dump()
|
||||
assert dump["answer_image"] == _SHOWN_REF
|
||||
assert dump["sources"] == [{"source": "docs", "path": "pic.png", "title": "pic"}]
|
||||
assert dump["related"] == []
|
||||
assert dump["suggestions"] == []
|
||||
assert dump["type"] == "done"
|
||||
assert dump["deflected"] is False
|
||||
|
||||
|
||||
def test_done_event_answer_image_ref_drops_its_own_null_image_url() -> None:
|
||||
"""The ref's OWN serializer still applies inside the frame: a
|
||||
text-shaped ref (``image_url=None`` — a corner that cannot occur
|
||||
server-side, since only image docs are shown, but a client-built
|
||||
frame could carry it) keeps the key and drops the null field
|
||||
(the :class:`SourceRef.image_url` omission rule)."""
|
||||
event = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[],
|
||||
answer_image=SourceRef(source="s", path="p.png", title="t"),
|
||||
)
|
||||
dump = event.model_dump()
|
||||
assert dump["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
|
||||
assert "image_url" not in dump["answer_image"]
|
||||
|
||||
|
||||
# --- the ``ChatMessage`` boundary (the saved-chat record key) ---
|
||||
|
||||
|
||||
def test_chat_message_brain_record_with_answer_image_parses() -> None:
|
||||
"""A brain record carrying ``answer_image`` (the shown doc's full
|
||||
ref shape) parses — the phase-113 regression: without the field,
|
||||
the ``extra="forbid"`` boundary would 422 every done-time auto-save
|
||||
that carries the key (the A2 quiet-failure path)."""
|
||||
msg = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
|
||||
)
|
||||
assert msg.answer_image is not None
|
||||
assert msg.answer_image.source == "docs"
|
||||
assert msg.answer_image.path == "pic.png"
|
||||
assert msg.answer_image.title == "pic"
|
||||
assert msg.answer_image.image_url == _SHOWN_REF["image_url"]
|
||||
|
||||
|
||||
def test_chat_message_without_or_null_answer_image_parses() -> None:
|
||||
"""``answer_image`` ABSENT (every pre-phase-124 record) and explicit
|
||||
``None`` both parse (the default) — and neither serializes the key
|
||||
(ABSENT, never ``null`` — pre-phase and image-less records
|
||||
round-trip byte-identically, the phase-50 contract)."""
|
||||
absent = ChatMessage.model_validate({"who": "brain", "text": "hi"})
|
||||
assert absent.answer_image is None
|
||||
assert "answer_image" not in absent.model_dump()
|
||||
|
||||
explicit_null = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "hi", "answer_image": None}
|
||||
)
|
||||
assert explicit_null.answer_image is None
|
||||
assert "answer_image" not in explicit_null.model_dump()
|
||||
|
||||
|
||||
def test_chat_message_with_answer_image_serializes_the_ref() -> None:
|
||||
"""A record WITH a shown ref keeps it in the dump — the restore
|
||||
path re-renders the answer figure from it (the round-trip)."""
|
||||
msg = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
|
||||
)
|
||||
dump = msg.model_dump()
|
||||
assert dump["answer_image"] == _SHOWN_REF
|
||||
|
||||
|
||||
def test_chat_message_user_image_field_still_parses_independently() -> None:
|
||||
"""The user record's phase-123 ``image`` (the stored PATH string of
|
||||
the question's attachment) is untouched and semantically distinct:
|
||||
it still parses on a user record, still serializes the path, and is
|
||||
independent of ``answer_image`` (a brain record carries the shown
|
||||
ref, never the attachment path)."""
|
||||
user = ChatMessage.model_validate(
|
||||
{
|
||||
"who": "user",
|
||||
"text": "What is in this image?",
|
||||
"image": "/api/chat-images/" + "a" * 32 + ".png",
|
||||
}
|
||||
)
|
||||
assert user.image == "/api/chat-images/" + "a" * 32 + ".png"
|
||||
assert user.answer_image is None
|
||||
dump = user.model_dump()
|
||||
assert dump["image"] == user.image
|
||||
assert "answer_image" not in dump
|
||||
|
||||
|
||||
def test_chat_message_answer_image_without_image_url_parses() -> None:
|
||||
"""``answer_image`` missing ``image_url`` parses — the field is
|
||||
optional on :class:`SourceRef` (its own serializer then drops the
|
||||
null on the way out)."""
|
||||
msg = ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "hi",
|
||||
"answer_image": {"source": "s", "path": "p.png", "title": "t"},
|
||||
}
|
||||
)
|
||||
assert msg.answer_image is not None
|
||||
assert msg.answer_image.image_url is None
|
||||
assert msg.model_dump()["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
|
||||
|
||||
|
||||
def test_chat_message_phase83_bounds_still_bind_the_answer_image_ref() -> None:
|
||||
"""The phase-83 value bounds bind the ref's fields EXACTLY like
|
||||
every other :class:`SourceRef` (``source`` ≤ 120, ``path`` ≤ 1000,
|
||||
``title`` ≤ 500 — the ``documents`` column lengths): one over any
|
||||
cap is a 422 at the boundary."""
|
||||
for bad in (
|
||||
{"source": "s" * 121, "path": "p", "title": "t"},
|
||||
{"source": "s", "path": "p" * 1001, "title": "t"},
|
||||
{"source": "s", "path": "p", "title": "t" * 501},
|
||||
):
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "hi", "answer_image": bad}
|
||||
)
|
||||
# Exactly AT the caps: parses (a server-built ref from a full-length
|
||||
# row fits by construction — the same pin as the done-event ref).
|
||||
at_caps = ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "hi",
|
||||
"answer_image": {"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500},
|
||||
}
|
||||
)
|
||||
assert at_caps.answer_image is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 05 — bug fix: suggested-tier image docs earn their source chips
|
||||
#
|
||||
# ``merge_cited_docs`` (``app/api/chat.py``) composes the citation tier
|
||||
# (the done frame's ``sources``): the phase-119 read-docs-only rule
|
||||
# plus phase 124's single documented exception — a suggested-tier image
|
||||
# doc's seed block IS its entire content (content == summary == the
|
||||
# vision description, phase 122 task 03), so "suggested" ≡ "read" for
|
||||
# it and it is auto-cited even on a zero-read grounded turn (a ``read``
|
||||
# would add only the marker line — the bug: the chip never appeared).
|
||||
|
||||
from app.api.chat import merge_cited_docs # noqa: E402
|
||||
|
||||
|
||||
def _uc_doc(source: str, path: str, *, is_image: bool = False) -> Document:
|
||||
"""A detached documents row (no DB) — ``merge_cited_docs`` touches
|
||||
only the ``(source, path)`` identity and ``is_image``."""
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/{source}/{path}",
|
||||
title=path.rsplit("/", 1)[-1],
|
||||
content="A red square." if is_image else "just text.",
|
||||
content_hash="0" * 64,
|
||||
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||
is_image=is_image,
|
||||
)
|
||||
|
||||
|
||||
def test_merge_cited_docs_zero_read_suggested_image_doc_is_cited() -> None:
|
||||
"""(a) The bug fix: a suggested-tier IMAGE doc + zero reads → it
|
||||
IS cited (the zero-read grounded turn — the phase-119 "chips
|
||||
nothing" fast path — chips it; pre-phase-124 this returned
|
||||
``[]``)."""
|
||||
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||
assert merge_cited_docs([], [img], deflected=False) == [img]
|
||||
|
||||
|
||||
def test_merge_cited_docs_read_image_doc_appears_once_at_read_position() -> None:
|
||||
"""(b) Dedup: an image doc the agent ALSO explicitly read appears
|
||||
exactly ONCE — at its READ position (the read loop sees it first;
|
||||
the suggestion merge must not re-add or relocate it)."""
|
||||
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||
text = _uc_doc("docs", "notes.md")
|
||||
merged = merge_cited_docs([img, text], [img, text], deflected=False)
|
||||
assert merged == [img, text] # once each, read order
|
||||
assert merged.count(img) == 1
|
||||
# Read position preserved even when the suggestion order differs:
|
||||
# the image doc read SECOND stays second (the suggested tier does
|
||||
# not move read docs).
|
||||
merged = merge_cited_docs([text, img], [img], deflected=False)
|
||||
assert merged == [text, img]
|
||||
|
||||
|
||||
def test_merge_cited_docs_zero_read_suggested_text_doc_not_cited() -> None:
|
||||
"""(c) The phase-119 contract intact: a suggested TEXT doc + zero
|
||||
reads → NOT cited (auto-citation is image docs only — a zero-read
|
||||
text turn still chips nothing)."""
|
||||
text = _uc_doc("docs", "notes.md")
|
||||
assert merge_cited_docs([], [text], deflected=False) == []
|
||||
|
||||
|
||||
def test_merge_cited_docs_deflected_cites_nothing() -> None:
|
||||
"""(d) A deflected turn cites NOTHING (phase 112) — even when the
|
||||
suggested tier (the weak hits, A3) holds image docs and even when
|
||||
``read_docs`` is non-empty by construction (deflected turns run no
|
||||
agent — the guard makes the contract explicit)."""
|
||||
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||
assert merge_cited_docs([], [img], deflected=True) == []
|
||||
assert merge_cited_docs([img], [img], deflected=True) == []
|
||||
|
||||
|
||||
def test_merge_cited_docs_two_suggested_image_docs_in_suggestion_order() -> None:
|
||||
"""(e) Two suggested image docs (zero reads, text docs interleaved
|
||||
in the tier) → BOTH are cited, in SUGGESTION order (the retrieval
|
||||
rank is the chip order) — the text members are skipped."""
|
||||
text1 = _uc_doc("docs", "a.md")
|
||||
second = _uc_doc("docs", "b.png", is_image=True)
|
||||
text2 = _uc_doc("docs", "c.md")
|
||||
first = _uc_doc("docs", "a.png", is_image=True)
|
||||
merged = merge_cited_docs([], [text1, second, text2, first], deflected=False)
|
||||
assert merged == [second, first] # suggestion order, images only
|
||||
# With a read doc present, the reads come first, then the image
|
||||
# docs in suggestion order.
|
||||
read = _uc_doc("docs", "read.md")
|
||||
merged = merge_cited_docs([read], [second, first], deflected=False)
|
||||
assert merged == [read, second, first]
|
||||
|
||||
|
||||
def test_merge_cited_docs_read_order_preserved_for_mixed_read_docs() -> None:
|
||||
"""(f) Read order is preserved for mixed read text docs and
|
||||
duplicate reads collapse (dedupe by ``(source, path)``, first
|
||||
occurrence wins) — with the suggested image doc appended AFTER
|
||||
the reads."""
|
||||
a = _uc_doc("docs", "a.md")
|
||||
b = _uc_doc("docs", "b.md")
|
||||
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||
merged = merge_cited_docs([b, a, b], [img, a], deflected=False)
|
||||
assert merged == [b, a, img]
|
||||
# A duplicate image read collapses too (once, at its read
|
||||
# position) — the suggestion merge adds nothing for it.
|
||||
merged = merge_cited_docs([img, img, a], [img], deflected=False)
|
||||
assert merged == [img, a]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 06 — the answer figure: live render, persistence, restore, shared
|
||||
#
|
||||
# House-style frontend source contracts (the phase-122/123 pattern —
|
||||
# the browser-level scenario is task 07's isolated E2E): the chat
|
||||
# page's ``appendAnswerImage`` helper (lazy img, the public bytes
|
||||
# route, the visible caption, the collapse-on-error rule, the chip
|
||||
# navigation affordance), its three wiring points (the ``done``
|
||||
# handler BEFORE the chip row, the ``rememberBrainTurn`` meta key, the
|
||||
# ``renderStoredMessage`` restore BEFORE the chip row), the shared
|
||||
# page's NON-INTERACTIVE local copy (zero controls), and the
|
||||
# answer-scale themed CSS (token-only, the 320px cap).
|
||||
|
||||
import re # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend" # noqa: E402
|
||||
APP_JS = FRONTEND / "assets" / "app.js" # noqa: E402
|
||||
SHARED_JS = FRONTEND / "assets" / "shared.js" # noqa: E402
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css" # noqa: E402
|
||||
|
||||
|
||||
def _function_body(js: str, header: str) -> str:
|
||||
"""The full text of the function whose header is ``header`` — from
|
||||
the header to its brace-matched closing ``}`` (the
|
||||
``test_image_documents.py`` house helper; the pinned functions' code
|
||||
carries balanced braces)."""
|
||||
start = js.index(header)
|
||||
body_open = js.index("{", start)
|
||||
depth = 0
|
||||
for j in range(body_open, len(js)):
|
||||
if js[j] == "{":
|
||||
depth += 1
|
||||
elif js[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : j + 1]
|
||||
raise AssertionError(f"unbalanced braces for {header!r}")
|
||||
|
||||
|
||||
def _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_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
|
||||
"""The chat page's ``appendAnswerImage`` (the visual sibling of
|
||||
``appendSourceImageFigure`` — the house createElement style, no
|
||||
HTML strings): the figure anchors on the chip's navigation
|
||||
affordance (the ``documentUrl`` href + the phase-26 same-page
|
||||
modal), the img is LAZY on the public bytes route, alt + the
|
||||
visible caption start at the TITLE and swap to the document's
|
||||
SUMMARY via the SAME ``fetchContentSummary`` pattern (fails soft —
|
||||
the title stands), and a FAILED IMAGE LOAD removes the whole
|
||||
figure (the collapse rule — never a broken icon)."""
|
||||
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
|
||||
assert 'fig.className = "answer-image";' in body
|
||||
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
|
||||
assert "openDocumentModal(ref.source, ref.path, fig);" in body
|
||||
assert "e.preventDefault();" in body # no new tab (phase 26)
|
||||
assert 'img.className = "answer-image-img";' in body
|
||||
assert "img.src = ref.image_url;" in body # the public bytes route
|
||||
assert 'img.loading = "lazy";' in body # A4: lazy-loaded
|
||||
assert "img.alt = ref.title || label;" in body # title first (always visible)
|
||||
assert 'caption.className = "answer-image-caption";' in body
|
||||
assert "caption.textContent = ref.title || label;" in body # visible caption
|
||||
assert "fetchContentSummary(ref.source, ref.path)" in body
|
||||
assert "img.alt = summary;" in body # the swap once it settles
|
||||
assert "caption.textContent = summary;" in body
|
||||
assert "if (!fig.isConnected) return;" in body # collapsed/cleared → no swap
|
||||
assert "fig.remove();" in body # the collapse-on-error rule
|
||||
# The guard: no ref (or a malformed one) → no figure DOM at all.
|
||||
assert "!ref" in body
|
||||
assert 'typeof ref.image_url !== "string"' in body
|
||||
|
||||
|
||||
def test_done_handler_renders_the_figure_before_the_chips() -> None:
|
||||
"""A4 position, live path: the ``done`` handler appends the figure
|
||||
to ``.msg-body`` right BEFORE ``appendSources`` creates the chip
|
||||
row (below the text, above the chips) — and only when the frame
|
||||
carries the key (a frame without it renders byte-identically,
|
||||
no figure DOM)."""
|
||||
js = _js()
|
||||
call = "if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);"
|
||||
assert call in js
|
||||
done = js.index('} else if (ev.type === "done") {')
|
||||
next_branch = js.index('} else if (ev.type === "tool_result") {')
|
||||
handler = js[done:next_branch]
|
||||
assert call in handler
|
||||
assert handler.index(call) < handler.index("appendSources(wrap, ev.sources);")
|
||||
|
||||
|
||||
def test_done_handler_persists_the_answer_image_meta() -> None:
|
||||
"""The done-time save point carries the key (the house
|
||||
undefined-drops-the-key pattern — the ``related`` line is the
|
||||
precedent): image-less / pre-phase turns store byte-identical
|
||||
records; a record WITH the key round-trips through the task-04
|
||||
boundary (``ChatMessage.answer_image``)."""
|
||||
js = _js()
|
||||
done = js.index('} else if (ev.type === "done") {')
|
||||
next_branch = js.index('} else if (ev.type === "tool_result") {')
|
||||
handler = js[done:next_branch]
|
||||
assert "answer_image: ev.answer_image || undefined," in handler
|
||||
|
||||
|
||||
def test_stored_message_restore_renders_the_figure_before_the_chips() -> None:
|
||||
"""The refresh landing: ``renderStoredMessage``'s brain branch
|
||||
renders ``m.answer_image`` through the SAME helper at the SAME A4
|
||||
position (before the chip row) — a record without the key renders
|
||||
byte-identically (pre-phase chats restore unchanged)."""
|
||||
body = _function_body(_js(), "function renderStoredMessage(m) {")
|
||||
call = "if (m.answer_image) appendAnswerImage(wrap, m.answer_image);"
|
||||
assert call in body
|
||||
assert body.index(call) < body.index("appendSources(wrap, m.sources);")
|
||||
|
||||
|
||||
def test_shared_page_figure_is_non_interactive() -> None:
|
||||
"""The shared page's local copy (the per-page duplication house
|
||||
style): a PLAIN non-interactive ``<figure>`` (owner-locked zero
|
||||
controls — no anchor, no href, no click handler), the LAZY img on
|
||||
the PUBLIC bytes route (the guest view is faithful — the
|
||||
phase-123 user-image contract), the title as the always-visible
|
||||
caption (the content endpoint is user-gated — a guest fetch would
|
||||
401; no fetch, the title stands), and the same collapse-on-error
|
||||
rule. Wired where the brain branch appends sources (figure
|
||||
BEFORE the chips)."""
|
||||
shared = _shared_js()
|
||||
body = _function_body(shared, "function addAnswerImage(wrap, ref) {")
|
||||
assert 'document.createElement("figure")' in body # plain figure, no anchor
|
||||
assert 'fig.className = "answer-image";' in body
|
||||
assert 'document.createElement("a")' not in body # zero controls
|
||||
assert ".href" not in body
|
||||
assert 'addEventListener("click"' not in body
|
||||
assert 'img.className = "answer-image-img";' in body
|
||||
assert "img.src = ref.image_url;" in body # the public bytes route
|
||||
assert 'img.loading = "lazy";' in body
|
||||
assert "img.alt = title;" in body
|
||||
assert 'caption.className = "answer-image-caption";' in body
|
||||
assert "caption.textContent = title;" in body
|
||||
assert "fig.remove()" in body # the collapse-on-error rule
|
||||
assert "fetchContentSummary" not in body # user-gated endpoint — no guest fetch
|
||||
# The brain branch wiring: figure BEFORE the chips.
|
||||
msg = _function_body(shared, "function renderSharedMessage(m) {")
|
||||
assert "addAnswerImage(wrap, m.answer_image);" in msg
|
||||
assert msg.index("addAnswerImage(wrap, m.answer_image);") < msg.index(
|
||||
"addSources(wrap, m.sources);"
|
||||
)
|
||||
|
||||
|
||||
def test_answer_image_css_is_answer_scale_and_themed() -> None:
|
||||
"""The ``.answer-image`` family (the ``.source-image`` language at
|
||||
answer scale, the zero-literal invariant so the monochrome themes
|
||||
carry it): the img is block, full-column-width safe, CAPPED at
|
||||
320px, contain on the theme's surface with the bordered-image
|
||||
treatment; the caption is the AA-safe muted ink (8.6:1 on the
|
||||
row's page background); the hover underline is scoped to the chat
|
||||
page's ANCHOR figure only (the shared page's plain ``<figure>`` is
|
||||
out of scope by element type)."""
|
||||
css = _css()
|
||||
for selector in (".answer-image", ".answer-image-img", ".answer-image-caption"):
|
||||
assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}"
|
||||
img = re.search(r"^\.answer-image-img \{\n([\s\S]*?)\n\}", css, re.M)
|
||||
assert img, "missing .answer-image-img rule"
|
||||
img_body = img.group(1)
|
||||
assert "max-height: 320px" in img_body # the answer-scale cap
|
||||
assert "object-fit: contain" in img_body
|
||||
assert "display: block" in img_body
|
||||
assert "background: var(--surface)" in img_body # the theme's surface
|
||||
assert "border: 1px solid var(--line)" in img_body
|
||||
assert "max-width: 100%" in img_body
|
||||
cap = re.search(r"^\.answer-image-caption \{\n([\s\S]*?)\n\}", css, re.M)
|
||||
assert cap, "missing .answer-image-caption rule"
|
||||
assert "color: var(--ink-soft)" in cap.group(1) # AA: 8.6:1 on --bg
|
||||
base = re.search(r"^\.answer-image \{\n([\s\S]*?)\n\}", css, re.M)
|
||||
assert base and "max-width: 100%" in base.group(1) # full bubble width
|
||||
hover = re.search(r"^a\.answer-image:hover \{([^}]*)\}", css, re.M)
|
||||
assert hover and "text-decoration: underline" in hover.group(1), (
|
||||
"the chat-page anchor figure keeps the flat-underline hover"
|
||||
)
|
||||
# Zero literals: the whole phase-124 block is token-only (no hex
|
||||
# colors — the monochrome theme grays it automatically).
|
||||
start = css.index("/* Phase 124 (task 06")
|
||||
end = css.index("/* Phase 113 (task 02): the related-docs row")
|
||||
assert start != -1 and end > start
|
||||
assert re.search(r"#[0-9a-fA-F]{3,6}\b", css[start:end]) is None
|
||||
Reference in New Issue
Block a user