diff --git a/.github/workflows/test_prereleases.yml b/.github/workflows/test_prereleases.yml index b9d9af935..0532d9638 100644 --- a/.github/workflows/test_prereleases.yml +++ b/.github/workflows/test_prereleases.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [windows-2019, macos-10.15, ubuntu-20.04] + platform: [windows-2019, macos-11, ubuntu-20.04] python: [3.8] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 099d7cb2c..059413f55 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-2019, macos-10.15, ubuntu-20.04] + os: [windows-2019, macos-11, ubuntu-20.04] python_version: ['3.7', '3.8', '3.9'] steps: - uses: actions/checkout@v2 @@ -92,7 +92,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ windows-2019, macos-10.15, ubuntu-20.04 ] + os: [ windows-2019, macos-11, ubuntu-20.04 ] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 diff --git a/MANIFEST.in b/MANIFEST.in index b3a3c7e6c..a825ceb3b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include package/tests/test_data/ome.xsd.xml include package/tests/test_data/napari_measurements_profile.json include package/tests/test_data/notebook/*.json include package/tests/test_data/old_saves/*/*/*.json +include package/tests/test_data/sample_batch_output.xlsx include Readme.md include changelog.md include pyproject.toml diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7ac83b53a..d75c58c3e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -106,7 +106,7 @@ stages: strategy: matrix: macos: - imageName: 'macos-10.15' + imageName: 'macos-11' windows: imageName: 'windows-2019' pool: {vmImage: $(imageName)} diff --git a/package/PartSeg/_roi_analysis/advanced_window.py b/package/PartSeg/_roi_analysis/advanced_window.py index 8e19c7eb5..2d77c1bb1 100644 --- a/package/PartSeg/_roi_analysis/advanced_window.py +++ b/package/PartSeg/_roi_analysis/advanced_window.py @@ -34,6 +34,7 @@ from PartSegCore.analysis.algorithm_description import AnalysisAlgorithmSelection from PartSegCore.analysis.measurement_base import AreaType, Leaf, MeasurementEntry, Node, PerComponent from PartSegCore.analysis.measurement_calculation import MEASUREMENT_DICT, MeasurementProfile +from PartSegCore.io_utils import LoadPlanJson from PartSegCore.universal_const import UNIT_SCALE, Units from PartSegData import icons_dir @@ -257,14 +258,14 @@ def export_profile(self): def import_profiles(self): dial = PLoadDialog( - "Segment profile (*.json)", + LoadPlanJson, settings=self._settings, path=IO_SAVE_DIRECTORY, caption="Import profile segment", ) if dial.exec_(): - file_path = dial.selectedFiles()[0] - profs, err = self._settings.load_part(file_path) + res = dial.get_result() + profs, err = res.load_class.load(res.load_location) if err: QMessageBox.warning(self, "Import error", "error during importing, part of data were filtered.") profiles_dict = self._settings.roi_profiles @@ -295,14 +296,14 @@ def export_pipeline(self): def import_pipeline(self): dial = PLoadDialog( - "Segment pipeline (*.json)", + LoadPlanJson, settings=self._settings, path=IO_SAVE_DIRECTORY, caption="Import pipeline segment", ) if dial.exec_(): - file_path = dial.selectedFiles()[0] - profs, err = self._settings.load_part(file_path) + res = dial.get_result() + profs, err = res.load_class.load(res.load_location) if err: QMessageBox.warning(self, "Import error", "error during importing, part of data were filtered.") profiles_dict = self._settings.roi_pipelines @@ -776,15 +777,15 @@ def export_measurement_profiles(self): def import_measurement_profiles(self): dial = PLoadDialog( - "Measurement profile (*.json)", + LoadPlanJson, settings=self.settings, path="io.export_directory", caption="Import settings profiles", parent=self, ) if dial.exec_(): - file_path = str(dial.selectedFiles()[0]) - stat, err = self.settings.load_part(file_path) + res = dial.get_result() + stat, err = res.load_class.load(res.load_location) if err: QMessageBox.warning(self, "Import error", "error during importing, part of data were filtered.") measurement_dict = self.settings.measurement_profiles diff --git a/package/PartSeg/_roi_analysis/prepare_plan_widget.py b/package/PartSeg/_roi_analysis/prepare_plan_widget.py index 22939695c..8ebef9b17 100644 --- a/package/PartSeg/_roi_analysis/prepare_plan_widget.py +++ b/package/PartSeg/_roi_analysis/prepare_plan_widget.py @@ -56,7 +56,7 @@ ) from PartSegCore.analysis.measurement_calculation import MeasurementProfile from PartSegCore.analysis.save_functions import save_dict -from PartSegCore.io_utils import SaveBase +from PartSegCore.io_utils import LoadPlanExcel, LoadPlanJson, SaveBase from PartSegCore.universal_const import Units from ..common_gui.custom_load_dialog import PLoadDialog @@ -1147,16 +1147,16 @@ def export_plans(self): def import_plans(self): dial = PLoadDialog( - "Calculation plans (*.json)", + [LoadPlanJson, LoadPlanExcel], settings=self.settings, path="io.batch_plan_directory", caption="Import calculation plans", ) if dial.exec_(): - file_path = dial.selectedFiles()[0] - plans, err = self.settings.load_part(file_path) + res = dial.get_result() + plans, err = res.load_class.load(res.load_location) if err: - QMessageBox.warning(self, "Import error", "error during importing, part of data were filtered.") + QMessageBox.warning(self, "Import error", f"error during importing, part of data were filtered. {err}") choose = ImportDialog(plans, self.settings.batch_plans, PlanPreview) if choose.exec_(): for original_name, final_name in choose.get_import_list(): @@ -1186,9 +1186,9 @@ def plan_preview(self, text): if self.protect: return text = str(text) - if text.strip() == "": + if not text.strip(): return - plan = self.settings.batch_plans[str(text)] # type: CalculationPlan + plan = self.settings.batch_plans[text] self.plan_view.set_plan(plan) diff --git a/package/PartSeg/common_backend/base_settings.py b/package/PartSeg/common_backend/base_settings.py index eba767e79..d968b612c 100644 --- a/package/PartSeg/common_backend/base_settings.py +++ b/package/PartSeg/common_backend/base_settings.py @@ -9,19 +9,7 @@ from contextlib import suppress from datetime import datetime from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - List, - MutableMapping, - NamedTuple, - Optional, - Sequence, - Tuple, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union import napari.utils.theme import numpy as np @@ -35,12 +23,12 @@ from PartSegCore import register from PartSegCore.color_image import default_colormap_dict, default_label_dict from PartSegCore.color_image.base_colors import starting_colors -from PartSegCore.io_utils import load_metadata_base +from PartSegCore.io_utils import load_matadata_part, load_metadata_base from PartSegCore.json_hooks import PartSegEncoder from PartSegCore.project_info import AdditionalLayerDescription, HistoryElement, ProjectInfoBase from PartSegCore.roi_info import ROIInfo from PartSegCore.segmentation.algorithm_base import ROIExtractionResult -from PartSegCore.utils import ProfileDict, check_loaded_dict +from PartSegCore.utils import ProfileDict from PartSegImage import Image if hasattr(napari.utils.theme, "get_theme"): @@ -698,16 +686,19 @@ def dump_part(self, file_path, path_in_dict, names=None): json.dump(data, ff, cls=self.json_encoder_class, indent=2) @classmethod - def load_part(cls, file_path): - data = cls.load_metadata(file_path) - bad_key = [] - if isinstance(data, MutableMapping) and not check_loaded_dict(data): - bad_key.extend(k for k, v in data.items() if not check_loaded_dict(v)) - for el in bad_key: - del data[el] - elif isinstance(data, ProfileDict) and not data.verify_data(): - bad_key = data.filter_data() - return data, bad_key + def load_part(cls, data: Union[Path, str]) -> Tuple[dict, List[str]]: # pragma: no cover + """ + Load serialized data. Get valid entries. + + :param data: path to file or string to be decoded. + :return: + """ + warnings.warn( + f"{cls.__name__}.load_part is deprecated. Please use PartSegCore.utils.load_matadata_part", + stacklevel=2, + category=FutureWarning, + ) + return load_matadata_part(data) def dump(self, folder_path: Union[Path, str, None] = None): """ diff --git a/package/PartSeg/common_gui/custom_load_dialog.py b/package/PartSeg/common_gui/custom_load_dialog.py index ab309d961..ecb09fc0d 100644 --- a/package/PartSeg/common_gui/custom_load_dialog.py +++ b/package/PartSeg/common_gui/custom_load_dialog.py @@ -16,7 +16,7 @@ class LoadProperty(typing.NamedTuple): load_class: typing.Type[LoadBase] -IORegister = typing.Union[typing.Dict[str, type(LoadBase)], type(LoadBase), str] +IORegister = typing.Union[typing.Dict[str, type(LoadBase)], type(LoadBase), str, typing.List[type(LoadBase)]] class IOMethodMock: @@ -66,6 +66,8 @@ def __init__( ): if isinstance(io_register, str): io_register = {io_register: IOMethodMock(io_register)} + if isinstance(io_register, list): + io_register = {x.get_name(): x for x in io_register} if not isinstance(io_register, typing.MutableMapping): io_register = {io_register.get_name(): io_register} super().__init__(parent, caption) @@ -109,8 +111,8 @@ def accept(self): else: super().accept() - def get_result(self): - chosen_class: LoadBase = self.io_register[self.selectedNameFilter()] + def get_result(self) -> LoadProperty: + chosen_class: typing.Type[LoadBase] = self.io_register[self.selectedNameFilter()] return LoadProperty(self.files_list, self.selectedNameFilter(), chosen_class) diff --git a/package/PartSeg/common_gui/custom_save_dialog.py b/package/PartSeg/common_gui/custom_save_dialog.py index c2206a1f9..36ad3f75f 100644 --- a/package/PartSeg/common_gui/custom_save_dialog.py +++ b/package/PartSeg/common_gui/custom_save_dialog.py @@ -151,7 +151,7 @@ def get_result(self) -> SaveProperty: class PSaveDialog(CustomSaveDialog): def __init__( self, - save_register: typing.Union[typing.Dict[str, type(SaveBase)], type(SaveBase)], + save_register: IORegister, *, settings: "BaseSettings", path: str, diff --git a/package/PartSegCore/analysis/batch_processing/batch_backend.py b/package/PartSegCore/analysis/batch_processing/batch_backend.py index 3b981b8aa..a22ed433f 100644 --- a/package/PartSegCore/analysis/batch_processing/batch_backend.py +++ b/package/PartSegCore/analysis/batch_processing/batch_backend.py @@ -72,6 +72,7 @@ from ...project_info import AdditionalLayerDescription, HistoryElement from ...roi_info import ROIInfo from ...segmentation import RestartableAlgorithm +from ...utils import iterate_names from .parallel_backend import BatchManager, SubprocessOrder @@ -800,15 +801,12 @@ def write_to_excel( @staticmethod def write_calculation_plan(writer: pd.ExcelWriter, calculation_plan: CalculationPlan): book: xlsxwriter.Workbook = writer.book - sheet_base_name = f"info {calculation_plan.name}"[:30] - sheet_name = sheet_base_name - if sheet_name in book.sheetnames: # pragma: no cover - for i in range(100): - sheet_name = f"{sheet_base_name[:26]} ({i})" - if sheet_name not in book.sheetnames: - break - else: - raise ValueError(f"Name collision in sheets with information about calculation plan: {sheet_name}") + sheet_name = iterate_names(f"info {calculation_plan.name}"[:30], book.sheetnames, 30) + if sheet_name is None: # pragma: no cover + raise ValueError( + "Name collision in sheets with information about calculation " + f"plan: {f'info {calculation_plan.name}'[:30]}" + ) sheet = book.add_worksheet(sheet_name) cell_format = book.add_format({"bold": True}) diff --git a/package/PartSegCore/io_utils.py b/package/PartSegCore/io_utils.py index 11048cc93..092f9fd0e 100644 --- a/package/PartSegCore/io_utils.py +++ b/package/PartSegCore/io_utils.py @@ -13,6 +13,7 @@ import numpy as np import pandas as pd import tifffile +from openpyxl import load_workbook from PartSegCore.json_hooks import partseg_object_hook from PartSegImage import ImageWriter @@ -20,6 +21,7 @@ from .algorithm_describe_base import AlgorithmDescribeBase, AlgorithmProperty from .project_info import ProjectInfoBase +from .utils import ProfileDict, check_loaded_dict, iterate_names class SegmentationType(Enum): @@ -207,6 +209,25 @@ def load_metadata_base(data: typing.Union[str, Path]): return decoded_data +def load_matadata_part(data: typing.Union[str, Path]) -> typing.Tuple[typing.Any, typing.List[str]]: + """ + Load serialized data. Get valid entries. + + :param data: path to file or string to be decoded. + :return: + """ + # TODO extract to function + data = load_metadata_base(data) + bad_key = [] + if isinstance(data, typing.MutableMapping) and not check_loaded_dict(data): + bad_key.extend(k for k, v in data.items() if not check_loaded_dict(v)) + for el in bad_key: + del data[el] + elif isinstance(data, ProfileDict) and not data.verify_data(): + bad_key = data.filter_data() + return data, bad_key + + def proxy_callback( range_changed: typing.Callable[[int, int], typing.Any], step_changed: typing.Callable[[int], typing.Any], @@ -385,3 +406,65 @@ def get_name(cls) -> str: @classmethod def get_fields(cls) -> typing.List[typing.Union[AlgorithmProperty, str]]: return ["text"] + + +class LoadPlanJson(LoadBase): + @classmethod + def get_short_name(cls): + return "plan_json" + + @classmethod + def load( + cls, + load_locations: typing.List[typing.Union[str, BytesIO, Path]], + range_changed: typing.Callable[[int, int], typing.Any] = None, + step_changed: typing.Callable[[int], typing.Any] = None, + metadata: typing.Optional[dict] = None, + ): + return load_matadata_part(load_locations[0]) + + @classmethod + def get_name(cls) -> str: + return "Calculation plans (*.json)" + + +class LoadPlanExcel(LoadBase): + @classmethod + def get_short_name(cls): + return "plan_excel" + + @classmethod + def load( + cls, + load_locations: typing.List[typing.Union[str, BytesIO, Path]], + range_changed: typing.Callable[[int, int], typing.Any] = None, + step_changed: typing.Callable[[int], typing.Any] = None, + metadata: typing.Optional[dict] = None, + ): + data_list, error_list = [], [] + + xlsx = load_workbook(filename=load_locations[0], read_only=True) + try: + for sheet_name in xlsx.sheetnames: + if sheet_name.startswith("info"): + data = xlsx[sheet_name].cell(row=2, column=2).value + try: + data, err = load_matadata_part(data) + data_list.append(data) + error_list.extend(err) + except ValueError: # pragma: no cover + error_list.append(f"Cannot load data from: {sheet_name}") + finally: + xlsx.close() + data_dict = {} + for calc_plan in data_list: + new_name = iterate_names(calc_plan.name, data_dict) + if new_name is None: # pragma: no cover + error_list.append(f"Cannot determine proper name for {calc_plan.name}") + calc_plan.name = new_name + data_dict[new_name] = calc_plan + return data_dict, error_list + + @classmethod + def get_name(cls) -> str: + return "Calculation plans from result (*.xlsx)" diff --git a/package/PartSegCore/utils.py b/package/PartSegCore/utils.py index eee4ec9ce..9ad452e20 100644 --- a/package/PartSegCore/utils.py +++ b/package/PartSegCore/utils.py @@ -408,3 +408,15 @@ def check_loaded_dict(dkt) -> bool: class BaseModel(PydanticBaseModel): class Config: extra = "forbid" + + +def iterate_names(base_name: str, data_dict, max_length=None) -> typing.Optional[str]: + if base_name not in data_dict: + return base_name[:max_length] + if max_length is not None: + max_length -= 5 + for i in range(1, 100): + res_name = f"{base_name[:max_length]} ({i})" + if res_name not in data_dict: + return res_name + return None diff --git a/package/tests/test_PartSeg/test_common_backend.py b/package/tests/test_PartSeg/test_common_backend.py index e8ab98ef2..3aecf3bfd 100644 --- a/package/tests/test_PartSeg/test_common_backend.py +++ b/package/tests/test_PartSeg/test_common_backend.py @@ -25,6 +25,7 @@ from PartSeg.common_gui.error_report import ErrorDialog from PartSegCore import state_store from PartSegCore.algorithm_describe_base import AlgorithmProperty, ROIExtractionProfile +from PartSegCore.io_utils import load_matadata_part from PartSegCore.mask_create import MaskProperty from PartSegCore.project_info import HistoryElement from PartSegCore.roi_info import ROIInfo @@ -665,9 +666,9 @@ def test_base_settings_partial_load_dump(self, tmp_path, qtbot): settings.dump_part(tmp_path / "data.json", "aaa.bb") settings.dump_part(tmp_path / "data2.json", "aaa.bb", names=["cc", "dd"]) - res = base_settings.BaseSettings.load_part(tmp_path / "data.json") + res = load_matadata_part(tmp_path / "data.json") assert res[0] == {"bb": 10, "cc": 11, "dd": 12, "ee": {"ff": 14, "gg": 15}} - res = base_settings.BaseSettings.load_part(tmp_path / "data2.json") + res = load_matadata_part(tmp_path / "data2.json") assert res[0] == {"cc": 11, "dd": 12} def test_base_settings_verify_image(self): diff --git a/package/tests/test_PartSeg/test_common_gui.py b/package/tests/test_PartSeg/test_common_gui.py index 213257c03..fd4542320 100644 --- a/package/tests/test_PartSeg/test_common_gui.py +++ b/package/tests/test_PartSeg/test_common_gui.py @@ -27,7 +27,13 @@ Appearance, ) from PartSeg.common_gui.algorithms_description import FieldsList, FormWidget, ListInput, QtAlgorithmProperty -from PartSeg.common_gui.custom_load_dialog import CustomLoadDialog, IOMethodMock, LoadProperty, PLoadDialog +from PartSeg.common_gui.custom_load_dialog import ( + CustomLoadDialog, + IOMethodMock, + LoadProperty, + LoadRegisterFileDialog, + PLoadDialog, +) from PartSeg.common_gui.custom_save_dialog import CustomSaveDialog, FormDialog, PSaveDialog from PartSeg.common_gui.equal_column_layout import EqualColumnLayout from PartSeg.common_gui.main_window import OPEN_DIRECTORY, OPEN_FILE, OPEN_FILE_FILTER, BaseMainWindow @@ -47,7 +53,7 @@ from PartSegCore.analysis.load_functions import LoadProject, LoadStackImage, load_dict from PartSegCore.analysis.save_functions import SaveAsTiff, SaveProject, save_dict from PartSegCore.class_register import register_class -from PartSegCore.io_utils import SaveBase +from PartSegCore.io_utils import LoadPlanExcel, LoadPlanJson, SaveBase from PartSegCore.utils import BaseModel from PartSegImage import Channel, Image, ImageWriter @@ -290,6 +296,10 @@ def test_create_load_dialog(qtbot): assert dialog.acceptMode() == CustomLoadDialog.AcceptOpen dialog = CustomLoadDialog(LoadProject, history=["/aaa/"]) assert dialog.acceptMode() == CustomLoadDialog.AcceptOpen + result = dialog.get_result() + assert result.load_class is LoadProject + assert result.selected_filter == LoadProject.get_name_with_suffix() + assert result.load_location == [] def test_create_save_dialog(qtbot): @@ -915,3 +925,33 @@ def test_per_dimension(self, qtbot): res.set_value(1) assert res.get_value() == [1, 1, 1] + + +SAMPLE_FILTER = "Sample text (*.txt)" +HEADER = "Header" + + +class TestLoadRegisterFileDialog: + def test_str_register(self): + dialog = LoadRegisterFileDialog(SAMPLE_FILTER, HEADER) + assert len(dialog.io_register) == 1 + assert isinstance(dialog.io_register[SAMPLE_FILTER], IOMethodMock) + + def test_single_entry(self): + dialog = LoadRegisterFileDialog(LoadPlanJson, HEADER) + assert len(dialog.io_register) == 1 + assert issubclass(dialog.io_register[LoadPlanJson.get_name()], LoadPlanJson) + + def test_list_register(self): + dialog = LoadRegisterFileDialog([LoadPlanJson, LoadPlanExcel], HEADER) + assert len(dialog.io_register) == 2 + assert issubclass(dialog.io_register[LoadPlanJson.get_name()], LoadPlanJson) + assert issubclass(dialog.io_register[LoadPlanExcel.get_name()], LoadPlanExcel) + + def test_dict_register(self): + dialog = LoadRegisterFileDialog( + {LoadPlanJson.get_name(): LoadPlanJson, LoadPlanExcel.get_name(): LoadPlanExcel}, HEADER + ) + assert len(dialog.io_register) == 2 + assert issubclass(dialog.io_register[LoadPlanJson.get_name()], LoadPlanJson) + assert issubclass(dialog.io_register[LoadPlanExcel.get_name()], LoadPlanExcel) diff --git a/package/tests/test_PartSeg/test_main_windows.py b/package/tests/test_PartSeg/test_main_windows.py index d5f9c078e..2fb5a0123 100644 --- a/package/tests/test_PartSeg/test_main_windows.py +++ b/package/tests/test_PartSeg/test_main_windows.py @@ -32,6 +32,7 @@ def test_opening(self, qtbot, tmpdir): main_window.main_menu.advanced_btn.click() main_window.advanced_window.close() main_window.advanced_window.close() + qtbot.wait(50) class TestMaskMainWindow: @@ -40,6 +41,7 @@ class TestMaskMainWindow: def test_opening(self, qtbot, tmpdir): main_window = mask_main_window.MainWindow(tmpdir, initial_image=False) qtbot.addWidget(main_window) + qtbot.wait(50) class TestLauncherMainWindow: @@ -62,6 +64,7 @@ def test_open_mask(self, qtbot, monkeypatch, tmp_path): main_window.prepare.start() QCoreApplication.processEvents() main_window.wind.hide() + qtbot.wait(50) # @pytest.mark.skipif((platform.system() == "Linux") and CI_BUILD, reason="vispy problem") @pytest.mark.enablethread @@ -69,7 +72,7 @@ def test_open_mask(self, qtbot, monkeypatch, tmp_path): @pyside_skip def test_open_analysis(self, qtbot, monkeypatch, tmp_path): monkeypatch.setattr(analysis_main_window, "CONFIG_FOLDER", str(tmp_path)) - if platform.system() == "Linux" and (GITHUB_ACTIONS or TRAVIS): + if platform.system() in {"Darwin", "Linux"} and (GITHUB_ACTIONS or TRAVIS): monkeypatch.setattr(analysis_main_window.MainWindow, "show", empty) main_window = LauncherMainWindow("Launcher") qtbot.addWidget(main_window) @@ -77,4 +80,7 @@ def test_open_analysis(self, qtbot, monkeypatch, tmp_path): with qtbot.waitSignal(main_window.prepare.finished): main_window.prepare.start() QCoreApplication.processEvents() + qtbot.wait(50) + qtbot.addWidget(main_window.wind) main_window.wind.hide() + qtbot.wait(50) diff --git a/package/tests/test_PartSegCore/test_io.py b/package/tests/test_PartSegCore/test_io.py index 092b7323e..e3db607b5 100644 --- a/package/tests/test_PartSegCore/test_io.py +++ b/package/tests/test_PartSegCore/test_io.py @@ -25,7 +25,7 @@ from PartSegCore.analysis.measurement_base import Leaf, MeasurementEntry from PartSegCore.analysis.measurement_calculation import MEASUREMENT_DICT, MeasurementProfile from PartSegCore.analysis.save_functions import SaveAsNumpy, SaveAsTiff, SaveCmap, SaveProject, SaveXYZ -from PartSegCore.io_utils import LoadBase, SaveBase, SaveROIAsNumpy, load_metadata_base +from PartSegCore.io_utils import LoadBase, LoadPlanExcel, LoadPlanJson, SaveBase, SaveROIAsNumpy, load_metadata_base from PartSegCore.json_hooks import PartSegEncoder, partseg_object_hook from PartSegCore.mask.history_utils import create_history_element_from_segmentation_tuple from PartSegCore.mask.io_functions import ( @@ -623,6 +623,25 @@ def test_old_saves_load(file_path): assert data.verify_data(), data.filter_data() +def test_load_plan_form_excel(bundle_test_dir): + data, err = LoadPlanExcel.load([bundle_test_dir / "sample_batch_output.xlsx"]) + assert err == [] + assert len(data) == 3 + assert isinstance(data["test3"], CalculationPlan) + assert isinstance(data["test4"], CalculationPlan) + assert isinstance(data["test3 (1)"], CalculationPlan) + assert LoadPlanExcel.get_name_with_suffix().endswith("(*.xlsx)") + assert LoadPlanExcel.get_short_name() == "plan_excel" + + +def test_load_json_plan(bundle_test_dir): + data, err = LoadPlanJson.load([bundle_test_dir / "measurements_profile.json"]) + assert err == [] + assert len(data) == 1 + assert LoadPlanJson.get_name_with_suffix().endswith("(*.json)") + assert LoadPlanJson.get_short_name() == "plan_json" + + update_name_json = """ {"problematic set": { "__MeasurementProfile__": true, diff --git a/package/tests/test_PartSegCore/test_utils.py b/package/tests/test_PartSegCore/test_utils.py index 36a685912..323d9c9af 100644 --- a/package/tests/test_PartSegCore/test_utils.py +++ b/package/tests/test_PartSegCore/test_utils.py @@ -5,7 +5,15 @@ import pytest from PartSegCore.json_hooks import PartSegEncoder, partseg_object_hook -from PartSegCore.utils import CallbackFun, CallbackMethod, EventedDict, ProfileDict, get_callback, recursive_update_dict +from PartSegCore.utils import ( + CallbackFun, + CallbackMethod, + EventedDict, + ProfileDict, + get_callback, + iterate_names, + recursive_update_dict, +) def test_callback_fun(): @@ -251,3 +259,21 @@ def dummy_call(): receiver.dc.assert_called_once() dkt.set("test.a", 2) assert receiver.empty.call_count == 5 + + +def test_iterate_names(): + assert iterate_names("aaaa", {}) == "aaaa" + assert iterate_names("aaaa", {"aaaa"}) == "aaaa (1)" + assert iterate_names("aaaa", {"aaaa", "aaaa (1)", "aaaa (3)"}) == "aaaa (2)" + + assert iterate_names("a" * 10, {}, 10) == "a" * 10 + assert iterate_names("a" * 11, {}, 10) == "a" * 10 + assert iterate_names("a" * 9, {"a" * 9}, 10) == "a" * 5 + " (1)" + + input_set = {"aaaaa"} + for _ in range(15): + input_set.add(iterate_names("a" * 5, input_set)) + assert iterate_names("a" * 5, input_set) == "a" * 5 + " (16)" + for _ in range(85): + input_set.add(iterate_names("a" * 5, input_set)) + assert iterate_names("a" * 5, input_set) is None diff --git a/package/tests/test_data/sample_batch_output.xlsx b/package/tests/test_data/sample_batch_output.xlsx new file mode 100644 index 000000000..c582d90f6 Binary files /dev/null and b/package/tests/test_data/sample_batch_output.xlsx differ