Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/test/test_linkify_citations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Unit tests for _linkify_citations (definition/description citation linking).

Regression guard for the dropped-`)` bug: the linkifier's `\\)?` used to consume
the closing paren of a `(Author, 2020)` citation without putting it back, so
definitions rendered as `(Author, 2020.` — see the LPT-neuron description. Pure,
offline tests (no backend).
"""
import re
import unittest

from vfbquery.vfb_queries import _linkify_citations

PUB_MAP = {
"Wei et al., 2020": "FBrf_WEI",
"Nern et al., 2025": "FBrf_NERN",
"Ito and Awasaki, 2015": "FBrf_ITO",
}


def _displayed(markdown):
"""Strip `[label](id)` markdown to the label, i.e. what a reader sees."""
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", markdown)


class LinkifyCitationsTest(unittest.TestCase):

def test_parenthesised_citation_keeps_its_closing_paren(self):
out = _linkify_citations("It is cholinergic (Wei et al., 2020).", PUB_MAP)
self.assertEqual(_displayed(out), "It is cholinergic (Wei et al., 2020).")
self.assertIn("[Wei et al., 2020](FBrf_WEI)", out)

def test_author_year_paren_form_is_normalised(self):
# "Author (2020)" -> the citation's own parens are folded into the link.
out = _linkify_citations("as shown by Wei et al. (2020) here.", PUB_MAP)
self.assertIn("[Wei et al., 2020](FBrf_WEI)", out)
self.assertEqual(_displayed(out), "as shown by Wei et al., 2020 here.")

def test_multiple_citations_in_one_paren_group(self):
out = _linkify_citations("mixed synapses (Wei et al., 2020; Nern et al., 2025).", PUB_MAP)
self.assertEqual(
_displayed(out),
"mixed synapses (Wei et al., 2020; Nern et al., 2025).")
self.assertIn("[Wei et al., 2020](FBrf_WEI)", out)
self.assertIn("[Nern et al., 2025](FBrf_NERN)", out)

def test_non_citation_parentheticals_untouched(self):
text = "innervates the lobula plate (LOP) layers 1 and 3 (IPS)."
self.assertEqual(_linkify_citations(text, PUB_MAP), text)

def test_already_linked_citation_not_double_wrapped(self):
text = "see [Wei et al., 2020](FBrf_WEI) for detail."
self.assertEqual(_linkify_citations(text, PUB_MAP), text)

def test_trailing_citation_at_end_of_text(self):
out = _linkify_citations("one per hemisphere (Wei et al., 2020)", PUB_MAP)
self.assertEqual(_displayed(out), "one per hemisphere (Wei et al., 2020)")

def test_the_full_lpt_description_loses_no_parens(self):
# The description from the reported screenshot (abbreviated), every
# citation parenthesised — none should lose its ')'.
text = ("posterior slope (IPS) (Wei et al., 2020). Its ventral branch has "
"mixed pre- and post-synapses (Wei et al., 2020; Nern et al., 2025). "
"It innervates around 30 columns (Nern et al., 2025). It is "
"cholinergic (Wei et al., 2020). There is one per hemisphere "
"(Nern et al., 2025).")
disp = _displayed(_linkify_citations(text, PUB_MAP))
self.assertEqual(disp, text)
self.assertEqual(disp.count("("), disp.count(")"))


if __name__ == "__main__":
unittest.main(verbosity=2)
102 changes: 75 additions & 27 deletions src/test/test_merge_synonyms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from vfbquery.term_info_queries import deserialize_term_info, synonym_type_label


# Trimmed real medulla (FBbt_00003748) term_info: the synonym block only.
# Trimmed real medulla (FBbt_00003748) term_info: the synonym block only, plus
# one synthetic row — an untyped "ME" (Doe et al., 2019) alongside the real typed
# "ME" (BRAIN_NAME_ABV) — so the untyped-distinct-from-typed case is exercised.
MEDULLA = """
{"term": {"core": {"iri": "http://purl.obolibrary.org/obo/FBbt_00003748", "symbol": "", "types": ["Entity", "Class", "Anatomy"], "short_form": "FBbt_00003748", "label": "medulla"}, "description": [], "comment": []},
"query": "Get JSON for Class", "version": "test", "parents": [], "relationships": [], "xrefs": [], "anatomy_channel_image": [],
Expand All @@ -11,7 +13,8 @@
{"synonym": {"scope": "has_related_synonym", "label": "ME_L", "type": "http://purl.obolibrary.org/obo/fbbt#name_in_banc"}, "pub": {"core": {"short_form": "doi_10_1101_2025_07_31_667571", "types": ["pub"], "label": "Bates et al., 2025"}, "FlyBase": "", "PubMed": "", "DOI": "10.1101/2025.07.31.667571"}},
{"synonym": {"scope": "has_related_synonym", "label": "ME_L", "type": "http://purl.obolibrary.org/obo/fbbt#name_in_banc"}, "pub": {"core": {"short_form": "Unattributed", "types": ["pub"], "label": ""}, "FlyBase": "", "PubMed": "", "DOI": ""}},
{"synonym": {"scope": "has_related_synonym", "label": "m", "type": ""}, "pub": {"core": {"short_form": "Unattributed", "types": ["pub"], "label": ""}, "FlyBase": "", "PubMed": "", "DOI": ""}},
{"synonym": {"scope": "has_exact_synonym", "label": "ME", "type": "http://purl.obolibrary.org/obo/fbbt#BRAIN_NAME_ABV"}, "pub": {"core": {"short_form": "FBrf0224194", "types": ["pub"], "label": "Ito et al., 2014"}, "FlyBase": "FBrf0224194", "PubMed": "", "DOI": ""}}
{"synonym": {"scope": "has_exact_synonym", "label": "ME", "type": "http://purl.obolibrary.org/obo/fbbt#BRAIN_NAME_ABV"}, "pub": {"core": {"short_form": "FBrf0224194", "types": ["pub"], "label": "Ito et al., 2014"}, "FlyBase": "FBrf0224194", "PubMed": "", "DOI": ""}},
{"synonym": {"scope": "has_exact_synonym", "label": "ME", "type": ""}, "pub": {"core": {"short_form": "FBrf0300000", "types": ["pub"], "label": "Doe et al., 2019"}, "FlyBase": "FBrf0300000", "PubMed": "", "DOI": ""}}
],
"def_pubs": [], "targeting_splits": []}
"""
Expand All @@ -20,37 +23,82 @@
class MergeSynonymsTest(unittest.TestCase):
def setUp(self):
self.syns = deserialize_term_info(MEDULLA).get_merged_synonyms()
self.by_label = {s["label"]: s for s in self.syns}

def test_each_synonym_appears_once(self):
labels = [s["label"] for s in self.syns]
self.assertEqual(sorted(labels), ["ME", "ME_L", "m"])
self.assertEqual(len(labels), len(set(labels)))

def test_multi_ref_synonym_merged(self):
# ME_L asserted by flywire + banc -> single entry, both refs, no Unattributed
pub = self.by_label["ME_L"]["publication"]
self.assertIn("Schlegel et al., 2024", pub)
self.assertIn("Bates et al., 2025", pub)
self.assertNotIn("Unattributed", pub)

def test_attributed_pubs_are_markdown_links(self):
# every pub with a short_form/id must render as a markdown ref
self.assertIn("[Schlegel et al., 2024](FBrf0260535)", self.by_label["ME_L"]["publication"])
self.assertIn("[Bates et al., 2025](doi_10_1101_2025_07_31_667571)", self.by_label["ME_L"]["publication"])
def _entry(self, label, type_frag):
"""The single entry for a (label, type) pair. type_frag matches the tail
of the raw type IRI; pass None for the untyped ('synonym') entry."""
matches = [
s for s in self.syns
if s["label"] == label and (
s["type"] in ("", "synonym") if type_frag is None
else s["type"].endswith(type_frag))
]
self.assertEqual(len(matches), 1,
f"expected exactly one {label!r}/{type_frag!r} entry, got {len(matches)}")
return matches[0]

def test_one_line_per_synonym_type_pair(self):
# ME_L named in flywire AND banc -> two lines; ME typed (BRAIN_NAME_ABV)
# AND untyped -> two lines; plus untyped 'm'. Each (label, type) once.
pairs = sorted((s["label"], s["type"].split("#")[-1].split("/")[-1]) for s in self.syns)
self.assertEqual(pairs, [
("ME", "BRAIN_NAME_ABV"),
("ME", "synonym"),
("ME_L", "name_in_banc"),
("ME_L", "name_in_flywire_fafb"),
("m", "synonym"),
])
# no (label, type) pair is duplicated
self.assertEqual(len(pairs), len(set(pairs)))

def test_untyped_distinct_from_typed(self):
# The same label 'ME' asserted with a type and without one are separate
# lines: the typed line carries its type token, the untyped line does not.
typed = self._entry("ME", "BRAIN_NAME_ABV")
untyped = self._entry("ME", None)
self.assertIn("BRAIN_NAME_ABV", typed["publication"])
self.assertIn("[Doe et al., 2019](FBrf0300000)", untyped["publication"])
self.assertNotIn("BRAIN_NAME_ABV", untyped["publication"])

def test_unattributed_with_type_shows_type_token(self):
# name_in_banc -> Unattributed: surface the type as a plain (unlinked) ref
pub = self.by_label["ME_L"]["publication"]
def test_attributed_pubs_are_markdown_links(self):
# each typed line carries its own pub as a markdown ref
self.assertIn("[Schlegel et al., 2024](FBrf0260535)",
self._entry("ME_L", "name_in_flywire_fafb")["publication"])
self.assertIn("[Bates et al., 2025](doi_10_1101_2025_07_31_667571)",
self._entry("ME_L", "name_in_banc")["publication"])

def test_all_types_shown_even_when_pub_attributed(self):
# A typed synonym whose only assertion is pub-attributed still shows its
# type token (not just the pub link) — the previous bug hid these.
self.assertIn("name_in_flywire_fafb",
self._entry("ME_L", "name_in_flywire_fafb")["publication"])
self.assertIn("name_in_banc",
self._entry("ME_L", "name_in_banc")["publication"])
self.assertIn("BRAIN_NAME_ABV",
self._entry("ME", "BRAIN_NAME_ABV")["publication"])

def test_type_token_not_a_link(self):
pub = self._entry("ME_L", "name_in_banc")["publication"]
self.assertIn("name_in_banc", pub)
self.assertNotIn("[name_in_banc]", pub) # not a link
self.assertNotIn("[name_in_banc]", pub) # plain text, not a markdown link

def test_type_token_precedes_its_reference(self):
# Within the parentheses the naming system leads, then the pub that
# attributes it: "name_in_banc, [Bates et al., 2025](...)".
pub = self._entry("ME_L", "name_in_banc")["publication"]
self.assertLess(pub.index("name_in_banc"), pub.index("Bates et al., 2025"))
self.assertTrue(pub.startswith("name_in_banc,"), pub)

def test_attributed_and_unattributed_same_type_collapse(self):
# ME_L/name_in_banc is asserted twice (Bates + Unattributed). One line,
# the token once, and no 'Unattributed' text leaks in.
pub = self._entry("ME_L", "name_in_banc")["publication"]
self.assertEqual(pub.count("name_in_banc"), 1)
self.assertNotIn("Unattributed", pub)

def test_unattributed_only_no_type_has_no_publication(self):
# 'm' is backed only by Unattributed with no type -> shown with no ref
self.assertNotIn("publication", self.by_label["m"])

def test_attributed_single_ref_kept(self):
self.assertIn("[Ito et al., 2014](FBrf0224194)", self.by_label["ME"]["publication"])
self.assertNotIn("publication", self._entry("m", None))


class SynonymTypeLabelTest(unittest.TestCase):
Expand Down
65 changes: 43 additions & 22 deletions src/vfbquery/term_info_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,17 +633,25 @@ def get_synonyms(self) -> List[str]:
return list()

def get_merged_synonyms(self) -> List[dict]:
"""Merge pub_syn into one entry per (scope, label) with the combined
list of refs.
"""Merge pub_syn into one entry per (scope, label, type) with the
combined list of refs.

The same synonym is often asserted by several datasets/papers, so
pub_syn holds one entry per (synonym, pub). This collapses them so each
synonym is shown once with the combined refs:
distinct (synonym, type) pair is shown on its own line:

- a synonym named under several systems (name_in_banc, name_in_flywire,
...) gets one line per naming system, and an untyped assertion of a
label is a distinct line from any typed assertion of the same label;
- any pub with a real id/short_form is rendered as a markdown link;
- the 'Unattributed' placeholder pub is never linked, but if the entry
carries a synonym type (e.g. name_in_banc) that type is shown as a
plain-text ref, since it is useful provenance for the user;
- the synonym type (name_in_banc, VFB_SYMBOL, ...) is ALWAYS shown as a
plain-text provenance token, whether or not that assertion is
pub-attributed — the naming system is useful provenance in its own
right, so a typed-but-attributed synonym no longer hides its type;
- grouping by type collapses an attributed and an 'Unattributed'
assertion of the SAME (type, synonym): the attributed pub supplies the
link, the Unattributed placeholder adds nothing and is dropped (no
'Unattributed' text, no empty ref);
- a synonym backed only by Unattributed with no type is shown with no
ref at all.
"""
Expand Down Expand Up @@ -678,33 +686,46 @@ def type_token(syn):
continue
label = getattr(syn.synonym, 'label', "") or ""
scope = getattr(syn.synonym, 'scope', "") or "exact"
stype = getattr(syn.synonym, 'type', "") or "synonym"
key = (scope, label)
raw_type = getattr(syn.synonym, 'type', "") or ""
# Key on the type too, so a synonym named under several systems gets
# one line per naming system, an untyped assertion is distinct from a
# typed one, and an attributed + Unattributed assertion of the SAME
# (type, synonym) collapse into a single entry.
key = (scope, label, raw_type)
if key not in grouped:
grouped[key] = {"label": label, "scope": scope, "type": stype, "refs": []}
grouped[key] = {"label": label, "scope": scope,
"type": raw_type or "synonym",
"type_token": type_token(syn), "refs": []}
order.append(key)
entry = grouped[key]
entry_pubs = list(getattr(syn, 'pubs', None) or [])
if getattr(syn, 'pub', None):
entry_pubs.append(syn.pub)
real_refs = [r for r in (pub_ref(p) for p in entry_pubs) if r]
if real_refs:
for ref in real_refs:
if ref not in entry["refs"]:
entry["refs"].append(ref)
else:
# no attributed pub for this assertion: fall back to the
# synonym type as an unlinked ref (e.g. name_in_banc)
tok = type_token(syn)
if tok and tok not in entry["refs"]:
entry["refs"].append(tok)
# Attributed pubs become markdown links; the Unattributed placeholder
# yields '' and so adds nothing — when an attributed assertion of the
# same type exists it is thereby dropped rather than shown separately.
for ref in (pub_ref(p) for p in entry_pubs):
if ref and ref not in entry["refs"]:
entry["refs"].append(ref)

