""" Unit tests for the PDF highlight mapper pipeline step (services/pdf_highlight_mapper.py). Builds a tiny 2-page PDF with PyMuPDF: page 1 has a real text layer (two lines), page 2 is blank (simulates a scanned / image-only page). """ import re from services.pdf_highlight_mapper import PdfHighlightMapper def _make_pdf(path, page1_text_lines=("The quick brown fox jumps over the lazy dog.", "The hedgehog sleeps under the autumn leaves.")): import fitz doc = fitz.open() page = doc.new_page(width=595, height=842) for i, line in enumerate(page1_text_lines): page.insert_text((72, 100 + i * 40), line) doc.new_page(width=595, height=842) # no text layer — "scanned" page doc.save(path) doc.close() def _span(text, verdict="AI_GENERATED", confidence=0.71, **kw): return {"text": text, "verdict": verdict, "confidence": confidence, **kw} def _mapper(tmp_path, pdf_name="paper.pdf"): pdf = tmp_path / pdf_name _make_pdf(pdf) return PdfHighlightMapper( pdf_path=str(pdf), pages_dir=str(tmp_path / "pages"), image_url_base="/jobs/job_test123/pages", ) def test_maps_span_to_normalized_boxes(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([_span("The quick brown fox jumps over the lazy dog.")]) assert len(result["pages"]) == 2 page1 = result["pages"][0] assert page1["pageNumber"] == 1 assert page1["hasTextLayer"] is True assert page1["imageUrl"] == "/jobs/job_test123/pages/1.png" assert page1["width"] == 595 * 2 and page1["height"] == 842 * 2 h = page1["highlights"][0] assert h["type"] == "ai" assert h["confidence"] == 0.71 assert "text" in h and len(h["boxes"]) == 1 box = h["boxes"][0] for k in ("x", "y", "width", "height"): assert 0.0 <= box[k] <= 1.0 assert box["x"] + box["width"] <= 1.0 + 1e-6 assert box["y"] + box["height"] <= 1.0 + 1e-6 assert result["unmatched"] == [] def test_span_crossing_lines_yields_multiple_boxes(tmp_path): mapper = _mapper(tmp_path) text = "The quick brown fox jumps over the lazy dog. The hedgehog sleeps under the autumn leaves." result = mapper.run([_span(text)]) boxes = result["pages"][0]["highlights"][0]["boxes"] assert len(boxes) == 2 # one rect per visual line the span touches assert boxes[0]["y"] != boxes[1]["y"] assert boxes[0]["y"] < boxes[1]["y"] def test_unmatched_span_omits_boxes_and_is_logged(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([_span("zzzz no such text exists anywhere zzzz")]) assert result["pages"][0]["highlights"] == [] assert result["pages"][1]["highlights"] == [] assert len(result["unmatched"]) == 1 u = result["unmatched"][0] assert "boxes" not in u assert u["type"] == "ai" assert u["confidence"] == 0.71 def test_scanned_page_flagged_but_not_dropped(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([_span("The quick brown fox jumps over the lazy dog.")]) page2 = result["pages"][1] assert page2["pageNumber"] == 2 assert page2["hasTextLayer"] is False assert page2["highlights"] == [] # the text-bearing page is still fully processed assert len(result["pages"][0]["highlights"]) == 1 def test_human_verdict_maps_to_type_human(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([_span("The hedgehog sleeps under the autumn leaves.", verdict="REAL")]) h = result["pages"][0]["highlights"][0] assert h["type"] == "human" assert len(h["boxes"]) == 1 def test_uncertain_spans_are_skipped(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([ _span("The quick brown fox jumps over the lazy dog.", verdict="UNCERTAIN"), ]) assert result["pages"][0]["highlights"] == [] assert result["unmatched"] == [] # skipped, not counted as unmatched def test_whitespace_and_case_robust_matching(tmp_path): mapper = _mapper(tmp_path) messy = "the quick\n brown\tfox jumps over the LAZY dog" result = mapper.run([_span(messy)]) assert len(result["pages"][0]["highlights"]) == 1 assert result["unmatched"] == [] def test_rasterizes_png_per_page(tmp_path): mapper = _mapper(tmp_path) mapper.run([_span("The quick brown fox jumps over the lazy dog.")]) pages_dir = tmp_path / "pages" assert (pages_dir / "1.png").exists() assert (pages_dir / "2.png").exists() import fitz img = fitz.Pixmap(str(pages_dir / "1.png")) assert img.width == 1190 and img.height == 1684 # 2x of 595x842 def test_multiple_spans_share_page(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([ _span("The quick brown fox jumps over the lazy dog."), _span("The hedgehog sleeps under the autumn leaves.", verdict="REAL"), ]) highlights = result["pages"][0]["highlights"] assert len(highlights) == 2 assert {h["type"] for h in highlights} == {"ai", "human"} assert result["unmatched"] == [] def test_missing_pdf_returns_empty(tmp_path): mapper = PdfHighlightMapper( pdf_path=str(tmp_path / "does_not_exist.pdf"), pages_dir=str(tmp_path / "pages"), ) result = mapper.run([_span("anything")]) assert result["pages"] == [] assert result["unmatched"] == [] def test_kind_and_meta_pass_through(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([{ "text": "The quick brown fox jumps over the lazy dog.", "kind": "similarity", "meta": {"source_index": 3}, }]) h = result["pages"][0]["highlights"][0] assert h["type"] == "similarity" assert h["kind"] == "similarity" assert h["meta"] == {"source_index": 3} assert len(h["boxes"]) == 1 def test_kind_span_without_verdict_is_processed(tmp_path): """A similarity span has no verdict at all — it must still be mapped.""" mapper = _mapper(tmp_path) result = mapper.run([{ "text": "The hedgehog sleeps under the autumn leaves.", "kind": "similarity", "meta": {"source_index": 1}, }]) assert result["pages"][0]["highlights"][0]["type"] == "similarity" assert result["unmatched"] == [] def test_unmatched_kind_span_carries_kind_and_meta(tmp_path): mapper = _mapper(tmp_path) result = mapper.run([{ "text": "zzzz nothing like this is on the page zzzz", "kind": "similarity", "meta": {"source_index": 4}, }]) u = result["unmatched"][0] assert u["type"] == "similarity" assert u["kind"] == "similarity" assert u["meta"] == {"source_index": 4} assert "boxes" not in u def test_mixed_ai_and_kind_spans_in_single_call(tmp_path): """The combined mapper call (plan.md §8.4) maps both span sets once.""" mapper = _mapper(tmp_path) result = mapper.run([ _span("The quick brown fox jumps over the lazy dog."), { "text": "The hedgehog sleeps under the autumn leaves.", "kind": "similarity", "meta": {"source_index": 2}, }, ]) highlights = result["pages"][0]["highlights"] types = {h["type"] for h in highlights} assert types == {"ai", "similarity"} assert any(h.get("meta") == {"source_index": 2} for h in highlights) assert result["unmatched"] == [] def test_hyphenated_line_break_word_matches_unhyphenated_span(tmp_path): """PDF line-break hyphenation ("experi-" / "ment") must still locate a span whose text is the un-hyphenated parsed paragraph ("experiment").""" pdf = tmp_path / "hyphen.pdf" _make_pdf( pdf, page1_text_lines=( "The quick brown fox jumps over the lazy", "experi-", "ment under the autumn leaves", ), ) mapper = PdfHighlightMapper( pdf_path=str(pdf), pages_dir=str(tmp_path / "pages"), ) result = mapper.run([ _span("The quick brown fox jumps over the lazy experiment under the autumn leaves."), ]) assert result["unmatched"] == [] h = result["pages"][0]["highlights"][0] assert len(h["boxes"]) == 3 # one rect per visual line assert h["type"] == "ai" def test_punctuation_and_case_differences_still_match(tmp_path): """Parsed paragraph text normalizes punctuation away; the PDF may keep it (semicolons, quotes). The span must still be located.""" pdf = tmp_path / "punct.pdf" _make_pdf( pdf, page1_text_lines=( "The hedgehog sleeps under the autumn; leaves.", "Foxes JUMP over the \"lazy\" dog.", ), ) mapper = PdfHighlightMapper( pdf_path=str(pdf), pages_dir=str(tmp_path / "pages"), ) result = mapper.run([ _span("the hedgehog sleeps under the autumn leaves foxes jump over the lazy dog", verdict="REAL"), ]) assert result["unmatched"] == [] h = result["pages"][0]["highlights"][0] assert h["type"] == "human" assert len(h["boxes"]) == 2 def test_pure_punctuation_words_do_not_break_matching(tmp_path): """Words that normalize to nothing (e.g. em-dashes) must not corrupt the normalized text stream or offsets.""" pdf = tmp_path / "dash.pdf" _make_pdf( pdf, page1_text_lines=( "The quick brown fox jumps over the lazy dog", "\u2014 and the hedgehog sleeps", ), ) mapper = PdfHighlightMapper( pdf_path=str(pdf), pages_dir=str(tmp_path / "pages"), ) result = mapper.run([ _span("The quick brown fox jumps over the lazy dog and the hedgehog sleeps."), ]) assert result["unmatched"] == [] h = result["pages"][0]["highlights"][0] assert len(h["boxes"]) == 2