Files
navigate/test/controller/test_controller.py
2025-12-04 16:07:30 +08:00

788 lines
25 KiB
Python

from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, ANY
import pytest
import numpy
import multiprocessing as mp
import logging
import platform
from logging.handlers import QueueHandler
from navigate.log_files.log_functions import log_setup
class _NullQueue:
"""Minimal queue-like sink for logging; avoids mp feeder threads on Windows."""
def put(self, _): # QueueHandler calls .put()
pass
def close(self):
pass
def cancel_join_thread(self):
pass
class DummySplashScreen:
def destroy(self):
pass
def _normalize_log_setup(start_listener):
"""Call log_setup and normalize its return to (log_queue, log_listener)."""
from navigate.log_files.log_functions import log_setup
try:
res = log_setup("logging.yml", logging_path=None, start_listener=start_listener)
except Exception:
res = None
# Accept (queue, listener), or queue-only, or None.
if isinstance(res, tuple) and len(res) == 2:
return res[0], res[1]
if res is not None and hasattr(res, "put"):
return res, None
# Fallbacks:
if platform.system() == "Windows":
# Avoid mp.Queue on Windows to prevent hangs in CI.
return _NullQueue(), None
# Non-Windows: a real mp.Queue is fine without a listener.
try:
return mp.Queue(), None
except Exception:
return _NullQueue(), None
def _remove_queue_handlers(target_queue=None):
# Detach and close any QueueHandler that targets target_queue.
def strip_handlers(logger):
for h in list(getattr(logger, "handlers", [])):
if isinstance(h, QueueHandler) and (
target_queue is None or getattr(h, "queue", None) is target_queue
):
try:
logger.removeHandler(h)
except Exception:
pass
try:
h.close()
except Exception:
pass
try:
strip_handlers(logging.getLogger())
for name, obj in logging.root.manager.loggerDict.items():
if isinstance(obj, logging.Logger):
strip_handlers(logging.getLogger(name))
except Exception:
pass
@pytest.fixture(scope="module")
def controller(tk_root):
from navigate.controller.controller import Controller
base_directory = Path.joinpath(
Path(__file__).resolve().parent.parent.parent, "src", "navigate"
)
configuration_directory = Path.joinpath(base_directory, "config")
configuration_path = Path.joinpath(configuration_directory, "configuration.yaml")
experiment_path = Path.joinpath(configuration_directory, "experiment.yml")
waveform_constants_path = Path.joinpath(
configuration_directory, "waveform_constants.yml"
)
rest_api_path = Path.joinpath(configuration_directory, "rest_api_config.yml")
waveform_templates_path = Path.joinpath(
configuration_directory, "waveform_templates.yml"
)
gui_configuration_path = Path.joinpath(
configuration_directory, "gui_configuration.yml"
)
multi_positions_path = Path.joinpath(configuration_directory, "multi_positions.yml")
args = SimpleNamespace(synthetic_hardware=True)
start_listener = platform.system() != "Windows"
log_queue, log_listener = _normalize_log_setup(start_listener)
controller = Controller(
root=tk_root,
splash_screen=DummySplashScreen(),
configuration_path=configuration_path,
experiment_path=experiment_path,
waveform_constants_path=waveform_constants_path,
rest_api_path=rest_api_path,
waveform_templates_path=waveform_templates_path,
gui_configuration_path=gui_configuration_path,
multi_positions_path=multi_positions_path,
log_queue=log_queue,
args=args,
)
# To make sure the testcases won't hang on because of the model.event_queue
# The changes here won't affect other testcases,
# because the testcases from other files use DummyController
# and DummyModel instead of this controller fixture
controller.model = MagicMock()
controller.threads_pool = MagicMock()
controller.model.get_offset_variance_maps.return_value = (None, None)
yield controller
try:
controller.execute("exit")
except SystemExit:
pass
# Tear down the controller properly
q = getattr(controller, "event_queue", None)
if q is not None:
try:
q.close()
except Exception:
pass
try:
q.cancel_join_thread()
except Exception:
pass
# Close any Pipes
if getattr(controller, "show_img_pipe", None):
try:
controller.show_img_pipe.close()
except Exception:
pass
try:
controller.manager.shutdown()
except Exception:
pass
# Detach QueueHandlers first so no more puts go to log_queue
_remove_queue_handlers(log_queue)
# Stop the queue listener (only if started)
try:
if start_listener and log_listener:
try:
log_listener.enqueue_sentinel()
except Exception:
pass
try:
log_listener.stop()
except Exception:
pass
except Exception:
pass
# Close the logging queue and skip join on Windows
if platform.system() == "Windows":
try:
log_queue.close()
except Exception:
pass
try:
log_queue.cancel_join_thread()
except Exception:
pass
logging.shutdown()
# As a last resort on Windows, hard-terminate any alive mp children
if platform.system() == "Windows":
try:
children = list(mp.active_children())
for p in children:
try:
p.terminate()
except Exception:
pass
for p in children:
try:
p.join(timeout=5)
except Exception:
pass
except Exception:
pass
def test_update_buffer(controller):
camera_parameters = controller.configuration["experiment"]["CameraParameters"]
controller.update_buffer()
assert controller.img_width == camera_parameters["img_x_pixels"]
assert controller.img_height == camera_parameters["img_y_pixels"]
# Make sure that the get_data_buffer method is not called.
assert controller.model.get_data_buffer.called is False
# Change the buffer size
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_x_pixels"
] = 100
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_y_pixels"
] = 100
controller.update_buffer()
# Make sure that the get_data_buffer method is called.
assert controller.model.get_data_buffer.called is True
# Confirm that the buffer size has been updated.
assert controller.img_width == 100
assert controller.img_height == 100
assert True
def test_change_microscope(controller):
# Get the microscopes from the configuration file
microscopes = controller.configuration["configuration"]["microscopes"]
# Iterate through the microscopes and change the microscope
for microscope_name in microscopes.keys():
# Patch the configuration_controller
controller.configuration_controller.change_microscope = MagicMock()
# Default zoom is '0.63x'
zoom = microscopes[microscope_name]["zoom"]["position"].keys()[0]
controller.configuration["experiment"]["MicroscopeState"]["zoom"] = zoom
# Change the microscope without passing the zoom
controller.change_microscope(microscope_name)
assert (
controller.configuration["experiment"]["MicroscopeState"]["microscope_name"]
== microscope_name
)
# Confirm that the zoom has not changed.
assert controller.configuration["experiment"]["MicroscopeState"]["zoom"] == zoom
# Call it and pass the zoom
zoom = microscopes[microscope_name]["zoom"]["position"].keys()[-1]
controller.change_microscope(microscope_name, zoom)
assert controller.configuration["experiment"]["MicroscopeState"]["zoom"] == zoom
# Make sure that the configuration_controller has been called.
assert controller.configuration_controller.change_microscope.called is True
# Have configuration controller return False
controller.configuration_controller.change_microscope.return_value = False
# Patch the stage controller, channels_tab_controller...
controller.stage_controller.initialize = MagicMock()
controller.channels_tab_controller.initialize = MagicMock()
camera_setting_controller = controller.camera_setting_controller
camera_setting_controller.update_camera_device_related_setting = MagicMock()
camera_setting_controller.calculate_physical_dimensions = MagicMock()
controller.camera_view_controller.update_snr = MagicMock()
# Call change microscope, assert patched methods are not called
controller.change_microscope(microscope_name)
assert controller.stage_controller.initialize.called is False
assert controller.channels_tab_controller.initialize.called is False
assert (
camera_setting_controller.update_camera_device_related_setting.called
is False
)
assert camera_setting_controller.calculate_physical_dimensions.called is False
assert controller.camera_view_controller.update_snr.called is False
# Have configuration controller return True
controller.configuration_controller.change_microscope.return_value = True
# Call change microscope, assert patched methods are called
controller.change_microscope(microscope_name)
assert controller.stage_controller.initialize.called is True
assert controller.channels_tab_controller.initialize.called is True
assert (
camera_setting_controller.update_camera_device_related_setting.called
is True
)
assert camera_setting_controller.calculate_physical_dimensions.called is True
assert controller.camera_view_controller.update_snr.called is True
# Test waveform popup controller.
controller.waveform_popup_controller = MagicMock()
controller.change_microscope(microscope_name)
assert (
controller.waveform_popup_controller.populate_experiment_values.called
is True
)
assert True
def test_initialize_cam_view(controller):
minmax_values = [0, 2**16 - 1]
image_metrics = [1, 0, 0]
controller.initialize_cam_view()
assert (
controller.camera_view_controller.image_palette["Min"].get() == minmax_values[0]
)
assert (
controller.camera_view_controller.image_palette["Max"].get() == minmax_values[1]
)
assert (
controller.camera_view_controller.image_metrics["Frames"].get()
== image_metrics[0]
)
assert True
def test_populate_experiment_setting(controller):
controller.populate_experiment_setting(in_initialize=False)
assert True
def test_prepare_acquire_data(controller):
# Test without warning message
controller.set_mode_of_sub = MagicMock()
assert controller.prepare_acquire_data() is True
assert controller.set_mode_of_sub.called is True
# Test with warning message. Challenging since controller is local.
# with patch('controller.tkinter.messagebox.showerror') as mock_showerror:
# controller.update_experiment_setting.return_value = "Warning!"
# assert controller.prepare_acquire_data() is False
# mock_showerror.assert_called_once()
assert True
def test_set_mode_of_sub(controller):
modes = ["customized", "stop", "live"]
for mode in modes:
controller.set_mode_of_sub(mode=mode)
assert True
def test_execute_stop_stage(controller):
controller.threads_pool.createThread = MagicMock()
controller.execute(command="stop_stage")
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_move_stage_and_update_info(controller):
positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("move_stage_and_update_info", positions)
for axis, value in positions.items():
assert (
float(controller.stage_controller.widget_vals[axis].get())
== positions[axis]
)
assert True
def test_execute_move_stage_and_acquire_image(controller):
positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.model.move_stage = MagicMock()
controller.threads_pool.createThread = MagicMock()
controller.execute("move_stage_and_acquire_image", positions)
assert controller.model.move_stage.called is True
for axis, value in positions.items():
assert (
float(controller.stage_controller.widget_vals[axis].get())
== positions[axis]
)
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_get_stage_position(controller):
# Set the positions in the GUI
set_positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("move_stage_and_update_info", set_positions)
# Get the positions from the GUI
get_positions = controller.execute("get_stage_position")
assert type(get_positions) is dict
axes = ["x", "y", "z", "theta", "f"]
for axis in axes:
assert axis in get_positions.keys()
# assert that get_positions is equal to set_positions
assert get_positions == set_positions
assert True
def test_execute_mark_position(controller):
set_positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("mark_position", set_positions)
# Get the positions from the multiposition table. Returns a list of lists.
get_positions = controller.multiposition_tab_controller.get_positions()
# Assert that the last position in get_positions is equal to set_positions
assert get_positions[-1] == [
set_positions["x"],
set_positions["y"],
set_positions["z"],
set_positions["theta"],
set_positions["f"],
]
assert True
def test_execute_resolution(controller):
pass
def test_execute_set_save(controller):
for save_data in [True, False]:
controller.execute("set_save", save_data)
assert controller.acquire_bar_controller.is_save == save_data
assert (
controller.configuration["experiment"]["MicroscopeState"]["is_save"]
== save_data
)
assert True
def test_execute_update_setting(controller):
controller.threads_pool.createThread = MagicMock()
args = ["resolution", {"resolution_mode", "1x"}]
controller.execute("update_setting", args)
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_stage_limits(controller):
controller.threads_pool.createThread = MagicMock()
for stage_limits in [True, False]:
controller.threads_pool.createThread.reset_mock()
controller.execute("stage_limits", stage_limits)
assert controller.stage_controller.stage_limits == stage_limits
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_autofocus(controller):
# Create mock objects
controller.threads_pool.createThread = MagicMock()
# Test non-acquiring case
controller.acquire_bar_controller.is_acquiring = False
controller.execute("autofocus")
controller.threads_pool.createThread.assert_called_with(
resourceName="camera",
target=controller.capture_image,
args=("autofocus", "live"),
)
# Test the acquiring case
controller.acquire_bar_controller.mode = "live"
controller.acquire_bar_controller.is_acquiring = True
controller.threads_pool.createThread.reset_mock()
controller.execute("autofocus")
controller.threads_pool.createThread.assert_called_once()
controller.threads_pool.createThread.assert_any_call(
resourceName="model", target=ANY
)
args, kwargs = controller.threads_pool.createThread.call_args
assert kwargs["resourceName"] == "model"
# Confirm that the lambda is callable.
assert callable(kwargs["target"])
assert True
def test_execute_eliminate_tiles(controller):
controller.threads_pool.createThread = MagicMock()
# Populate Feature List
controller.menu_controller.feature_list_names = ["Remove Empty Tiles"]
# Set the mode to live
controller.acquire_bar_controller.set_mode("live")
# Execute the command
controller.execute("eliminate_tiles")
assert controller.acquire_bar_controller.get_mode() == "customized"
# Assert that the thread pool is called
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_load_features(controller):
controller.threads_pool.createThread = MagicMock()
controller.execute("load_features")
controller.threads_pool.createThread.assert_any_call("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
assert True
def test_execute_acquire_and_save_return(controller):
# Prepare mock objects.
controller.prepare_acquire_data = MagicMock()
controller.acquire_bar_controller.stop_acquire = MagicMock()
controller.camera_setting_controller.calculate_physical_dimensions = MagicMock()
# Prepare mock returns
controller.prepare_acquire_data.return_value = False
# Test and make sure return is called
controller.execute("acquire_and_save")
assert controller.acquire_bar_controller.stop_acquire.called is True
assert (
controller.camera_setting_controller.calculate_physical_dimensions.called
is False
)
assert True
def test_execute_acquire_and_acquire_and_save(controller):
# The modes "customized" & "live" results in the thread not being called.
# TODO: Figure out why the thread is not being called.
# controller.plugin_acquisition_modes = {}
# controller.threads_pool.createThread = MagicMock()
#
# for statement in ["acquire", "acquire_and_save"]:
# for mode in ["z-stack", "single"]:
# controller.acquire_bar_controller.mode = mode
# controller.execute(statement)
# controller.threads_pool.createThread.assert_called_with(
# "camera",
# controller.capture_image,
# args=(
# "acquire",
# controller.acquire_bar_controller.mode,
# ),
# )
# controller.stop_acquisition_flag = True
# controller.threads_pool.createThread.reset_mock()
pass
def test_execute_stop_acquire(controller):
# TODO: Currently hangs indefinitely.
# Prepare mock objects.
# controller.show_img_pipe.poll = MagicMock()
# controller.show_img_pipe.recv = MagicMock()
# controller.sloppy_stop = MagicMock()
# controller.threads_pool.createThread = MagicMock()
#
# # Prepare mock returns
# controller.show_img_pipe.poll.return_value = True
#
# # Test and make sure return is called
# controller.stop_acquisition_flag = False
# controller.execute("acquire", "continuous")
# controller.execute("stop_acquire")
pass
def test_execute_exit(controller):
# Essentially already tested by teardown of controller fixture.
pass
def test_execute_adaptive_optics(controller):
controller.threads_pool.createThread = MagicMock()
for command in [
"flatten_mirror",
"zero_mirror",
"set_mirror",
"set_mirror_from_wcs",
]:
controller.execute(command)
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
controller.execute("tony_wilson")
controller.threads_pool.createThread.assert_called_with(
"camera", controller.capture_image, args=("tony_wilson", "live")
)
def test_execute_random(controller):
controller.threads_pool.createThread = MagicMock()
for command in ["random1", "random2"]:
controller.execute(command)
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
assert True
def test_capture_image(controller):
count = 0
def get_image_id():
nonlocal count
count += 1
if count >= 10:
return "stop"
return numpy.random.randint(0, 10)
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
width = controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_x_pixels"
]
height = controller.configuration["experiment"]["CameraParameters"][
microscope_name
]["img_y_pixels"]
images = numpy.random.rand(10, width, height)
controller.data_buffer = images
work_thread = MagicMock()
work_thread.join = MagicMock()
controller.threads_pool.createThread = MagicMock()
controller.threads_pool.createThread.return_value = work_thread
controller.show_img_pipe.recv = get_image_id
controller.show_img_pipe.poll = MagicMock()
controller.show_img_pipe.poll.return_value = False
# Deal with stop_acquire
controller.sloppy_stop = MagicMock()
controller.menu_controller.feature_id_val.set = MagicMock()
# Deal with camera view controller trying to launch a thread
controller.camera_view_controller.is_displaying_image = MagicMock()
controller.camera_view_controller.is_displaying_image.return_value = True
for command in ["acquire"]: # "autofocus"
for mode in ["continuous", "live", "z-stack", "single"]:
controller.capture_image(command, mode)
# Evaluate calls
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
controller.stop_acquisition_flag = True
controller.threads_pool.createThread.reset_mock()
assert controller.acquire_bar_controller.framerate != 0
assert controller.camera_setting_controller.framerate_widgets[
"max_framerate"
].get() != str(0)
assert True
def test_launch_additional_microscope():
# This looks awful to test...
pass
def test_move_stage(controller):
pos_dict = {"x": 1, "y": 2.0, "z": 3.14, "theta": 400, "f": 5.01}
controller.model.move_stage = MagicMock()
controller.move_stage(pos_dict)
controller.model.move_stage.assert_called_with(pos_dict)
def test_stop_stage(controller):
controller.model.stop_stage = MagicMock()
controller.stop_stage()
controller.model.stop_stage.assert_called_with()
def test_update_stage_controller_silent(controller):
pos_dict = {"x": 1, "y": 2.0, "z": 3.14, "theta": 400, "f": 5.01}
controller.update_stage_controller_silent(pos_dict)
for axis, value in pos_dict.items():
assert (
float(controller.stage_controller.widget_vals[axis].get()) == pos_dict[axis]
)
@pytest.mark.parametrize(
"acquisition_mode, sensor_mode, readout_direction, template_name, "
"expected_template_name",
[
("live", "Normal", "", "Bidirectional", "Default"),
("z-stack", "Normal", "", "Bidirectional", "Default"),
("customized", "Normal", "", "Bidirectional", "Bidirectional"),
("live", "Light-Sheet", "Top-To-Bottom", "Bidirectional", "Default"),
("live", "Light-Sheet", "Bidirectional", "Bidirectional", "Bidirectional"),
(
"customized",
"Light-Sheet",
"Bidirectional",
"Bidirectional",
"Bidirectional",
),
("z-stack", "Light-Sheet", "Bidirectional", "Default", "Bidirectional"),
("z-stack", "Light-Sheet", "Top-To-Bottom", "Default", "Default"),
],
)
def test_waveform_template(
controller,
acquisition_mode,
sensor_mode,
readout_direction,
template_name,
expected_template_name,
):
controller.configuration["experiment"]["MicroscopeState"][
"waveform_template"
] = template_name
controller.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = acquisition_mode
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"number_of_pixels"
] = 10
controller.populate_experiment_setting(in_initialize=True)
controller.camera_setting_controller.mode_widgets["Readout"].set(readout_direction)
controller.camera_setting_controller.mode_widgets["Sensor"].set(sensor_mode)
controller.update_experiment_setting()
assert (
controller.configuration["experiment"]["MicroscopeState"]["waveform_template"]
== expected_template_name
)
assert True