diff --git a/pyproject.toml b/pyproject.toml index 70cddf807..514af149c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,3 +86,6 @@ Source = "https://github.com/TheDeanLab/navigate" [tool.setuptools.dynamic] version = {file = "src/navigate/VERSION"} + +[tool.coverage.run] +relative_files = true diff --git a/src/navigate/tools/main_functions.py b/src/navigate/tools/main_functions.py index 802fa8b01..97e9c5308 100644 --- a/src/navigate/tools/main_functions.py +++ b/src/navigate/tools/main_functions.py @@ -99,18 +99,18 @@ def evaluate_parser_input_arguments(args): configuration_path = args.config_file if args.experiment_file: - assert ( - args.experiment_file.exists() - ), "experiment_file file Path {} not valid".format(args.experiment_file) + assert args.experiment_file.exists(), ( + "experiment_file file Path {} not valid".format(args.experiment_file) + ) experiment_path = args.experiment_file if args.waveform_constants_file: - assert ( - args.waveform_constants_path.exists() - ), "waveform_constants_path Path {} not valid".format( - args.waveform_constants_path + assert args.waveform_constants_file.exists(), ( + "waveform_constants_file Path {} not valid".format( + args.waveform_constants_file + ) ) - waveform_constants_path = args.waveform_constants_path + waveform_constants_path = args.waveform_constants_file if args.rest_api_file: assert args.rest_api_file.exists(), "rest_api_file Path {} not valid".format( @@ -119,21 +119,21 @@ def evaluate_parser_input_arguments(args): rest_api_path = args.rest_api_file if args.waveform_templates_file: - assert ( - args.waveform_templates_file.exists() - ), "waveform_templates Path {} not valid".format(args.waveform_templates_file) + assert args.waveform_templates_file.exists(), ( + "waveform_templates Path {} not valid".format(args.waveform_templates_file) + ) waveform_templates_path = args.waveform_templates_file if args.gui_config_file: - assert ( - args.gui_config_file.exists() - ), "gui_configuration Path {} not valid".format(args.gui_config_file) + assert args.gui_config_file.exists(), ( + "gui_configuration Path {} not valid".format(args.gui_config_file) + ) gui_configuration_path = args.gui_config_file if args.multi_positions_file: - assert ( - args.multi_positions_file.exists() - ), "multi_positions Path {} not valid".format(args.multi_positions_file) + assert args.multi_positions_file.exists(), ( + "multi_positions Path {} not valid".format(args.multi_positions_file) + ) multi_positions_path = args.multi_positions_file # Creating Loggers etc., they exist globally so no need to pass @@ -177,7 +177,7 @@ def create_parser() -> argparse.ArgumentParser: required=False, default=False, action="store_true", - help="Configurator - " "GUI for preparing a configuration.yaml file..", + help="Configurator - GUI for preparing a configuration.yaml file..", ) input_args.add_argument( diff --git a/test/log_files/test_filters.py b/test/log_files/test_filters.py new file mode 100644 index 000000000..df2d5a355 --- /dev/null +++ b/test/log_files/test_filters.py @@ -0,0 +1,22 @@ +import logging + +import pytest + +from navigate.log_files.filters import NonPerfFilter, PerformanceFilter + + +@pytest.mark.parametrize( + ("levelname", "perf_expected", "non_perf_expected"), + [ + ("PERFORMANCE", True, False), + ("INFO", False, True), + ("DEBUG", False, True), + ], +) +def test_filters_route_performance_records_by_level( + levelname, perf_expected, non_perf_expected +): + record = logging.makeLogRecord({"levelname": levelname, "msg": "test message"}) + + assert PerformanceFilter().filter(record) is perf_expected + assert NonPerfFilter().filter(record) is non_perf_expected diff --git a/test/log_files/test_log_functions_additional.py b/test/log_files/test_log_functions_additional.py new file mode 100644 index 000000000..34cab9f2f --- /dev/null +++ b/test/log_files/test_log_functions_additional.py @@ -0,0 +1,129 @@ +import json +import logging +import queue +from datetime import datetime, timedelta + +from navigate.log_files.log_functions import ( + PERFORMANCE, + eliminate_old_log_files, + find_filename, + get_folder_date, + load_performance_log, + log_setup, +) + + +def _snapshot_handlers(): + snapshots = {} + root_logger = logging.getLogger() + snapshots[root_logger] = list(root_logger.handlers) + + for name, obj in logging.root.manager.loggerDict.items(): + if isinstance(obj, logging.Logger): + logger = logging.getLogger(name) + snapshots[logger] = list(logger.handlers) + + return snapshots + + +def _restore_handlers(snapshots): + for logger, handlers in snapshots.items(): + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + logger.handlers = handlers + + +def test_find_filename_only_matches_filename_key(): + assert find_filename("filename", "debug.log") is True + assert find_filename("level", "DEBUG") is False + + +def test_get_folder_date_parses_valid_names_and_rejects_invalid_names(): + assert get_folder_date("2026-03-21-1530") == datetime(2026, 3, 21, 15, 30) + assert get_folder_date("not-a-date") is False + + +def test_eliminate_old_log_files_removes_only_expired_timestamp_dirs(tmp_path): + expired = tmp_path / (datetime.now() - timedelta(days=31)).strftime("%Y-%m-%d-%H%M") + current = tmp_path / datetime.now().strftime("%Y-%m-%d-%H%M") + invalid = tmp_path / "keep-me" + + expired.mkdir() + current.mkdir() + invalid.mkdir() + + eliminate_old_log_files(tmp_path) + + assert not expired.exists() + assert current.exists() + assert invalid.exists() + + +def test_load_performance_log_reads_latest_valid_log_and_skips_invalid_json( + tmp_path, monkeypatch +): + logs_dir = tmp_path / "logs" + older_dir = logs_dir / "2026-03-20-1200" + latest_dir = logs_dir / "2026-03-21-1200" + hidden_dir = logs_dir / ".ignored" + + older_dir.mkdir(parents=True) + latest_dir.mkdir() + hidden_dir.mkdir() + + (older_dir / "performance.log").write_text(json.dumps({"run": "older"}) + "\n") + (latest_dir / "performance.log").write_text( + json.dumps({"run": "latest"}) + "\nnot-json\n" + json.dumps({"step": 2}) + "\n" + ) + + monkeypatch.setattr( + "navigate.log_files.log_functions.get_navigate_path", lambda: tmp_path + ) + + assert load_performance_log() == [{"run": "latest"}, {"step": 2}] + + +def test_log_setup_returns_provided_queue_and_start_listener_creates_listener( + tmp_path, monkeypatch +): + handler_snapshots = _snapshot_handlers() + monkeypatch.setattr( + "navigate.log_files.log_functions.mp.Queue", lambda *args, **kwargs: queue.Queue() + ) + + provided_queue = queue.Queue() + listener = None + try: + returned_queue = log_setup("logging.yml", tmp_path, queue=provided_queue) + + assert returned_queue is provided_queue + + log_queue, listener = log_setup("logging.yml", tmp_path, start_listener=True) + assert log_queue is not None + logger = logging.getLogger("model") + logger.log(PERFORMANCE, json.dumps({"kind": "perf"})) + finally: + try: + if listener is not None: + listener.stop() + finally: + _restore_handlers(handler_snapshots) + logging.shutdown() + + timestamp_dirs = [path for path in tmp_path.iterdir() if path.is_dir()] + assert timestamp_dirs + + +def test_load_performance_log_returns_none_when_latest_log_file_is_missing( + tmp_path, monkeypatch +): + logs_dir = tmp_path / "logs" + latest_dir = logs_dir / "2026-03-21-1200" + latest_dir.mkdir(parents=True) + + monkeypatch.setattr( + "navigate.log_files.log_functions.get_navigate_path", lambda: tmp_path + ) + + assert load_performance_log() is None diff --git a/test/model/data_sources/test_factory.py b/test/model/data_sources/test_factory.py new file mode 100644 index 000000000..f59197b69 --- /dev/null +++ b/test/model/data_sources/test_factory.py @@ -0,0 +1,40 @@ +import sys +import types +from unittest.mock import Mock + +import pytest + +from navigate.model import data_sources + + +@pytest.mark.parametrize( + ("file_type", "module_name", "class_name"), + [ + ("TIFF", "tiff_data_source", "TiffDataSource"), + ("OME-TIFF", "tiff_data_source", "TiffDataSource"), + ("H5", "bdv_data_source", "BigDataViewerDataSource"), + ("N5", "bdv_data_source", "BigDataViewerDataSource"), + ("OME-Zarr", "zarr_data_source", "OMEZarrDataSource"), + ], +) +def test_get_data_source_returns_expected_class( + monkeypatch, file_type, module_name, class_name +): + fake_module_name = f"{data_sources.__name__}.{module_name}" + fake_module = types.ModuleType(fake_module_name) + fake_class = type(class_name, (), {}) + + setattr(fake_module, class_name, fake_class) + monkeypatch.setitem(sys.modules, fake_module_name, fake_module) + + assert data_sources.get_data_source(file_type) is fake_class + + +def test_get_data_source_logs_and_raises_for_unknown_type(monkeypatch): + logger = Mock() + monkeypatch.setattr(data_sources, "logger", logger) + + with pytest.raises(NotImplementedError, match="Unknown file type CSV. Cannot open."): + data_sources.get_data_source("CSV") + + logger.error.assert_called_once_with("Unknown file type CSV. Cannot open.") diff --git a/test/model/test_threads.py b/test/model/test_threads.py new file mode 100644 index 000000000..92f1c4ce5 --- /dev/null +++ b/test/model/test_threads.py @@ -0,0 +1,68 @@ +from queue import Queue +from unittest.mock import MagicMock + +import pytest + +import navigate.model.utils.threads as threads_module +from navigate.model.utils.exceptions import UserVisibleException + + +def test_thread_with_warning_enqueues_user_visible_exception(): + warning_queue = Queue() + logger = MagicMock() + + def target(): + raise UserVisibleException("Camera disconnected") + + thread = threads_module.ThreadWithWarning( + target=target, + name="worker-visible", + warning_queue=warning_queue, + logger=logger, + ) + + with pytest.raises(UserVisibleException, match="Camera disconnected"): + thread.run() + + logger.error.assert_called_once_with( + "Error in thread worker-visible: Camera disconnected" + ) + assert warning_queue.get_nowait() == ("warning", "Camera disconnected") + + +def test_thread_with_warning_does_not_enqueue_non_user_visible_exception(): + warning_queue = Queue() + logger = MagicMock() + + def target(): + raise RuntimeError("boom") + + thread = threads_module.ThreadWithWarning( + target=target, + name="worker-generic", + warning_queue=warning_queue, + logger=logger, + ) + + with pytest.raises(RuntimeError, match="boom"): + thread.run() + + logger.error.assert_called_once_with("Error in thread worker-generic: boom") + assert warning_queue.empty() + + +def test_thread_with_warning_uses_module_logger_by_default(monkeypatch): + logger = MagicMock() + monkeypatch.setattr(threads_module, "logger", logger) + calls = [] + + thread = threads_module.ThreadWithWarning( + target=lambda: calls.append("ran"), + name="worker-success", + ) + + thread.run() + + assert calls == ["ran"] + assert not hasattr(thread, "_warning_queue") + logger.error.assert_not_called() diff --git a/test/test_commit_additional.py b/test/test_commit_additional.py new file mode 100644 index 000000000..73d1a937c --- /dev/null +++ b/test/test_commit_additional.py @@ -0,0 +1,51 @@ +import subprocess +from unittest.mock import MagicMock + +from navigate._commit import get_git_revision_hash, get_version_from_file + + +def test_get_git_revision_hash_restores_working_directory_on_success(monkeypatch): + chdir = MagicMock() + monkeypatch.setattr("navigate._commit.os.getcwd", lambda: "/original") + monkeypatch.setattr("navigate._commit.os.chdir", chdir) + monkeypatch.setattr( + "navigate._commit.subprocess.check_output", + MagicMock(side_effect=[b"true", b"abc123"]), + ) + + assert get_git_revision_hash() == "abc123" + assert chdir.call_args_list[-1].args[0] == "/original" + + +def test_get_git_revision_hash_returns_none_when_git_is_unavailable(monkeypatch): + chdir = MagicMock() + monkeypatch.setattr("navigate._commit.os.getcwd", lambda: "/original") + monkeypatch.setattr("navigate._commit.os.chdir", chdir) + monkeypatch.setattr( + "navigate._commit.subprocess.check_output", + MagicMock(side_effect=FileNotFoundError), + ) + + assert get_git_revision_hash() is None + assert chdir.call_args_list[-1].args[0] == "/original" + + +def test_get_git_revision_hash_returns_none_when_repo_check_is_false(monkeypatch): + check_output = MagicMock(return_value=b"") + monkeypatch.setattr("navigate._commit.subprocess.check_output", check_output) + + assert get_git_revision_hash() is None + check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], stderr=subprocess.DEVNULL + ) + + +def test_get_version_from_file_reads_custom_version_file(tmp_path, monkeypatch): + version_file = tmp_path / "CUSTOM_VERSION" + version_file.write_text("9.9.9\n") + + monkeypatch.setattr( + "navigate._commit.os.path.abspath", lambda path: str(tmp_path / "module.py") + ) + + assert get_version_from_file("CUSTOM_VERSION") == "9.9.9" diff --git a/test/test_main_additional.py b/test/test_main_additional.py new file mode 100644 index 000000000..e5a989248 --- /dev/null +++ b/test/test_main_additional.py @@ -0,0 +1,111 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import navigate.main as main_module + + +class _FakeRoot: + def __init__(self): + self.withdraw = MagicMock() + self.mainloop = MagicMock() + + +def _evaluation_result(): + return ( + "config.yml", + "experiment.yml", + "waveforms.yml", + "rest.yml", + "templates.yml", + "logdir", + False, + "gui.yml", + "positions.yml", + ) + + +def test_main_uses_controller_branch_and_stops_log_listener(monkeypatch, capsys): + root = _FakeRoot() + splash = object() + args = SimpleNamespace(configurator=False) + listener = MagicMock() + controller = MagicMock() + configurator = MagicMock() + + monkeypatch.setattr(main_module.platform, "system", lambda: "Linux") + monkeypatch.setattr(main_module.tk, "Tk", lambda: root) + monkeypatch.setattr(main_module, "SplashScreen", MagicMock(return_value=splash)) + monkeypatch.setattr( + main_module, + "create_parser", + lambda: SimpleNamespace(parse_args=lambda: args), + ) + monkeypatch.setattr( + main_module, + "evaluate_parser_input_arguments", + lambda parsed_args: _evaluation_result(), + ) + monkeypatch.setattr( + main_module, "log_setup", MagicMock(return_value=("queue", listener)) + ) + monkeypatch.setattr(main_module, "Controller", controller) + monkeypatch.setattr(main_module, "Configurator", configurator) + + main_module.main() + + assert ( + "WARNING: navigate was built to operate on a Windows platform." + in capsys.readouterr().out + ) + root.withdraw.assert_called_once_with() + root.mainloop.assert_called_once_with() + controller.assert_called_once_with( + root=root, + splash_screen=splash, + configuration_path="config.yml", + experiment_path="experiment.yml", + waveform_constants_path="waveforms.yml", + rest_api_path="rest.yml", + waveform_templates_path="templates.yml", + gui_configuration_path="gui.yml", + multi_positions_path="positions.yml", + log_queue="queue", + args=args, + ) + configurator.assert_not_called() + listener.stop.assert_called_once_with() + + +def test_main_uses_configurator_branch(monkeypatch, capsys): + root = _FakeRoot() + splash = object() + args = SimpleNamespace(configurator=True) + listener = MagicMock() + controller = MagicMock() + configurator = MagicMock() + + monkeypatch.setattr(main_module.platform, "system", lambda: "Windows") + monkeypatch.setattr(main_module.tk, "Tk", lambda: root) + monkeypatch.setattr(main_module, "SplashScreen", MagicMock(return_value=splash)) + monkeypatch.setattr( + main_module, + "create_parser", + lambda: SimpleNamespace(parse_args=lambda: args), + ) + monkeypatch.setattr( + main_module, + "evaluate_parser_input_arguments", + lambda parsed_args: _evaluation_result(), + ) + monkeypatch.setattr( + main_module, "log_setup", MagicMock(return_value=("queue", listener)) + ) + monkeypatch.setattr(main_module, "Controller", controller) + monkeypatch.setattr(main_module, "Configurator", configurator) + + main_module.main() + + assert capsys.readouterr().out == "" + configurator.assert_called_once_with(root, splash) + controller.assert_not_called() + listener.stop.assert_called_once_with() diff --git a/test/tools/test_common_functions_additional.py b/test/tools/test_common_functions_additional.py new file mode 100644 index 000000000..367e8210a --- /dev/null +++ b/test/tools/test_common_functions_additional.py @@ -0,0 +1,71 @@ +from types import ModuleType, SimpleNamespace + +import navigate.tools.common_functions as common_functions + + +def test_load_module_from_file_returns_none_on_module_not_found(monkeypatch): + def raise_module_not_found(module): + raise ModuleNotFoundError("missing dependency") + + monkeypatch.setattr( + common_functions.importlib.util, + "spec_from_file_location", + lambda module_name, file_path: SimpleNamespace( + loader=SimpleNamespace(exec_module=raise_module_not_found) + ), + ) + monkeypatch.setattr( + common_functions.importlib.util, + "module_from_spec", + lambda spec: ModuleType("fake_module"), + ) + + assert common_functions.load_module_from_file("fake_module", "/tmp/fake.py") is None + + +def test_load_param_from_module_success(monkeypatch): + module = SimpleNamespace(target=123) + monkeypatch.setattr( + common_functions.importlib, "import_module", lambda module_name: module + ) + + assert common_functions.load_param_from_module("pkg.module", "target") == 123 + + +def test_load_param_from_module_returns_none_for_missing_module(monkeypatch): + def raise_module_not_found(module_name): + raise ModuleNotFoundError(module_name) + + monkeypatch.setattr( + common_functions.importlib, "import_module", raise_module_not_found + ) + + assert common_functions.load_param_from_module("missing.module", "target") is None + + +def test_decode_bytes_handles_memoryview_and_non_bytes(): + assert common_functions.decode_bytes(memoryview(b"hello")) == "hello" + assert common_functions.decode_bytes("not-bytes") == "" + + +def test_decode_bytes_returns_empty_string_on_decode_error(): + class BadBytes(bytes): + def decode(self, errors="ignore"): + raise RuntimeError("decode failure") + + assert common_functions.decode_bytes(BadBytes(b"boom")) == "" + + +def test_variable_with_lock_acquires_and_releases_lock(): + variable = common_functions.VariableWithLock(list) + + assert variable.value == [] + assert variable.lock.locked() is False + + with variable as locked_variable: + assert locked_variable is variable + assert variable.lock.locked() is True + locked_variable.value.append("item") + + assert variable.lock.locked() is False + assert variable.value == ["item"] diff --git a/test/tools/test_decorators_additional.py b/test/tools/test_decorators_additional.py new file mode 100644 index 000000000..9299fbebd --- /dev/null +++ b/test/tools/test_decorators_additional.py @@ -0,0 +1,87 @@ +import json +from unittest.mock import Mock + +import pytest + +import navigate.tools.decorators as decorators + + +def test_performance_monitor_logs_visible_args_and_result(monkeypatch): + logger = Mock() + perf_counter = Mock(side_effect=[100, 160]) + + monkeypatch.setattr(decorators, "logger", logger) + monkeypatch.setattr(decorators.time, "perf_counter_ns", perf_counter) + monkeypatch.setattr(decorators.time, "time", lambda: 12.5) + + @decorators.performance_monitor( + prefix="Acquire", + display_args=lambda *args: {"arg0": args[0]}, + display_result=lambda result: f"result:{result}", + ) + def sample(value): + return value + 1 + + assert sample(4) == 5 + + payload = json.loads(logger.performance.call_args.args[0]) + assert payload["kind"] == "Acquire" + assert payload["args"] == {"arg0": 4} + assert payload["result"] == "result:5" + assert payload["duration_ns"] == 60 + assert payload["timestamp"] == 12.5 + + +def test_performance_monitor_hides_args_and_result_by_default(monkeypatch): + logger = Mock() + monkeypatch.setattr(decorators, "logger", logger) + monkeypatch.setattr(decorators.time, "perf_counter_ns", Mock(side_effect=[1, 2])) + monkeypatch.setattr(decorators.time, "time", lambda: 1.0) + + @decorators.performance_monitor() + def sample(): + return "done" + + assert sample() == "done" + + payload = json.loads(logger.performance.call_args.args[0]) + assert payload["kind"] == "General" + assert payload["args"] == "Hidden" + assert payload["result"] == "Hidden" + + +def test_log_initialization_logs_success(monkeypatch): + logger = Mock() + monkeypatch.setattr(decorators.logging, "getLogger", Mock(return_value=logger)) + + class Device: + __module__ = "navigate.fake_device" + + def __init__(self, port, baudrate=None): + self.port = port + self.baudrate = baudrate + + Device = decorators.log_initialization(Device) + device = Device("COM1", baudrate=115200) + + assert device.port == "COM1" + logger.info.assert_called_once() + assert "Device" in logger.info.call_args.args[0] + + +def test_log_initialization_logs_failure_and_reraises(monkeypatch): + logger = Mock() + monkeypatch.setattr(decorators.logging, "getLogger", Mock(return_value=logger)) + + class Device: + __module__ = "navigate.fake_device" + + def __init__(self, port): + raise ValueError(f"bad port: {port}") + + Device = decorators.log_initialization(Device) + + with pytest.raises(ValueError, match="bad port: COM2"): + Device("COM2") + + assert logger.error.call_count == 3 diff --git a/test/tools/test_file_functions_more.py b/test/tools/test_file_functions_more.py new file mode 100644 index 000000000..d33b1367e --- /dev/null +++ b/test/tools/test_file_functions_more.py @@ -0,0 +1,13 @@ +from types import SimpleNamespace + +from navigate.tools import file_functions + + +def test_get_ram_info_returns_total_and_available(monkeypatch): + monkeypatch.setattr( + file_functions.psutil, + "virtual_memory", + lambda: SimpleNamespace(total=1024, available=256), + ) + + assert file_functions.get_ram_info() == (1024, 256) diff --git a/test/tools/test_image.py b/test/tools/test_image.py index e13153d0a..c356bdb65 100644 --- a/test/tools/test_image.py +++ b/test/tools/test_image.py @@ -32,6 +32,7 @@ # Standard Library Imports import unittest +from unittest.mock import Mock, patch # Third-Party Imports import numpy as np @@ -59,7 +60,7 @@ def test_text_array(self): def test_text_array_output_type(self): """Confirm output is np.ndarray object""" text_output = text_array(text="Navigate") - assert type(text_output) == np.ndarray + assert isinstance(text_output, np.ndarray) def test_text_array_output_height(self): """Confirm that the output is approximately the correct height @@ -80,28 +81,32 @@ def test_text_array_output_height(self): class TestCreateArrowImage(unittest.TestCase): def test_create_arrow_image(self): xys = [(50, 50), (150, 50), (200, 100)] - image = create_arrow_image(xys, direction="right") - self.assertIsInstance(image, Image.Image) - self.assertEqual(image.width, 300) - self.assertEqual(image.height, 200) - - xys = [(50, 50), (150, 50), (200, 100)] - image = create_arrow_image(xys, 400, 300, direction="left") - self.assertIsInstance(image, Image.Image) - self.assertEqual(image.width, 400) - self.assertEqual(image.height, 300) - - image2 = create_arrow_image(xys, 500, 400, direction="up", image=image) - self.assertIsInstance(image2, Image.Image) - self.assertEqual(image2.width, 400) - self.assertEqual(image2.height, 300) - assert image == image2 - - image3 = create_arrow_image(xys, 500, 400, direction="down", image=image) - self.assertIsInstance(image3, Image.Image) - self.assertEqual(image3.width, 400) - self.assertEqual(image3.height, 300) - assert image == image3 + style = Mock() + style.lookup.return_value = "systemWindowText" + + with patch("navigate.tools.image.ttk.Style", return_value=style): + image = create_arrow_image(xys, direction="right") + self.assertIsInstance(image, Image.Image) + self.assertEqual(image.width, 300) + self.assertEqual(image.height, 200) + + xys = [(50, 50), (150, 50), (200, 100)] + image = create_arrow_image(xys, 400, 300, direction="left") + self.assertIsInstance(image, Image.Image) + self.assertEqual(image.width, 400) + self.assertEqual(image.height, 300) + + image2 = create_arrow_image(xys, 500, 400, direction="up", image=image) + self.assertIsInstance(image2, Image.Image) + self.assertEqual(image2.width, 400) + self.assertEqual(image2.height, 300) + assert image == image2 + + image3 = create_arrow_image(xys, 500, 400, direction="down", image=image) + self.assertIsInstance(image3, Image.Image) + self.assertEqual(image3.width, 400) + self.assertEqual(image3.height, 300) + assert image == image3 if __name__ == "__main__": diff --git a/test/tools/test_linear_algebra_additional.py b/test/tools/test_linear_algebra_additional.py new file mode 100644 index 000000000..03f5846ec --- /dev/null +++ b/test/tools/test_linear_algebra_additional.py @@ -0,0 +1,38 @@ +import numpy as np + +from navigate.tools.linear_algebra import affine_rotation, affine_shear + + +def test_affine_rotation_xyz_combines_all_three_axes(): + x_angle, y_angle, z_angle = 10, 20, 30 + + cx, sx = np.cos(np.deg2rad(x_angle)), np.sin(np.deg2rad(x_angle)) + cy, sy = np.cos(np.deg2rad(y_angle)), np.sin(np.deg2rad(y_angle)) + cz, sz = np.cos(np.deg2rad(z_angle)), np.sin(np.deg2rad(z_angle)) + + x_transform = np.array( + [[1, 0, 0, 0], [0, cx, -sx, 0], [0, sx, cx, 0], [0, 0, 0, 1]] + ) + y_transform = np.array( + [[cy, 0, sy, 0], [0, 1, 0, 0], [-sy, 0, cy, 0], [0, 0, 0, 1]] + ) + z_transform = np.array( + [[cz, -sz, 0, 0], [sz, cz, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + ) + expected = np.matmul(np.matmul(x_transform.T, y_transform).T, z_transform) + + np.testing.assert_array_almost_equal( + affine_rotation(x=x_angle, y=y_angle, z=z_angle), expected + ) + + +def test_affine_shear_yz_and_zy_share_same_branch(): + expected = np.eye(4, 4) + expected[1, 2] = 0.5 + + np.testing.assert_array_almost_equal( + affine_shear(2, 4, 8, dimension="YZ", angle=45), expected + ) + np.testing.assert_array_almost_equal( + affine_shear(2, 4, 8, dimension="ZY", angle=45), expected + ) diff --git a/test/tools/test_main_functions_additional.py b/test/tools/test_main_functions_additional.py new file mode 100644 index 000000000..92c8c0ea4 --- /dev/null +++ b/test/tools/test_main_functions_additional.py @@ -0,0 +1,131 @@ +import re + +import pytest + +from navigate.tools.main_functions import create_parser, evaluate_parser_input_arguments + + +def test_create_parser_and_evaluate_apply_all_cli_overrides(tmp_path, monkeypatch): + defaults = ( + "default_config_path", + "default_experiment_path", + "default_waveform_constants_path", + "default_rest_api_path", + "default_waveform_templates_path", + "default_gui_configuration_path", + "default_multi_positions_path", + ) + monkeypatch.setattr( + "navigate.tools.main_functions.get_configuration_paths", lambda: defaults + ) + + config_file = tmp_path / "configuration.yml" + experiment_file = tmp_path / "experiment.yml" + waveform_constants_file = tmp_path / "waveform_constants.yml" + rest_api_file = tmp_path / "rest_api.yml" + waveform_templates_file = tmp_path / "waveform_templates.yml" + gui_config_file = tmp_path / "gui_configuration.yml" + multi_positions_file = tmp_path / "multi_positions.yml" + logging_config = tmp_path / "logging.yml" + + for file_path in [ + config_file, + experiment_file, + waveform_constants_file, + rest_api_file, + waveform_templates_file, + gui_config_file, + multi_positions_file, + logging_config, + ]: + file_path.write_text("test") + + parser = create_parser() + args = parser.parse_args( + [ + "--configurator", + "--config-file", + str(config_file), + "--experiment-file", + str(experiment_file), + "--waveform-constants-file", + str(waveform_constants_file), + "--rest-api-file", + str(rest_api_file), + "--waveform-templates-file", + str(waveform_templates_file), + "--gui-config-file", + str(gui_config_file), + "--multi-positions-file", + str(multi_positions_file), + "--logging-config", + str(logging_config), + ] + ) + + assert evaluate_parser_input_arguments(args) == ( + config_file, + experiment_file, + waveform_constants_file, + rest_api_file, + waveform_templates_file, + logging_config, + True, + gui_config_file, + multi_positions_file, + ) + + +def test_evaluate_parser_input_arguments_uses_defaults_when_no_overrides(monkeypatch): + defaults = ( + "default_config_path", + "default_experiment_path", + "default_waveform_constants_path", + "default_rest_api_path", + "default_waveform_templates_path", + "default_gui_configuration_path", + "default_multi_positions_path", + ) + monkeypatch.setattr( + "navigate.tools.main_functions.get_configuration_paths", lambda: defaults + ) + + args = create_parser().parse_args([]) + + assert evaluate_parser_input_arguments(args) == ( + "default_config_path", + "default_experiment_path", + "default_waveform_constants_path", + "default_rest_api_path", + "default_waveform_templates_path", + None, + False, + "default_gui_configuration_path", + "default_multi_positions_path", + ) + + +def test_evaluate_parser_input_arguments_rejects_missing_waveform_constants_file( + tmp_path, monkeypatch +): + defaults = ( + "default_config_path", + "default_experiment_path", + "default_waveform_constants_path", + "default_rest_api_path", + "default_waveform_templates_path", + "default_gui_configuration_path", + "default_multi_positions_path", + ) + monkeypatch.setattr( + "navigate.tools.main_functions.get_configuration_paths", lambda: defaults + ) + + missing_file = tmp_path / "missing-waveforms.yml" + args = create_parser().parse_args(["--waveform-constants-file", str(missing_file)]) + + with pytest.raises( + AssertionError, + match=re.escape(f"waveform_constants_file Path {missing_file} not valid"), + ): + evaluate_parser_input_arguments(args) diff --git a/test/tools/test_multipos_table_tools.py b/test/tools/test_multipos_table_tools.py index 5e0bfb6e2..3b6ee539f 100644 --- a/test/tools/test_multipos_table_tools.py +++ b/test/tools/test_multipos_table_tools.py @@ -32,18 +32,35 @@ # Standard library imports import unittest import pytest -import tkinter as tk -from tkinter import ttk from math import ceil +from types import SimpleNamespace # Third party imports import numpy as np +import pandas as pd # Local application imports from navigate.tools.multipos_table_tools import ( update_table, ) -from navigate.view.main_window_content.multiposition_tab import MultiPositionTable + + +class _HeadlessTable: + def __init__(self): + self.model = SimpleNamespace(df=pd.DataFrame()) + self.currentrow = -1 + self.reset_colors_called = 0 + self.redraw_called = 0 + self.table_changed_called = 0 + + def resetColors(self): + self.reset_colors_called += 1 + + def redraw(self): + self.redraw_called += 1 + + def tableChanged(self): + self.table_changed_called += 1 @pytest.mark.parametrize("pair", zip([5.6, -3.8, 0], [1, -1, 1])) @@ -56,7 +73,7 @@ def test_sign(pair): def listize(x): - if type(x) == np.ndarray: + if isinstance(x, np.ndarray): return list(x) else: return [x] @@ -186,8 +203,7 @@ def test_calc_num_tiles(dist, overlap, roi_length): # roi_length = 525 expected_num_tiles = ceil( # abs(dist - overlap * roi_length) / abs(roi_length - overlap * roi_length) - (dist - overlap * roi_length) - / (roi_length - overlap * roi_length) + (dist - overlap * roi_length) / (roi_length - overlap * roi_length) ) result = calc_num_tiles(dist, overlap, roi_length) @@ -197,12 +213,10 @@ def test_calc_num_tiles(dist, overlap, roi_length): class UpdateTableTestCase(unittest.TestCase): def setUp(self): - self.root = tk.Tk() - self.frame = ttk.Frame() - self.table = MultiPositionTable(parent=self.frame) + self.table = _HeadlessTable() def tearDown(self) -> None: - self.root.destroy() + pass def test_pos_match_axes(self): pos = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]) @@ -250,7 +264,6 @@ def test_pos_match_axes(self): ) def test_pos_axes_mismatch_more_axes(self): - # axes has 5 entries, pos has only 3 columns pos = np.array([[1, 2, 3], [4, 5, 6], (7, 8, 9)]) diff --git a/test/tools/test_sdf_additional.py b/test/tools/test_sdf_additional.py new file mode 100644 index 000000000..69c791ab8 --- /dev/null +++ b/test/tools/test_sdf_additional.py @@ -0,0 +1,11 @@ +import numpy as np + +from navigate.tools.sdf import volume_from_sdf + + +def test_volume_from_sdf_respects_pixel_size_and_z_subsampling(): + volume = volume_from_sdf(lambda points: points[2], N=4, pixel_size=2, subsample_z=2) + + assert volume.shape == (2, 4, 4) + np.testing.assert_array_equal(volume[0], np.full((4, 4), -3.0)) + np.testing.assert_array_equal(volume[1], np.full((4, 4), 1.0)) diff --git a/test/tools/test_slicing_additional.py b/test/tools/test_slicing_additional.py new file mode 100644 index 000000000..9d87b8e70 --- /dev/null +++ b/test/tools/test_slicing_additional.py @@ -0,0 +1,20 @@ +import pytest + +from navigate.tools.slicing import ensure_iter, ensure_slice, key_len + + +def test_key_len_raises_for_empty_sequences(): + with pytest.raises(IndexError, match="Too few indices."): + key_len(()) + + +def test_ensure_iter_handles_ellipsis_full_slice_and_clipping(): + assert list(ensure_iter((slice(None), Ellipsis), 0, 3)) == [0, 1, 2] + assert list(ensure_iter(slice(5, 20, 2), 0, 6)) == [5] + assert list(ensure_iter(slice(10, 20, 2), 0, 6)) == [] + assert list(ensure_iter(10, 0, 5)) == [4] + + +def test_ensure_slice_handles_ellipsis_and_default_slice(): + assert ensure_slice((slice(1, 3), Ellipsis), 0) == slice(1, 3, None) + assert ensure_slice((slice(1, 3), Ellipsis), 1) == slice(None, None, None) diff --git a/test/tools/test_tk_thread_guard_additional.py b/test/tools/test_tk_thread_guard_additional.py new file mode 100644 index 000000000..20e5b14ba --- /dev/null +++ b/test/tools/test_tk_thread_guard_additional.py @@ -0,0 +1,21 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import navigate.tools.tk_thread_guard as guard + + +def test_install_skips_missing_and_noncallable_tk_methods(monkeypatch): + class PartialTkApp: + call = "not-callable" + + def eval(self, *args, **kwargs): + return ("eval", args, kwargs) + + root = SimpleNamespace(tk=PartialTkApp()) + logger = Mock() + + monkeypatch.setattr(guard, "_guard_disabled_by_environment", lambda: False) + + assert guard.install_tk_thread_guard(root, logger=logger) is True + assert root.tk.eval("expr")[0] == "eval" + logger.info.assert_called() diff --git a/tools-coverage.xml b/tools-coverage.xml new file mode 100644 index 000000000..a4df814bc --- /dev/null +++ b/tools-coverage.xml @@ -0,0 +1,777 @@ + + + + + + src/navigate/tools + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +