feat: init
This commit is contained in:
321
test/model/devices/pump/test_tecan.py
Normal file
321
test/model/devices/pump/test_tecan.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
|
||||
# All rights reserved.
|
||||
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted for academic and research use only (subject to the
|
||||
# limitations in the disclaimer below) provided that the following conditions are met:
|
||||
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
|
||||
# * Redistributions in binary form must reproduce the above copyright
|
||||
# notice, this list of conditions and the following disclaimer in the
|
||||
# documentation and/or other materials provided with the distribution.
|
||||
|
||||
# * Neither the name of the copyright holders nor the names of its
|
||||
# contributors may be used to endorse or promote products derived from this
|
||||
# software without specific prior written permission.
|
||||
|
||||
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
|
||||
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
||||
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
||||
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
|
||||
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# Standard Library Imports
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
# Third Party Imports
|
||||
|
||||
# Local Imports
|
||||
from navigate.model.devices.pump.tecan import XCaliburPump
|
||||
from navigate.model.utils.exceptions import UserVisibleException
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
def __init__(self, port, baudrate, timeout):
|
||||
self.commands = [] # Record of all sent commands (as bytes).
|
||||
self.is_open = True # Pretend the serial port is open.
|
||||
self.last_command = None # Stores the last command sent (as string, no \r).
|
||||
self.command_responses = (
|
||||
{}
|
||||
) # Maps command strings (e.g., "S5") to fake byte responses.
|
||||
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
|
||||
def open(self):
|
||||
self.is_open = True
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
def write(self, data: bytes):
|
||||
"""
|
||||
Simulate sending a command to the pump.
|
||||
|
||||
- Updates last_command with the stripped string version (used for read lookup).
|
||||
- Appends the raw byte-formatted command to the commands list to keep track of which order the commands are sent.
|
||||
"""
|
||||
|
||||
self.last_command = data.decode("ascii").strip()
|
||||
self.commands.append(data)
|
||||
|
||||
def read(self, n: int) -> bytes:
|
||||
"""
|
||||
Simulate receiving a response from the pump.
|
||||
|
||||
If a response has been predefined for the last command (e.g., to simulate an error or custom reply),
|
||||
that specific response is returned.
|
||||
|
||||
Otherwise, a default success response (b"/00") is returned to simulate normal operation.
|
||||
"""
|
||||
if self.last_command in self.command_responses:
|
||||
return self.command_responses[self.last_command]
|
||||
return b"/00" # If no command has been sent yet, return the "success" response as fallback.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_pump():
|
||||
"""
|
||||
Fixture that returns an XCaliburPump with a mocked serial connection.
|
||||
"""
|
||||
|
||||
# Pick some speeds within the known bounds 0-40.
|
||||
min_speed_code = 2
|
||||
max_speed_code = 19
|
||||
port = "FAKE"
|
||||
baudrate = 9600
|
||||
timeout = 0.5
|
||||
|
||||
fake_serial = FakeSerial(port=port, baudrate=baudrate, timeout=timeout)
|
||||
|
||||
config = {
|
||||
"min_speed_code": min_speed_code,
|
||||
"max_speed_code": max_speed_code,
|
||||
"fine_positioning": False,
|
||||
}
|
||||
|
||||
pump = XCaliburPump(
|
||||
microscope_name="TestPump",
|
||||
device_connection=fake_serial,
|
||||
configuration=config,
|
||||
)
|
||||
|
||||
return pump
|
||||
|
||||
|
||||
def test_set_speed_command_rejected(fake_pump):
|
||||
"""
|
||||
Simulate a firmware-level rejection of a valid speed code.
|
||||
|
||||
This test configures the FakeSerial to return error code '/03' (Invalid Operand)
|
||||
in response to a speed code that is within the allowed local range. This models a case
|
||||
where the driver sends a syntactically valid command (e.g., 'S4'), but the pump
|
||||
firmware rejects the operand value due to internal state or configuration.
|
||||
|
||||
The test verifies that the driver:
|
||||
- Sends the command correctly.
|
||||
- Parses the response.
|
||||
- Raises a RuntimeError with an appropriate error message.
|
||||
"""
|
||||
valid_speed = fake_pump.max_speed_code - 1 # Within bounds.
|
||||
|
||||
fake_pump.serial.command_responses["S" + str(valid_speed)] = (
|
||||
b"/03" # Simulate command-response.
|
||||
)
|
||||
|
||||
# Make sure the pre-defined response raises the correct error.
|
||||
with pytest.raises(
|
||||
UserVisibleException,
|
||||
match="Pump error /3: Invalid operand - bad parameter value",
|
||||
):
|
||||
fake_pump.set_speed(valid_speed)
|
||||
|
||||
|
||||
@patch("navigate.model.devices.pump.tecan.Serial")
|
||||
def test_connect_and_initialize_success(
|
||||
mock_serial_class,
|
||||
): # Argument passed automatically from patch (mocked version of Serial).
|
||||
"""
|
||||
Simulate a successful connection using FakeSerial via patching.
|
||||
"""
|
||||
# Create a custom FakeSerial instance to return instead of MagicMock.
|
||||
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
|
||||
fake_serial.command_responses["ZR"] = b"/00" # Simulate valid response.
|
||||
|
||||
# Tell the mock object what to return instead of Serial.
|
||||
mock_serial_class.return_value = fake_serial
|
||||
|
||||
# Simulate the connect call that is done when all device connections are set up.
|
||||
# Will be the same as fake_serial if successful.
|
||||
serial_connection = XCaliburPump.connect(port="FAKE", baudrate=9600, timeout=0.5)
|
||||
|
||||
mock_serial_class.assert_called_once_with(port="FAKE", baudrate=9600, timeout=0.5)
|
||||
|
||||
# Create the pump and call connect - now it will receive the FakeSerial.
|
||||
pump = XCaliburPump(
|
||||
microscope_name="TestPump",
|
||||
device_connection=serial_connection,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
pump.initialize_pump()
|
||||
|
||||
# Assertions
|
||||
assert pump.serial == fake_serial
|
||||
assert fake_serial.commands[-1] == b"ZR\r"
|
||||
assert fake_serial.is_open
|
||||
|
||||
|
||||
@patch("serial.Serial")
|
||||
def test_initialization_error(
|
||||
mock_serial_class,
|
||||
): # Argument passed automatically from patch (mocked version of Serial).
|
||||
"""
|
||||
Simulate a pump that fails to initialize (command 'ZR', response '/01').
|
||||
|
||||
Verifies that:
|
||||
- The 'ZR' command is sent.
|
||||
- The driver raises RuntimeError when pump reports an init failure.
|
||||
"""
|
||||
# Create a custom FakeSerial instance to return instead of MagicMock.
|
||||
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
|
||||
fake_serial.command_responses["ZR"] = b"/01" # Simulate "fail" response.
|
||||
|
||||
# Make sure Serial() returns this custom fake.
|
||||
mock_serial_class.return_value = fake_serial
|
||||
|
||||
# Create the pump.
|
||||
pump = XCaliburPump(
|
||||
microscope_name="TestPump",
|
||||
device_connection=fake_serial,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
# Expect a RuntimeError due to /01 response.
|
||||
with pytest.raises(
|
||||
UserVisibleException, match="Pump error /1: Initialization error"
|
||||
):
|
||||
pump.initialize_pump()
|
||||
|
||||
# Check that the correct command was sent.
|
||||
assert pump.serial.commands[-1] == b"ZR\r"
|
||||
|
||||
|
||||
# NOTE: We do not wrap or handle exceptions in XCaliburPump.connect().
|
||||
# Errors like Serial(port=...) failures are allowed to propagate.
|
||||
# Therefore, no test is needed for connect() error handling.
|
||||
|
||||
|
||||
def test_send_command_raises_if_serial_is_none():
|
||||
"""
|
||||
Verifies that send_command() raises if self.serial is None.
|
||||
"""
|
||||
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
|
||||
|
||||
pump = XCaliburPump(
|
||||
microscope_name="TestPump",
|
||||
device_connection=fake_serial,
|
||||
configuration={},
|
||||
)
|
||||
pump.serial = None # Simulate uninitialized or failed connection
|
||||
|
||||
with pytest.raises(UserVisibleException, match="Serial object is None"):
|
||||
pump.send_command("ZR")
|
||||
|
||||
|
||||
def test_move_absolute_success_standard_and_fine_modes(fake_pump):
|
||||
"""
|
||||
Test that move_absolute() sends the correct command and succeeds in both
|
||||
standard and fine positioning modes, assuming valid position input.
|
||||
|
||||
Verifies that:
|
||||
- The correct 'A{pos}' command is sent.
|
||||
- The pump responds with success.
|
||||
- No exception is raised.
|
||||
"""
|
||||
# --- Standard mode ---
|
||||
fake_pump.fine_positioning = False
|
||||
position_std = 3000 # Max allowed position in standard (non-fine) mode.
|
||||
|
||||
# Predefine the pump's response to this specific absolute move command.
|
||||
fake_pump.serial.command_responses[f"A{position_std}"] = b"/00"
|
||||
|
||||
# Send the move_absolute command (which internally sends 'A{position}' + parses response).
|
||||
fake_pump.move_absolute(position_std)
|
||||
|
||||
# Verify that the correct byte-encoded command was sent to the serial interface.
|
||||
assert fake_pump.serial.commands[-1] == f"A{position_std}\r".encode()
|
||||
|
||||
# --- Fine positioning mode ---
|
||||
fake_pump.fine_positioning = True
|
||||
position_fine = 24000 # Max allowed position in fine mode.
|
||||
fake_pump.serial.command_responses[f"A{position_fine}"] = b"/00"
|
||||
|
||||
fake_pump.move_absolute(position_fine)
|
||||
assert fake_pump.serial.commands[-1] == f"A{position_fine}\r".encode()
|
||||
|
||||
|
||||
def test_move_absolute_out_of_bounds_raises(fake_pump):
|
||||
"""
|
||||
Verify that move_absolute() raises UserVisibleException when given a position
|
||||
outside the valid range for the current positioning mode.
|
||||
"""
|
||||
# Standard mode: max is 3000.
|
||||
fake_pump.fine_positioning = False
|
||||
with pytest.raises(UserVisibleException, match="out of bounds"):
|
||||
fake_pump.move_absolute(3000 + 1)
|
||||
|
||||
# Fine mode: max is 24000.
|
||||
fake_pump.fine_positioning = True
|
||||
with pytest.raises(UserVisibleException, match="out of bounds"):
|
||||
fake_pump.move_absolute(24000 + 1)
|
||||
|
||||
|
||||
def test_set_fine_positioning_mode_toggle(fake_pump):
|
||||
"""
|
||||
Verify that set_fine_positioning_mode() sends the correct 'N' and 'R' commands,
|
||||
handles responses properly, and updates the fine_positioning attribute.
|
||||
"""
|
||||
|
||||
# Mock responses for enabling fine positioning.
|
||||
# "N1" loads the fine mode into the buffer; "R" applies the change.
|
||||
# Both return "/00" to simulate success.
|
||||
fake_pump.serial.command_responses["N1"] = b"/00"
|
||||
fake_pump.serial.command_responses["R"] = b"/00"
|
||||
|
||||
# Enable fine positioning mode.
|
||||
fake_pump.set_fine_positioning_mode(True)
|
||||
|
||||
# Check that the internal state was updated.
|
||||
assert fake_pump.fine_positioning is True
|
||||
|
||||
# Confirm that the correct commands were sent in the correct order
|
||||
# inside set_fine_positioning_mode().
|
||||
assert fake_pump.serial.commands[-2] == b"N1\r"
|
||||
assert fake_pump.serial.commands[-1] == b"R\r"
|
||||
|
||||
# Now test disabling fine positioning mode.
|
||||
# "N0" loads standard mode; "R" applies it. Again, simulate success.
|
||||
fake_pump.serial.command_responses["N0"] = b"/00"
|
||||
fake_pump.serial.command_responses["R"] = b"/00"
|
||||
|
||||
fake_pump.set_fine_positioning_mode(False)
|
||||
|
||||
assert fake_pump.fine_positioning is False
|
||||
assert fake_pump.serial.commands[-2] == b"N0\r"
|
||||
assert fake_pump.serial.commands[-1] == b"R\r"
|
||||
|
||||
|
||||
# TODO: Once pump is integrated into Model/Controller, test that
|
||||
# UserVisibleException raised by pump results in a warning event.
|
||||
Reference in New Issue
Block a user