"""Unit tests: markdown image-reference extraction (phase 125, task 02). ``extract_image_refs`` is a pure function (LOCKED A1 — the storage content of ``Document.image_context``), so the tests pin the exact contract the enrichment pipeline (tasks 03/04) will rely on: * one :class:`ImageRef` per ``![alt](path)`` reference outside code fences (multiple per line extracted separately, same window); * the context window — up to *N* paragraphs before and AFTER the reference paragraph, joined with blank lines, the reference paragraph itself excluded (the reference is already ``alt_text`` + ``path``); * fence semantics identical to the chunker (PLAN §5) — fences are ATOMIC paragraphs (one context unit, blank lines inside don't split it) and references inside a fence are literal text — skipped; * edge cases: empty alt, start/end of file, headings, paths with spaces, nested parens (accepted miss), empty content, no refs, 1-based ``line_number``. """ from __future__ import annotations import pytest from app.rag.md_images import CONTEXT_PARAGRAPHS, ImageRef, extract_image_refs def test_default_constant() -> None: assert CONTEXT_PARAGRAPHS == 2 doc = "P1.\n\nP2.\n\nP3.\n\n![a](a.png)\n\nQ1.\n\nQ2.\n\nQ3.\n" assert extract_image_refs(doc) == extract_image_refs(doc, context_paragraphs=2) def test_single_reference_with_surrounding_paragraphs() -> None: doc = ( "# Title\n" "\n" "First paragraph.\n" "\n" "Second paragraph.\n" "\n" "![alt](image.png)\n" "\n" "Third paragraph.\n" "\n" "Fourth paragraph.\n" ) refs = extract_image_refs(doc) assert refs == [ ImageRef( path="image.png", alt_text="alt", context=( "First paragraph.\n\n" "Second paragraph.\n\n" "Third paragraph.\n\n" "Fourth paragraph." ), line_number=7, ) ] def test_multiple_references_on_different_lines() -> None: doc = ( "Intro.\n" "\n" "Para A.\n" "\n" "![one](one.png)\n" "\n" "Para B.\n" "\n" "Para C.\n" "\n" "Para D.\n" "\n" "![two](two.png)\n" "\n" "Outro.\n" ) refs = extract_image_refs(doc) assert [r.path for r in refs] == ["one.png", "two.png"] assert [r.alt_text for r in refs] == ["one", "two"] assert [r.line_number for r in refs] == [5, 13] # Each gets its OWN window (N=2 around its own paragraph — spaced # apart so the windows don't contain the other reference line). assert refs[0].context == "Intro.\n\nPara A.\n\nPara B.\n\nPara C." assert refs[1].context == "Para C.\n\nPara D.\n\nOutro." def test_multiple_references_on_same_line_share_window() -> None: doc = "Before.\n\nSee ![a](a.png) and ![b](b.png) here.\n\nAfter.\n" refs = extract_image_refs(doc) assert [r.path for r in refs] == ["a.png", "b.png"] assert [r.alt_text for r in refs] == ["a", "b"] assert [r.line_number for r in refs] == [3, 3] # Same reference paragraph → same context, reference line excluded. assert refs[0].context == "Before.\n\nAfter." assert refs[1].context == "Before.\n\nAfter." def test_reference_in_code_fence_is_skipped() -> None: doc = ( "Intro.\n" "\n" "```\n" "![not real](fenced.png)\n" "```\n" "\n" "![real](real.png)\n" ) refs = extract_image_refs(doc) assert [r.path for r in refs] == ["real.png"] # The fence is an ATOMIC paragraph and counts as one context unit # inside the N=2 window (chunker semantics, PLAN §5). assert refs[0].context == "Intro.\n\n```\n![not real](fenced.png)\n```" def test_fence_with_blank_lines_inside_is_one_atomic_block() -> None: doc = ( "P1.\n" "\n" "```\n" "line one\n" "\n" "line three\n" "```\n" "\n" "![a](a.png)\n" ) refs = extract_image_refs(doc) assert len(refs) == 1 # The fence (with its inner blank line) is ONE atomic context unit, # occupying a single slot of the N=2 window before the reference # (if it split on the inner blank line, the window would hold only # "P1" + the fence's first lines instead of P1 + the whole fence). assert refs[0].context == "P1.\n\n```\nline one\n\nline three\n```" def test_tilde_fence_and_long_fence_markers() -> None: doc = "~~~\n![skip](skip.png)\n~~~\n\n![keep](keep.png)\n" assert [r.path for r in extract_image_refs(doc)] == ["keep.png"] # Four-backtick fence (marker = first three chars), inner blank line. doc = "````\n\n![skip](skip2.png)\n````\n\n![keep](keep2.png)\n" assert [r.path for r in extract_image_refs(doc)] == ["keep2.png"] def test_unclosed_fence_skips_rest_of_file() -> None: doc = "```\n![skip](a.png)\nmore text after an unclosed fence\n" assert extract_image_refs(doc) == [] def test_reference_with_no_alt_text() -> None: refs = extract_image_refs("![](image.png)") assert refs == [ ImageRef(path="image.png", alt_text="", context="", line_number=1) ] def test_reference_at_start_of_file() -> None: doc = "![first](first.png)\n\nP1.\n\nP2.\n\nP3.\n" refs = extract_image_refs(doc) assert len(refs) == 1 # Only paragraphs after exist; the N=2 cap still applies (P3 left out). assert refs[0].context == "P1.\n\nP2." assert refs[0].line_number == 1 def test_reference_at_end_of_file() -> None: doc = "P1.\n\nP2.\n\nP3.\n\nP4.\n\n![last](last.png)\n" refs = extract_image_refs(doc) assert len(refs) == 1 # Only paragraphs before exist; the N=2 window is the two paragraphs # immediately before the reference (P1 and P2 left out). assert refs[0].context == "P3.\n\nP4." assert refs[0].line_number == 9 def test_reference_in_heading_line() -> None: doc = ( "Intro paragraph.\n" "\n" "## ![alt](image.png)\n" "\n" "Outro paragraph.\n" ) refs = extract_image_refs(doc) assert len(refs) == 1 assert refs[0].alt_text == "alt" assert refs[0].path == "image.png" assert refs[0].line_number == 3 # The heading line is the reference paragraph — excluded from the # context; only the surrounding text remains. assert refs[0].context == "Intro paragraph.\n\nOutro paragraph." assert "##" not in refs[0].context def test_context_paragraphs_zero_and_custom() -> None: doc = "P1.\n\nP2.\n\nP3.\n\n![a](a.png)\n\nQ1.\n\nQ2.\n\nQ3.\n" assert extract_image_refs(doc, context_paragraphs=0)[0].context == "" assert extract_image_refs(doc, context_paragraphs=1)[0].context == "P3.\n\nQ1." assert ( extract_image_refs(doc, context_paragraphs=3)[0].context == "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3." ) # A window LARGER than the file gets whatever is available. assert ( extract_image_refs(doc, context_paragraphs=10)[0].context == "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3." ) def test_negative_context_paragraphs_raises() -> None: with pytest.raises(ValueError, match="context_paragraphs must be >= 0"): extract_image_refs("![a](a.png)", context_paragraphs=-1) def test_empty_and_reference_free_content() -> None: assert extract_image_refs("") == [] assert extract_image_refs("\n\n \n") == [] assert extract_image_refs("# No images here\n\nJust plain text.\n") == [] # A link is not an image reference; an empty target is not a ref. assert extract_image_refs("[a link](http://x.example/) ![empty]()\n") == [] def test_image_nested_in_link() -> None: refs = extract_image_refs("![alt](img.png) wrapped: [![alt](img2.png)](http://x/)\n") assert [r.path for r in refs] == ["img.png", "img2.png"] def test_alt_text_with_nested_brackets() -> None: # The alt field is ``[^]]*`` — it may OPEN a bracket (``[``) but # the first CLOSING bracket (``]``) ends it: an alt with a nested # ``[`` (``a [1``) still captures the path, while an alt that # itself CARRIES a ``]`` (``a [1] v2``) breaks the whole match # (the ``]`` ends the alt field, and no ``(`` follows) — an # accepted miss, the locked regex's limitation (task 02), the # alt-side twin of the nested-parens path miss above. refs = extract_image_refs("![a [1](img.png)\n") assert refs == [ImageRef(path="img.png", alt_text="a [1", context="", line_number=1)] assert extract_image_refs("![a [1] v2](img.png)\n") == [] def test_paths_with_spaces_and_parens() -> None: # Spaces are fine; surrounding whitespace in the target is stripped. refs = extract_image_refs("![a](diagram v2.png) and ![b]( x.png )\n") assert [r.path for r in refs] == ["diagram v2.png", "x.png"] # Nested parens: the regex stops at the first ')' — an accepted # miss (rare in practice, phase 125 task 02). refs = extract_image_refs("![a](a(b).png)\n") assert refs[0].path == "a(b" def test_line_numbers_are_one_based() -> None: doc = "\n\n\n# Head\n\n\n![a](a.png)\n" refs = extract_image_refs(doc) # Three leading blanks + "# Head" + two blanks = lines 1..6; the # reference is on line 7. assert refs[0].line_number == 7 def test_multi_line_reference_paragraph_contributes_nothing() -> None: # The reference sits in the MIDDLE of a paragraph (no blank lines): # the whole reference paragraph is excluded from the context. doc = ( "Before.\n" "\n" "Line one.\n" "Line two with ![alt](img.png)\n" "Line three.\n" "\n" "After.\n" ) refs = extract_image_refs(doc) assert refs[0].context == "Before.\n\nAfter."