File size: 11,898 Bytes
ac25980 92cb20a ac25980 92cb20a ac25980 9f55f84 92cb20a 9f55f84 92cb20a 9f55f84 92cb20a 9f55f84 2c2de0b 92cb20a ac25980 2c2de0b ac25980 6c4dd46 9f55f84 6c4dd46 9f55f84 2c2de0b 9f55f84 6c4dd46 4b2492e be172fd 92cb20a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | 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()
|