carbon-a-database-explorer / tests /test_taxonomy.py
lvwerra's picture
lvwerra HF Staff
Fold the views into one panel, right-size the controls, and open Database from a group (#7)
be172fd
Raw History Blame Contribute Delete
11.9 kB
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'<rect x="[\d.]+" y="([\d.]+)" width="[\d.]+" height="15" class="bar-outline"', html)})
def test_expansion_keeps_every_ancestor_level(self):
self.assertEqual(len(self.levels(self.taxonomy.view("2759"))), 2)
self.assertEqual(len(self.levels(self.taxonomy.view(DEFAULT_PATH))), 3)
html = self.taxonomy.view("2759/33154/33208")
levels = self.levels(html)
self.assertEqual(len(levels), 4)
# Levels run down the page, evenly spaced and clear of each other, so
# depth costs height rather than width.
pitches = {round(b - a) for a, b in zip(levels, levels[1:])}
self.assertEqual(len(pitches), 1, levels)
caption_and_bar = 46 + 2 * 15 + 15
self.assertGreater(pitches.pop(), caption_and_bar)
self.assertEqual(html.count('class="tree-node is-selected"'), 3)
def test_search_path_expands_lineage(self):
path = self.taxonomy.taxon_path(9606)
self.assertTrue(path.startswith("2759/33154/33208/"))
self.assertTrue(path.endswith("/9606"))
self.assertIn(f'data-route="{path}"', self.taxonomy.view_taxon(9606))
def test_other_lineages_expand_as_columns(self):
html = self.taxonomy.view("2759/2759~6")
self.assertIn('data-path="2759/2759~6/2759~12"', html)
def test_english_names_label_latin_clades(self):
html = self.taxonomy.view("2759/33154/33208")
for scientific, english in (("Opisthokonta", "animals and fungi"), ("Metazoa", "animals"),
("Eumetazoa", "animals with true tissues"), ("Porifera", "sponges")):
self.assertIn(f'class="tree-label">{scientific}</text>', html)
self.assertIn(f'class="tree-english">{english}</text>', html)
# A gloss that only repeats the scientific name is dropped by the build.
self.assertIn('class="tree-label">Fungi</text>', html)
self.assertNotIn('class="tree-english">fungi</text>', html)
def test_one_tooltip_per_node(self):
html = self.taxonomy.view(DEFAULT_PATH)
# <title> 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()