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.
972 lines
41 KiB
Python
972 lines
41 KiB
Python
"""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
|