Skip to content

Commit 789661e

Browse files
kjy5Copilot
andauthored
467 get 100 coverage on backend and utils (#468)
* Test converters * Test preamble and update check * Use parametrize on exception * Test get bindings * Get binding instance * Apply static analysis * Utils covered * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Fixed error message usage --------- Co-authored-by: Copilot <[email protected]>
1 parent 9ebe48f commit 789661e

File tree

8 files changed

+313
-19
lines changed

8 files changed

+313
-19
lines changed

.idea/ephys-link.iml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/misc.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ephys_link/front_end/gui.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from vbl_aquarium.models.ephys_link import EphysLinkOptions
2121

2222
from ephys_link.__about__ import __version__ as version
23-
from ephys_link.utils.startup import get_binding_display_to_cli_name
23+
from ephys_link.utils.startup import get_bindings
2424

2525
# Define options path.
2626
OPTIONS_DIR = join(user_config_dir(), "VBL", "Ephys Link")
@@ -156,7 +156,7 @@ def _build_gui(self) -> None:
156156
platform_type_settings = ttk.LabelFrame(mainframe, text="Platform Type", padding=3)
157157
platform_type_settings.grid(column=0, row=1, sticky="news")
158158

159-
for index, (display_name, cli_name) in enumerate(get_binding_display_to_cli_name().items()):
159+
for index, (display_name, cli_name) in enumerate(self._get_binding_display_to_cli_name().items()):
160160
ttk.Radiobutton(
161161
platform_type_settings,
162162
text=display_name,
@@ -202,3 +202,11 @@ def _launch_server(self) -> None:
202202
"""
203203
self._submit = True
204204
self._root.destroy()
205+
206+
def _get_binding_display_to_cli_name(self) -> dict[str, str]:
207+
"""Get mapping of display to CLI option names of the available platform bindings.
208+
209+
Returns:
210+
Dictionary of platform binding display name to CLI option name.
211+
"""
212+
return {binding_type.get_display_name(): binding_type.get_cli_name() for binding_type in get_bindings()}

src/ephys_link/utils/constants.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,21 @@ def client_disconnected_without_being_connected_error(client_sid: str) -> str:
8686

8787
MALFORMED_REQUEST_ERROR = {"error": "Malformed request."}
8888
UNKNOWN_EVENT_ERROR = {"error": "Unknown event."}
89+
90+
UNABLE_TO_CHECK_FOR_UPDATES_ERROR = (
91+
"Unable to check for updates. Ignore updates or use the -i flag to disable checks.\n"
92+
)
93+
94+
95+
def ump_4_3_deprecation_error(cli_name: str):
96+
return f"CLI option '{cli_name}' is deprecated and will be removed in v3.0.0. Use 'ump' instead."
97+
98+
99+
def unrecognized_platform_type_error(cli_name: str) -> str:
100+
"""Generate an error message for when the platform type is not recognized.
101+
Args:
102+
cli_name: The platform type that is not recognized.
103+
Returns:
104+
str: The error message.
105+
"""
106+
return f'Platform type "{cli_name}" not recognized.'

src/ephys_link/utils/startup.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from ephys_link.bindings.mpm_binding import MPMBinding
1313
from ephys_link.front_end.console import Console
1414
from ephys_link.utils.base_binding import BaseBinding
15-
from ephys_link.utils.constants import ASCII, BINDINGS_DIRECTORY
15+
from ephys_link.utils.constants import (
16+
ASCII,
17+
BINDINGS_DIRECTORY,
18+
UNABLE_TO_CHECK_FOR_UPDATES_ERROR,
19+
ump_4_3_deprecation_error,
20+
unrecognized_platform_type_error,
21+
)
1622

1723

1824
def preamble() -> None:
@@ -39,9 +45,7 @@ def check_for_updates(console: Console) -> None:
3945
console.critical_print(f"Update available: {latest_version} (current: {__version__})")
4046
console.critical_print("Download at: https://github.com/VirtualBrainLab/ephys-link/releases/latest")
4147
except (ConnectionError, ConnectTimeout):
42-
console.error_print(
43-
"UPDATE", "Unable to check for updates. Ignore updates or use the the -i flag to disable checks.\n"
44-
)
48+
console.error_print("UPDATE", UNABLE_TO_CHECK_FOR_UPDATES_ERROR)
4549

4650

4751
def get_bindings() -> list[type[BaseBinding]]:
@@ -80,7 +84,7 @@ def get_binding_instance(options: EphysLinkOptions, console: Console) -> BaseBin
8084
if selected_type in ("ump-4", "ump-3"):
8185
console.error_print(
8286
"DEPRECATION",
83-
f"CLI option '{selected_type}' is deprecated and will be removed in v3.0.0. Use 'ump' instead.",
87+
ump_4_3_deprecation_error(selected_type),
8488
)
8589
selected_type = "ump"
8690

@@ -93,15 +97,6 @@ def get_binding_instance(options: EphysLinkOptions, console: Console) -> BaseBin
9397
return binding_type()
9498

9599
# Raise an error if the platform type is not recognized.
96-
error_message = f'Platform type "{options.type}" not recognized.'
100+
error_message = unrecognized_platform_type_error(selected_type)
97101
console.critical_print(error_message)
98102
raise ValueError(error_message)
99-
100-
101-
def get_binding_display_to_cli_name() -> dict[str, str]:
102-
"""Get mapping of display to CLI option names of the available platform bindings.
103-
104-
Returns:
105-
Dictionary of platform binding display name to CLI option name.
106-
"""
107-
return {binding_type.get_display_name(): binding_type.get_cli_name() for binding_type in get_bindings()}

tests/utils/__init__.py

Whitespace-only changes.

tests/utils/test_converters.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Unit tests for converters.py with 100% coverage."""
2+
3+
from vbl_aquarium.models.unity import Vector4
4+
5+
from ephys_link.utils.converters import list_to_vector4, scalar_mm_to_um, um_to_mm, vector4_to_array, vector_mm_to_um
6+
from tests.conftest import DUMMY_VECTOR4
7+
8+
9+
class TestConverters:
10+
"""Test class for converters module with 100% coverage."""
11+
12+
def test_scalar_mm_to_um_positive(self):
13+
"""Test scalar mm to um conversion with positive value."""
14+
result = scalar_mm_to_um(5.5)
15+
assert result == 5500.0
16+
17+
def test_scalar_mm_to_um_negative(self):
18+
"""Test scalar mm to um conversion with negative value."""
19+
result = scalar_mm_to_um(-2.3)
20+
assert result == -2300.0
21+
22+
def test_scalar_mm_to_um_zero(self):
23+
"""Test scalar mm to um conversion with zero."""
24+
result = scalar_mm_to_um(0.0)
25+
assert result == 0.0
26+
27+
def test_vector_mm_to_um(self):
28+
"""Test vector mm to um conversion using dummy vector."""
29+
# DUMMY_VECTOR4 is Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
30+
result = vector_mm_to_um(DUMMY_VECTOR4)
31+
expected = Vector4(x=1000.0, y=2000.0, z=3000.0, w=4000.0)
32+
33+
assert result == expected
34+
35+
def test_vector_mm_to_um_with_zeros(self):
36+
"""Test vector mm to um conversion with zero values."""
37+
input_vector = Vector4(x=0.0, y=5.0, z=0.0, w=-1.0)
38+
result = vector_mm_to_um(input_vector)
39+
expected = Vector4(x=0.0, y=5000.0, z=0.0, w=-1000.0)
40+
41+
assert result == expected
42+
43+
def test_um_to_mm(self):
44+
"""Test um to mm conversion."""
45+
input_vector = Vector4(x=1000.0, y=2000.0, z=3000.0, w=4000.0)
46+
result = um_to_mm(input_vector)
47+
expected = Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
48+
49+
assert result == expected
50+
51+
def test_um_to_mm_with_decimals(self):
52+
"""Test um to mm conversion with decimal results."""
53+
input_vector = Vector4(x=500.0, y=1500.0, z=2500.0, w=3500.0)
54+
result = um_to_mm(input_vector)
55+
expected = Vector4(x=0.5, y=1.5, z=2.5, w=3.5)
56+
57+
assert result == expected
58+
59+
def test_vector4_to_array(self):
60+
"""Test Vector4 to array conversion using dummy vector."""
61+
# DUMMY_VECTOR4 is Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
62+
result = vector4_to_array(DUMMY_VECTOR4)
63+
64+
assert result == [1.0, 2.0, 3.0, 4.0]
65+
assert isinstance(result, list)
66+
assert all(isinstance(x, float) for x in result)
67+
68+
def test_vector4_to_array_with_negative_values(self):
69+
"""Test Vector4 to array conversion with negative values."""
70+
input_vector = Vector4(x=-1.0, y=0.0, z=5.0, w=-10.0)
71+
result = vector4_to_array(input_vector)
72+
73+
assert result == [-1.0, 0.0, 5.0, -10.0]
74+
75+
def test_list_to_vector4_exact_four_elements(self):
76+
"""Test list to Vector4 conversion with exactly 4 elements."""
77+
float_list = [1.0, 2.0, 3.0, 4.0]
78+
result = list_to_vector4(float_list)
79+
expected = Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
80+
81+
assert result == expected
82+
83+
def test_list_to_vector4_less_than_four_elements(self):
84+
"""Test list to Vector4 conversion with less than 4 elements (padding with zeros)."""
85+
# Test with 3 elements
86+
float_list = [1.0, 2.0, 3.0]
87+
result = list_to_vector4(float_list)
88+
assert result == Vector4(x=1.0, y=2.0, z=3.0, w=0.0)
89+
90+
# Test with 2 elements
91+
float_list = [5.0, 6.0]
92+
result = list_to_vector4(float_list)
93+
assert result == Vector4(x=5.0, y=6.0, z=0.0, w=0.0)
94+
95+
# Test with 1 element
96+
float_list = [7.0]
97+
result = list_to_vector4(float_list)
98+
assert result == Vector4(x=7.0, y=0.0, z=0.0, w=0.0)
99+
100+
def test_list_to_vector4_empty_list(self):
101+
"""Test list to Vector4 conversion with empty list (all zeros)."""
102+
float_list: list[float] = []
103+
result = list_to_vector4(float_list)
104+
expected = Vector4(x=0.0, y=0.0, z=0.0, w=0.0)
105+
106+
assert result == expected
107+
108+
def test_list_to_vector4_more_than_four_elements(self):
109+
"""Test list to Vector4 conversion with more than 4 elements (ignores extra)."""
110+
float_list = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
111+
result = list_to_vector4(float_list)
112+
expected = Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
113+
114+
assert result == expected
115+
116+
def test_list_to_vector4_with_integers(self):
117+
"""Test list to Vector4 conversion with integer inputs."""
118+
int_list = [1, 2, 3, 4]
119+
result = list_to_vector4(int_list) # pyright: ignore[reportArgumentType]
120+
expected = Vector4(x=1.0, y=2.0, z=3.0, w=4.0)
121+
122+
assert result == expected
123+
124+
# Verify they're floats in the Vector4
125+
assert isinstance(result.x, float)
126+
assert isinstance(result.y, float)
127+
assert isinstance(result.z, float)
128+
assert isinstance(result.w, float)
129+
130+
def test_list_to_vector4_mixed_types(self):
131+
"""Test list to Vector4 conversion with mixed int and float types."""
132+
mixed_list = [1, 2.5, 3, 4.5]
133+
result = list_to_vector4(mixed_list)
134+
expected = Vector4(x=1.0, y=2.5, z=3.0, w=4.5)
135+
136+
assert result == expected

tests/utils/test_startup.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import sys
2+
from io import StringIO
3+
4+
import pytest
5+
from pytest_mock import MockerFixture
6+
from requests import ConnectionError, ConnectTimeout
7+
from vbl_aquarium.models.ephys_link import EphysLinkOptions
8+
9+
from ephys_link.__about__ import __version__
10+
from ephys_link.bindings.fake_binding import FakeBinding
11+
from ephys_link.bindings.mpm_binding import MPMBinding
12+
from ephys_link.front_end.console import Console
13+
from ephys_link.utils.base_binding import BaseBinding
14+
from ephys_link.utils.constants import (
15+
ASCII,
16+
UNABLE_TO_CHECK_FOR_UPDATES_ERROR,
17+
ump_4_3_deprecation_error,
18+
unrecognized_platform_type_error,
19+
)
20+
from ephys_link.utils.startup import check_for_updates, get_binding_instance, get_bindings, preamble
21+
22+
23+
class TestStartup:
24+
@pytest.fixture
25+
def console(self, mocker: MockerFixture) -> Console:
26+
"""Fixture for mock console."""
27+
return mocker.Mock(spec=Console)
28+
29+
def test_preamble(self) -> None:
30+
"""Test the preamble function."""
31+
# Arrange.
32+
captured_output = StringIO()
33+
sys.stdout = captured_output
34+
35+
# Act.
36+
preamble()
37+
38+
# Assert.
39+
sys.stdout = sys.__stdout__
40+
output = captured_output.getvalue()
41+
42+
assert "Ephys Link" in output
43+
assert "This is the Ephys Link server window." in output
44+
assert __version__ in output
45+
assert ASCII in output
46+
47+
def test_check_for_updates_is_newer(self, console: Console, mocker: MockerFixture) -> None:
48+
"""Test the check_for_updates function."""
49+
# Add mocks and spies.
50+
spied_critical_print = mocker.spy(console, "critical_print")
51+
52+
# Mock requests.get to return a fake response with a lower version.
53+
fake_response = mocker.Mock()
54+
fake_response.json.return_value = [{"name": "0.0.0"}] # pyright: ignore [reportAny]
55+
_ = mocker.patch("ephys_link.utils.startup.get", return_value=fake_response)
56+
57+
# Act
58+
check_for_updates(console)
59+
60+
# Assert: critical_print should NOT be called since no update is available.
61+
spied_critical_print.assert_not_called()
62+
63+
def test_check_for_updates_is_older(self, console: Console, mocker: MockerFixture) -> None:
64+
"""Test the check_for_updates function with a newer version."""
65+
# Add mocks and spies.
66+
spied_critical_print = mocker.spy(console, "critical_print")
67+
68+
# Mock the json() method of the response from get
69+
fake_response = mocker.Mock()
70+
fake_response.json.return_value = [{"name": "1000000.0.0"}] # pyright: ignore [reportAny]
71+
_ = mocker.patch("ephys_link.utils.startup.get", return_value=fake_response)
72+
73+
# Act
74+
check_for_updates(console)
75+
76+
# Assert: critical_print should be called since an update is available.
77+
spied_critical_print.assert_called()
78+
79+
@pytest.mark.parametrize("exception", [ConnectionError, ConnectTimeout])
80+
def test_check_for_updates_connection_errors(
81+
self, exception: ConnectionError | ConnectTimeout, console: Console, mocker: MockerFixture
82+
) -> None:
83+
"""Test the check_for_updates function with connection-related errors."""
84+
# Add mocks and spies.
85+
spied_error_print = mocker.spy(console, "error_print")
86+
87+
# Mock requests.get to raise a ConnectionError or ConnectTimeout.
88+
_ = mocker.patch("ephys_link.utils.startup.get", side_effect=exception)
89+
90+
# Act
91+
check_for_updates(console)
92+
93+
# Assert: error_print should be called with the correct message.
94+
spied_error_print.assert_called_with("UPDATE", UNABLE_TO_CHECK_FOR_UPDATES_ERROR)
95+
96+
def test_get_bindings_returns_valid_bindings(self):
97+
"""Test that get_bindings returns a list of valid binding classes."""
98+
# Act.
99+
bindings = get_bindings()
100+
101+
# Assert.
102+
assert isinstance(bindings, list)
103+
assert all(issubclass(b, BaseBinding) for b in bindings)
104+
assert BaseBinding not in bindings
105+
106+
@pytest.mark.parametrize(("cli_name", "binding"), [("fake", FakeBinding), ("pathfinder-mpm", MPMBinding)])
107+
def test_get_binding_instance(
108+
self, cli_name: str, binding: FakeBinding | MPMBinding, console: Console, mocker: MockerFixture
109+
):
110+
"""Test that get_binding_instance returns an instance of the requested binding class."""
111+
# Arrange.
112+
spied_error_print = mocker.spy(console, "error_print")
113+
fake_options = EphysLinkOptions(type=cli_name)
114+
115+
# Act.
116+
binding_instance = get_binding_instance(fake_options, console)
117+
118+
# Assert.
119+
# noinspection PyTypeChecker
120+
assert isinstance(binding_instance, binding) # pyright: ignore [reportArgumentType]
121+
spied_error_print.assert_not_called()
122+
123+
@pytest.mark.parametrize("cli_name", ["ump-4", "ump-3"])
124+
def test_get_binding_instance_ump(self, cli_name: str, console: Console, mocker: MockerFixture):
125+
"""Test that get_binding_instance returns an instance of the UmpBinding class and handles deprecation."""
126+
# Arrange.
127+
spied_error_print = mocker.spy(console, "error_print")
128+
fake_options = EphysLinkOptions(type=cli_name)
129+
_ = mocker.patch("ephys_link.bindings.ump_binding.UmpBinding", autospec=True)
130+
131+
# Act.
132+
with pytest.raises(ValueError, match=unrecognized_platform_type_error("ump")) as e:
133+
_ = get_binding_instance(fake_options, console)
134+
135+
# Assert.
136+
spied_error_print.assert_called_once_with("DEPRECATION", ump_4_3_deprecation_error(cli_name))
137+
assert str(e.value) == unrecognized_platform_type_error("ump")

0 commit comments

Comments
 (0)