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