Skip to content

Commit 9ebe48f

Browse files
authored
454 add unit tests (#466)
* WIP setup, don't quite understand mocking yet * WIP refactoring bindings * Refactored binding * Test get_display_name * Test get_platform_info * WIP get_manipulators, should use patch * Correct usage of patching * Fix other tests to patch * Test get manipulator exception * Use fixtures for instances * Add coverage, add spy * Test get_position * angles and shank * Reorganize values, test inside_brain set position * WIP test tolerance * Moved console, test set tolerance * set position * set depth, set inside brain * 100% cover platform_handler * WIP correcting names * Review fixes * Refactor test cases to remove fake_binding parameter and improve clarity * Return fake_binding usage * Use mocked binding * Use mocks instead of instances * Test server launch * Test server init * Test proxy client launch and init * Connect and disconnect * 100% backend coverage * Added test workflow * Fixed workflow * Fixed copilot suggestions * Hatch static analysis
1 parent dfdb0db commit 9ebe48f

File tree

13 files changed

+1486
-136
lines changed

13 files changed

+1486
-136
lines changed

.github/workflows/test.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Test
2+
on:
3+
pull_request:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
test:
10+
name: Test
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: 🛎 Checkout
15+
uses: actions/checkout@v4
16+
with:
17+
ref: ${{ github.head_ref }}
18+
19+
- name: 🔭 Install UV
20+
uses: astral-sh/setup-uv@v6
21+
with:
22+
enable-cache: true
23+
cache-dependency-glob: "**/pyproject.toml"
24+
25+
- name: 🐍 Setup Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: "3.13"
29+
30+
- name: 📦 Install Hatch
31+
uses: pypa/hatch@install
32+
33+
- name: 🧪 Run Tests
34+
run: hatch run tests

pyproject.toml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,20 @@ exclude = ["/.github", "/.idea", "/docs"]
5959
installer = "uv"
6060
python = "3.13"
6161
dependencies = [
62-
"pyinstaller==6.12.0",
63-
"basedpyright==1.28.5",
62+
"pyinstaller==6.13.0",
63+
"basedpyright==1.29.1",
64+
"pytest==8.3.5",
65+
"pytest-cov==6.1.1",
66+
"pytest-mock==3.14.0",
67+
"pytest-asyncio==0.26.0"
6468
]
6569
[tool.hatch.envs.default.scripts]
6670
exe = "pyinstaller.exe ephys_link.spec -y -- -d && pyinstaller.exe ephys_link.spec -y"
6771
exe-clean = "pyinstaller.exe ephys_link.spec -y --clean"
6872
check = "basedpyright"
6973
check-watched = "basedpyright --watch"
74+
tests = "pytest"
75+
cov = "pytest --cov=ephys_link --cov-report=html --cov-report=term-missing"
7076

7177
[tool.hatch.envs.docs]
7278
installer = "uv"
@@ -88,5 +94,16 @@ exclude = ["typings"]
8894
unsafe-fixes = true
8995

9096
[tool.basedpyright]
91-
include = ["src/ephys_link"]
92-
strict = ["src/ephys_link"]
97+
include = ["src/ephys_link", "tests"]
98+
strict = ["src/ephys_link", "tests"]
99+
100+
[tool.pytest.ini_options]
101+
asyncio_default_fixture_loop_scope = "function"
102+
103+
[tool.coverage.run]
104+
source_pkgs = ["ephys_link"]
105+
branch = true
106+
omit = [
107+
"tests/*",
108+
"scripts/*",
109+
]

scripts/move_tester.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from asyncio import run
22

3-
from vbl_aquarium.models.ephys_link import EphysLinkOptions, SetDepthRequest
3+
from vbl_aquarium.models.ephys_link import SetDepthRequest
44
from vbl_aquarium.models.unity import Vector4
55

66
from ephys_link.back_end.platform_handler import PlatformHandler
7-
from ephys_link.utils.console import Console
7+
from ephys_link.bindings.mpm_binding import MPMBinding
8+
from ephys_link.front_end.console import Console
89

910
c = Console(enable_debug=True)
10-
p = PlatformHandler(EphysLinkOptions(type="pathfinder-mpm"), c)
11+
p = PlatformHandler(MPMBinding(), c)
1112
# target = Vector4()
1213
target = Vector4(x=7.5, y=7.5, z=7.5, w=7.5)
1314

src/ephys_link/__main__.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
from ephys_link.back_end.platform_handler import PlatformHandler
1717
from ephys_link.back_end.server import Server
1818
from ephys_link.front_end.cli import CLI
19+
from ephys_link.front_end.console import Console
1920
from ephys_link.front_end.gui import GUI
20-
from ephys_link.utils.console import Console
21-
from ephys_link.utils.startup import check_for_updates, preamble
21+
from ephys_link.utils.startup import check_for_updates, get_binding_instance, preamble
2222

2323

2424
def main() -> None:
@@ -37,13 +37,16 @@ def main() -> None:
3737
if not options.ignore_updates:
3838
check_for_updates(console)
3939

40-
# 4. Instantiate the Platform Handler with the appropriate platform bindings.
41-
platform_handler = PlatformHandler(options, console)
40+
# 4. Instantiate the requested platform binding.
41+
binding = get_binding_instance(options, console)
4242

43-
# 5. Add hotkeys for emergency stop.
43+
# 5. Instantiate the Platform Handler with the appropriate platform bindings.
44+
platform_handler = PlatformHandler(binding, console)
45+
46+
# 6. Add hotkeys for emergency stop.
4447
_ = add_hotkey("ctrl+alt+shift+q", lambda: run(platform_handler.emergency_stop()))
4548

46-
# 6. Start the server.
49+
# 7. Start the server.
4750
Server(options, platform_handler, console).launch()
4851

4952

src/ephys_link/back_end/platform_handler.py

Lines changed: 25 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88
"""
99

1010
from typing import final
11-
from uuid import uuid4
1211

1312
from vbl_aquarium.models.ephys_link import (
1413
AngularResponse,
1514
BooleanStateResponse,
16-
EphysLinkOptions,
1715
GetManipulatorsResponse,
1816
PlatformInfo,
1917
PositionalResponse,
@@ -23,81 +21,38 @@
2321
SetPositionRequest,
2422
ShankCountResponse,
2523
)
26-
from vbl_aquarium.models.unity import Vector4
2724

28-
from ephys_link.bindings.mpm_binding import MPMBinding
25+
from ephys_link.front_end.console import Console
2926
from ephys_link.utils.base_binding import BaseBinding
30-
from ephys_link.utils.console import Console
27+
from ephys_link.utils.constants import (
28+
EMERGENCY_STOP_MESSAGE,
29+
NO_SET_POSITION_WHILE_INSIDE_BRAIN_ERROR,
30+
did_not_reach_target_depth_error,
31+
did_not_reach_target_position_error,
32+
)
3133
from ephys_link.utils.converters import vector4_to_array
32-
from ephys_link.utils.startup import get_bindings
3334

3435

3536
@final
3637
class PlatformHandler:
3738
"""Handler for platform commands."""
3839

39-
def __init__(self, options: EphysLinkOptions, console: Console) -> None:
40+
def __init__(self, binding: BaseBinding, console: Console) -> None:
4041
"""Initialize platform handler.
4142
4243
Args:
43-
options: CLI options.
44+
binding: Binding instance for the platform.
4445
console: Console instance.
4546
"""
46-
# Store the CLI options.
47-
self._options = options
48-
4947
# Store the console.
5048
self._console = console
5149

5250
# Define bindings based on platform type.
53-
self._bindings = self._get_binding_instance(options)
51+
self._bindings = binding
5452

5553
# Record which IDs are inside the brain.
5654
self._inside_brain: set[str] = set()
5755

58-
# Generate a Pinpoint ID for proxy usage.
59-
self._pinpoint_id = str(uuid4())[:8]
60-
61-
def _get_binding_instance(self, options: EphysLinkOptions) -> BaseBinding:
62-
"""Match the platform type to the appropriate bindings.
63-
64-
Args:
65-
options: CLI options.
66-
67-
Raises:
68-
ValueError: If the platform type is not recognized.
69-
70-
Returns:
71-
Bindings for the specified platform type.
72-
"""
73-
74-
# What the user supplied.
75-
selected_type = options.type
76-
77-
for binding_type in get_bindings():
78-
binding_cli_name = binding_type.get_cli_name()
79-
80-
# Notify deprecation of "ump-4" and "ump-3" CLI options and fix.
81-
if selected_type in ("ump-4", "ump-3"):
82-
self._console.error_print(
83-
"DEPRECATION",
84-
f"CLI option '{selected_type}' is deprecated and will be removed in v3.0.0. Use 'ump' instead.",
85-
)
86-
selected_type = "ump"
87-
88-
if binding_cli_name == selected_type:
89-
# Pass in HTTP port for Pathfinder MPM.
90-
if binding_cli_name == "pathfinder-mpm":
91-
return MPMBinding(options.mpm_port)
92-
93-
# Otherwise just return the binding.
94-
return binding_type()
95-
96-
# Raise an error if the platform type is not recognized.
97-
error_message = f'Platform type "{options.type}" not recognized.'
98-
self._console.critical_print(error_message)
99-
raise ValueError(error_message)
100-
10156
# Platform metadata.
10257

10358
def get_display_name(self) -> str:
@@ -152,7 +107,7 @@ async def get_position(self, manipulator_id: str) -> PositionalResponse:
152107
)
153108
except Exception as e: # noqa: BLE001
154109
self._console.exception_error_print("Get Position", e)
155-
return PositionalResponse(error=str(e))
110+
return PositionalResponse(error=self._console.pretty_exception(e))
156111
else:
157112
return PositionalResponse(position=unified_position)
158113

@@ -202,9 +157,8 @@ async def set_position(self, request: SetPositionRequest) -> PositionalResponse:
202157
try:
203158
# Disallow setting manipulator position while inside the brain.
204159
if request.manipulator_id in self._inside_brain:
205-
error_message = 'Can not move manipulator while inside the brain. Set the depth ("set_depth") instead.'
206-
self._console.error_print("Set Position", error_message)
207-
return PositionalResponse(error=error_message)
160+
self._console.error_print("Set Position", NO_SET_POSITION_WHILE_INSIDE_BRAIN_ERROR)
161+
return PositionalResponse(error=NO_SET_POSITION_WHILE_INSIDE_BRAIN_ERROR)
208162

209163
# Move to the new position.
210164
final_platform_position = await self._bindings.set_position(
@@ -222,11 +176,7 @@ async def set_position(self, request: SetPositionRequest) -> PositionalResponse:
222176

223177
# Check if the axis is within the movement tolerance.
224178
if abs(axis) > self._bindings.get_movement_tolerance():
225-
error_message = (
226-
f"Manipulator {request.manipulator_id} did not reach target"
227-
f" position on axis {list(Vector4.model_fields.keys())[index]}."
228-
f" Requested: {request.position}, got: {final_unified_position}."
229-
)
179+
error_message = did_not_reach_target_position_error(request, index, final_unified_position)
230180
self._console.error_print("Set Position", error_message)
231181
return PositionalResponse(error=error_message)
232182
except Exception as e: # noqa: BLE001
@@ -246,26 +196,22 @@ async def set_depth(self, request: SetDepthRequest) -> SetDepthResponse:
246196
"""
247197
try:
248198
# Move to the new depth.
249-
final_platform_depth = await self._bindings.set_depth(
199+
final_depth = await self._bindings.set_depth(
250200
manipulator_id=request.manipulator_id,
251-
depth=self._bindings.unified_space_to_platform_space(Vector4(w=request.depth)).w,
201+
depth=request.depth,
252202
speed=request.speed,
253203
)
254-
final_unified_depth = self._bindings.platform_space_to_unified_space(Vector4(w=final_platform_depth)).w
255204

256205
# Return error if movement did not reach target within tolerance.
257-
if abs(final_unified_depth - request.depth) > self._bindings.get_movement_tolerance():
258-
error_message = (
259-
f"Manipulator {request.manipulator_id} did not reach target depth."
260-
f" Requested: {request.depth}, got: {final_unified_depth}."
261-
)
206+
if abs(final_depth - request.depth) > self._bindings.get_movement_tolerance():
207+
error_message = did_not_reach_target_depth_error(request, final_depth)
262208
self._console.error_print("Set Depth", error_message)
263209
return SetDepthResponse(error=error_message)
264210
except Exception as e: # noqa: BLE001
265211
self._console.exception_error_print("Set Depth", e)
266212
return SetDepthResponse(error=self._console.pretty_exception(e))
267213
else:
268-
return SetDepthResponse(depth=final_unified_depth)
214+
return SetDepthResponse(depth=final_depth)
269215

270216
async def set_inside_brain(self, request: SetInsideBrainRequest) -> BooleanStateResponse:
271217
"""Mark a manipulator as inside the brain or not.
@@ -278,16 +224,11 @@ async def set_inside_brain(self, request: SetInsideBrainRequest) -> BooleanState
278224
Returns:
279225
Inside brain state of the manipulator and an error message if any.
280226
"""
281-
try:
282-
if request.inside:
283-
self._inside_brain.add(request.manipulator_id)
284-
else:
285-
self._inside_brain.discard(request.manipulator_id)
286-
except Exception as e: # noqa: BLE001
287-
self._console.exception_error_print("Set Inside Brain", e)
288-
return BooleanStateResponse(error=self._console.pretty_exception(e))
227+
if request.inside:
228+
self._inside_brain.add(request.manipulator_id)
289229
else:
290-
return BooleanStateResponse(state=request.inside)
230+
self._inside_brain.discard(request.manipulator_id)
231+
return BooleanStateResponse(state=request.inside)
291232

292233
async def stop(self, manipulator_id: str) -> str:
293234
"""Stop a manipulator.
@@ -316,12 +257,12 @@ async def stop_all(self) -> str:
316257
for manipulator_id in await self._bindings.get_manipulators():
317258
await self._bindings.stop(manipulator_id)
318259
except Exception as e: # noqa: BLE001
319-
self._console.exception_error_print("Stop", e)
260+
self._console.exception_error_print("Stop All", e)
320261
return self._console.pretty_exception(e)
321262
else:
322263
return ""
323264

324265
async def emergency_stop(self) -> None:
325266
"""Stops all manipulators with a message."""
326-
self._console.critical_print("Emergency Stopping All Manipulators...")
267+
self._console.critical_print(EMERGENCY_STOP_MESSAGE)
327268
_ = await self.stop_all()

0 commit comments

Comments
 (0)