322 lines
12 KiB
Python
322 lines
12 KiB
Python
# 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.
|