Skip to content

Commit 3338f39

Browse files
Add initial unit tests and package-focused coverage
1 parent edfebbe commit 3338f39

6 files changed

Lines changed: 227 additions & 10 deletions

File tree

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[run]
2+
source =
3+
gff3tool
4+
5+
[report]
6+
show_missing = True
7+
skip_covered = False

.github/workflows/build.yml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,14 @@ jobs:
2323
if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi
2424
- name: Test with coverage
2525
run: |
26-
coverage run -a ./gff3tool/bin/gff3_QC.py -g example_file/example.gff3 -f example_file/reference.fa -o error.txt
27-
coverage run -a ./gff3tool/bin/gff3_fix.py -qc_r error.txt -g example_file/example.gff3 -og corrected.gff3
28-
coverage run -a ./gff3tool/bin/gff3_merge.py -g1 example_file/new_models.gff3 -g2 example_file/reference.gff3 -f example_file/reference.fa -og merged.gff -r merged_report.txt
29-
coverage run -a ./gff3tool/bin/gff3_merge.py -g1 example_file/new_models.gff3 -g2 example_file/reference.gff3 -f example_file/reference.fa -og merged.gff -u1 example_file/u1.txt -u2 example_file/u2.txt -r merged_report.txt
30-
coverage run -a ./gff3tool/bin/gff3_merge.py -g1 example_file/new_models.gff3 -g2 example_file/reference.gff3 -f example_file/reference.fa -og merged.gff -u1 example_file/u1.txt -r merged_report.txt
31-
coverage run -a ./gff3tool/bin/gff3_merge.py -g1 example_file/new_models.gff3 -g2 example_file/reference.gff3 -f example_file/reference.fa -og merged.gff -u2 example_file/u2.txt -r merged_report.txt
32-
coverage run -a ./gff3tool/bin/gff3_merge.py -g1 example_file/new_models_w_replace.gff3 -g2 example_file/reference.gff3 -f example_file/reference.fa -og merged.gff -r merged_report.txt -noAuto
33-
coverage run -a ./gff3tool/bin/gff3_sort.py -g example_file/example.gff3 -og example-sorted.gff3
34-
coverage run -a ./gff3tool/bin/gff3_to_fasta.py -g example_file/example.gff3 -f example_file/reference.fa -st all -d simple -o test_sequences
26+
coverage erase
27+
coverage run -a tests.py
28+
coverage run -a -m unittest discover -s tests/unit -p "test_*.py"
3529
- name: after success
3630
env:
3731
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3832
run: |
39-
coverage report
33+
coverage report -m
4034
coveralls --service=github
4135
codecov
4236

tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
set -euo pipefail
33

44
python tests.py
5+
python -m unittest discover -s tests/unit -p 'test_*.py'

