diff --git a/src/test/test_linkify_citations.py b/src/test/test_linkify_citations.py new file mode 100644 index 0000000..379e7d8 --- /dev/null +++ b/src/test/test_linkify_citations.py @@ -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) diff --git a/src/test/test_merge_synonyms.py b/src/test/test_merge_synonyms.py index ae823c6..e98675f 100644 --- a/src/test/test_merge_synonyms.py +++ b/src/test/test_merge_synonyms.py @@ -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": [], @@ -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": []} """ @@ -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): diff --git a/src/vfbquery/term_info_queries.py b/src/vfbquery/term_info_queries.py index 376d5fc..32a31b8 100644 --- a/src/vfbquery/term_info_queries.py +++ b/src/vfbquery/term_info_queries.py @@ -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. """ @@ -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 ", ". 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 diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index 849af43..9543632 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -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"(?