**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`.
427 lines
16 KiB
Python
427 lines
16 KiB
Python
"""Unit tests: SVG text extraction (phase 181, task 01).
|
||
|
||
:func:`app.rag.svg_text.extract_svg_text` is a PURE function — the text
|
||
that makes an SVG document retrievable WITHOUT a vision call (LOCKED
|
||
A1). No DB, no filesystem, no LLM: every case is a literal SVG string,
|
||
so the whole contract is pinned here before the importer branch (task
|
||
02) relies on it.
|
||
|
||
The matrix:
|
||
|
||
* the guards that run BEFORE any parse — empty / whitespace-only input,
|
||
the :data:`~app.rag.svg_text.MAX_SVG_BYTES` cap, the
|
||
``<!DOCTYPE``/``<!ENTITY`` refusal (case-insensitive, both a doctype
|
||
wrapper and a bare entity declaration), malformed XML → ``None``
|
||
(never a raise);
|
||
* extraction — ``<title>``/``<desc>``/``<text>`` in DOCUMENT order,
|
||
``<tspan>`` children joined with a space, namespace-prefixed and
|
||
namespaced tags treated alike, ``xlink:`` attributes ignored,
|
||
whitespace normalised, repeats dropped case-insensitively keeping the
|
||
first, ``&``/``—`` decoded by the parser;
|
||
* the :data:`~app.rag.svg_text.MAX_SVG_TEXT_CHARS` cap — cut at the
|
||
last newline/period before the cap, hard-cut only when the window
|
||
holds no boundary at all;
|
||
* the LOCKED A2 structural fallback — stem, shape census (counts,
|
||
plural, top-3 selection with a document-order tie-break) and the
|
||
``viewBox`` clause, each omitted when the file does not state it.
|
||
|
||
Importer-level tests (the vector branch, the no-vision-call seam) live
|
||
in the phase's ingest module (tasks 02/05).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from app.rag.svg_text import (
|
||
MAX_SVG_BYTES,
|
||
MAX_SVG_TEXT_CHARS,
|
||
SvgText,
|
||
extract_svg_text,
|
||
local_name,
|
||
)
|
||
|
||
SVG_NS = "http://www.w3.org/2000/svg"
|
||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||
|
||
|
||
# --- constants -------------------------------------------------------------
|
||
|
||
|
||
def test_module_constants_are_pinned() -> None:
|
||
"""The caps are part of the contract the importer's tests assume."""
|
||
assert MAX_SVG_BYTES == 2 * 1024 * 1024
|
||
assert MAX_SVG_TEXT_CHARS == 4000
|
||
|
||
|
||
# --- the local_name helper -------------------------------------------------
|
||
|
||
|
||
def test_local_name_strips_the_clark_notation_namespace() -> None:
|
||
assert local_name(f"{{{SVG_NS}}}title") == "title"
|
||
assert local_name("title") == "title"
|
||
|
||
|
||
def test_local_name_of_a_non_string_tag_is_empty() -> None:
|
||
"""Comment / PI markers are callables — they carry no name."""
|
||
assert local_name(object()) == ""
|
||
|
||
|
||
# --- guards (no parse attempted) -------------------------------------------
|
||
|
||
|
||
def test_empty_input_is_a_refusal_not_a_crash() -> None:
|
||
assert extract_svg_text(b"") is None
|
||
assert extract_svg_text(b"", stem="diagram") is None
|
||
|
||
|
||
def test_whitespace_only_input_is_a_refusal() -> None:
|
||
assert extract_svg_text(b" \n\t \r\n") is None
|
||
|
||
|
||
def test_bytes_over_the_size_cap_are_refused() -> None:
|
||
"""The memory guard: an over-cap file never reaches the parser."""
|
||
padded = b"<svg><title>" + b"x" * (MAX_SVG_BYTES + 1) + b"</title></svg>"
|
||
assert len(padded) > MAX_SVG_BYTES
|
||
assert extract_svg_text(padded, stem="huge") is None
|
||
|
||
|
||
def test_bytes_exactly_at_the_size_cap_are_parsed() -> None:
|
||
"""The cap is ``>``-based: exactly ``MAX_SVG_BYTES`` is in range."""
|
||
opener, closer = b"<svg><title>", b"</title></svg>"
|
||
filler = b"x" * (MAX_SVG_BYTES - len(opener) - len(closer))
|
||
result = extract_svg_text(opener + filler + closer)
|
||
assert result is not None
|
||
assert result.structural is False
|
||
|
||
|
||
def test_doctype_with_an_internal_entity_is_refused() -> None:
|
||
"""Stdlib ElementTree happily EXPANDS an internal entity — proving
|
||
why the doctype-bearing input is refused outright (billion-laughs
|
||
class): the reference must never be resolved."""
|
||
svg = b'<!DOCTYPE svg [<!ENTITY x "y">]><svg><title>&x;</title></svg>'
|
||
assert extract_svg_text(svg, stem="bomb") is None
|
||
|
||
|
||
def test_bare_entity_declaration_is_refused() -> None:
|
||
assert extract_svg_text(b'<!ENTITY x "y"><svg><title>x</title></svg>') is None
|
||
|
||
|
||
def test_entity_declaration_hidden_inside_content_is_refused() -> None:
|
||
"""The marker scan is over the raw bytes — a declaration anywhere
|
||
(even in a position the parser would reject) refuses the file."""
|
||
assert extract_svg_text(b"<svg>\n<!entity x 'y'>\n</svg>") is None
|
||
|
||
|
||
def test_guards_are_case_insensitive() -> None:
|
||
assert extract_svg_text(b'<!doctype svg SYSTEM "http://x/y.dtd"><svg/>') is None
|
||
assert extract_svg_text(b'<!entity x "y"><svg/>') is None
|
||
|
||
|
||
def test_malformed_xml_is_a_refusal_not_an_exception() -> None:
|
||
"""The caller's fail-soft is a skip (``images_failed``), so a parse
|
||
error must never escape."""
|
||
assert extract_svg_text(b"<svg><title>unclosed") is None
|
||
assert extract_svg_text(b"<svg><rect></svg>") is None
|
||
assert extract_svg_text(b"\x00\x01\x02") is None
|
||
assert extract_svg_text(b"not xml at all") is None
|
||
|
||
|
||
# --- extraction: title / desc / text ---------------------------------------
|
||
|
||
|
||
def test_title_desc_and_text_are_collected_in_document_order() -> None:
|
||
svg = (
|
||
b"<svg>"
|
||
b"<title>Top Title</title>"
|
||
b"<desc>What the diagram shows</desc>"
|
||
b"<text>first label</text>"
|
||
b"<text>second label</text>"
|
||
b"</svg>"
|
||
)
|
||
assert extract_svg_text(svg, stem="topo") == SvgText(
|
||
text="Top Title\nWhat the diagram shows\nfirst label\nsecond label",
|
||
structural=False,
|
||
)
|
||
|
||
|
||
def test_a_title_after_its_shape_still_follows_document_order() -> None:
|
||
svg = b"<svg><text>later</text><g><title>group title</title></g><desc>d</desc></svg>"
|
||
assert extract_svg_text(svg) == SvgText(
|
||
text="later\ngroup title\nd", structural=False
|
||
)
|
||
|
||
|
||
def test_title_and_desc_concatenate_nested_markup() -> None:
|
||
svg = b"<title>Deploy <tspan>flow</tspan> map</title>"
|
||
full = b"<svg>" + svg + b"</svg>"
|
||
assert extract_svg_text(full) == SvgText(text="Deploy flow map", structural=False)
|
||
|
||
|
||
def test_text_children_are_joined_with_a_space() -> None:
|
||
"""``<text>CPU<tspan>usage</tspan></text>`` reads "CPU usage" —
|
||
without the space the labels weld into one unsearchable token."""
|
||
svg = b'<svg><text>CPU<tspan>usage</tspan>7d</text></svg>'
|
||
assert extract_svg_text(svg) == SvgText(text="CPU usage 7d", structural=False)
|
||
|
||
|
||
def test_whitespace_is_normalised_in_every_item() -> None:
|
||
svg = b"<svg><title> rack\n\t layout map </title><text> a\n b </text></svg>"
|
||
assert extract_svg_text(svg) == SvgText(
|
||
text="rack layout map\na b", structural=False
|
||
)
|
||
|
||
|
||
def test_blank_title_is_dropped_and_never_stops_the_walk() -> None:
|
||
svg = b"<svg><title> </title><desc> </desc><text>real label</text></svg>"
|
||
assert extract_svg_text(svg) == SvgText(text="real label", structural=False)
|
||
|
||
|
||
def test_only_blank_text_falls_back_to_the_structural_line() -> None:
|
||
"""A blank ``<title>``/``<text>`` contributes no words, so the file
|
||
is still described by what it is (the empty ``<text>`` counts as a
|
||
drawn element)."""
|
||
svg = b'<svg viewBox="0 0 4 4"><title></title><text> </text></svg>'
|
||
assert extract_svg_text(svg, stem="blank") == SvgText(
|
||
text='Vector diagram "blank" — 1 shape (text), viewBox 4×4', structural=True
|
||
)
|
||
|
||
|
||
def test_repeats_are_dropped_case_insensitively_keeping_the_first() -> None:
|
||
svg = (
|
||
b"<svg>"
|
||
b"<desc>Power Draw</desc>"
|
||
b"<title>power draw</title>"
|
||
b"<text>Other</text>"
|
||
b"</svg>"
|
||
)
|
||
assert extract_svg_text(svg) == SvgText(text="Power Draw\nOther", structural=False)
|
||
|
||
|
||
def test_parser_decodes_entity_references_in_the_text() -> None:
|
||
"""``&`` comes out decoded — the renderer re-escapes on display
|
||
(phase 180), so storing the decoded value is the correct direction."""
|
||
svg = b"<svg><title>Red & Blue — map</title></svg>"
|
||
assert extract_svg_text(svg) == SvgText(text="Red & Blue — map", structural=False)
|
||
|
||
|
||
def test_attributes_are_never_text() -> None:
|
||
svg = (
|
||
b'<svg xmlns:xlink="'
|
||
+ XLINK_NS.encode()
|
||
+ b'"><title lang="en">Visible</title>'
|
||
b'<image xlink:href="secret-internal-name.png"/></svg>'
|
||
)
|
||
assert extract_svg_text(svg, stem="diagram") == SvgText(
|
||
text="Visible", structural=False
|
||
)
|
||
|
||
|
||
def test_an_image_only_svg_yields_no_words_from_its_href() -> None:
|
||
"""ASSUMPTION (task 01): an embedded ``<image>`` is not followed —
|
||
its ``href`` is not text, so the structural line describes the file."""
|
||
svg = (
|
||
b'<svg xmlns:xlink="'
|
||
+ XLINK_NS.encode()
|
||
+ b'" viewBox="0 0 10 10">'
|
||
b'<image xlink:href="inner.png"/>'
|
||
b"</svg>"
|
||
)
|
||
result = extract_svg_text(svg, stem="poster")
|
||
assert result is not None
|
||
assert result.structural is True
|
||
assert result.text == 'Vector diagram "poster" — 1 shape (image), viewBox 10×10'
|
||
|
||
|
||
# --- extraction: namespaces ------------------------------------------------
|
||
|
||
|
||
def test_the_default_svg_namespace_is_ignored() -> None:
|
||
svg = (
|
||
b'<svg xmlns="'
|
||
+ SVG_NS.encode()
|
||
+ b'"><title>Namespace title</title><desc>ns desc</desc>'
|
||
b'<text>ns label</text></svg>'
|
||
)
|
||
assert extract_svg_text(svg) == SvgText(
|
||
text="Namespace title\nns desc\nns label", structural=False
|
||
)
|
||
|
||
|
||
def test_a_declared_prefix_form_counts_like_the_plain_tag() -> None:
|
||
"""``ElementTree`` expands ``<svg:title>`` to Clark notation, so the
|
||
local-name match covers it for free."""
|
||
svg = (
|
||
b'<svg xmlns:svg="'
|
||
+ SVG_NS.encode()
|
||
+ b'"><svg:title>Prefixed</svg:title>'
|
||
b"<svg:desc>Prefixed desc</svg:desc></svg>"
|
||
)
|
||
assert extract_svg_text(svg) == SvgText(
|
||
text="Prefixed\nPrefixed desc", structural=False
|
||
)
|
||
|
||
|
||
def test_a_foreign_namespace_element_is_not_worded_text() -> None:
|
||
"""A ``<title>`` in a NON-SVG vocabulary is still matched by local
|
||
name (the walk is namespace-agnostic) while the foreign element
|
||
counts as a shape when it carries no words."""
|
||
svg = (
|
||
b'<svg xmlns:h="http://www.w3.org/1999/xhtml">'
|
||
b'<foreignObject><h:p>paragraph</h:p></foreignObject>'
|
||
b"</svg>"
|
||
)
|
||
result = extract_svg_text(svg, stem="mixed")
|
||
assert result is not None
|
||
assert result.structural is True
|
||
assert result.text == 'Vector diagram "mixed" — 2 shapes (foreignObject, p)'
|
||
|
||
|
||
def test_the_xml_declaration_encoding_is_honoured() -> None:
|
||
svg = b"<?xml version='1.0' encoding='UTF-8'?><svg><title>Caf\xc3\xa9</title></svg>"
|
||
assert extract_svg_text(svg) == SvgText(text="Café", structural=False)
|
||
|
||
|
||
def test_comments_are_not_text_and_not_shapes() -> None:
|
||
svg = b'<svg viewBox="0 0 2 2"><!-- a comment --><path d="M0 0"/></svg>'
|
||
result = extract_svg_text(svg, stem="clean")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "clean" — 1 shape (path), viewBox 2×2'
|
||
|
||
|
||
# --- the MAX_SVG_TEXT_CHARS cap --------------------------------------------
|
||
|
||
|
||
def test_extraction_under_the_cap_is_untouched() -> None:
|
||
svg = b"<svg><title>" + b"x" * (MAX_SVG_TEXT_CHARS - 20) + b"</title></svg>"
|
||
result = extract_svg_text(svg)
|
||
assert result is not None
|
||
assert len(result.text) == MAX_SVG_TEXT_CHARS - 20
|
||
|
||
|
||
def test_over_cap_text_ends_on_a_sentence_boundary() -> None:
|
||
svg = b"<svg><title>" + b"x. " * 2000 + b"</title></svg>"
|
||
result = extract_svg_text(svg)
|
||
assert result is not None
|
||
assert result.structural is False
|
||
assert len(result.text) <= MAX_SVG_TEXT_CHARS
|
||
assert len(result.text) > MAX_SVG_TEXT_CHARS - 10
|
||
assert result.text.endswith(".")
|
||
assert result.text.count("x.") < 2000 # the cut is mid-item
|
||
|
||
|
||
def test_over_cap_text_prefers_the_last_newline_boundary() -> None:
|
||
"""Two items joined with ``\\n``: the newline is the LAST boundary
|
||
inside the window, so the second item is dropped whole (never a
|
||
partial word) and the trailing newline is stripped."""
|
||
first = b"a" * (MAX_SVG_TEXT_CHARS - 10)
|
||
svg = b"<svg><title>" + first + b"</title><text>" + b"b" * 200 + b"</text></svg>"
|
||
result = extract_svg_text(svg)
|
||
assert result is not None
|
||
assert result.text == first.decode()
|
||
|
||
|
||
def test_over_cap_text_without_any_boundary_is_hard_cut() -> None:
|
||
svg = b"<svg><title>" + b"x" * (MAX_SVG_TEXT_CHARS + 1000) + b"</title></svg>"
|
||
result = extract_svg_text(svg)
|
||
assert result is not None
|
||
assert result.text == "x" * MAX_SVG_TEXT_CHARS
|
||
|
||
|
||
# --- the structural fallback (LOCKED A2) -----------------------------------
|
||
|
||
|
||
def test_single_shape_line_matches_the_pinned_wording() -> None:
|
||
svg = b'<svg><path d="M0 0 L1 1"/></svg>'
|
||
assert extract_svg_text(svg, stem="topo") == SvgText(
|
||
text='Vector diagram "topo" — 1 shape (path)', structural=True
|
||
)
|
||
|
||
|
||
def test_shape_noun_pluralises_and_counts_are_listed() -> None:
|
||
svg = b"<svg><path/><path/><path/></svg>"
|
||
assert extract_svg_text(svg, stem="topo") == SvgText(
|
||
text='Vector diagram "topo" — 3 shapes (path×3)', structural=True
|
||
)
|
||
|
||
|
||
def test_top_three_kinds_with_document_order_tie_break() -> None:
|
||
svg = b"<svg><g/><g/><g/><path/><path/><circle/><rect/></svg>"
|
||
result = extract_svg_text(svg, stem="census")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "census" — 7 shapes (g×3, path×2, circle)'
|
||
assert result.structural is True
|
||
|
||
|
||
def test_structure_elements_are_never_counted_as_shapes() -> None:
|
||
svg = b"<svg><title> </title><desc/><g><rect/></g></svg>"
|
||
result = extract_svg_text(svg, stem="nested")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "nested" — 2 shapes (g, rect)'
|
||
|
||
|
||
def test_a_nested_svg_element_is_structure_too() -> None:
|
||
svg = b"<svg><g><svg><rect/></svg></g></svg>"
|
||
result = extract_svg_text(svg, stem="inset")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "inset" — 2 shapes (g, rect)'
|
||
|
||
|
||
def test_viewbox_reports_width_and_height() -> None:
|
||
svg = b'<svg viewBox="0 0 128 64"><path/></svg>'
|
||
result = extract_svg_text(svg, stem="wide")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "wide" — 1 shape (path), viewBox 128×64'
|
||
|
||
|
||
def test_viewbox_accepts_commas_and_integral_floats() -> None:
|
||
svg = b'<svg viewBox=" 0 , 0 , 8.0 , 6.0 "><path/></svg>'
|
||
result = extract_svg_text(svg, stem="comma")
|
||
assert result is not None
|
||
assert result.text == 'Vector diagram "comma" — 1 shape (path), viewBox 8×6'
|
||
|
||
|
||
def test_viewbox_keeps_fractional_sizes_without_padding() -> None:
|
||
svg = b'<svg viewBox="0 0 8.5 6.25"><g><circle/></g></svg>'
|
||
result = extract_svg_text(svg)
|
||
assert result is not None
|
||
assert result.text == "Vector diagram — 2 shapes (g, circle), viewBox 8.5×6.25"
|
||
|
||
|
||
def test_unparseable_viewbox_omits_the_clause() -> None:
|
||
for raw in (b"", b"0 0 8", b"0 0 8 6 4", b"0 0 wide tall", b"0 0 NaN NaN"):
|
||
svg = b'<svg viewBox="' + raw + b'"><path/></svg>'
|
||
result = extract_svg_text(svg, stem="odd")
|
||
assert result is not None, raw
|
||
assert result.text == 'Vector diagram "odd" — 1 shape (path)', raw
|
||
|
||
|
||
def test_missing_viewbox_omits_the_clause() -> None:
|
||
result = extract_svg_text(b"<svg><path/></svg>", stem="nobox")
|
||
assert result is not None
|
||
assert "viewBox" not in result.text
|
||
|
||
|
||
def test_viewbox_without_shapes_is_the_only_fact() -> None:
|
||
svg = b'<svg viewBox="0 0 16 9"></svg>'
|
||
assert extract_svg_text(svg, stem="canvas") == SvgText(
|
||
text='Vector diagram "canvas" — viewBox 16×9', structural=True
|
||
)
|
||
|
||
|
||
def test_empty_stem_never_renders_empty_quotes() -> None:
|
||
assert extract_svg_text(b"<svg></svg>") == SvgText(
|
||
text="Vector diagram", structural=True
|
||
)
|
||
assert extract_svg_text(b'<svg viewBox="0 0 1 1"><path/></svg>') == SvgText(
|
||
text="Vector diagram — 1 shape (path), viewBox 1×1", structural=True
|
||
)
|
||
|
||
|
||
def test_a_root_that_is_not_svg_still_yields_honest_text() -> None:
|
||
"""The walk is tag-based — a mis-named root is not rejected, and the
|
||
root is never counted among the shapes."""
|
||
result = extract_svg_text(b"<html><title>Legacy</title></html>", stem="legacy")
|
||
assert result == SvgText(text="Legacy", structural=False)
|
||
structural = extract_svg_text(b"<html><body/></html>", stem="legacy")
|
||
assert structural == SvgText(
|
||
text='Vector diagram "legacy" — 1 shape (body)', structural=True
|
||
)
|