tests/unit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit test package for GFF3toolkit."""

tests/unit/test_id_processor.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import unittest
2+
3+
from gff3tool.lib import id_processor
4+
5+
6+
class DummyGFF:
7+
def __init__(self, line_count=0):
8+
self.lines = [{} for _ in range(line_count)]
9+
10+
11+
class TestIdProcessor(unittest.TestCase):
12+
def test_idgenerator_zero_pads_and_increments(self):
13+
result = id_processor.idgenerator("GENE", 9, 4)
14+
self.assertEqual(result["ID"], "GENE0010")
15+
self.assertEqual(result["maxnum"], 10)
16+
17+
def test_simple_id_replace_updates_numeric_portion(self):
18+
model = {"type": "mRNA", "attributes": {"ID": "ID0001-RA"}}
19+
id_processor.simpleIDreplace(model, "ID0123")
20+
self.assertEqual(model["attributes"]["ID"], "ID0123-RA")
21+
22+
def test_simple_id_replace_assigns_id_when_missing(self):
23+
model = {"type": "gene", "attributes": {}}
24+
id_processor.simpleIDreplace(model, "LOC0007")
25+
self.assertEqual(model["attributes"]["ID"], "LOC0007gene")
26+
27+
def test_new_parent_model_sets_id_name_and_line_index(self):
28+
oldmodel = {
29+
"attributes": {"ID": "old1", "Name": "old1"},
30+
"children": [{"attributes": {"ID": "child"}}],
31+
"line_index": 3,
32+
}
33+
gff = DummyGFF(line_count=5)
34+
35+
new_model = id_processor.newParentModel(oldmodel, "new1", gff)
36+
37+
self.assertEqual(new_model["attributes"]["ID"], "new1")
38+
self.assertEqual(new_model["attributes"]["Name"], "new1")
39+
self.assertEqual(new_model["line_index"], 5)
40+
self.assertEqual(new_model["children"], [])
41+
42+
def test_new_child_model_resets_parent_links_and_children(self):
43+
ochild = {
44+
"type": "mRNA",
45+
"attributes": {"ID": "LOC0001-RA", "Parent": ["old_parent"], "Name": "LOC0001-RA"},
46+
"parents": [[{"attributes": {"ID": "old_parent"}}]],
47+
"children": [{"attributes": {"ID": "LOC0001-RA-exon"}}],
48+
"line_index": 0,
49+
}
50+
gff = DummyGFF(line_count=8)
51+
52+
nchild = id_processor.newChildModel(ochild, "LOC0002", gff)
53+
54+
self.assertEqual(nchild["line_index"], 8)
55+
self.assertEqual(nchild["parents"], [])
56+
self.assertEqual(nchild["attributes"]["Parent"], [])
57+
self.assertEqual(nchild["attributes"]["ID"], "LOC0002-RA")
58+
self.assertEqual(nchild["attributes"]["Name"], "LOC0002-RA")
59+
self.assertEqual(nchild["children"], [])
60+
61+
62+
if __name__ == "__main__":
63+
unittest.main()

tests/unit/test_merge.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import io
2+
import unittest
3+
from collections import defaultdict
4+
from unittest import mock
5+
6+
from gff3tool.lib.gff3_merge import merge
7+
8+
9+
class FakeGroups:
10+
def __init__(self, **_kwargs):
11+
self.mapName2ID = {}
12+
self.info = []
13+
self.mapType2Log = {
14+
"other": "OTHER",
15+
"Delete": "DELETE",
16+
"simple": "SIMPLE",
17+
"multi-ref": "MULTI",
18+
}
19+
self.id2name = {}
20+
pgff = _kwargs.get("Pgff")
21+
if pgff is not None:
22+
for line in pgff.lines:
23+
line_id = line.get("attributes", {}).get("ID")
24+
if line_id:
25+
self.id2name[line_id] = line.get("attributes", {}).get("Name", line_id)
26+
27+
def replacer(self, *_args, **_kwargs):
28+
root = _args[0]
29+
for child in root.get("children", []):
30+
child.setdefault("attributes", {})["replace_type"] = "other"
31+
return None
32+
33+
def replacer_multi(self, *_args, **_kwargs):
34+
return "ok"
35+
36+
def name2id(self, *_args, **_kwargs):
37+
return None
38+
39+
40+
class FakeGff:
41+
def __init__(self, lines):
42+
self.lines = lines
43+
self.features = defaultdict(list)
44+
for line in lines:
45+
line_id = line.get("attributes", {}).get("ID")
46+
if line_id:
47+
self.features[line_id].append(line)
48+
self.written_output = None
49+
50+
def collect_roots(self, line):
51+
return [line]
52+
53+
def collect_descendants(self, line):
54+
return line.get("children", [])
55+
56+
def write(self, output_gff):
57+
self.written_output = output_gff
58+
59+
60+
class TestMergeMain(unittest.TestCase):
61+
def _make_root_with_child(self, root_id, child_status="active", child_replace=None):
62+
if child_replace is None:
63+
child_replace = ["NA"]
64+
65+
child = {
66+
"line_type": "feature",
67+
"type": "mRNA",
68+
"line_status": "removed" if child_status == "removed" else "active",
69+
"line_raw": f"raw-{root_id}-child",
70+
"attributes": {
71+
"ID": f"{root_id}-RA",
72+
"replace": list(child_replace),
73+
},
74+
"parents": [],
75+
"children": [],
76+
}
77+
if child_status == "removed":
78+
child["attributes"]["status"] = "Delete"
79+
80+
root = {
81+
"line_type": "feature",
82+
"type": "gene",
83+
"attributes": {"ID": root_id},
84+
"children": [child],
85+
}
86+
return root, child
87+
88+
def test_delete_with_na_replace_raises_system_exit(self):
89+
wa_root, wa_child = self._make_root_with_child("gene1", child_status="active", child_replace=["NA"])
90+
other_root, other_child = self._make_root_with_child("gene1", child_status="removed", child_replace=["NA"])
91+
92+
wa_gff = FakeGff([wa_root, wa_child])
93+
other_gff = FakeGff([other_root, other_child])
94+
95+
def fake_gff_factory(gff_file=None, logger=None):
96+
if gff_file == "WA_sorted.gff":
97+
return wa_gff
98+
if gff_file == "other_sorted.gff":
99+
return other_gff
100+
raise AssertionError(f"Unexpected gff file: {gff_file}")
101+
102+
with mock.patch.object(merge.gff3_sort, "main", autospec=True), \
103+
mock.patch.object(merge.replace_OGS, "Groups", FakeGroups), \
104+
mock.patch.object(merge, "Gff3", side_effect=fake_gff_factory), \
105+
mock.patch.object(merge, "remove_files_from_list", autospec=True):
106+
with self.assertRaises(SystemExit) as cm:
107+
merge.main(
108+
gff_file1="wa.gff3",
109+
gff_file2="other.gff3",
110+
output_gff="out.gff3",
111+
report_fh=io.StringIO(),
112+
)
113+
114+
self.assertIn("replace tag for Delete replacement cannot be NA", str(cm.exception))
115+
116+
def test_main_writes_output_and_cleans_temp_files(self):
117+
wa_root, wa_child = self._make_root_with_child("geneX", child_status="active", child_replace=["NA"])
118+
other_root, other_child = self._make_root_with_child("geneY", child_status="active", child_replace=["NA"])
119+
120+
wa_gff = FakeGff([wa_root, wa_child])
121+
other_gff = FakeGff([other_root, other_child])
122+
123+
def fake_gff_factory(gff_file=None, logger=None):
124+
if gff_file == "WA_sorted.gff":
125+
return wa_gff
126+
if gff_file == "other_sorted.gff":
127+
return other_gff
128+
raise AssertionError(f"Unexpected gff file: {gff_file}")
129+
130+
report = io.StringIO()
131+
132+
with mock.patch.object(merge.gff3_sort, "main", autospec=True), \
133+
mock.patch.object(merge.replace_OGS, "Groups", FakeGroups), \
134+
mock.patch.object(merge, "Gff3", side_effect=fake_gff_factory), \
135+
mock.patch.object(merge, "remove_files_from_list", autospec=True) as rm_files:
136+
merge.main(
137+
gff_file1="wa.gff3",
138+
gff_file2="other.gff3",
139+
output_gff="final.gff3",
140+
report_fh=report,
141+
)
142+
143+
self.assertEqual(other_gff.written_output, "final.gff3")
144+
rm_files.assert_called_once_with(["WA_sorted.gff", "other_sorted.gff"])
145+
report_output = report.getvalue()
146+
self.assertIn("# Number of WA loci", report_output)
147+
self.assertIn("Change_log", report_output)
148+
149+
150+
if __name__ == "__main__":
151+
unittest.main()

0 commit comments

Comments
 (0)