File size: 7,080 Bytes
dd2b8fb
92cb20a
dd2b8fb
92cb20a
 
dd2b8fb
aa5d49c
92cb20a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd2b8fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa5d49c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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, Taxonomy


@unittest.skipUnless(DATABASE.exists(), "taxonomy snapshot unavailable")
class TaxonomyTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.taxonomy = Taxonomy()

    def columns(self, html):
        return sorted({float(x) for x in re.findall(r'<rect x="([\d.]+)" y="[\d.]+" width="14"', html)})

    def test_expansion_keeps_every_ancestor_column(self):
        self.assertEqual(len(self.columns(self.taxonomy.view("2759"))), 2)
        self.assertEqual(len(self.columns(self.taxonomy.view(DEFAULT_PATH))), 3)
        html = self.taxonomy.view("2759/33154/33208")
        self.assertEqual(len(self.columns(html)), 4)
        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)
        self.assertEqual(html.count("data-detail="), html.count('class="tree-hit"'))

    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)
            # Columns are laid out left to right, so group the icons by their x.
            drawn = re.findall(r'<image class="tree-icon" x="([\d.]+)"[^>]*?/data/icons/([0-9a-f-]+)\.svg"', html)
            columns = {}
            for x, image in drawn:
                columns.setdefault(round(float(x)), []).append(image)
            for x, images in columns.items():
                self.assertEqual(len(images), len(set(images)), f"{path} column at {x}")

    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)), 500, path)
            self.assertGreater(float(box.group(2)), 400, path)

    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()