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>
498 lines
17 KiB
Python
498 lines
17 KiB
Python
"""Tests for bib.pincite — pinpoint citation system."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from bib.pincite import (
|
|
Pincite,
|
|
_classify_locator,
|
|
build_citation_graph,
|
|
check_pincite_keys,
|
|
format_pincite_block,
|
|
inject_pincites_into_source,
|
|
list_pincites,
|
|
parse_pincites,
|
|
upsert_pincites,
|
|
)
|
|
from bib.store import Store
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
"""In-memory-like store using a temp database."""
|
|
db = tmp_path / "test_bib.sqlite"
|
|
s = Store(database=str(db))
|
|
return s
|
|
|
|
|
|
@pytest.fixture
|
|
def store_with_items(store):
|
|
"""Store with two items for pincite testing."""
|
|
from bib.item import Source
|
|
|
|
store.create(Source(key="JX46GQ9K", title="Tag vocabulary item"))
|
|
store.create(Source(key="9ASETKJ4", title="Nature citation standards"))
|
|
return store
|
|
|
|
|
|
# ── Parse ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestParsePincites:
|
|
def test_basic(self):
|
|
doc = ":pincite:`JX46GQ9K p.14` -- NDC validation rules"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert len(result) == 1
|
|
assert result[0].item_key == "JX46GQ9K"
|
|
assert result[0].locator == "p.14"
|
|
assert result[0].note == "NDC validation rules"
|
|
assert result[0].fn_path == "mod.fn"
|
|
|
|
def test_no_locator(self):
|
|
doc = ":pincite:`JX46GQ9K`"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert len(result) == 1
|
|
assert result[0].locator == ""
|
|
|
|
def test_no_note(self):
|
|
doc = ":pincite:`JX46GQ9K p.14`"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert len(result) == 1
|
|
assert result[0].note == ""
|
|
|
|
def test_section_locator(self):
|
|
doc = ":pincite:`9ASETKJ4 §3.2` — exclusion criteria"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert result[0].locator == "§3.2"
|
|
assert result[0].locator_type == "section"
|
|
assert result[0].note == "exclusion criteria"
|
|
|
|
def test_cfr_locator(self):
|
|
doc = ":pincite:`ABC23456 42 CFR §414.22` -- payment rates"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert result[0].locator == "42 CFR §414.22"
|
|
assert result[0].locator_type == "cfr"
|
|
|
|
def test_fr_locator(self):
|
|
doc = ":pincite:`DEF78923 90 FR 86252` -- final rule"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert result[0].locator == "90 FR 86252"
|
|
assert result[0].locator_type == "fr"
|
|
|
|
def test_multiple(self):
|
|
doc = textwrap.dedent("""
|
|
:pincite:`JX46GQ9K p.14` -- first
|
|
:pincite:`9ASETKJ4 §3.2` -- second
|
|
:pincite:`ABC23456 Ch. 12` -- third
|
|
""")
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert len(result) == 3
|
|
|
|
def test_deduplication(self):
|
|
doc = ":pincite:`JX46GQ9K p.14` -- first\n:pincite:`JX46GQ9K p.14` -- dupe"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert len(result) == 1
|
|
|
|
def test_empty_docstring(self):
|
|
assert parse_pincites("", "mod.fn") == []
|
|
assert parse_pincites(None, "mod.fn") == []
|
|
|
|
def test_no_pincites(self):
|
|
doc = "Just a plain docstring with no citations."
|
|
assert parse_pincites(doc, "mod.fn") == []
|
|
|
|
def test_invalid_key_charset(self):
|
|
# 0, 1, O, L are not valid Zotero key chars
|
|
doc = ":pincite:`00000000 p.1` -- bad key"
|
|
assert parse_pincites(doc, "mod.fn") == []
|
|
|
|
def test_em_dash(self):
|
|
doc = ":pincite:`JX46GQ9K p.14` — em dash note"
|
|
result = parse_pincites(doc, "mod.fn")
|
|
assert result[0].note == "em dash note"
|
|
|
|
|
|
# ── Classify locator ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestClassifyLocator:
|
|
def test_page(self):
|
|
assert _classify_locator("p.14") == "page"
|
|
assert _classify_locator("pp.8-12") == "page"
|
|
|
|
def test_section(self):
|
|
assert _classify_locator("§3.2") == "section"
|
|
|
|
def test_cfr(self):
|
|
assert _classify_locator("42 CFR §414.22") == "cfr"
|
|
|
|
def test_fr(self):
|
|
assert _classify_locator("90 FR 86252") == "fr"
|
|
|
|
def test_chapter(self):
|
|
assert _classify_locator("Ch. 12") == "chapter"
|
|
|
|
def test_paragraph(self):
|
|
assert _classify_locator("¶4") == "paragraph"
|
|
|
|
def test_empty(self):
|
|
assert _classify_locator("") == ""
|
|
|
|
|
|
# ── Store operations ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestUpsertPincites:
|
|
def test_insert(self, store_with_items):
|
|
pincites = [
|
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14", note="test"),
|
|
]
|
|
count = upsert_pincites(store_with_items, pincites)
|
|
assert count == 1
|
|
|
|
result = list_pincites(store_with_items, fn_path="mod.fn")
|
|
assert len(result) == 1
|
|
assert result[0].item_key == "JX46GQ9K"
|
|
|
|
def test_upsert_idempotent(self, store_with_items):
|
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14")
|
|
upsert_pincites(store_with_items, [p])
|
|
upsert_pincites(store_with_items, [p])
|
|
result = list_pincites(store_with_items, fn_path="mod.fn")
|
|
assert len(result) == 1
|
|
|
|
def test_skips_missing_item(self, store_with_items):
|
|
p = Pincite(fn_path="mod.fn", item_key="ZZZZZZZZ", locator="p.1")
|
|
count = upsert_pincites(store_with_items, [p])
|
|
assert count == 0
|
|
|
|
def test_tags_added(self, store_with_items):
|
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14")
|
|
upsert_pincites(store_with_items, [p])
|
|
item = store_with_items.get("JX46GQ9K")
|
|
assert any("pin:" in t for t in item.tags)
|
|
|
|
|
|
class TestListPincites:
|
|
def test_by_fn(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="a.b", item_key="JX46GQ9K", locator="p.1"),
|
|
Pincite(fn_path="c.d", item_key="9ASETKJ4", locator="§2"),
|
|
],
|
|
)
|
|
result = list_pincites(store_with_items, fn_path="a.b")
|
|
assert len(result) == 1
|
|
assert result[0].item_key == "JX46GQ9K"
|
|
|
|
def test_by_item(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="a.b", item_key="JX46GQ9K", locator="p.1"),
|
|
Pincite(fn_path="c.d", item_key="JX46GQ9K", locator="p.2"),
|
|
],
|
|
)
|
|
result = list_pincites(store_with_items, item_key="JX46GQ9K")
|
|
assert len(result) == 2
|
|
|
|
def test_empty_store(self, store):
|
|
assert list_pincites(store) == []
|
|
|
|
|
|
# ── Check keys ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCheckKeys:
|
|
def test_valid(self, store_with_items):
|
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K")
|
|
errors = check_pincite_keys([p], store_with_items)
|
|
assert len(errors) == 0
|
|
|
|
def test_invalid(self, store_with_items):
|
|
p = Pincite(fn_path="mod.fn", item_key="NOTFOUND")
|
|
errors = check_pincite_keys([p], store_with_items)
|
|
assert len(errors) == 1
|
|
assert "not found" in errors[0][1]
|
|
|
|
|
|
# ── Knowledge graph ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestCitationGraph:
|
|
def test_build(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="mod.fn_a", item_key="JX46GQ9K", locator="p.1"),
|
|
Pincite(fn_path="mod.fn_b", item_key="JX46GQ9K", locator="p.2"),
|
|
Pincite(fn_path="mod.fn_a", item_key="9ASETKJ4", locator="§3"),
|
|
],
|
|
)
|
|
graph = build_citation_graph(store_with_items)
|
|
assert len(graph.nodes) == 4 # 2 functions + 2 items
|
|
assert len(graph.edges) == 3
|
|
|
|
def test_functions_citing(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="a.b", item_key="JX46GQ9K"),
|
|
Pincite(fn_path="c.d", item_key="JX46GQ9K"),
|
|
],
|
|
)
|
|
graph = build_citation_graph(store_with_items)
|
|
fns = graph.functions_citing("JX46GQ9K")
|
|
assert set(fns) == {"a.b", "c.d"}
|
|
|
|
def test_items_cited_by(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="a.b", item_key="JX46GQ9K"),
|
|
Pincite(fn_path="a.b", item_key="9ASETKJ4"),
|
|
],
|
|
)
|
|
graph = build_citation_graph(store_with_items)
|
|
items = graph.items_cited_by("a.b")
|
|
assert set(items) == {"JX46GQ9K", "9ASETKJ4"}
|
|
|
|
def test_to_mermaid(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1"),
|
|
],
|
|
)
|
|
graph = build_citation_graph(store_with_items)
|
|
mermaid = graph.to_mermaid()
|
|
assert "graph LR" in mermaid
|
|
assert "mod_fn" in mermaid
|
|
|
|
def test_to_networkx_dict(self, store_with_items):
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[
|
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K"),
|
|
],
|
|
)
|
|
graph = build_citation_graph(store_with_items)
|
|
d = graph.to_networkx_dict()
|
|
assert d["directed"] is True
|
|
assert len(d["nodes"]) == 2
|
|
assert len(d["links"]) == 1
|
|
|
|
|
|
# ── Format + inject ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestFormatPinciteBlock:
|
|
def test_single(self):
|
|
p = Pincite(fn_path="m.f", item_key="JX46GQ9K", locator="p.14", note="test")
|
|
block = format_pincite_block([p])
|
|
assert ":pincite:`JX46GQ9K p.14` -- test" in block
|
|
assert block.startswith("References")
|
|
|
|
def test_no_locator(self):
|
|
p = Pincite(fn_path="m.f", item_key="JX46GQ9K")
|
|
block = format_pincite_block([p])
|
|
assert ":pincite:`JX46GQ9K`" in block
|
|
|
|
def test_empty(self):
|
|
assert format_pincite_block([]) == ""
|
|
|
|
|
|
class TestInjectPincites:
|
|
def test_append_to_docstring(self, tmp_path):
|
|
source = textwrap.dedent('''
|
|
def foo():
|
|
"""Existing docstring."""
|
|
return 1
|
|
''').lstrip()
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
|
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
|
assert result is not None
|
|
assert ":pincite:`JX46GQ9K p.14`" in result
|
|
assert "Existing docstring." in result
|
|
|
|
def test_skip_no_docstring(self, tmp_path):
|
|
source = "def foo():\n return 1\n"
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
result = inject_pincites_into_source(f, "foo", "block")
|
|
assert result is None
|
|
|
|
def test_skip_missing_function(self, tmp_path):
|
|
source = "def bar():\n pass\n"
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
result = inject_pincites_into_source(f, "foo", "block")
|
|
assert result is None
|
|
|
|
def test_syntax_error_returns_none(self, tmp_path):
|
|
"""Lines 505, 506: inject returns None for syntax errors."""
|
|
f = tmp_path / "bad.py"
|
|
f.write_text("def incomplete(:")
|
|
result = inject_pincites_into_source(f, "incomplete", "block")
|
|
assert result is None
|
|
|
|
def test_replace_existing_references(self, tmp_path):
|
|
"""Lines 542, 543, 545, 553, 557-559: replace existing References block."""
|
|
source = textwrap.dedent('''
|
|
def foo():
|
|
"""Existing docstring.
|
|
|
|
References
|
|
~~~~~~~~~~
|
|
:pincite:`AAAAAAAA p.1` -- old ref
|
|
"""
|
|
return 1
|
|
''').lstrip()
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- new ref"
|
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
|
assert result is not None
|
|
assert "JX46GQ9K" in result
|
|
assert "AAAAAAAA" not in result
|
|
assert "Existing docstring." in result
|
|
|
|
def test_write_when_not_dry_run(self, tmp_path):
|
|
"""Lines 569, 572: inject actually writes when dry_run=False."""
|
|
source = textwrap.dedent('''
|
|
def foo():
|
|
"""Some docstring."""
|
|
return 1
|
|
''').lstrip()
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
|
result = inject_pincites_into_source(f, "foo", block, dry_run=False)
|
|
assert result is not None
|
|
# File should have been written
|
|
content = f.read_text()
|
|
assert "JX46GQ9K" in content
|
|
|
|
def test_no_change_returns_none(self, tmp_path):
|
|
"""Line 569: if source is unchanged, returns None."""
|
|
source = textwrap.dedent('''
|
|
def foo():
|
|
"""References
|
|
~~~~~~~~~~
|
|
:pincite:`JX46GQ9K p.14` -- test
|
|
"""
|
|
return 1
|
|
''').lstrip()
|
|
f = tmp_path / "test_mod.py"
|
|
f.write_text(source)
|
|
# The exact same block — may or may not change depending on indent
|
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
|
# Either None (unchanged) or a string (reformatted); both acceptable
|
|
assert result is None or isinstance(result, str)
|
|
|
|
|
|
# ── extract_all_pincites edge cases (lines 255, 260, 275) ───────────
|
|
|
|
|
|
class TestExtractAllPincitesEdge:
|
|
def test_default_src_root(self):
|
|
"""Line 255: extract_all_pincites(None) defaults to src/."""
|
|
from bib.pincite import extract_all_pincites
|
|
|
|
# Just verify it doesn't crash and returns a list
|
|
result = extract_all_pincites()
|
|
assert isinstance(result, list)
|
|
|
|
def test_skips_egg_info(self, tmp_path):
|
|
"""Line 260: skips .egg-info directories."""
|
|
from bib.pincite import extract_all_pincites
|
|
|
|
src = tmp_path / "src"
|
|
(src / "pkg.egg-info").mkdir(parents=True)
|
|
(src / "pkg.egg-info" / "mod.py").write_text(
|
|
textwrap.dedent('''\
|
|
@narwhalify
|
|
def fn():
|
|
""":pincite:`ABCD2345 p.1` -- ref"""
|
|
pass
|
|
''')
|
|
)
|
|
result = extract_all_pincites(src)
|
|
assert len(result) == 0
|
|
|
|
def test_skips_no_docstring(self, tmp_path):
|
|
"""Line 275: narwhalify function with no docstring is skipped."""
|
|
from bib.pincite import extract_all_pincites
|
|
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
(src / "mod.py").write_text(
|
|
textwrap.dedent("""\
|
|
@narwhalify
|
|
def fn():
|
|
pass
|
|
""")
|
|
)
|
|
result = extract_all_pincites(src)
|
|
assert len(result) == 0
|
|
|
|
|
|
# ── build_citation_graph item lookup failure (lines 427, 428) ───────
|
|
|
|
|
|
class TestBuildCitationGraphEdge:
|
|
def test_item_get_failure_uses_key(self, store_with_items):
|
|
"""Lines 427, 428: when store.get fails, label falls back to key."""
|
|
from unittest.mock import patch
|
|
|
|
from bib.pincite import build_citation_graph, upsert_pincites
|
|
|
|
upsert_pincites(
|
|
store_with_items,
|
|
[Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1")],
|
|
)
|
|
# Patch store.get to raise KeyError for the item
|
|
orig_get = store_with_items.get
|
|
|
|
def failing_get(key):
|
|
if key == "JX46GQ9K":
|
|
raise KeyError("not found")
|
|
return orig_get(key)
|
|
|
|
with patch.object(store_with_items, "get", side_effect=failing_get):
|
|
graph = build_citation_graph(store_with_items)
|
|
# The item node should use the key as fallback label
|
|
item_nodes = [n for n in graph.nodes if n.kind == "item"]
|
|
assert len(item_nodes) == 1
|
|
assert item_nodes[0].label == "JX46GQ9K"
|
|
|
|
|
|
# ── upsert_pincites tag add_tag failure (lines 338, 339) ────────────
|
|
|
|
|
|
class TestUpsertPincitesTagFailure:
|
|
def test_tag_error_suppressed(self, store_with_items):
|
|
"""Lines 338, 339: add_tag exceptions don't prevent upsert."""
|
|
from unittest.mock import patch
|
|
|
|
from bib.pincite import upsert_pincites
|
|
|
|
with patch.object(
|
|
store_with_items, "add_tag", side_effect=Exception("tag err")
|
|
):
|
|
count = upsert_pincites(
|
|
store_with_items,
|
|
[Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1")],
|
|
)
|
|
assert count == 1
|