Files
brain-of-reese/tests/unit/test_svg_text_extraction.py
ducoterra 814a400e6d
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 181_svg_document_preview
**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**; ⑤`![alt](/api/documents/<id>/image)` 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`.
2026-10-14 22:54:43 -04:00

427 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit 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, ``&amp;``/``&#8212;`` 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:
"""``&amp;`` 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 &amp; Blue &#8212; 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
)