result = []
for key in order:
entry = grouped[key]
synonym = {"label": entry["label"], "scope": entry["scope"], "type": entry["type"]}
if entry["refs"]:
synonym["publication"] = ", ".join(entry["refs"])
# Type first, then its pub link(s): the synonym type as an unlinked
# provenance token (name_in_banc, VFB_SYMBOL, ...) leads, so each line
# reads "<naming system>, <the pub that attributes it>". The type is
# always shown for a typed synonym, even when its only assertion is
# pub-attributed.
refs = []
tok = entry["type_token"]
if tok:
refs.append(tok)
for ref in entry["refs"]:
if ref not in refs:
refs.append(ref)
if refs:
synonym["publication"] = ", ".join(refs)
result.append(synonym)
return result

Expand Down
8 changes: 7 additions & 1 deletion src/vfbquery/vfb_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,13 @@ def _linkify_citations(text, pub_map, term_id="", field=""):
items.sort(key=lambda x: len(x[0]), reverse=True)
linked = text
for author, year, sf in items:
pat = re.compile(r"(?<!\[)" + re.escape(author) + r"(?:,\s*|\s+\(|\s+)" + year + r"\)?(?!\])")
# Only consume a closing ')' when the citation itself opened one — the
# "Author (2020)" form, where the parens belong to the citation. For the
# "(Author, 2020)" form the ')' belongs to the surrounding sentence and
# must be left in place (a bare `\)?` swallowed it, dropping the ')').
# `(\()` captures the citation's own '(' (group 1); `(?(1)\)|)` requires a
# ')' only when that '(' was matched.
pat = re.compile(r"(?<!\[)" + re.escape(author) + r"(?:,\s*|\s+(\()|\s+)" + year + r"(?(1)\)|)(?!\])")
linked = pat.sub("[" + author + ", " + year + "](" + sf + ")", linked)
# Flag citations in the prose we could not link -- they should also be in References.
matched_keys = {(a, y) for a, y, _ in items}
Expand Down
Loading