Files
navigate/test/model/devices/pump/test_tecan.py
2025-12-04 16:07:30 +08:00

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.