import json import re import sqlite3 import unittest from refresh_common_names import CURATED from taxonomy import COMMON_NAMES, DATABASE, DEFAULT_PATH, ICON_DIR, ICONS, MAX_ENGLISH, SHORTCUTS, Taxonomy @unittest.skipUnless(DATABASE.exists(), "taxonomy snapshot unavailable") class TaxonomyTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.taxonomy = Taxonomy() def levels(self, html): """Each level sits at its own y; bars are horizontal with a fixed height.""" return sorted({float(y) for y in re.findall(r'{scientific}', html) self.assertIn(f'class="tree-english">{english}', html) # A gloss that only repeats the scientific name is dropped by the build. self.assertIn('class="tree-label">Fungi', html) self.assertNotIn('class="tree-english">fungi', html) def test_one_tooltip_per_node(self): html = self.taxonomy.view(DEFAULT_PATH) # would render the browser's own tooltip on top of the styled one. self.assertNotIn("<title>", html) self.assertIn('data-detail="Viridiplantae (green plants) ·', html) def test_counts_live_in_the_tooltip_not_the_chart(self): html = self.taxonomy.view(DEFAULT_PATH) chart = html[html.index("<svg"):html.index("</svg>")] drawn = re.findall(r'<text[^>]*>([^<]*)</text>', chart) self.assertTrue(drawn) for text in drawn: self.assertNotRegex(text, r"\d,\d|%", f"counts belong in the tooltip, not the chart: {text}") self.assertIn("33,594 of 70,395 assemblies", html) def test_hovering_a_group_also_lights_its_inflowing_flux(self): html = self.taxonomy.view("2759/33154/33208") chart = html[html.index("<svg"):html.index("</svg>")] flows = dict(re.findall(r'<path d="[^"]*" class="flow flow-\w+" data-node="([\d-]+)" data-detail="([^"]*)"', chart)) groups = dict(re.findall(r'<g class="tree-node[^"]*"[^>]*data-node="([\d-]+)"[^>]*data-detail="([^"]*)"', chart)) self.assertTrue(flows) # Every ribbon carries the id of the group it arrives at, and the same text. for uid, detail in flows.items(): self.assertIn(uid, groups, uid) self.assertEqual(groups[uid], detail, uid) # The root has nothing flowing into it; every other level does. self.assertFalse(any(uid.startswith("0-") for uid in flows)) self.assertTrue(any(uid.startswith("1-") for uid in flows)) def test_curated_glosses_match_the_snapshot(self): with sqlite3.connect(f"{DATABASE.resolve().as_uri()}?mode=ro", uri=True) as conn: for taxid, (scientific, english) in CURATED.items(): row = conn.execute("SELECT name FROM taxa WHERE taxid=?", (taxid,)).fetchone() self.assertIsNotNone(row, f"taxid {taxid} is not in the snapshot") self.assertEqual(row[0], scientific, f"taxid {taxid}") self.assertLessEqual(len(english), MAX_ENGLISH, f"taxid {taxid} gloss is clipped in the chart") def test_published_names_cover_the_curated_table(self): names = json.loads(COMMON_NAMES.read_text())["names"] redundant = {t for t, (scientific, english) in CURATED.items() if scientific.casefold() == english.casefold()} self.assertEqual({int(t) for t in names} & set(CURATED), set(CURATED) - redundant) self.assertEqual(names["6040"], "sponges") # straight from NCBI Taxonomy def test_icons_are_free_to_use_and_present(self): if not ICONS.exists(): self.skipTest("silhouettes unavailable") payload = json.loads(ICONS.read_text()) self.assertTrue(payload["images"]) for image, meta in payload["images"].items(): # NonCommercial is legally murky for a company-run Space and ShareAlike # would reach into the repository; refresh_icons.py filters both out. self.assertNotIn("-nc", meta["license"], image) self.assertNotIn("-sa", meta["license"], image) self.assertTrue((ICON_DIR / f"{image}.svg").is_file(), image) for taxid, image in payload["taxa"].items(): self.assertIn(image, payload["images"], taxid) def test_small_groups_inherit_an_ancestor_icon(self): if not self.taxonomy.icons: self.skipTest("silhouettes unavailable") with sqlite3.connect(f"{DATABASE.resolve().as_uri()}?mode=ro", uri=True) as conn: conn.row_factory = sqlite3.Row own, borrowed = self.taxonomy.icon_for(conn, 2759) self.assertTrue(own) self.assertFalse(borrowed) # A taxon with no icon of its own still resolves, through its lineage. uncovered = next(r['taxid'] for r in conn.execute( "SELECT taxid FROM taxa WHERE total_count=1 ORDER BY taxid") if r['taxid'] in self.taxonomy.eukaryote_ids and r['taxid'] not in self.taxonomy.icons) image, borrowed = self.taxonomy.icon_for(conn, uncovered) self.assertTrue(image) self.assertTrue(borrowed) self.assertTrue((ICON_DIR / f"{image}.svg").is_file()) def test_borrowed_icons_never_repeat_within_a_column(self): if not self.taxonomy.icons: self.skipTest("silhouettes unavailable") for path in ("2759", DEFAULT_PATH, "2759/33154/33208", "2759/33154/33208/6072/33213"): html = self.taxonomy.view(path) # Levels are laid out top to bottom, so group the icons by their y. drawn = re.findall(r'<image class="tree-icon" x="[\d.]+" y="([\d.]+)"[^>]*?/data/icons/([0-9a-f-]+)\.svg"', html) levels = {} for y, image in drawn: levels.setdefault(round(float(y)), []).append(image) for y, images in levels.items(): self.assertEqual(len(images), len(set(images)), f"{path} level at {y}") def test_chart_references_icons_by_url(self): if not self.taxonomy.icons: self.skipTest("silhouettes unavailable") html = self.taxonomy.view(DEFAULT_PATH) self.assertIn('class="tree-icon"', html) for image in re.findall(r'href="[^"]*/data/icons/([0-9a-f-]+)\.svg"', html): self.assertIn(image, self.taxonomy.icon_credits) def test_viewbox_spans_the_whole_chart(self): # A stray local named width or height inside the node loop once shrank the # viewBox to 23x23, which renders as a blank chart with every element intact. for path in ("2759", DEFAULT_PATH, "2759/33154/33208"): box = re.search(r'<svg viewBox="0 0 ([\d.]+) ([\d.]+)"', self.taxonomy.view(path)) self.assertIsNotNone(box, path) self.assertGreater(float(box.group(1)), 1000, path) self.assertGreater(float(box.group(2)), 200, path) def test_shortcuts_expand_their_whole_lineage(self): html = self.taxonomy.view(DEFAULT_PATH) for taxid, name in SHORTCUTS: self.assertIn(f">{name}</button>", html) path = self.taxonomy.taxon_path(taxid) self.assertIn(f'data-path="{path}"', html) self.assertTrue(path.startswith("2759/"), name) self.assertTrue(path.endswith(f"/{taxid}"), name) def test_selected_path_flux_stays_highlighted(self): for path in (DEFAULT_PATH, "2759/33154/33208", "2759/33154/33208/6072/33213"): html = self.taxonomy.view(path) chart = html[html.index("<svg"):html.index("</svg>")] trunk = re.findall(r'class="flow flow-(\w+) is-path" data-node="([\d-]+)"', chart) selected = re.findall(r'<g class="tree-node is-selected"[^>]*data-node="([\d-]+)"', chart) # One level transition per step below the root, each carrying the # annotated and not-annotated halves of one group's inflow. self.assertEqual(sorted(set(uid for _, uid in trunk)), sorted(selected[1:]), path) self.assertLessEqual(len(trunk), 2 * (len(selected) - 1), path) # A trunk ribbon is still an ordinary ribbon, hoverable like any other. self.assertNotIn('class="flow flow-annotated is-path" data-node="0-0"', chart) def test_search_matches_english_names(self): for text, expected in (("sponges", "Porifera"), ("jellyfish", "Scyphozoa"), ("birds", "Aves"), ("human", "Homo sapiens")): found = self.taxonomy.search(text) self.assertTrue(found, text) # The closest match leads, so it can be preselected in the picker. self.assertTrue(found[0][0].startswith(expected + " · "), f"{text} -> {found[0][0]}") self.assertEqual(self.taxonomy.search("9606")[0][1], "9606") self.assertEqual(self.taxonomy.search("x"), []) def test_database_jump_offers_an_annotated_accession(self): with sqlite3.connect(f"{DATABASE.resolve().as_uri()}?mode=ro", uri=True) as conn: if not conn.execute("SELECT name FROM sqlite_master WHERE name='assemblies'").fetchone(): self.skipTest("assembly table unavailable") annotated = set(json.loads((DATABASE.parent / "coverage.json").read_text())["accessions"]) for path in (DEFAULT_PATH, "2759/33154/33208", self.taxonomy.taxon_path(40674)): found = self.taxonomy.assemblies_for(path, 3) self.assertTrue(found, path) # Every offer is a published annotation, never a dead end. for accession in found: self.assertIn(accession, annotated, path) # Mammal accessions must sit inside the mammal subtree, not above it. mammals = set(self.taxonomy.assemblies_for(self.taxonomy.taxon_path(40674), 50)) birds = set(self.taxonomy.assemblies_for(self.taxonomy.taxon_path(8782), 50)) self.assertFalse(mammals & birds) def test_rejects_paths_outside_the_tree(self): for path in ("1", "33208", "2759/33208", "2759/2759~3", "2759/9606", "abc"): with self.assertRaises(ValueError, msg=path): self.taxonomy.view(path) if __name__ == "__main__": unittest.main()