Skip to content

Commit 5729df3

Browse files
committed
add unit tests
1 parent 18aab0a commit 5729df3

File tree

9 files changed

+770
-0
lines changed

9 files changed

+770
-0
lines changed

.github/workflows/test.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [ main, develop, fix-*, feat-* ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ['3.10', '3.11', '3.12']
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
25+
- name: Install dependencies
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install -e .
29+
pip install pytest pytest-cov pandas
30+
31+
- name: Run unit tests
32+
run: |
33+
python -m pytest tests/ -v --tb=short --cov=cnb_tools --cov-report=term-missing
34+
35+
- name: Test CLI basics
36+
run: |
37+
cnb-tools
38+
cnb-tools --help
39+
cnb-tools submission --help
40+

docs/changelog/release-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
### Internal
1717
- Drop support for Python 3.9 (reached [end of life](https://devguide.python.org/versions/))
18+
- Add unit tests
1819

1920
## 0.3.2
2021

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Shared pytest fixtures for cnb-tools tests."""
2+
3+
import os
4+
import sys
5+
from unittest.mock import MagicMock
6+
7+
import pandas as pd
8+
import pytest
9+
from synapseclient import Evaluation, SubmissionStatus
10+
11+
12+
def pytest_configure(config):
13+
"""Allow test scripts to import scripts from parent folder."""
14+
src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
15+
sys.path.insert(0, src_path)
16+
17+
18+
@pytest.fixture
19+
def mock_syn():
20+
"""Fixture for mocked Synapse client."""
21+
return MagicMock()
22+
23+
24+
@pytest.fixture
25+
def mock_submission_status():
26+
"""Fixture for mocked SubmissionStatus."""
27+
status = MagicMock(spec=SubmissionStatus)
28+
status.status = "SCORED"
29+
status.submissionAnnotations = MagicMock()
30+
status.submissionAnnotations.__getitem__ = lambda self, key: {
31+
"score": 0.95,
32+
"passed": True,
33+
}.get(key)
34+
return status
35+
36+
37+
@pytest.fixture
38+
def mock_submission_file():
39+
"""Fixture for mocked file submission."""
40+
sub = MagicMock()
41+
sub.id = "12345"
42+
sub.evaluationId = "98765"
43+
sub.createdOn = "2025-11-26T10:30:00.000Z"
44+
sub.get.side_effect = lambda key: {"teamId": 111, "userId": 222}.get(key)
45+
return sub
46+
47+
48+
@pytest.fixture
49+
def mock_submission_docker():
50+
"""Fixture for mocked Docker submission."""
51+
sub = MagicMock()
52+
sub.id = "12345"
53+
sub.evaluationId = "98765"
54+
sub.dockerRepositoryName = "docker.synapse.org/syn12345/model"
55+
sub.dockerDigest = "sha256:abc123"
56+
sub.__contains__ = lambda self, key: key == "dockerDigest"
57+
return sub
58+
59+
60+
@pytest.fixture
61+
def mock_evaluation():
62+
"""Fixture for mocked Evaluation."""
63+
eval_obj = MagicMock(spec=Evaluation)
64+
eval_obj.id = "98765"
65+
eval_obj.name = "Test Queue"
66+
eval_obj.contentSource = "syn12345"
67+
return eval_obj
68+
69+
70+
@pytest.fixture
71+
def truth_ids():
72+
"""Fixture for truth IDs."""
73+
return pd.Series(["id1", "id2", "id3"], name="ids")
74+
75+
76+
@pytest.fixture
77+
def pred_ids_valid():
78+
"""Fixture for valid prediction IDs."""
79+
return pd.Series(["id1", "id2", "id3"], name="ids")
80+
81+
82+
@pytest.fixture
83+
def pred_ids_invalid():
84+
"""Fixture for invalid prediction IDs."""
85+
return pd.Series(["id1", "id1", "id4"], name="ids")
86+
87+
88+
@pytest.fixture
89+
def pred_values_valid():
90+
"""Fixture for valid prediction values."""
91+
return pd.DataFrame(
92+
{
93+
"predictions": [0, 1, 1],
94+
"probabilities": [0.25, 0.6, 0.83],
95+
}
96+
)
97+
98+
99+
@pytest.fixture
100+
def pred_values_invalid():
101+
"""Fixture for invalid prediction values."""
102+
return pd.DataFrame(
103+
{
104+
"predictions": [0, 1, None],
105+
"probabilities": [-0.5, 1.0, 1.5],
106+
}
107+
)

tests/test_annotation.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Unit tests for cnb_tools.modules.annotation"""
2+
3+
from unittest.mock import MagicMock, Mock, mock_open, patch
4+
5+
import pytest
6+
from synapseclient.core.exceptions import SynapseHTTPError
7+
8+
from cnb_tools.modules import annotation
9+
from cnb_tools.modules.base import UnknownSynapseID
10+
11+
12+
class TestGetSubmissionStatus:
13+
"""Tests for get_submission_status function"""
14+
15+
@patch("cnb_tools.modules.annotation.get_synapse_client")
16+
def test_get_submission_status_success(
17+
self, mock_get_client, mock_syn, mock_submission_status
18+
):
19+
"""Test successfully getting submission status"""
20+
mock_get_client.return_value = mock_syn
21+
mock_syn.getSubmissionStatus.return_value = mock_submission_status
22+
23+
result = annotation.get_submission_status(12345)
24+
25+
mock_syn.getSubmissionStatus.assert_called_once_with(12345)
26+
assert result == mock_submission_status
27+
28+
@patch("cnb_tools.modules.annotation.get_synapse_client")
29+
def test_get_submission_status_invalid_id(self, mock_get_client, mock_syn):
30+
"""Test error handling for invalid submission ID"""
31+
mock_get_client.return_value = mock_syn
32+
mock_response = Mock()
33+
mock_response.json.return_value = {"reason": "Submission not found"}
34+
mock_syn.getSubmissionStatus.side_effect = SynapseHTTPError(
35+
response=mock_response
36+
)
37+
38+
with pytest.raises(UnknownSynapseID) as exc_info:
39+
annotation.get_submission_status(99999)
40+
assert "Submission not found" in str(exc_info.value)
41+
42+
43+
class TestUpdateAnnotations:
44+
"""Tests for update_annotations function"""
45+
46+
@patch("cnb_tools.modules.annotation.get_synapse_client")
47+
@patch("cnb_tools.modules.annotation.get_submission_status")
48+
def test_update_annotations_success(
49+
self, mock_get_status, mock_get_client, mock_syn, mock_submission_status, capsys
50+
):
51+
"""Test successfully updating annotations"""
52+
mock_get_client.return_value = mock_syn
53+
mock_get_status.return_value = mock_submission_status
54+
mock_syn.store.return_value = mock_submission_status
55+
56+
# Mock the submissionAnnotations attribute as a dictionary
57+
mock_submission_status.submissionAnnotations = MagicMock()
58+
59+
new_annots = {"new_field": "value"}
60+
result = annotation.update_annotations(12345, new_annots, verbose=False)
61+
62+
mock_submission_status.submissionAnnotations.update.assert_called_once_with(
63+
new_annots
64+
)
65+
mock_syn.store.assert_called_once_with(mock_submission_status)
66+
67+
captured = capsys.readouterr()
68+
assert "Submission ID 12345 annotations updated" in captured.out
69+
assert result == mock_submission_status
70+
71+
@patch("cnb_tools.modules.annotation.get_synapse_client")
72+
@patch("cnb_tools.modules.annotation.get_submission_status")
73+
def test_update_annotations_verbose(
74+
self, mock_get_status, mock_get_client, mock_syn, mock_submission_status, capsys
75+
):
76+
"""Test updating annotations with verbose output"""
77+
mock_get_client.return_value = mock_syn
78+
mock_get_status.return_value = mock_submission_status
79+
mock_syn.store.return_value = mock_submission_status
80+
81+
# Mock submissionAnnotations as a dict for JSON serialization
82+
mock_submission_status.submissionAnnotations = {"score": 0.95, "passed": True}
83+
84+
annotation.update_annotations(12345, {"test": "value"}, verbose=True)
85+
86+
captured = capsys.readouterr()
87+
assert "Annotations:" in captured.out
88+
assert "score" in captured.out
89+
90+
91+
class TestUpdateAnnotationsFromFile:
92+
"""Tests for update_annotations_from_file function"""
93+
94+
@patch("cnb_tools.modules.annotation.with_retry")
95+
@patch(
96+
"builtins.open",
97+
new_callable=mock_open,
98+
read_data='{"score": 0.85, "status": "pass", "null_field": null, "empty_list": []}',
99+
)
100+
def test_update_annotations_from_file(self, mock_file, mock_retry):
101+
"""Test updating annotations from JSON file"""
102+
mock_retry.return_value = MagicMock()
103+
104+
annotation.update_annotations_from_file(12345, "test.json", verbose=False)
105+
106+
mock_file.assert_called_once_with("test.json", encoding="utf-8")
107+
108+
# Verify with_retry was called
109+
assert mock_retry.called
110+
111+
# Verify null and empty list values were filtered
112+
call_args = mock_retry.call_args
113+
assert call_args[1]["wait"] == 3
114+
assert call_args[1]["retries"] == 10
115+
116+
117+
class TestUpdateSubmissionStatus:
118+
"""Tests for update_submission_status function"""
119+
120+
@patch("cnb_tools.modules.annotation.get_synapse_client")
121+
@patch("cnb_tools.modules.annotation.get_submission_status")
122+
def test_update_submission_status(
123+
self, mock_get_status, mock_get_client, mock_syn, mock_submission_status, capsys
124+
):
125+
"""Test updating submission status"""
126+
mock_get_client.return_value = mock_syn
127+
mock_get_status.return_value = mock_submission_status
128+
mock_syn.store.return_value = mock_submission_status
129+
130+
result = annotation.update_submission_status(12345, "ACCEPTED")
131+
132+
assert mock_submission_status.status == "ACCEPTED"
133+
mock_syn.store.assert_called_once_with(mock_submission_status)
134+
135+
captured = capsys.readouterr()
136+
assert "Updated submission ID 12345 to status: ACCEPTED" in captured.out
137+
assert result == mock_submission_status

tests/test_participant.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Unit tests for cnb_tools.modules.participant"""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from synapseclient import Team
7+
from synapseclient.core.exceptions import SynapseHTTPError
8+
9+
from cnb_tools.modules import participant
10+
11+
12+
class TestGetParticipantName:
13+
"""Tests for get_participant_name function"""
14+
15+
@patch("cnb_tools.modules.participant.get_synapse_client")
16+
def test_get_participant_name_team(self, mock_get_client, mock_syn):
17+
"""Test getting team name"""
18+
mock_get_client.return_value = mock_syn
19+
mock_syn.getTeam.return_value = {"name": "Dream Team"}
20+
21+
result = participant.get_participant_name(12345)
22+
23+
assert result == "Dream Team"
24+
mock_syn.getTeam.assert_called_once_with(12345)
25+
26+
@patch("cnb_tools.modules.participant.get_synapse_client")
27+
def test_get_participant_name_user(self, mock_get_client, mock_syn):
28+
"""Test getting username when team lookup fails"""
29+
mock_get_client.return_value = mock_syn
30+
mock_syn.getTeam.side_effect = SynapseHTTPError(response=MagicMock())
31+
mock_syn.getUserProfile.return_value = {"userName": "john_doe"}
32+
33+
result = participant.get_participant_name(67890)
34+
35+
assert result == "john_doe"
36+
mock_syn.getUserProfile.assert_called_once_with(67890)
37+
38+
39+
class TestCreateTeam:
40+
"""Tests for create_team function"""
41+
42+
@patch("cnb_tools.modules.participant.typer.confirm")
43+
@patch("cnb_tools.modules.participant.get_synapse_client")
44+
def test_create_team_new(self, mock_get_client, mock_confirm, mock_syn):
45+
"""Test creating a new team"""
46+
mock_get_client.return_value = mock_syn
47+
mock_syn.getTeam.side_effect = ValueError("Team not found")
48+
49+
result = participant.create_team(
50+
name="New Team", description="Test description", can_public_join=True
51+
)
52+
53+
assert isinstance(result, Team)
54+
assert result.name == "New Team"
55+
assert result.description == "Test description"
56+
assert result.canPublicJoin is True
57+
58+
@patch("cnb_tools.modules.participant.typer.confirm")
59+
@patch("cnb_tools.modules.participant.get_synapse_client")
60+
def test_create_team_existing_confirmed(
61+
self, mock_get_client, mock_confirm, mock_syn
62+
):
63+
"""Test using an existing team when user confirms"""
64+
mock_get_client.return_value = mock_syn
65+
existing_team = MagicMock(spec=Team)
66+
existing_team.name = "Dream Team"
67+
mock_syn.getTeam.return_value = existing_team
68+
mock_confirm.return_value = True
69+
70+
result = participant.create_team(name="Dream Team")
71+
72+
assert result == existing_team
73+
mock_confirm.assert_called_once_with(
74+
"Team 'Dream Team' already exists. Use this team?"
75+
)
76+
77+
@patch("cnb_tools.modules.participant.typer.confirm")
78+
@patch("cnb_tools.modules.participant.get_synapse_client")
79+
def test_create_team_existing_declined(
80+
self, mock_get_client, mock_confirm, mock_syn
81+
):
82+
"""Test declining to use existing team exits"""
83+
mock_get_client.return_value = mock_syn
84+
existing_team = MagicMock(spec=Team)
85+
mock_syn.getTeam.return_value = existing_team
86+
mock_confirm.return_value = False
87+
88+
with pytest.raises(SystemExit) as exc_info:
89+
participant.create_team(name="Existing Team")
90+
91+
assert "Try again with a new challenge name" in str(exc_info.value)

0 commit comments

Comments
 (0)