Files
stack/tests/zot/test_extract.py
kert 8149da543b fix(zot): drop L from Zotero key charset
Zotero's allowedKeyChars excludes L (alongside 0, 1, O), but the
local generators in zot.db and bib.store emitted L, producing
keys the live Zotero UI flags as invalid. Align both generators
and the pincite parser regex; pin the charset via assertion and
add regression tests for L/O rejection.

Also sweep stale fixture and docstring keys (JX46GQ9L,
9ASETLJ4, IJKL3456, JRNLEFGH, WEBIJKLM) for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:40:44 -04:00

372 lines
13 KiB
Python

"""Tests for zot.extract — tag/annotation extraction for docstrings."""
from __future__ import annotations
from pathlib import Path
import pytest
from zot.db import TYPE_MAP, Db
from zot.extract import (
Extractor,
Quote,
_extract_quotes_from_note,
_page_from_link,
_parse_note_html,
)
HOST_DB = Path("data/zotero/data/zotero.sqlite")
# ── HTML parsing ─────────────────────────────────────────────────
class TestParseNoteHtml:
def test_simple_paragraph(self):
segs = _parse_note_html("<p>Hello world</p>")
assert len(segs) == 1
assert segs[0]["text"] == "Hello world"
def test_multiple_paragraphs(self):
segs = _parse_note_html("<p>First</p><p>Second</p>")
assert len(segs) == 2
def test_link_extraction(self):
html = '<p><a href="zotero://open-pdf/0_ABC12345/14">text</a></p>'
segs = _parse_note_html(html)
assert segs[0]["link"] == "zotero://open-pdf/0_ABC12345/14"
def test_nested_tags(self):
html = "<p><strong>Bold</strong> and <em>italic</em></p>"
segs = _parse_note_html(html)
assert "Bold" in segs[0]["text"]
def test_zotero_note_format(self):
html = (
'<div class="zotero-note znv1">'
"<p><strong>Extracted Annotations</strong></p>"
'<p>"Some quoted text" (<a href="zotero://open-pdf/0_KEY/5">Author :5</a>)</p>'
"</div>"
)
segs = _parse_note_html(html)
assert len(segs) >= 2
class TestPageFromLink:
def test_extracts_page(self):
assert _page_from_link("zotero://open-pdf/0_CUNC7XC6/14") == 14
def test_page_one(self):
assert _page_from_link("zotero://open-pdf/0_KEY/1") == 1
def test_no_match(self):
assert _page_from_link("https://example.com") is None
def test_empty(self):
assert _page_from_link("") is None
# ── Quote extraction ─────────────────────────────────────────────
class TestExtractQuotes:
def test_simple_quote(self):
html = '<p>"This is a quoted annotation."</p>'
quotes = _extract_quotes_from_note(html, "TESTKEY2")
assert len(quotes) == 1
assert quotes[0].text == "This is a quoted annotation."
assert quotes[0].item_key == "TESTKEY2"
def test_quote_with_page_link(self):
html = (
'<p>"Important finding about outcomes."'
' (<a href="zotero://open-pdf/0_ABC/14">Smith :14</a>)</p>'
)
quotes = _extract_quotes_from_note(html, "ABCD2345")
assert len(quotes) == 1
assert quotes[0].page == 14
def test_skips_header(self):
html = (
"<p><strong>Extracted Annotations (1/1/2025)</strong></p>"
'<p>"Actual quote."</p>'
)
quotes = _extract_quotes_from_note(html, "KEY12345")
assert len(quotes) == 1
assert quotes[0].text == "Actual quote."
def test_multiple_quotes(self):
html = '<p>"First quote."</p><p>"Second quote."</p>'
quotes = _extract_quotes_from_note(html, "KEY12345")
assert len(quotes) == 2
def test_no_quotes(self):
html = "<p>This is just a regular note.</p>"
quotes = _extract_quotes_from_note(html, "KEY12345")
assert len(quotes) == 0
# ── Quote model ──────────────────────────────────────────────────
class TestQuote:
def test_pincite_directive_basic(self):
q = Quote(text="Important finding.", item_key="ABCD2345")
assert q.pincite_directive() == ':pincite:`ABCD2345` — "Important finding."'
def test_pincite_directive_with_page(self):
q = Quote(text="Finding.", page=14, item_key="ABCD2345")
assert "p.14" in q.pincite_directive()
def test_pincite_directive_with_section(self):
q = Quote(text="Finding.", section="§2.2.1", page=8, item_key="ABCD2345")
d = q.pincite_directive()
assert "§2.2.1" in d
assert "p.8" in d
def test_pincite_truncates_long_text(self):
q = Quote(text="x" * 200, item_key="ABCD2345")
d = q.pincite_directive()
assert "..." in d
assert len(d) < 300
def test_as_tag(self):
q = Quote(text="t", page=14, item_key="ABCD2345")
assert q.as_tag() == "pin:ABCD2345/p.14"
def test_as_tag_with_section(self):
q = Quote(text="t", section="§2.2.1", item_key="ABCD2345")
assert q.as_tag() == "pin:ABCD2345/§2.2.1"
# ── Extractor with DB ────────────────────────────────────────────
@pytest.fixture()
def extractor(zotero_db) -> Extractor:
path = zotero_db
db = Db(path)
# Create items with notes
id1 = db.create_item(TYPE_MAP["statute"], key="STATABCD")
db.set_fields(
id1, {"nameOfAct": "PFS 2026 Final Rule", "url": "https://ex.com/pfs"}
)
db.sync_tags(id1, ["module:pfs", "year:2026"])
db.add_note(
id1,
"<p><strong>Extracted Annotations</strong></p>"
'<p>"The physician fee schedule determines payment rates."'
' (<a href="zotero://open-pdf/0_XYZ/3">CMS :3</a>)</p>'
'<p>"Geographic adjustments apply to all services."</p>',
)
id2 = db.create_item(TYPE_MAP["journalArticle"], key="JRNKEFGH")
db.set_fields(
id2,
{
"title": "Skin Substitute Review",
"DOI": "10.1234/test",
"publicationTitle": "JAMA",
},
)
db.sync_tags(id2, ["module:skin-subs"])
db.add_creators(id2, [("Jane", "Smith")])
# Item with no notes
id3 = db.create_item(TYPE_MAP["webpage"], key="WEBIJKKM")
db.set_fields(id3, {"title": "CMS Data Portal", "url": "https://cms.gov"})
db.sync_tags(id3, ["module:pfs"])
db.commit()
ex = Extractor(db)
yield ex
ex.close()
class TestExtractorQuotes:
def test_quotes_for_item(self, extractor: Extractor):
quotes = extractor.quotes_for_item("STATABCD")
assert len(quotes) == 2
assert quotes[0].text == "The physician fee schedule determines payment rates."
assert quotes[0].page == 3
def test_quotes_for_missing_item(self, extractor: Extractor):
assert extractor.quotes_for_item("ZZZZZZZZ") == []
def test_quotes_for_item_no_notes(self, extractor: Extractor):
assert extractor.quotes_for_item("WEBIJKKM") == []
def test_quotes_for_tag(self, extractor: Extractor):
result = extractor.quotes_for_tag("module:pfs")
assert "STATABCD" in result
assert len(result["STATABCD"]) == 2
class TestExtractorTags:
def test_tags_for_item(self, extractor: Extractor):
tags = extractor.tags_for_item("STATABCD")
assert "module:pfs" in tags
assert "year:2026" in tags
def test_tags_for_missing(self, extractor: Extractor):
assert extractor.tags_for_item("ZZZZZZZZ") == []
def test_items_by_tag_namespace(self, extractor: Extractor):
result = extractor.items_by_tag_namespace("module")
assert "pfs" in result
assert "STATABCD" in result["pfs"]
assert "skin-subs" in result
assert "JRNKEFGH" in result["skin-subs"]
class TestExtractorDocstring:
def test_docstring_block_with_quotes(self, extractor: Extractor):
block = extractor.docstring_block("STATABCD")
assert "References" in block
assert ":pincite:" in block
assert "physician fee schedule" in block
def test_docstring_block_no_notes(self, extractor: Extractor):
block = extractor.docstring_block("WEBIJKKM")
assert "References" in block
assert "CMS Data Portal" in block
def test_pincite_directives(self, extractor: Extractor):
directives = extractor.pincite_directives("STATABCD")
assert len(directives) == 2
assert all(d.startswith(":pincite:") for d in directives)
class TestExtractorExport:
def test_export_provenance(self, extractor: Extractor):
items = extractor.export_provenance()
assert len(items) == 4
keys = {i["key"] for i in items}
assert "STATABCD" in keys
stat = next(i for i in items if i["key"] == "STATABCD")
assert stat["item_type"] == "statute"
assert "module:pfs" in stat["tags"]
assert len(stat["quotes"]) == 2
assert (
stat["quotes"][0]["text"]
== "The physician fee schedule determines payment rates."
)
def test_export_provenance_filtered(self, extractor: Extractor):
items = extractor.export_provenance(tag="module:skin-subs")
assert len(items) == 1
assert items[0]["key"] == "JRNKEFGH"
# ── Integration with real DB ─────────────────────────────────────
@pytest.mark.skipif(
not HOST_DB.exists(),
reason="zotero.sqlite not available — only runs against live Zotero",
)
class TestExtractorRealDb:
def test_extract_real_notes(self, host_db):
with Extractor(str(host_db)) as ex:
# Find items with notes
rows = ex.db.con.execute(
"SELECT DISTINCT i.key FROM items i "
"JOIN itemNotes n ON i.itemID = n.parentItemID "
"LIMIT 5"
).fetchall()
for row in rows:
quotes = ex.quotes_for_item(row[0])
# Just verify it doesn't crash
assert isinstance(quotes, list)
def test_export_provenance_real(self, host_db):
with Extractor(str(host_db)) as ex:
# Small export
items = (
ex.db.search_by_tag("module:skin-subs")
if ex.db.search_by_tag("module:skin-subs")
else []
)
if items:
result = ex.export_provenance(tag="module:skin-subs")
assert isinstance(result, list)
# ── Gap coverage — missed lines ────────────────────────────────
class TestExtractQuotesPageFromSourceRef:
"""Lines 167-169: page extracted from source_ref like ':5' when no PDF link."""
def test_page_from_source_ref(self):
html = '<p>"Quote with ref" (Author :5)</p>'
quotes = _extract_quotes_from_note(html, "REFKEY23")
assert len(quotes) == 1
assert quotes[0].page == 5
assert quotes[0].source_ref == "Author :5"
def test_page_from_source_ref_no_page(self):
html = '<p>"Quote no page" (Author)</p>'
quotes = _extract_quotes_from_note(html, "REFKEY24")
assert len(quotes) == 1
assert quotes[0].page is None
class TestFieldsForItemMissing:
"""Line 282: fields_for_item returns {} for missing item."""
def test_fields_for_missing_item(self, extractor: Extractor):
assert extractor.fields_for_item("ZZZZZZZZ") == {}
class TestDocstringBlockSections:
"""Lines 306-311, 314-315, 334-336: docstring_block with sections filter."""
def test_docstring_block_with_sections(self, extractor: Extractor):
block = extractor.docstring_block("STATABCD", sections=["§2.2.1"])
assert "References" in block
assert ":pincite:" in block
# Section should be assigned to quotes that lack one
assert "§2.2.1" in block
def test_docstring_block_sections_no_match(self, extractor: Extractor):
"""Sections filter keeps all quotes via the else branch (line 314)."""
block = extractor.docstring_block("STATABCD", sections=["§99.99"])
assert "References" in block
assert ":pincite:" in block
class TestExportProvenanceSkipsNone:
"""Line 373: export_provenance skips items where get_item returns None."""
def test_export_provenance_skips_none(self, extractor: Extractor):
from unittest.mock import patch
# Mock get_item to return None for one item to exercise line 373
orig_get_item = extractor._db.get_item
def _patched_get_item(item_id):
# Return None for the first call to trigger the continue
if item_id == 99999:
return None
return orig_get_item(item_id)
# Mock search_by_tag to include a nonexistent item
orig_search = extractor._db.search_by_tag
def _patched_search(tag_name):
result = orig_search(tag_name)
return [99999] + result
with (
patch.object(extractor._db, "get_item", side_effect=_patched_get_item),
patch.object(extractor._db, "search_by_tag", side_effect=_patched_search),
):
items = extractor.export_provenance(tag="module:pfs")
# Should not crash; the 99999 item is skipped
assert isinstance(items, list)
# Only the real items should be present
assert all(i["key"] != "" for i in items)