diff --git a/pyproject.toml b/pyproject.toml index 99cd71b6..60926a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,11 @@ dependencies = [ ] [project.optional-dependencies] +test = [ + 'pytest', + 'pytest-cov', +] + ci = [ 'pyinstaller', 'licensename', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e45f3a9b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package for EasyReflectometryApp backend unit tests. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..e768d6d8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +from PySide6.QtCore import QCoreApplication + + +@pytest.fixture(scope='session') +def qcore_application(): + app = QCoreApplication.instance() + if app is None: + app = QCoreApplication([]) + yield app + app.quit() diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 00000000..031a80aa --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,489 @@ +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + + +class ValueHolder: + def __init__(self, value): + self.value = value + + +class FlaggedValueHolder(ValueHolder): + def __init__(self, value, enabled=True): + super().__init__(value) + self.enabled = enabled + + +class FakeMaterial: + def __init__(self, name, sld=0.0, isld=0.0): + self.name = name + self.sld = ValueHolder(sld) + self.isld = ValueHolder(isld) + + +class FakeLayer: + def __init__( + self, + name='Layer', + material=None, + thickness=10.0, + roughness=2.0, + solvent=None, + area_per_molecule=0.1, + solvent_fraction=0.2, + molecular_formula='formula', + ): + self.name = name + self.material = material or FakeMaterial('Air') + self.solvent = solvent or FakeMaterial('D2O') + self._thickness = FlaggedValueHolder(thickness) + self._roughness = FlaggedValueHolder(roughness) + self.area_per_molecule = area_per_molecule + self.solvent_fraction = solvent_fraction + self.molecular_formula = molecular_formula + + @property + def thickness(self): + return self._thickness + + @thickness.setter + def thickness(self, value): + if isinstance(value, FlaggedValueHolder): + self._thickness = value + else: + self._thickness.value = value + + @property + def roughness(self): + return self._roughness + + @roughness.setter + def roughness(self, value): + if isinstance(value, FlaggedValueHolder): + self._roughness = value + else: + self._roughness.value = value + + +class FakeLayerAreaPerMolecule(FakeLayer): + pass + + +class FakeLayerCollection(list): + def __init__(self, layers=()): + super().__init__(layers) + self.data = self + + def add_layer(self): + self.append(FakeLayer(name=f'Layer {len(self) + 1}')) + + def duplicate_layer(self, index): + source = self[index] + duplicate = type(source)( + name=source.name, + material=source.material, + thickness=source.thickness.value, + roughness=source.roughness.value, + solvent=getattr(source, 'solvent', None), + area_per_molecule=getattr(source, 'area_per_molecule', 0.1), + solvent_fraction=getattr(source, 'solvent_fraction', 0.2), + molecular_formula=getattr(source, 'molecular_formula', 'formula'), + ) + self.insert(index + 1, duplicate) + + def remove(self, index): + self.pop(index) + + def move_up(self, index): + self[index - 1], self[index] = self[index], self[index - 1] + + def move_down(self, index): + self[index], self[index + 1] = self[index + 1], self[index] + + +class FakeAssembly: + def __init__(self, name='Assembly', assembly_type='Multi-layer', layers=None): + self.name = name + self.type = assembly_type + self.layers = FakeLayerCollection(layers or [FakeLayer()]) + + +class FakeMultilayer(FakeAssembly): + def __init__(self, name='Assembly', layers=None): + super().__init__(name=name, assembly_type='Multi-layer', layers=layers) + + +class FakeRepeatingMultilayer(FakeAssembly): + def __init__(self, repetitions=1, name='Repeating Multi-layer', layers=None): + super().__init__(name=name, assembly_type='Repeating Multi-layer', layers=layers) + self.repetitions = ValueHolder(repetitions) + + +class FakeSurfactantLayer(FakeAssembly): + def __init__(self, name='Surfactant Layer', layers=None): + super().__init__(name=name, assembly_type='Surfactant Layer', layers=layers or [FakeLayer(), FakeLayer()]) + self.constrain_area_per_molecule = 'False' + self.conformal_roughness = 'False' + + +class FakeSample(list): + def __init__(self, assemblies=()): + super().__init__(assemblies) + self.data = self + + def add_assembly(self): + self.append(FakeMultilayer(name=f'Assembly {len(self) + 1}')) + + def duplicate_assembly(self, index): + source = self[index] + if isinstance(source, FakeRepeatingMultilayer): + duplicate = FakeRepeatingMultilayer(repetitions=source.repetitions.value, name=source.name, layers=list(source.layers)) + elif isinstance(source, FakeSurfactantLayer): + duplicate = FakeSurfactantLayer(name=source.name, layers=list(source.layers)) + else: + duplicate = FakeMultilayer(name=source.name, layers=list(source.layers)) + self.insert(index + 1, duplicate) + + def remove_assembly(self, index): + self.pop(index) + + def move_up(self, index): + self[index - 1], self[index] = self[index], self[index - 1] + + def move_down(self, index): + self[index], self[index + 1] = self[index + 1], self[index] + + +class FakeResolutionFunction: + def __init__(self, constant): + self.constant = constant + + +class FakeModel: + def __init__( + self, + name='Model', + unique_name='model-1', + color='#000000', + scale=1.0, + background=0.0, + resolution_function=None, + sample=None, + user_data=None, + ): + self.name = name + self.unique_name = unique_name + self.color = color + self.scale = ValueHolder(scale) + self.background = ValueHolder(background) + self.resolution_function = resolution_function + self.sample = sample or FakeSample() + self.user_data = user_data or {} + self.add_assemblies_called = 0 + self.interface = None + + def add_assemblies(self): + self.add_assemblies_called += 1 + self.sample = FakeSample( + [ + FakeMultilayer(name='Assembly 1', layers=[FakeLayer(name='Layer 1')]), + FakeMultilayer(name='Assembly 2', layers=[FakeLayer(name='Layer 2')]), + FakeMultilayer(name='Assembly 3', layers=[FakeLayer(name='Layer 3')]), + ] + ) + + +class FakeModelCollection(list): + def add_model(self): + self.append(make_model(name=f'Model {len(self) + 1}', unique_name=f'model-{len(self) + 1}')) + + def duplicate_model(self, index): + source = self[index] + duplicate = make_model( + name=source.name, + unique_name=f'{source.unique_name}-copy', + color=source.color, + scale=source.scale.value, + background=source.background.value, + resolution_function=source.resolution_function, + sample=source.sample, + user_data=dict(source.user_data), + ) + self.insert(index + 1, duplicate) + + def move_up(self, index): + self[index - 1], self[index] = self[index], self[index - 1] + + def move_down(self, index): + self[index], self[index + 1] = self[index + 1], self[index] + + +class FakeMaterialCollection(list): + def add_material(self, material=None): + self.append(material or FakeMaterial(f'Material {len(self) + 1}')) + + def duplicate_material(self, index): + source = self[index] + self.insert(index + 1, FakeMaterial(source.name, source.sld.value, source.isld.value)) + + def move_up(self, index): + self[index - 1], self[index] = self[index], self[index - 1] + + def move_down(self, index): + self[index], self[index + 1] = self[index + 1], self[index] + + +class FakeCalculatorController: + def __init__(self, available_interfaces): + self.available_interfaces = list(available_interfaces) + self.switched_to = None + + def switch(self, value): + self.switched_to = value + + +class FakeExperiment: + def __init__(self, name, model=None, x=None, y=None, ye=None): + self.name = name + self.model = model + self.x = [] if x is None else x + self.y = [] if y is None else y + self.ye = [] if ye is None else ye + + +class FakeMinimizerValue: + def __init__(self, name): + self.name = name + + +class FakeParameter: + def __init__( + self, + name, + unique_name, + value=0.0, + variance=0.0, + minimum=0.0, + maximum=100.0, + unit='', + free=False, + independent=True, + enabled=True, + dependency_expression='', + dependency_map=None, + ): + self.name = name + self.unique_name = unique_name + self.value = value + self.variance = variance + self.min = minimum + self.max = maximum + self.unit = unit + self.free = free + self.independent = independent + self.enabled = enabled + self.dependency_expression = dependency_expression + self.dependency_map = dependency_map or {} + + def make_dependent_on(self, dependency_expression, dependency_map): + self.independent = False + self.dependency_expression = dependency_expression + self.dependency_map = dependency_map + + +class FakeFitResult: + def __init__(self, success=True, chi2=1.0, n_pars=1, x=None, reduced_chi=0.5, minimizer_engine='stub'): + self.success = success + self.chi2 = chi2 + self.n_pars = n_pars + self.x = [] if x is None else x + self.reduced_chi = reduced_chi + self.minimizer_engine = minimizer_engine + + +class FakeProject: + @property + def _current_model_index(self): + return self.current_model_index + + @_current_model_index.setter + def _current_model_index(self, value): + self.current_model_index = value + + def __init__( + self, + materials=None, + experiments=None, + models=None, + calculator_interfaces=None, + calculator_name='refnx', + minimizer_name='LeastSquares', + ): + self._materials = materials or FakeMaterialCollection() + self.current_material_index = 0 + self._experiments = experiments or {} + self.experiments = self._experiments + self._current_experiment_index = 0 + self._models = models or FakeModelCollection() + self.models = self._models + self.current_model_index = 0 + self.current_assembly_index = 0 + self.current_layer_index = 0 + self._calculator = FakeCalculatorController(calculator_interfaces or ['refnx', 'refl1d']) + self.calculator = calculator_name + self.minimizer = FakeMinimizerValue(minimizer_name) + self._fitter = None + self.fitter = None + self._info = {'name': 'Demo Project', 'short_description': 'Demo Description', 'modified': '2026-03-19'} + self.created = False + self.path = Path('C:/tmp/demo-project') + self.q_min = 0.01 + self.q_max = 0.5 + self.q_resolution = 200 + self.parameters = [] + self.calls = [] + + def default_model(self): + self.calls.append(('default_model',)) + + def reset(self): + self.calls.append(('reset',)) + + def create(self): + self.created = True + self.calls.append(('create',)) + + def save_as_json(self, overwrite=False): + self.calls.append(('save_as_json', overwrite)) + + def load_from_json(self, path): + self.calls.append(('load_from_json', path)) + + def load_experiment_for_model_at_index(self, path, index): + self.calls.append(('load_experiment_for_model_at_index', path, index)) + + def load_new_experiment(self, path): + self.calls.append(('load_new_experiment', path)) + + def count_datasets_in_file(self, path): + self.calls.append(('count_datasets_in_file', path)) + return 3 + + def load_all_experiments_from_file(self, path): + self.calls.append(('load_all_experiments_from_file', path)) + return 2 + + def set_sample_from_orso(self, sample): + self.calls.append(('set_sample_from_orso', sample)) + + def add_sample_from_orso(self, sample): + self.calls.append(('add_sample_from_orso', sample)) + self.models.append(sample) + + def replace_models_from_orso(self, sample): + self.calls.append(('replace_models_from_orso', sample)) + self.models[:] = [sample] + + def experimental_data_for_model_at_index(self, index): + self.calls.append(('experimental_data_for_model_at_index', index)) + if index >= len(self.models): + raise IndexError(index) + return object() + + def set_path_project_parent(self, path): + self.calls.append(('set_path_project_parent', path)) + + def sample_data_for_model_at_index(self, index): + self.calls.append(('sample_data_for_model_at_index', index)) + return SimpleNamespace(x=[], y=[]) + + def sld_data_for_model_at_index(self, index): + self.calls.append(('sld_data_for_model_at_index', index)) + return SimpleNamespace(x=[], y=[]) + + def get_index_air(self): + return [material.name for material in self._materials].index('Air') + + def get_index_si(self): + return [material.name for material in self._materials].index('Si') + + def get_index_sio2(self): + return [material.name for material in self._materials].index('SiO2') + + def get_index_d2o(self): + return [material.name for material in self._materials].index('D2O') + + +@dataclass +class FakeWorkerFitter: + method_result: object = None + error: Exception | None = None + + def __post_init__(self): + self.calls = [] + + def fit(self, *args, **kwargs): + self.calls.append((args, kwargs)) + if self.error is not None: + raise self.error + return self.method_result + + +def make_material(name, sld=0.0, isld=0.0): + return FakeMaterial(name, sld=sld, isld=isld) + + +def make_material_collection(*materials): + return FakeMaterialCollection(materials) + + +def make_experiment(name, model=None, x=None, y=None, ye=None): + return FakeExperiment(name=name, model=model, x=x, y=y, ye=ye) + + +def make_layer(**kwargs): + return FakeLayer(**kwargs) + + +def make_layer_collection(*layers): + return FakeLayerCollection(layers) + + +def make_assembly(name='Assembly', assembly_type='Multi-layer', layers=None): + if assembly_type == 'Repeating Multi-layer': + return FakeRepeatingMultilayer(name=name, layers=layers) + if assembly_type == 'Surfactant Layer': + return FakeSurfactantLayer(name=name, layers=layers) + return FakeMultilayer(name=name, layers=layers) + + +def make_sample(*assemblies): + return FakeSample(assemblies) + + +def make_model(**kwargs): + return FakeModel(**kwargs) + + +def make_model_collection(*models): + return FakeModelCollection(models) + + +def make_parameter(**kwargs): + return FakeParameter(**kwargs) + + +def make_fit_result(**kwargs): + return FakeFitResult(**kwargs) + + +def make_project(**kwargs): + return FakeProject(**kwargs) + + +def make_worker_fitter(method_result=None, error=None): + return FakeWorkerFitter(method_result=method_result, error=error) + + +def make_multi_fitter_stub(tolerance=1e-6, max_evaluations=5000): + return SimpleNamespace(tolerance=tolerance, max_evaluations=max_evaluations) diff --git a/tests/test_logic_assemblies.py b/tests/test_logic_assemblies.py new file mode 100644 index 00000000..2926bc25 --- /dev/null +++ b/tests/test_logic_assemblies.py @@ -0,0 +1,81 @@ +from EasyReflectometryApp.Backends.Py.logic import assemblies as assemblies_module +from tests.factories import FakeMultilayer +from tests.factories import FakeRepeatingMultilayer +from tests.factories import FakeSurfactantLayer +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def test_from_assemblies_collection_to_list_of_dicts_includes_special_fields(monkeypatch): + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + repeating = FakeRepeatingMultilayer(repetitions=3, name='Repeat') + surfactant = FakeSurfactantLayer(name='Surf') + surfactant.constrain_area_per_molecule = 'True' + surfactant.conformal_roughness = 'True' + + result = assemblies_module._from_assemblies_collection_to_list_of_dicts([FakeMultilayer(name='Plain'), repeating, surfactant]) + + assert result[0]['type'] == 'Multi-layer' + assert result[1]['repetitions'].value == 3 + assert result[2]['constrain_apm'] == 'True' + assert result[2]['conformal_roughness'] == 'True' + + +def test_assemblies_add_new_and_type_transitions(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample(make_assembly(name='Existing', layers=[make_layer(material=materials[1])])) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + logic = assemblies_module.Assemblies(project) + + logic.add_new() + assert logic._assemblies[-1].layers[0].material.name == 'Si' + + assert logic.set_name_at_current_index('Renamed Assembly') is True + assert logic._assemblies[0].name == 'Renamed Assembly' + + assert logic.set_type_at_current_index('Repeating Multi-layer') is True + assert isinstance(logic._assemblies[0], FakeRepeatingMultilayer) + assert logic.repetitions_at_current_index == '1' + assert logic.set_repeated_layer_reptitions(5) is True + assert logic.repetitions_at_current_index == '5' + + assert logic.set_type_at_current_index('Surfactant Layer') is True + assert isinstance(logic._assemblies[0], FakeSurfactantLayer) + assert logic._assemblies[0].layers[0].solvent.name == 'Air' + assert logic._assemblies[0].layers[1].solvent.name == 'D2O' + assert logic.set_constrain_apm('True') is True + assert logic.set_conformal_roughness('True') is True + + +def test_assemblies_duplicate_move_and_remove(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + model = make_model(sample=make_sample(make_assembly(name='A1'), make_assembly(name='A2'))) + project = make_project(models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = assemblies_module.Assemblies(project) + + logic.duplicate_selected() + assert len(logic._assemblies) == 3 + + logic.move_selected_up() + assert project.current_assembly_index == 0 + + logic.move_selected_down() + assert project.current_assembly_index == 1 + + logic.remove_at_index('2') + assert len(logic._assemblies) == 2 diff --git a/tests/test_logic_calculators.py b/tests/test_logic_calculators.py new file mode 100644 index 00000000..724e9731 --- /dev/null +++ b/tests/test_logic_calculators.py @@ -0,0 +1,34 @@ +from EasyReflectometryApp.Backends.Py.logic.calculators import Calculators +from tests.factories import make_project + + +def test_available_returns_configured_interfaces(): + project = make_project(calculator_interfaces=['refnx', 'refl1d']) + + logic = Calculators(project) + + assert logic.available() == ['refnx', 'refl1d'] + assert logic.current_index() == 0 + + +def test_set_current_index_updates_project_calculator(): + project = make_project(calculator_interfaces=['refnx', 'refl1d']) + + logic = Calculators(project) + + changed = logic.set_current_index(1) + + assert changed is True + assert logic.current_index() == 1 + assert project._calculator.switched_to == 'refl1d' + + +def test_set_current_index_returns_false_when_unchanged(): + project = make_project(calculator_interfaces=['refnx', 'refl1d']) + + logic = Calculators(project) + + changed = logic.set_current_index(0) + + assert changed is False + assert project.calculator == 'refnx' diff --git a/tests/test_logic_experiments.py b/tests/test_logic_experiments.py new file mode 100644 index 00000000..12b7901a --- /dev/null +++ b/tests/test_logic_experiments.py @@ -0,0 +1,85 @@ +from EasyReflectometryApp.Backends.Py.logic.experiments import Experiments +from tests.factories import make_experiment +from tests.factories import make_project + + +def test_available_orders_mapping_like_experiments_by_key(): + model_a = object() + model_b = object() + experiments = { + 5: make_experiment('Later', model=model_b), + 2: make_experiment('Earlier', model=model_a), + } + project = make_project(experiments=experiments, models=[model_a, model_b]) + logic = Experiments(project) + + assert logic.available() == ['Earlier', 'Later'] + assert logic.model_on_experiment(0) is model_a + + +def test_set_current_index_and_rename_current_experiment(): + experiments = [make_experiment('First'), make_experiment('Second')] + project = make_project(experiments=experiments) + logic = Experiments(project) + + assert logic.set_current_index(1) is True + assert logic.current_index() == 1 + + logic.set_experiment_name('Renamed') + + assert experiments[1].name == 'Renamed' + assert logic.set_current_index(1) is False + + +def test_model_index_on_current_experiment_returns_index_or_minus_one(): + model_a = object() + model_b = object() + experiments = [make_experiment('First', model=model_b)] + project = make_project(experiments=experiments, models=[model_a, model_b]) + logic = Experiments(project) + + assert logic.model_index_on_experiment() == 1 + + experiments[0].model = None + + assert logic.model_index_on_experiment() == -1 + + +def test_set_model_on_experiment_updates_current_experiment_model(): + model_a = object() + model_b = object() + experiments = [make_experiment('First', model=model_a)] + project = make_project(experiments=experiments, models=[model_a, model_b]) + logic = Experiments(project) + + logic.set_model_on_experiment(1) + + assert experiments[0].model is model_b + + +def test_remove_experiment_updates_current_index_for_mapping_storage(): + experiments = { + 10: make_experiment('First'), + 20: make_experiment('Second'), + 30: make_experiment('Third'), + } + project = make_project(experiments=experiments) + project._current_experiment_index = 2 + logic = Experiments(project) + + logic.remove_experiment(1) + + assert list(project._experiments.keys()) == [10, 30] + assert project._current_experiment_index == 1 + + +def test_remove_last_remaining_experiment_resets_index_to_zero(): + experiments = [make_experiment('Only')] + project = make_project(experiments=experiments) + project._current_experiment_index = 0 + logic = Experiments(project) + + logic.remove_experiment(0) + + assert project._experiments == [] + assert project._current_experiment_index == 0 diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py new file mode 100644 index 00000000..8dc844d2 --- /dev/null +++ b/tests/test_logic_fitting.py @@ -0,0 +1,138 @@ +import sys +from types import ModuleType +from types import SimpleNamespace + +import numpy as np + +from EasyReflectometryApp.Backends.Py.logic import fitting as fitting_module +from tests.factories import make_experiment +from tests.factories import make_fit_result +from tests.factories import make_model +from tests.factories import make_project + + +class StubMinimizersLogic: + def __init__(self, selected=None, tolerance=1e-5, max_iterations=250): + self._selected = selected + self.tolerance = tolerance + self.max_iterations = max_iterations + + def selected_minimizer_enum(self): + return self._selected + + +class FakeEasyScienceMultiFitter: + def __init__(self): + self.switched_to = None + self.tolerance = None + self.max_evaluations = None + self.minimizer = SimpleNamespace(package='fake-engine', _method='fake-method') + + def switch_minimizer(self, value): + self.switched_to = value + + +class FakeMultiFitter: + def __init__(self, *models): + self.models = models + self.easy_science_multi_fitter = FakeEasyScienceMultiFitter() + + +def install_fake_multifitter(monkeypatch): + # Inject a fake module into sys.modules because the real easyreflectometry.fitting + # module pulls in heavy calculator backends at import time. Using monkeypatch.setitem + # ensures the original module is restored automatically after the test. + fake_module = ModuleType('easyreflectometry.fitting') + fake_module.MultiFitter = FakeMultiFitter + monkeypatch.setitem(sys.modules, 'easyreflectometry.fitting', fake_module) + + +def test_prepare_threaded_fit_handles_empty_experiments(): + project = make_project(experiments={}) + logic = fitting_module.Fitting(project) + + result = logic.prepare_threaded_fit(StubMinimizersLogic()) + + assert result == (None, None, None, None, None) + assert logic.fit_error_message == 'No experiments to fit' + assert logic.fit_finished is True + assert logic.show_results_dialog is True + + +def test_prepare_threaded_fit_builds_masked_arrays_and_configures_minimizer(monkeypatch): + install_fake_multifitter(monkeypatch) + model_a = make_model(name='A') + model_b = make_model(name='B') + experiments = { + 5: make_experiment('Exp B', model=model_b, x=np.array([5.0, 6.0]), y=np.array([7.0, 8.0]), ye=np.array([9.0, 16.0])), + 2: make_experiment('Exp A', model=model_a, x=np.array([1.0, 2.0, 3.0]), y=np.array([4.0, 5.0, 6.0]), ye=np.array([1.0, 0.0, 4.0])), + } + project = make_project(experiments=experiments) + logic = fitting_module.Fitting(project) + selected = SimpleNamespace(name='DREAM') + + fitter, x_data, y_data, weights, method = logic.prepare_threaded_fit(StubMinimizersLogic(selected, 1e-4, 321)) + + assert fitter.switched_to is selected + assert fitter.tolerance == 1e-4 + assert fitter.max_evaluations == 321 + assert [values.tolist() for values in x_data] == [[1.0, 3.0], [5.0, 6.0]] + assert [values.tolist() for values in y_data] == [[4.0, 6.0], [7.0, 8.0]] + assert [values.tolist() for values in weights] == [[1.0, 0.5], [1 / 3, 0.25]] + assert method is None + + +def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_fit() + logic.on_fit_finished([ + make_fit_result(success=True, chi2=4.0, n_pars=2, x=[1, 2, 3], reduced_chi=1.1), + make_fit_result(success=True, chi2=6.0, n_pars=2, x=[1, 2, 3, 4], reduced_chi=1.2), + ]) + + assert logic.fit_finished is True + assert logic.fit_success is True + assert logic.fit_n_pars == 4 + assert logic.fit_chi2 == 10.0 + + logic.on_fit_finished(make_fit_result(success=False, chi2=9.0, n_pars=1, x=[1, 2], reduced_chi=4.5)) + assert logic.fit_success is False + assert logic.fit_n_pars == 1 + assert logic.fit_chi2 == 9.0 + + +def test_fit_failure_and_cancellation_state_transitions(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_fit_failed('boom') + assert logic.fit_error_message == 'boom' + assert logic.fit_finished is True + assert logic.show_results_dialog is True + + logic.stop_fit() + assert logic.fit_cancelled is True + assert logic.fit_error_message == 'Fitting cancelled by user' + + logic.reset_stop_flag() + assert logic.fit_cancelled is False + + +def test_start_stop_handles_success_and_fiterror(): + project = make_project(models=[object()]) + project.fitter = SimpleNamespace(fit_single_data_set_1d=lambda exp_data: make_fit_result(success=True, chi2=1.7, reduced_chi=1.7)) + logic = fitting_module.Fitting(project) + + logic.start_stop() + assert logic.fit_finished is True + assert logic.show_results_dialog is True + assert logic.fit_chi2 == 1.7 + + def _raise_fit_error(exp_data): + raise fitting_module.FitError('fit failed') + + project.fitter = SimpleNamespace(fit_single_data_set_1d=_raise_fit_error) + logic.start_stop() + assert 'fit failed' in logic.fit_error_message diff --git a/tests/test_logic_helpers.py b/tests/test_logic_helpers.py new file mode 100644 index 00000000..cb49b554 --- /dev/null +++ b/tests/test_logic_helpers.py @@ -0,0 +1,31 @@ +from types import SimpleNamespace + +from EasyReflectometryApp.Backends.Py.logic.helpers import IO +from EasyReflectometryApp.Backends.Py.logic.helpers import get_original_name + + +def test_format_msg_main_prefix_and_columns(): + message = IO.formatMsg('main', 'alpha', 'beta') + + assert message.startswith('* ') + assert 'alpha' in message + assert 'beta' in message + assert ' ▌ ' in message + + +def test_format_msg_sub_prefix(): + message = IO.formatMsg('sub', 'alpha') + + assert message.startswith(' - ') + + +def test_get_original_name_uses_user_data_value(): + obj = SimpleNamespace(name='Current Name', user_data={'original_name': 'Original Name'}) + + assert get_original_name(obj) == 'Original Name' + + +def test_get_original_name_falls_back_to_name_without_dict_user_data(): + obj = SimpleNamespace(name='Current Name', user_data=None) + + assert get_original_name(obj) == 'Current Name' diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py new file mode 100644 index 00000000..ff69bcb7 --- /dev/null +++ b/tests/test_logic_layers.py @@ -0,0 +1,101 @@ +from EasyReflectometryApp.Backends.Py.logic import layers as layers_module +from tests.factories import FakeLayerAreaPerMolecule +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_layer_collection +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def test_from_layers_collection_to_list_of_dicts_respects_assembly_enablement(monkeypatch): + monkeypatch.setattr(layers_module, 'LayerAreaPerMolecule', FakeLayerAreaPerMolecule) + layer = FakeLayerAreaPerMolecule( + name='Headgroup', + material=make_material('Tail Material'), + thickness=12.0, + roughness=3.0, + solvent=make_material('Water'), + area_per_molecule=44.0, + solvent_fraction=0.35, + molecular_formula='C12H25', + ) + + regular = layers_module._from_layers_collection_to_list_of_dicts(make_layer_collection(layer), 'regular') + superphase = layers_module._from_layers_collection_to_list_of_dicts(make_layer_collection(layer), 'superphase') + subphase = layers_module._from_layers_collection_to_list_of_dicts(make_layer_collection(layer), 'subphase') + + assert regular[0]['thickness_enabled'] == 'True' + assert regular[0]['roughness_enabled'] == 'True' + assert regular[0]['formula'] == 'C12H25' + assert regular[0]['apm'] == '44.0' + assert regular[0]['solvent'] == 'Water' + assert regular[0]['solvation'] == '0.35' + assert superphase[0]['thickness_enabled'] == 'False' + assert superphase[0]['roughness_enabled'] == 'False' + assert subphase[0]['thickness_enabled'] == 'False' + assert subphase[0]['roughness_enabled'] == 'True' + + +def test_layers_add_new_creates_si_material_when_missing(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + materials = make_material_collection(make_material('Air'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top'), + make_assembly(name='Middle'), + make_assembly(name='Bottom'), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = layers_module.Layers(project) + + logic.add_new() + + assert [material.name for material in project._materials] == ['Air', 'D2O', 'Si'] + assert logic._layers[-1].material.name == 'Si' + assert logic._layers[-1].name == 'Si Layer' + + +def test_layers_move_duplicate_and_setters_update_current_layer(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + materials = make_material_collection(make_material('Air'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly( + name='Middle', + layers=[ + make_layer(name='Layer A', material=materials[0], thickness=10.0, roughness=2.0), + make_layer(name='Layer B', material=materials[1], thickness=20.0, roughness=4.0), + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + project.current_layer_index = 1 + logic = layers_module.Layers(project) + + assert logic._assembly_type == 'regular' + assert logic.set_name_at_current_index('Renamed') is True + assert logic.set_thickness_at_current_index(25.0) is True + assert logic.set_roughness_at_current_index(5.0) is True + assert logic.set_material_at_current_index(0) is True + assert logic._layers[1].name == 'Air Layer' + assert logic.set_material_at_current_index(0) is False + + logic.duplicate_selected() + assert len(logic._layers) == 3 + + logic.move_selected_up() + assert project.current_layer_index == 0 + + logic.move_selected_down() + assert project.current_layer_index == 1 + + logic.remove_at_index('2') + assert len(logic._layers) == 2 diff --git a/tests/test_logic_material.py b/tests/test_logic_material.py new file mode 100644 index 00000000..a31fd8c9 --- /dev/null +++ b/tests/test_logic_material.py @@ -0,0 +1,60 @@ +from EasyReflectometryApp.Backends.Py.logic.material import Material +from EasyReflectometryApp.Backends.Py.logic.material import _from_materials_collection_to_list_of_dicts +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_project + + +def test_from_materials_collection_to_list_of_dicts_serializes_values(): + materials = make_material_collection( + make_material('Air', sld=0.0, isld=0.0), + make_material('Si', sld=2.07, isld=0.1), + ) + + result = _from_materials_collection_to_list_of_dicts(materials) + + assert result == [ + {'label': 'Air', 'sld': '0.0', 'isld': '0.0'}, + {'label': 'Si', 'sld': '2.07', 'isld': '0.1'}, + ] + + +def test_material_logic_add_duplicate_move_and_remove(): + materials = make_material_collection( + make_material('Air', sld=0.0), + make_material('Si', sld=2.07), + ) + project = make_project(materials=materials) + logic = Material(project) + + logic.add_new() + assert len(project._materials) == 3 + + project.current_material_index = 1 + logic.duplicate_selected() + assert len(project._materials) == 4 + assert project._materials[2].name == 'Si' + + logic.move_selected_up() + assert project.current_material_index == 0 + + logic.move_selected_down() + assert project.current_material_index == 1 + + logic.remove_at_index('3') + assert len(project._materials) == 3 + + +def test_material_setters_return_change_state(): + materials = make_material_collection(make_material('Air', sld=0.0, isld=0.0)) + project = make_project(materials=materials) + logic = Material(project) + + assert logic.set_name_at_current_index('Vacuum') is True + assert logic.set_name_at_current_index('Vacuum') is False + + assert logic.set_sld_at_current_index(1.23) is True + assert logic.set_sld_at_current_index(1.23) is False + + assert logic.set_isld_at_current_index(0.45) is True + assert logic.set_isld_at_current_index(0.45) is False diff --git a/tests/test_logic_minimizers.py b/tests/test_logic_minimizers.py new file mode 100644 index 00000000..9da9bffa --- /dev/null +++ b/tests/test_logic_minimizers.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace + +from EasyReflectometryApp.Backends.Py.logic import minimizers as minimizers_module +from tests.factories import make_multi_fitter_stub +from tests.factories import make_project + + +class FakeEnumValue: + def __init__(self, name): + self.name = name + + def __repr__(self): + return self.name + + +class FakeAvailableMinimizers: + LMFit = FakeEnumValue('LMFit') + Bumps = FakeEnumValue('Bumps') + DFO = FakeEnumValue('DFO') + DREAM = FakeEnumValue('DREAM') + SciPy = FakeEnumValue('SciPy') + + def __iter__(self): + return iter([self.LMFit, self.Bumps, self.DFO, self.DREAM, self.SciPy]) + + +def test_minimizers_filters_out_blocked_entries(monkeypatch): + monkeypatch.setattr(minimizers_module, 'AvailableMinimizers', FakeAvailableMinimizers()) + project = make_project(minimizer_name='Initial') + + logic = minimizers_module.Minimizers(project) + + assert logic.minimizers_available() == ['DREAM', 'SciPy'] + assert logic.selected_minimizer_enum().name == 'DREAM' + + +def test_minimizers_set_index_updates_project_and_runtime_properties(monkeypatch): + monkeypatch.setattr(minimizers_module, 'AvailableMinimizers', FakeAvailableMinimizers()) + project = make_project(minimizer_name='Initial') + project._fitter = SimpleNamespace(easy_science_multi_fitter=make_multi_fitter_stub()) + + logic = minimizers_module.Minimizers(project) + + assert logic.set_minimizer_current_index(1) is True + assert project.minimizer.name == 'SciPy' + assert logic.set_minimizer_current_index(1) is False + + assert logic.tolerance == 1e-6 + assert logic.max_iterations == 5000 + assert logic.set_tolerance(2e-6) is True + assert logic.set_tolerance(2e-6) is False + assert logic.set_max_iterations(7000) is True + assert logic.set_max_iterations(7000) is False + + +def test_minimizers_return_defaults_without_fitter(monkeypatch): + monkeypatch.setattr(minimizers_module, 'AvailableMinimizers', FakeAvailableMinimizers()) + project = make_project(minimizer_name='Initial') + + logic = minimizers_module.Minimizers(project) + + assert logic.tolerance == 1e-6 + assert logic.max_iterations == 5000 + assert logic.set_tolerance(2e-6) is False + assert logic.set_max_iterations(7000) is False diff --git a/tests/test_logic_models.py b/tests/test_logic_models.py new file mode 100644 index 00000000..d202cf07 --- /dev/null +++ b/tests/test_logic_models.py @@ -0,0 +1,89 @@ +from EasyReflectometryApp.Backends.Py.logic import models as models_module +from tests.factories import FakeResolutionFunction +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project + + +def test_from_models_collection_to_list_of_dicts_uses_original_name(): + models = make_model_collection( + make_model(name='Internal 1', user_data={'original_name': 'Visible 1'}, color='#111111'), + make_model(name='Internal 2', color='#222222'), + ) + + result = models_module._from_models_collection_to_list_of_dicts(models) + + assert result == [ + {'label': 'Visible 1', 'color': '#111111'}, + {'label': 'Internal 2', 'color': '#222222'}, + ] + + +def test_model_properties_and_resolution_handling(monkeypatch): + monkeypatch.setattr(models_module, 'PercentageFwhm', FakeResolutionFunction) + models = make_model_collection( + make_model(name='Model A', scale=1.2, background=0.3, resolution_function=FakeResolutionFunction(7.5)), + make_model(name='Model B', resolution_function=object()), + ) + project = make_project(models=models) + logic = models_module.Models(project) + + assert logic.name_at_current_index == 'Model A' + assert logic.scaling_at_current_index == 1.2 + assert logic.background_at_current_index == 0.3 + assert logic.resolution_at_current_index == '7.5' + assert logic.models_names == ['Model A', 'Model B'] + + assert logic.set_name_at_current_index('Renamed') is True + assert logic.set_scaling_at_current_index('2.5') is True + assert logic.set_background_at_current_index('0.7') is True + assert logic.set_resolution_at_current_index('3.3') is True + assert models[0].resolution_function.constant == 3.3 + + project.current_model_index = 1 + assert logic.resolution_at_current_index == '-' + + +def test_default_model_content_populates_expected_materials(): + materials = make_material_collection(make_material('Air'), make_material('SiO2'), make_material('Si')) + project = make_project(materials=materials, models=make_model_collection(make_model(name='New Model'))) + model = project._models[0] + logic = models_module.Models(project) + + logic.default_model_content(model) + + assert model.add_assemblies_called == 1 + assert model.sample.data[0].name == 'Superphase' + assert model.sample.data[0].layers.data[0].material.name == 'Air' + assert model.sample.data[0].layers.data[0].thickness.value == 0.0 + assert model.sample.data[0].layers.data[0].roughness.value == 0.0 + assert model.sample.data[1].name == 'SiO2' + assert model.sample.data[1].layers.data[0].material.name == 'SiO2' + assert model.sample.data[1].layers.data[0].thickness.value == 100.0 + assert model.sample.data[1].layers.data[0].roughness.value == 3.0 + assert model.sample.data[2].name == 'Substrate' + assert model.sample.data[2].layers.data[0].material.name == 'Si' + assert model.sample.data[2].layers.data[0].roughness.value == 1.2 + + +def test_model_collection_operations_update_current_index(): + materials = make_material_collection(make_material('Air'), make_material('SiO2'), make_material('Si')) + models = make_model_collection(make_model(name='M1'), make_model(name='M2')) + project = make_project(materials=materials, models=models) + logic = models_module.Models(project) + + logic.add_new() + assert len(project._models) == 3 + assert project.current_model_index == 2 + + logic.duplicate_selected_model() + assert len(project._models) == 4 + assert project.current_model_index == 3 + + logic.move_selected_up() + assert project.current_model_index == 2 + + logic.move_selected_down() + assert project.current_model_index == 3 diff --git a/tests/test_logic_parameters.py b/tests/test_logic_parameters.py new file mode 100644 index 00000000..7c8fbf5b --- /dev/null +++ b/tests/test_logic_parameters.py @@ -0,0 +1,307 @@ +from types import SimpleNamespace + +from EasyReflectometryApp.Backends.Py.logic import parameters as parameters_module +from tests.factories import FakeParameter +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_parameter +from tests.factories import make_project + + +class FakeMap: + def __init__(self, paths, names): + self._paths = paths + self._names = names + + def find_path(self, model_unique_name, parameter_unique_name): + return self._paths.get((model_unique_name, parameter_unique_name), []) + + def get_item_by_key(self, key): + return SimpleNamespace(name=self._names[key]) + + +def test_from_parameters_to_list_of_dicts_prefixes_layers_and_deduplicates_shared_params(monkeypatch): + monkeypatch.setattr(parameters_module, 'Parameter', FakeParameter) + fake_map = FakeMap( + { + ('m1', 'thickness'): ['group_t1', 'param_t1'], + ('m2', 'thickness'): ['group_t2', 'param_t2'], + ('m1', 'scale'): ['group_scale', 'param_scale'], + ('m2', 'scale'): ['group_scale', 'param_scale'], + ('m1', 'dep'): ['group_dep', 'param_dep'], + }, + { + 'group_t1': 'LayerA', + 'param_t1': 'thickness', + 'group_t2': 'LayerA', + 'param_t2': 'thickness', + 'group_scale': 'Instrument', + 'param_scale': 'scale', + 'group_dep': 'Instrument', + 'param_dep': 'background', + }, + ) + monkeypatch.setattr(parameters_module.global_object, 'map', fake_map) + + models = make_model_collection( + make_model(name='M1 internal', unique_name='m1', user_data={'original_name': 'M1'}), + make_model(name='M2 internal', unique_name='m2', user_data={'original_name': 'M2'}), + ) + scale = make_parameter(name='scale', unique_name='scale', value=1.5, free=False, enabled=True) + thickness = make_parameter(name='thickness', unique_name='thickness', value=20.0, free=True, enabled=True) + dependent = make_parameter( + name='background', + unique_name='dep', + value=0.2, + free=False, + independent=False, + dependency_expression='2*a', + dependency_map={'a': scale}, + enabled=False, + ) + + result = parameters_module._from_parameters_to_list_of_dicts([thickness, scale, dependent], models) + + assert [entry['display_name'] for entry in result] == [ + 'M1 LayerA thickness', + 'Instrument scale', + 'Instrument background', + 'M2 LayerA thickness', + ] + assert result[1]['dependency'] == '' + assert result[2]['dependency'] == '2*Instrument scale' + assert result[2]['enabled'] is False + assert result[0]['alias'] == 'm1_layera_thickness' + assert result[3]['alias'] == 'm2_layera_thickness' + + +def test_parameters_filtering_metadata_and_current_parameter_updates(monkeypatch): + monkeypatch.setattr(parameters_module, 'count_free_parameters', lambda project: 2) + monkeypatch.setattr(parameters_module, 'count_fixed_parameters', lambda project: 1) + project = make_project() + logic = parameters_module.Parameters(project) + free_parameter = make_parameter(name='Scale', unique_name='scale', value=1.0, minimum=0.0, maximum=2.0, free=True) + fixed_parameter = make_parameter(name='Thickness', unique_name='layer.thickness', value=10.0, minimum=1.0, maximum=50.0) + mocked_parameters = [ + { + 'name': 'Instrument scale', + 'display_name': 'Instrument scale', + 'group': 'Instrument', + 'unique_name': 'scale', + 'fit': True, + 'enabled': True, + 'independent': True, + 'alias': 'instrument_scale', + 'object': free_parameter, + }, + { + 'name': 'Layer thickness', + 'display_name': 'Layer thickness', + 'group': 'Layer', + 'unique_name': 'layer.thickness', + 'fit': False, + 'enabled': True, + 'independent': False, + 'alias': 'layer_thickness', + 'object': fixed_parameter, + }, + { + 'name': 'Hidden background', + 'display_name': 'Hidden background', + 'group': 'Experiment', + 'unique_name': 'background', + 'fit': False, + 'enabled': False, + 'independent': True, + 'alias': 'hidden_background', + 'object': make_parameter(name='Background', unique_name='background'), + }, + ] + monkeypatch.setattr(logic, 'all_parameters', lambda: mocked_parameters) + + assert logic.as_status_string == '3 (2 free, 1 fixed)' + assert logic.set_name_filter_criteria(' scale ') is True + assert [entry['display_name'] for entry in logic.parameters] == ['Instrument scale'] + assert logic.set_variability_filter_criteria('fixed') is True + logic.set_name_filter_criteria('') + assert [entry['display_name'] for entry in logic.parameters] == ['Layer thickness'] + + metadata = logic.constraint_metadata() + assert metadata == [ + {'alias': 'hidden_background', 'displayName': 'Hidden background', 'group': 'Experiment', 'independent': True}, + {'alias': 'instrument_scale', 'displayName': 'Instrument scale', 'group': 'Instrument', 'independent': True}, + {'alias': 'layer_thickness', 'displayName': 'Layer thickness', 'group': 'Layer', 'independent': False}, + ] + + logic.set_variability_filter_criteria('all') + logic.set_current_index(0) + assert logic.set_current_parameter_value('1.5') is True + assert logic.set_current_parameter_min('0.2') is True + assert logic.set_current_parameter_max('3.2') is True + assert logic.set_current_parameter_fit(False) is True + assert free_parameter.value == 1.5 + assert free_parameter.min == 0.2 + assert free_parameter.max == 3.2 + assert free_parameter.free is False + + +def test_add_constraint_supports_arithmetic_and_constant_dependencies(): + independent = make_parameter(name='Scale', unique_name='scale', value=2.0) + dependent = make_parameter(name='Background', unique_name='background', value=0.5) + project = make_project() + project.parameters = [independent, dependent] + logic = parameters_module.Parameters(project) + + logic.add_constraint(1, '=', 3.0, '*', 0) + assert dependent.dependency_expression == 'a*b' + assert dependent.dependency_map == {'a': independent, 'b': 3.0} + + logic.add_constraint(1, '=', 7.0, '', -1) + assert dependent.dependency_expression == 'a' + assert dependent.dependency_map == {'a': 7.0} + + +def test_from_parameters_to_list_of_dicts_handles_alias_edge_cases_and_fallbacks(monkeypatch): + monkeypatch.setattr(parameters_module, 'Parameter', FakeParameter) + + class ParameterWithoutEnabled: + def __init__(self, name, unique_name, value=0.0, independent=True, dependency_expression='', dependency_map=None): + self.name = name + self.unique_name = unique_name + self.value = value + self.variance = 0.0 + self.min = 0.0 + self.max = 10.0 + self.unit = '' + self.free = False + self.independent = independent + self.dependency_expression = dependency_expression + self.dependency_map = dependency_map or {} + + fake_map = FakeMap( + { + ('m1', 'p1'): ['group_same', 'param_same'], + ('m1', 'p2'): ['group_same', 'param_same'], + ('m1', 'reserved'): ['group_np', 'param_np'], + ('m1', 'numeric'): ['group_num', 'param_num'], + ('m1', 'empty'): ['only_key'], + ('m1', 'dep_other'): ['group_dep', 'param_dep'], + ('m1', 'no_enabled'): ['group_ne', 'param_ne'], + }, + { + 'group_same': 'Same Group', + 'param_same': 'Same Name', + 'group_np': 'Reserved', + 'param_np': 'np', + 'group_num': '123', + 'param_num': '456', + 'group_dep': 'DepGroup', + 'param_dep': 'DepName', + 'group_ne': 'Visible', + 'param_ne': 'Parameter', + }, + ) + monkeypatch.setattr(parameters_module.global_object, 'map', fake_map) + + models = make_model_collection(make_model(name='M1', unique_name='m1', user_data={'original_name': 'M1'})) + param1 = make_parameter(name='same', unique_name='p1') + param2 = make_parameter(name='same', unique_name='p2') + reserved = make_parameter(name='np', unique_name='reserved') + numeric = make_parameter(name='456', unique_name='numeric') + empty = make_parameter(name='!!!', unique_name='empty') + dep_other = make_parameter( + name='dep', + unique_name='dep_other', + independent=False, + dependency_expression='a+1', + dependency_map={'a': 'external'}, + ) + no_enabled = ParameterWithoutEnabled(name='visible', unique_name='no_enabled', value=2.0) + + result = parameters_module._from_parameters_to_list_of_dicts( + [param1, param2, reserved, numeric, empty, dep_other, no_enabled], + models, + ) + + aliases = [entry['alias'] for entry in result] + assert aliases[0] == 'same_group_same_name' + assert aliases[1] == 'same_group_same_name_1' + assert aliases[2] == 'reserved_np' + assert aliases[3] == 'p_123_456' + assert result[4]['display_name'] == '!!!' + assert aliases[4] == 'param' + assert result[5]['dependency'] == 'external+1' + assert result[6]['enabled'] is True + + +def test_parameters_special_filters_and_invalid_variability_normalization(monkeypatch): + project = make_project() + logic = parameters_module.Parameters(project) + mocked_parameters = [ + {'name': 'Cell length a', 'display_name': 'Cell length a', 'group': 'cell', 'unique_name': 'cell.a', 'fit': True, 'enabled': True, 'independent': True, 'alias': 'cell_a', 'object': object()}, + {'name': 'Atom site x', 'display_name': 'Atom site x', 'group': 'atom_site', 'unique_name': 'atom_site.x', 'fit': False, 'enabled': True, 'independent': True, 'alias': 'atom_site_x', 'object': object()}, + {'name': 'Biso', 'display_name': 'adp b_iso', 'group': 'thermal', 'unique_name': 'b_iso', 'fit': False, 'enabled': True, 'independent': True, 'alias': 'b_iso', 'object': object()}, + {'name': 'Instrument scale', 'display_name': 'Instrument scale', 'group': 'instrument', 'unique_name': 'scale', 'fit': True, 'enabled': True, 'independent': True, 'alias': 'scale', 'object': object()}, + {'name': 'Layer thickness', 'display_name': 'Layer thickness', 'group': 'layer', 'unique_name': 'layer.thickness', 'fit': True, 'enabled': True, 'independent': True, 'alias': 'thickness', 'object': object()}, + ] + monkeypatch.setattr(logic, 'all_parameters', lambda: mocked_parameters) + + assert logic.set_variability_filter_criteria('INVALID') is False + assert logic.variability_filter_criteria == 'all' + + logic.set_name_filter_criteria('model') + assert [entry['display_name'] for entry in logic.parameters] == ['Cell length a', 'Atom site x', 'adp b_iso', 'Layer thickness'] + + logic.set_name_filter_criteria('experiment') + assert [entry['display_name'] for entry in logic.parameters] == ['Instrument scale'] + + logic.set_name_filter_criteria('cell') + assert [entry['display_name'] for entry in logic.parameters] == ['Cell length a'] + + logic.set_name_filter_criteria('atom_site') + assert [entry['display_name'] for entry in logic.parameters] == ['Atom site x'] + + logic.set_name_filter_criteria('b_iso') + assert [entry['display_name'] for entry in logic.parameters] == ['adp b_iso'] + + +def test_parameter_current_selection_edge_cases_and_unsupported_constraint(monkeypatch, capsys): + project = make_project() + logic = parameters_module.Parameters(project) + parameter = make_parameter(name='Scale', unique_name='scale', value=1.0, free=True) + monkeypatch.setattr( + logic, + 'all_parameters', + lambda: [ + { + 'name': 'Scale', + 'display_name': 'Scale', + 'group': 'Instrument', + 'unique_name': 'scale', + 'fit': True, + 'enabled': True, + 'independent': True, + 'alias': 'scale', + 'object': parameter, + } + ], + ) + + assert logic.set_current_index(0) is False + assert logic.set_current_index(5) is True + assert logic.set_current_parameter_value('2.0') is False + assert logic.set_current_parameter_min('0.2') is False + assert logic.set_current_parameter_max('4.0') is False + + logic.set_current_index(0) + assert logic.set_current_parameter_fit(True) is False + + dependent = make_parameter(name='Background', unique_name='background', value=0.5) + project.parameters = [parameter, dependent] + logic.add_constraint(1, '=', 2.0, '', 0) + + # NOTE: The production code reports this error via print(). If the error reporting + # mechanism changes to logging, this assertion must be updated to use caplog instead. + captured = capsys.readouterr() + assert 'Unsupported type' in captured.out + assert dependent.independent is True diff --git a/tests/test_logic_project.py b/tests/test_logic_project.py new file mode 100644 index 00000000..699fa613 --- /dev/null +++ b/tests/test_logic_project.py @@ -0,0 +1,113 @@ +from pathlib import Path + +from EasyReflectometryApp.Backends.Py.logic.project import Project +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def make_project_with_sample(): + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer')]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer')]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer')]), + ) + model = make_model(sample=sample) + materials = make_material_collection(make_material('Air'), make_material('SiO2'), make_material('Si'), make_material('D2O')) + return make_project(materials=materials, models=make_model_collection(model)) + + +def test_project_constructor_initializes_default_model_and_fixed_layer_enablement(): + project_lib = make_project_with_sample() + + logic = Project(project_lib) + + assert ('default_model',) in project_lib.calls + assert logic.created is False + assert project_lib.models[0].sample[0].layers[0].thickness.enabled is False + assert project_lib.models[0].sample[0].layers[0].roughness.enabled is False + assert project_lib.models[0].sample[-1].layers[-1].thickness.enabled is False + + +def test_project_metadata_and_q_properties_delegate(): + project_lib = make_project_with_sample() + logic = Project(project_lib) + + assert logic.path == str(project_lib.path) + assert logic.root_path == str(project_lib.path.parent) + logic.root_path = 'C:/new/location/project.json' + assert project_lib.calls[-1] == ('set_path_project_parent', Path('C:/new/location')) + + logic.name = 'Updated Project' + logic.description = 'Updated Description' + assert logic.name == 'Updated Project' + assert logic.description == 'Updated Description' + assert logic.creation_date == '2026-03-19' + + assert logic.set_q_min('0.02') is True + assert logic.set_q_min('0.02') is False + assert logic.set_q_max('0.7') is True + assert logic.set_q_resolution('400') is True + assert logic.q_min == 0.02 + assert logic.q_max == 0.7 + assert logic.q_resolution == 400 + + +def test_project_info_and_delegated_file_operations(): + project_lib = make_project_with_sample() + logic = Project(project_lib) + + info = logic.info() + assert info['name'] == 'Demo Project' + assert info['location'] == project_lib.path + + logic.create() + logic.save() + logic.load('demo.json') + logic.load_experiment('exp.ort') + logic.load_new_experiment('new.ort') + assert logic.count_datasets_in_file('file.ort') == 3 + assert logic.load_all_experiments_from_file('file.ort') == 2 + + assert ('create',) in project_lib.calls + assert ('save_as_json', False) in project_lib.calls + assert ('save_as_json', True) in project_lib.calls + assert ('load_from_json', 'demo.json') in project_lib.calls + assert ('load_experiment_for_model_at_index', 'exp.ort', 0) in project_lib.calls + assert ('load_new_experiment', 'new.ort') in project_lib.calls + + +def test_project_experimental_data_and_orso_model_updates(): + project_lib = make_project_with_sample() + logic = Project(project_lib) + + assert logic.experimental_data_at_current_index is True + project_lib.current_model_index = 5 + assert logic.experimental_data_at_current_index is False + + added_model = make_model(sample=make_sample(make_assembly(), make_assembly(), make_assembly())) + logic.add_sample_from_orso(added_model) + assert project_lib.models[-1].sample[0].layers[0].thickness.enabled is False + + replacement_model = make_model(sample=make_sample(make_assembly(), make_assembly(), make_assembly())) + logic.replace_models_from_orso(replacement_model) + assert project_lib.models[0].sample[0].layers[0].thickness.enabled is False + + logic.set_sample_from_orso('sample') + assert ('set_sample_from_orso', 'sample') in project_lib.calls + + +def test_project_reset_calls_reset_and_default_model(): + project_lib = make_project_with_sample() + logic = Project(project_lib) + project_lib.calls.clear() + + logic.reset() + + assert project_lib.calls == [('reset',), ('default_model',)] + diff --git a/tests/test_logic_status.py b/tests/test_logic_status.py new file mode 100644 index 00000000..1dbc499f --- /dev/null +++ b/tests/test_logic_status.py @@ -0,0 +1,35 @@ +from EasyReflectometryApp.Backends.Py.logic.status import Status +from tests.factories import FakeMinimizerValue +from tests.factories import make_project + + +class StubMinimizersLogic: + def __init__(self, available, index): + self._available = available + self._index = index + + def minimizers_available(self): + return self._available + + def minimizer_current_index(self): + return self._index + + +def test_status_uses_minimizer_logic_when_present(): + project = make_project(experiments={1: object(), 3: object()}, minimizer_name='ProjectDefault') + status = Status(project) + status.set_minimizers_logic(StubMinimizersLogic(['A', 'B'], 1)) + + assert status.project == 'Demo Project' + assert status.minimizer == 'B' + assert status.calculator == 'refnx' + assert status.experiments_count == '2' + + +def test_status_falls_back_to_project_minimizer_for_invalid_index(): + project = make_project() + project.minimizer = FakeMinimizerValue('FallbackMinimizer') + status = Status(project) + status.set_minimizers_logic(StubMinimizersLogic(['A'], 10)) + + assert status.minimizer == 'FallbackMinimizer' diff --git a/tests/test_logic_summary.py b/tests/test_logic_summary.py new file mode 100644 index 00000000..8cc40e13 --- /dev/null +++ b/tests/test_logic_summary.py @@ -0,0 +1,267 @@ +from types import SimpleNamespace + +import numpy as np + +from EasyReflectometryApp.Backends.Py.logic import summary as summary_module +from tests.factories import make_assembly +from tests.factories import make_experiment +from tests.factories import make_layer +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +class FakeSummaryLib: + def __init__(self, project_lib): + self.project_lib = project_lib + self.saved_pdf_path = None + + def compile_html_summary(self, figures=False): + suffix = ' with figures' if figures else '' + return f'

Base{suffix}

' + + def save_pdf_summary(self, path): + self.saved_pdf_path = path + + +class FakeCalculatorRuntime: + def reflectity_profile(self, x, unique_name): + return np.asarray(x) * 0 + 0.25 + + +class FakeCalculatorFactory: + def __call__(self): + return FakeCalculatorRuntime() + + +class FakeAxis: + def __init__(self): + self.plot_calls = [] + self.errorbar_calls = [] + self.legend_called = False + self.labels = {} + + def set_xlabel(self, value): + self.labels['xlabel'] = value + + def set_ylabel(self, value): + self.labels['ylabel'] = value + + def set_yscale(self, value): + self.labels['yscale'] = value + + def errorbar(self, *args, **kwargs): + self.errorbar_calls.append((args, kwargs)) + + def plot(self, *args, **kwargs): + self.plot_calls.append((args, kwargs)) + + def has_data(self): + return bool(self.plot_calls or self.errorbar_calls) + + def legend(self, **kwargs): + self.legend_called = True + + +class FakeFigure: + def __init__(self): + self.axes = [FakeAxis(), FakeAxis()] + self.saved = None + self._index = 0 + + def add_subplot(self, *_args, **_kwargs): + axis = self.axes[self._index] + self._index += 1 + return axis + + def savefig(self, path, dpi): + self.saved = (path, dpi) + + +class FakePyplot: + def __init__(self): + self.figure_obj = None + self.closed = None + self.show_called = False + + def figure(self, **_kwargs): + self.figure_obj = FakeFigure() + return self.figure_obj + + def close(self, figure): + self.closed = figure + + def show(self): + self.show_called = True + + +class FakeGridSpecModule: + class _GridSpec: + def __getitem__(self, item): + return item + + @staticmethod + def GridSpec(*_args, **_kwargs): + return FakeGridSpecModule._GridSpec() + + +def make_summary_project(tmp_path): + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top')]), + make_assembly(name='Middle', layers=[make_layer(name='Middle')]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom')]), + ) + models = make_model_collection(make_model(name='Model <1>', unique_name='m1', sample=sample, color='#123456')) + project = make_project(models=models) + project.path = tmp_path / 'report-dir' + project._calculator = FakeCalculatorFactory() + project.experiments = {2: make_experiment('Exp <1>', model=models[0], x=np.array([0.1, 0.2]), y=np.array([1.0, 2.0]), ye=np.array([0.1, 0.2]))} + project._experiments = project.experiments + project.sample_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([0.1]), y=np.array([1.0])) + project.sld_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([1.0, 2.0]), y=np.array([3.0, 4.0])) + return project + + +def test_summary_html_and_save_operations(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_summary_project(tmp_path) + logic = summary_module.Summary(project) + + html = logic.as_html + assert 'All Samples' in html + assert 'All Experiments' in html + assert 'Model <1>' in html + assert 'Exp <1>' in html + + logic.save_as_html() + html_path = project.path / 'summary.html' + assert html_path.exists() + assert 'Base with figures' in html_path.read_text(encoding='utf-8') + + logic.save_as_pdf() + assert logic._summary.saved_pdf_path == project.path / 'summary.pdf' + + +def test_summary_make_plot_save_plot_and_show_plot(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_summary_project(tmp_path) + logic = summary_module.Summary(project) + fake_pyplot = FakePyplot() + monkeypatch.setattr(logic, '_plt', lambda: fake_pyplot) + monkeypatch.setattr(logic, '_gridspec', lambda: FakeGridSpecModule) + + figure = logic.make_plot(10.0, 8.0) + + reflectivity_axis, sld_axis = figure.axes + assert reflectivity_axis.errorbar_calls + assert len(reflectivity_axis.plot_calls) == 1 + assert sld_axis.plot_calls + assert reflectivity_axis.legend_called is True + + target = tmp_path / 'plots' / 'plot.png' + logic.save_plot(str(target), 10.0, 8.0) + assert fake_pyplot.figure_obj.saved == (target, 600) + assert fake_pyplot.closed is fake_pyplot.figure_obj + + logic.show_plot(10.0, 8.0) + assert fake_pyplot.show_called is True + + +def test_summary_ordering_and_empty_sections(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_project(models=make_model_collection()) + project.path = tmp_path / 'empty-report' + project.experiments = {} + project._experiments = {} + logic = summary_module.Summary(project) + + assert logic._ordered_experiments() == [] + assert logic._all_models_section_html() == '

All Samples

No samples available.

' + assert logic._all_experiments_section_html() == '

All Experiments

No experiments available.

' + + +def test_summary_injection_and_explicit_paths(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_summary_project(tmp_path) + logic = summary_module.Summary(project) + logic.file_name = 'custom-summary' + logic.plot_file_name = 'custom-plots' + + injected = logic._inject_multimodel_multiexperiment_sections('
base
') + + assert 'All Samples' in injected + assert 'All Experiments' in injected + assert logic.file_path == project.path / 'custom-summary' + assert logic.plot_file_path == project.path / 'custom-plots' + + html_target = tmp_path / 'explicit' / 'report.html' + pdf_target = tmp_path / 'explicit' / 'report.pdf' + logic.save_as_html(str(html_target)) + logic.save_as_pdf(str(pdf_target)) + + assert html_target.exists() + assert logic._summary.saved_pdf_path == pdf_target + + +def test_summary_experiment_section_handles_empty_names_missing_models_and_nan_ranges(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_project(models=make_model_collection()) + project.path = tmp_path / 'report' + project.experiments = [make_experiment('', model=None, x=np.array([]), y=np.array([]), ye=np.array([]))] + project._experiments = project.experiments + logic = summary_module.Summary(project) + + html = logic._all_experiments_section_html() + + assert 'Experiment 1' in html + assert 'N/A' in html + assert 'nan' in html + + +def test_summary_make_plot_uses_plain_plot_without_valid_errors_and_sample_fallback(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_summary_project(tmp_path) + project.experiments = [ + make_experiment('No Errors', model=project.models[0], x=np.array([0.1, 0.2]), y=np.array([1.0, 2.0]), ye=None), + make_experiment('Mismatched Errors', model=project.models[0], x=np.array([0.3, 0.4]), y=np.array([3.0, 4.0]), ye=np.array([0.5])), + ] + project._experiments = project.experiments + logic = summary_module.Summary(project) + fake_pyplot = FakePyplot() + monkeypatch.setattr(logic, '_plt', lambda: fake_pyplot) + monkeypatch.setattr(logic, '_gridspec', lambda: FakeGridSpecModule) + + figure = logic.make_plot(10.0, 8.0) + reflectivity_axis = figure.axes[0] + assert reflectivity_axis.errorbar_calls == [] + assert len(reflectivity_axis.plot_calls) == 4 + + project.experiments = [] + project._experiments = [] + project.sample_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([0.1, 0.2]), y=np.array([2.0, 3.0])) + project.sld_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([]), y=np.array([])) + figure = logic.make_plot(10.0, 8.0) + reflectivity_axis = figure.axes[0] + assert reflectivity_axis.legend_called is True + + +def test_summary_make_plot_skips_empty_series_and_does_not_add_legend_without_reflectivity(tmp_path, monkeypatch): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = make_project(models=make_model_collection(make_model(name='Model A', color=''))) + project.path = tmp_path / 'empty-plots' + project.experiments = [] + project._experiments = [] + project.sample_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([]), y=np.array([])) + project.sld_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([]), y=np.array([])) + logic = summary_module.Summary(project) + fake_pyplot = FakePyplot() + monkeypatch.setattr(logic, '_plt', lambda: fake_pyplot) + monkeypatch.setattr(logic, '_gridspec', lambda: FakeGridSpecModule) + + figure = logic.make_plot(10.0, 8.0) + reflectivity_axis = figure.axes[0] + + assert reflectivity_axis.plot_calls == [] + assert reflectivity_axis.errorbar_calls == [] + assert reflectivity_axis.legend_called is False diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py new file mode 100644 index 00000000..044b68b6 --- /dev/null +++ b/tests/test_py_backend.py @@ -0,0 +1,213 @@ +from types import SimpleNamespace + +from PySide6.QtCore import QObject +from PySide6.QtCore import Signal + +from EasyReflectometryApp.Backends.Py import py_backend as backend_module + + +class StubLoggerLevelHandler: + def __init__(self, parent): + self.parent = parent + + +class StubHome(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + +class StubProject(QObject): + externalNameChanged = Signal() + externalCreatedChanged = Signal() + externalProjectLoaded = Signal() + externalProjectReset = Signal() + + def __init__(self, _project_lib, parent=None): + super().__init__(parent) + + +class StubSample(QObject): + externalSampleChanged = Signal() + externalRefreshPlot = Signal() + modelsTableChanged = Signal() + materialsTableChanged = Signal() + modelsIndexChanged = Signal() + assembliesTableChanged = Signal() + assembliesIndexChanged = Signal() + + def __init__(self, _project_lib): + super().__init__() + self.clear_calls = 0 + + def _clearCacheAndEmitLayersChanged(self): + self.clear_calls += 1 + + +class StubExperiment(QObject): + externalExperimentChanged = Signal() + experimentChanged = Signal() + + def __init__(self, _project_lib): + super().__init__() + + +class StubAnalysis(QObject): + externalMinimizerChanged = Signal() + externalCalculatorChanged = Signal() + externalParametersChanged = Signal() + externalFittingChanged = Signal() + externalExperimentChanged = Signal() + experimentsChanged = Signal() + parametersChanged = Signal() + + def __init__(self, _project_lib, parent=None): + super().__init__(parent) + self._minimizers_logic = object() + self._selected = [0] + self.received_indices = None + self.clear_calls = 0 + + @property + def experimentsSelectedCount(self): + return len(self._selected) + + @property + def selectedExperimentIndices(self): + return self._selected + + def setSelectedExperimentIndices(self, indices): + self.received_indices = indices + self._selected = list(indices) + + def _clearCacheAndEmitParametersChanged(self): + self.clear_calls += 1 + + +class StubSummary(QObject): + createdChanged = Signal() + summaryChanged = Signal() + + def __init__(self, _project_lib, parent=None): + super().__init__(parent) + + +class StubStatusLogic: + def __init__(self): + self.minimizers_logic = None + + def set_minimizers_logic(self, value): + self.minimizers_logic = value + + +class StubStatus(QObject): + statusChanged = Signal() + + def __init__(self, _project_lib): + super().__init__() + self._status_logic = StubStatusLogic() + + +class StubPlotting(QObject): + sampleChartRangesChanged = Signal() + sldChartRangesChanged = Signal() + experimentChartRangesChanged = Signal() + samplePageResetAxes = Signal() + + def __init__(self, _project_lib, parent=None): + super().__init__(parent) + self.reset_calls = 0 + self.refresh_calls = {'sample': 0, 'experiment': 0, 'analysis': 0} + self._multi = True + self._individual = [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}] + + @property + def isMultiExperimentMode(self): + return self._multi + + @property + def individualExperimentDataList(self): + return self._individual + + def getExperimentDataPoints(self, experiment_index): + return [{'x': float(experiment_index), 'y': 0.0}] + + def getAnalysisDataPoints(self, experiment_index): + return [{'x': float(experiment_index), 'measured': 0.0, 'calculated': 0.0}] + + def reset_data(self): + self.reset_calls += 1 + + def refreshSamplePage(self): + self.refresh_calls['sample'] += 1 + + def refreshExperimentPage(self): + self.refresh_calls['experiment'] += 1 + + def refreshAnalysisPage(self): + self.refresh_calls['analysis'] += 1 + + +def _make_backend(monkeypatch): + monkeypatch.setattr(backend_module, 'ProjectLib', lambda: object()) + monkeypatch.setattr(backend_module, 'Home', StubHome) + monkeypatch.setattr(backend_module, 'Project', StubProject) + monkeypatch.setattr(backend_module, 'Sample', StubSample) + monkeypatch.setattr(backend_module, 'Experiment', StubExperiment) + monkeypatch.setattr(backend_module, 'Analysis', StubAnalysis) + monkeypatch.setattr(backend_module, 'Summary', StubSummary) + monkeypatch.setattr(backend_module, 'Status', StubStatus) + monkeypatch.setattr(backend_module, 'Plotting1d', StubPlotting) + monkeypatch.setattr(backend_module, 'LoggerLevelHandler', StubLoggerLevelHandler) + return backend_module.PyBackend() + + +def test_backend_constructor_wires_minimizers_logic(monkeypatch, qcore_application): + backend = _make_backend(monkeypatch) + + assert backend._status._status_logic.minimizers_logic is backend._analysis._minimizers_logic + + +def test_analysis_selection_bridge_updates_analysis_and_emits_signal(monkeypatch, qcore_application): + backend = _make_backend(monkeypatch) + count = {'changed': 0} + backend.multiExperimentSelectionChanged.connect(lambda: count.__setitem__('changed', count['changed'] + 1)) + + backend.analysisSetSelectedExperimentIndices((2, 4)) + + assert backend._analysis.received_indices == [2, 4] + assert backend.analysisExperimentsSelectedCount == 2 + assert backend.analysisSelectedExperimentIndices == [2, 4] + assert count['changed'] == 1 + + +def test_backend_relay_project_changed_triggers_refresh_chain(monkeypatch, qcore_application): + backend = _make_backend(monkeypatch) + counts = {'status': 0, 'summary': 0, 'axes': 0} + backend._status.statusChanged.connect(lambda: counts.__setitem__('status', counts['status'] + 1)) + backend._summary.summaryChanged.connect(lambda: counts.__setitem__('summary', counts['summary'] + 1)) + backend._plotting_1d.samplePageResetAxes.connect(lambda: counts.__setitem__('axes', counts['axes'] + 1)) + + backend._relay_project_page_project_changed() + + assert backend._sample.clear_calls == 1 + assert backend._analysis.clear_calls == 1 + assert backend._plotting_1d.reset_calls == 1 + assert backend._plotting_1d.refresh_calls == {'sample': 1, 'experiment': 1, 'analysis': 1} + assert counts == {'status': 1, 'summary': 1, 'axes': 1} + + +def test_backend_refresh_plots_emits_ranges_and_multi_signal(monkeypatch, qcore_application): + backend = _make_backend(monkeypatch) + counts = {'sample': 0, 'sld': 0, 'exp': 0, 'multi': 0} + backend._plotting_1d.sampleChartRangesChanged.connect(lambda: counts.__setitem__('sample', counts['sample'] + 1)) + backend._plotting_1d.sldChartRangesChanged.connect(lambda: counts.__setitem__('sld', counts['sld'] + 1)) + backend._plotting_1d.experimentChartRangesChanged.connect(lambda: counts.__setitem__('exp', counts['exp'] + 1)) + backend.multiExperimentSelectionChanged.connect(lambda: counts.__setitem__('multi', counts['multi'] + 1)) + + backend._refresh_plots() + + assert counts == {'sample': 1, 'sld': 1, 'exp': 1, 'multi': 1} + assert backend.plottingIsMultiExperimentMode is True + assert backend.plottingIndividualExperimentDataList == [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}] + assert backend.plottingGetExperimentDataPoints(3) == [{'x': 3.0, 'y': 0.0}] + assert backend.plottingGetAnalysisDataPoints(5) == [{'x': 5.0, 'measured': 0.0, 'calculated': 0.0}] diff --git a/tests/test_py_experiment.py b/tests/test_py_experiment.py new file mode 100644 index 00000000..9f3277f4 --- /dev/null +++ b/tests/test_py_experiment.py @@ -0,0 +1,93 @@ +from EasyReflectometryApp.Backends.Py import experiment as experiment_module + + +class StubModelsLogic: + def __init__(self, _project_lib): + self.scaling_at_current_index = 1.5 + self.background_at_current_index = 0.02 + self.resolution_at_current_index = '2%' + self.index = 0 + self.set_scaling_result = True + self.set_background_result = True + self.last_scaling = None + self.last_background = None + + def set_scaling_at_current_index(self, value): + self.last_scaling = value + return self.set_scaling_result + + def set_background_at_current_index(self, value): + self.last_background = value + return self.set_background_result + + +class StubProjectLogic: + def __init__(self, _project_lib): + self.experimental_data_at_current_index = True + self.dataset_counts = {} + self.loaded_all = [] + self.loaded_new = [] + + def count_datasets_in_file(self, path): + return self.dataset_counts.get(path, 1) + + def load_all_experiments_from_file(self, path): + self.loaded_all.append(path) + + def load_new_experiment(self, path): + self.loaded_new.append(path) + + +def _build_experiment(monkeypatch): + monkeypatch.setattr(experiment_module, 'ModelsLogic', StubModelsLogic) + monkeypatch.setattr(experiment_module, 'ProjectLogic', StubProjectLogic) + return experiment_module.Experiment(project_lib=object()) + + +def test_experiment_properties_and_set_model_index(monkeypatch, qcore_application): + experiment = _build_experiment(monkeypatch) + + assert experiment.scaling == 1.5 + assert experiment.background == 0.02 + assert experiment.resolution == '2%' + assert experiment.experimentalData is True + + experiment.setModelIndex(3) + assert experiment._model_logic.index == 3 + + +def test_setters_emit_signals_only_when_logic_accepts_change(monkeypatch, qcore_application): + experiment = _build_experiment(monkeypatch) + changed = {'experiment': 0, 'external': 0} + experiment.experimentChanged.connect(lambda: changed.__setitem__('experiment', changed['experiment'] + 1)) + experiment.externalExperimentChanged.connect(lambda: changed.__setitem__('external', changed['external'] + 1)) + + experiment.setScaling(2.5) + experiment.setBackground(0.1) + + assert experiment._model_logic.last_scaling == 2.5 + assert experiment._model_logic.last_background == 0.1 + assert changed == {'experiment': 2, 'external': 2} + + experiment._model_logic.set_scaling_result = False + experiment._model_logic.set_background_result = False + experiment.setScaling(7.0) + experiment.setBackground(5.0) + + assert changed == {'experiment': 2, 'external': 2} + + +def test_load_routes_single_vs_multi_dataset_paths(monkeypatch, qcore_application): + experiment = _build_experiment(monkeypatch) + experiment._project_logic.dataset_counts = {'A': 2, 'B': 1} + monkeypatch.setattr(experiment_module.IO, 'generalizePath', lambda path: path) + + changed = {'experiment': 0, 'external': 0} + experiment.experimentChanged.connect(lambda: changed.__setitem__('experiment', changed['experiment'] + 1)) + experiment.externalExperimentChanged.connect(lambda: changed.__setitem__('external', changed['external'] + 1)) + + experiment.load('A,B') + + assert experiment._project_logic.loaded_all == ['A'] + assert experiment._project_logic.loaded_new == ['B'] + assert changed == {'experiment': 2, 'external': 2} diff --git a/tests/test_py_helpers.py b/tests/test_py_helpers.py new file mode 100644 index 00000000..2086bf0f --- /dev/null +++ b/tests/test_py_helpers.py @@ -0,0 +1,54 @@ +import warnings + +from EasyReflectometryApp.Backends.Py import helpers as helpers_module + + +def test_generalize_path_non_windows_returns_parsed_path(monkeypatch): + monkeypatch.setattr(helpers_module.sys, 'platform', 'linux') + + result = helpers_module.IO.generalizePath('file:///tmp/demo/file.dat') + + assert result == '/tmp/demo/file.dat' + + +def test_generalize_path_windows_strips_leading_slash_and_normalizes(monkeypatch): + monkeypatch.setattr(helpers_module.sys, 'platform', 'win32') + + result = helpers_module.IO.generalizePath('/C:/demo/folder/file.dat') + + assert result == 'C:\\demo\\folder\\file.dat' + + +def test_local_file_to_url_windows_branch(monkeypatch): + monkeypatch.setattr(helpers_module.sys, 'platform', 'win32') + + result = helpers_module.IO.localFileToUrl('C:/demo/folder/file.dat') + + assert result.startswith('file:///') + assert '/demo/folder/file.dat' in result + + +def test_to_std_dev_smallest_precision_for_large_std_dev(): + value_str, std_dev_str, combined = helpers_module.IO.toStdDevSmalestPrecision(12.7, 2.6) + + assert value_str == '13' + assert std_dev_str == '3' + assert combined == '13(3)' + + +def test_to_std_dev_smallest_precision_for_fractional_std_dev(): + value_str, std_dev_str, combined = helpers_module.IO.toStdDevSmalestPrecision(12.345, 0.034) + + assert value_str == '12.35' + assert std_dev_str == '0.03' + assert combined == '12.35(3)' + + +def test_old_precision_formatter_still_returns_three_parts(): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + value_str, std_dev_str, combined = helpers_module.IO.toStdDevSmalestPrecision_OLD(1.23, 0.04) + + assert value_str + assert std_dev_str + assert '(' in combined and ')' in combined diff --git a/tests/test_py_home.py b/tests/test_py_home.py new file mode 100644 index 00000000..2bcd6835 --- /dev/null +++ b/tests/test_py_home.py @@ -0,0 +1,23 @@ +from EasyReflectometryApp.Backends.Py.home import Home + + +def test_home_version_contains_number_and_date(qcore_application): + home = Home() + + version = home.version + + assert set(version.keys()) == {'number', 'date'} + assert isinstance(version['number'], str) + assert isinstance(version['date'], str) + assert version['number'] + + +def test_home_urls_contains_expected_keys(qcore_application): + home = Home() + + urls = home.urls + + assert set(urls.keys()) == {'homepage', 'issues', 'license', 'documentation', 'dependencies'} + for value in urls.values(): + assert isinstance(value, str) + assert value diff --git a/tests/test_py_plotting_1d.py b/tests/test_py_plotting_1d.py new file mode 100644 index 00000000..4731c002 --- /dev/null +++ b/tests/test_py_plotting_1d.py @@ -0,0 +1,196 @@ +from types import SimpleNamespace + +import numpy as np +from PySide6.QtCore import QObject + +from EasyReflectometryApp.Backends.Py.plotting_1d import Plotting1d + + +class FakeSeries: + def __init__(self): + self.clears = 0 + self.points = [] + + def clear(self): + self.clears += 1 + self.points.clear() + + def append(self, x, y): + self.points.append((x, y)) + + +class FakeData: + def __init__(self, x=None, y=None, ye=None, model=None): + self.x = np.asarray([] if x is None else x) + self.y = np.asarray([] if y is None else y) + self.ye = np.asarray([] if ye is None else ye) + self.model = model + + def data_points(self): + if self.ye.size > 0: + return list(zip(self.x, self.y, self.ye, strict=False)) + return list(zip(self.x, self.y, strict=False)) + + +class FakeProject: + def __init__(self): + self.current_model_index = 0 + self.current_experiment_index = 0 + self.q_min = 0.01 + self.q_max = 0.5 + self.models = [ + SimpleNamespace(color='#111111', scale=SimpleNamespace(value=0.0), background=SimpleNamespace(value=0.0)), + SimpleNamespace(color='#222222', scale=SimpleNamespace(value=2.0), background=SimpleNamespace(value=1e-6)), + ] + self._experiments = {0: object(), 1: object()} + self._sample = { + 0: FakeData(x=[0.1, 0.2], y=[1.0, 2.0]), + 1: FakeData(x=[0.05, 0.4], y=[3.0, 4.0]), + } + self._sld = { + 0: FakeData(x=[1.0, 2.0], y=[-0.5, 0.5]), + 1: FakeData(x=[1.5, 3.0], y=[-1.0, 1.0]), + } + self._exp = { + 0: FakeData(x=[0.1, 0.2], y=[1e-6, 1e-5], ye=[1e-8, 1e-8], model=self.models[0]), + 1: FakeData(x=[0.15, 0.3], y=[2e-6, 3e-5], ye=[1e-8, 1e-8], model=self.models[1]), + } + + def sample_data_for_model_at_index(self, index): + return self._sample[index] + + def model_data_for_model_at_index(self, index, q_values=None): + if q_values is None: + return FakeData(x=[0.1, 0.2], y=[1e-6, 2e-6]) + return FakeData(x=q_values, y=np.ones_like(q_values) * 2e-6) + + def sld_data_for_model_at_index(self, index): + return self._sld[index] + + def experimental_data_for_model_at_index(self, index): + return self._exp[index] + + +class FakeAnalysisProxy: + def __init__(self, selected): + self._selected_experiment_indices = selected + + def get_concatenated_experiment_data(self): + return FakeData(x=[0.1, 0.2, 0.3], y=[1e-6, 2e-6, 3e-6], ye=[1e-8, 1e-8, 1e-8]) + + def get_individual_experiment_data_list(self): + return [ + {'name': 'E0', 'color': '#111111', 'index': 0, 'data': FakeData(x=[0.1], y=[1e-6], ye=[1e-8])}, + {'name': 'E1', 'color': '#222222', 'index': 1, 'data': FakeData(x=[0.2], y=[2e-6], ye=[1e-8])}, + ] + + +class FakeBackendParent(QObject): + def __init__(self, selected): + super().__init__() + self._analysis = FakeAnalysisProxy(selected) + + +def _make_plotting(selected=None): + project = FakeProject() + proxy = FakeBackendParent([0] if selected is None else selected) + plotting = Plotting1d(project, parent=proxy) + plotting._chartRefs['QtCharts']['experimentPage']['measuredSerie'] = FakeSeries() + plotting._chartRefs['QtCharts']['experimentPage']['errorUpperSerie'] = FakeSeries() + plotting._chartRefs['QtCharts']['experimentPage']['errorLowerSerie'] = FakeSeries() + plotting._chartRefs['QtCharts']['analysisPage']['measuredSerie'] = FakeSeries() + plotting._chartRefs['QtCharts']['analysisPage']['calculatedSerie'] = FakeSeries() + return plotting, project + + +def test_plotting_mode_and_axis_toggles_emit_signals(qcore_application): + plotting, _project = _make_plotting() + counts = {'plot': 0, 'axis': 0, 'sld': 0, 'ref': 0} + plotting.plotModeChanged.connect(lambda: counts.__setitem__('plot', counts['plot'] + 1)) + plotting.axisTypeChanged.connect(lambda: counts.__setitem__('axis', counts['axis'] + 1)) + plotting.sldAxisReversedChanged.connect(lambda: counts.__setitem__('sld', counts['sld'] + 1)) + plotting.referenceLineVisibilityChanged.connect(lambda: counts.__setitem__('ref', counts['ref'] + 1)) + + assert plotting.plotRQ4 is False + assert plotting.yMainAxisTitle == 'R(q)' + plotting.togglePlotRQ4() + assert plotting.plotRQ4 is True + assert plotting.yMainAxisTitle == 'R(q)×q⁴' + + assert plotting.xAxisType == 'linear' + plotting.toggleXAxisType() + assert plotting.xAxisType == 'log' + + assert plotting.sldXDataReversed is False + plotting.reverseSldXData() + assert plotting.sldXDataReversed is True + + assert plotting.scaleShown is False + assert plotting.bkgShown is False + plotting.flipScaleShown() + plotting.flipBkgShown() + assert plotting.scaleShown is True + assert plotting.bkgShown is True + assert counts == {'plot': 1, 'axis': 1, 'sld': 1, 'ref': 2} + + +def test_plotting_reference_lines_use_defaults_and_visibility(qcore_application): + plotting, _project = _make_plotting() + + assert plotting.getBackgroundData() == [] + assert plotting.getScaleData() == [] + + plotting.flipBkgShown() + plotting.flipScaleShown() + bkg = plotting.getBackgroundData() + scale = plotting.getScaleData() + + assert len(bkg) == 2 + assert len(scale) == 2 + assert bkg[0]['y'] == -10.0 + assert scale[0]['y'] == 0.0 + + bkg_analysis = plotting.getBackgroundDataForAnalysis() + scale_analysis = plotting.getScaleDataForAnalysis() + assert len(bkg_analysis) == 2 + assert len(scale_analysis) == 2 + + +def test_plotting_empty_range_fallbacks_and_experiment_min_y(qcore_application): + plotting, project = _make_plotting() + project._sample = {0: FakeData(), 1: FakeData()} + project._sld = {0: FakeData(), 1: FakeData()} + project._exp = {0: FakeData(x=[0.1, 0.2], y=[0.0, -1.0], ye=[0.0, 0.0])} + + assert plotting.sampleMinX == 0.0 + assert plotting.sampleMaxX == 1.0 + assert plotting.sampleMinY == -10.0 + assert plotting.sampleMaxY == 0.0 + + assert plotting.sldMinX == 0.0 + assert plotting.sldMaxX == 1.0 + assert plotting.sldMinY == -1.0 + assert plotting.sldMaxY == 1.0 + + assert plotting.experimentMinY == -10.0 + + +def test_plotting_multi_experiment_mode_refreshes_via_signal(qcore_application): + plotting, _project = _make_plotting(selected=[0, 1]) + count = {'changed': 0} + plotting.experimentDataChanged.connect(lambda: count.__setitem__('changed', count['changed'] + 1)) + + assert plotting.isMultiExperimentMode is True + assert len(plotting.individualExperimentDataList) == 2 + + plotting.drawMeasuredOnExperimentChart() + plotting.drawCalculatedAndMeasuredOnAnalysisChart() + + assert count['changed'] == 2 + + +def test_plotting_get_model_color_fallback(qcore_application): + plotting, _project = _make_plotting() + + assert plotting.getModelColor(0) == '#111111' + assert plotting.getModelColor(100) == '#000000' diff --git a/tests/test_py_project.py b/tests/test_py_project.py new file mode 100644 index 00000000..7ee9056b --- /dev/null +++ b/tests/test_py_project.py @@ -0,0 +1,122 @@ +import warnings + +from EasyReflectometryApp.Backends.Py import project as project_module + + +class StubProjectLogic: + def __init__(self, _project_lib): + self.created = False + self.creation_date = '2026-03-22' + self.path = 'project.json' + self.name = 'Demo' + self.description = 'Desc' + self.root_path = 'C:/work' + self.created_calls = 0 + self.loaded_paths = [] + self.saved_calls = 0 + self.reset_calls = 0 + self.added_samples = [] + self.replaced_samples = [] + + def create(self): + self.created_calls += 1 + self.created = True + + def load(self, path): + self.loaded_paths.append(path) + + def save(self): + self.saved_calls += 1 + + def reset(self): + self.reset_calls += 1 + + def add_sample_from_orso(self, sample): + self.added_samples.append(sample) + + def replace_models_from_orso(self, sample): + self.replaced_samples.append(sample) + + +def _build_project(monkeypatch): + monkeypatch.setattr(project_module, 'ProjectLogic', StubProjectLogic) + return project_module.Project(project_lib=object()) + + +def test_setters_emit_only_on_change(monkeypatch, qcore_application): + project = _build_project(monkeypatch) + counts = {'name': 0, 'external_name': 0, 'description': 0, 'location': 0} + project.nameChanged.connect(lambda: counts.__setitem__('name', counts['name'] + 1)) + project.externalNameChanged.connect(lambda: counts.__setitem__('external_name', counts['external_name'] + 1)) + project.descriptionChanged.connect(lambda: counts.__setitem__('description', counts['description'] + 1)) + project.locationChanged.connect(lambda: counts.__setitem__('location', counts['location'] + 1)) + + project.setName('Demo') + project.setDescription('Desc') + project.setLocation('C:/work') + assert counts == {'name': 0, 'external_name': 0, 'description': 0, 'location': 0} + + project.setName('Updated') + project.setDescription('Updated Desc') + project.setLocation('D:/new') + assert counts == {'name': 1, 'external_name': 1, 'description': 1, 'location': 1} + + +def test_load_create_reset_and_signals(monkeypatch, qcore_application): + project = _build_project(monkeypatch) + monkeypatch.setattr(project_module, 'generalizePath', lambda path: f'gen:{path}') + + counts = {'created': 0, 'external_created': 0, 'external_loaded': 0, 'external_reset': 0} + project.createdChanged.connect(lambda: counts.__setitem__('created', counts['created'] + 1)) + project.externalCreatedChanged.connect(lambda: counts.__setitem__('external_created', counts['external_created'] + 1)) + project.externalProjectLoaded.connect(lambda: counts.__setitem__('external_loaded', counts['external_loaded'] + 1)) + project.externalProjectReset.connect(lambda: counts.__setitem__('external_reset', counts['external_reset'] + 1)) + + project.create() + project.load('in.json') + project.save() + project.reset() + + assert project._logic.created_calls == 1 + assert project._logic.loaded_paths == ['gen:in.json'] + assert project._logic.saved_calls == 1 + assert project._logic.reset_calls == 1 + assert counts == {'created': 3, 'external_created': 2, 'external_loaded': 1, 'external_reset': 1} + + +def test_sample_load_append_and_replace(monkeypatch, qcore_application): + project = _build_project(monkeypatch) + monkeypatch.setattr(project_module, 'generalizePath', lambda path: f'gen:{path}') + monkeypatch.setattr(project_module.orso, 'load_orso', lambda path: f'orso:{path}') + monkeypatch.setattr(project_module, 'load_orso_model', lambda _orso_data: 'sample-model') + + loaded = {'count': 0} + project.externalProjectLoaded.connect(lambda: loaded.__setitem__('count', loaded['count'] + 1)) + + project.sampleLoad('sample.orso', append=True) + project.sampleLoad('sample.orso', append=False) + + assert project._logic.added_samples == ['sample-model'] + assert project._logic.replaced_samples == ['sample-model'] + assert loaded['count'] == 2 + + +def test_sample_load_emits_warning_when_model_missing(monkeypatch, qcore_application): + project = _build_project(monkeypatch) + monkeypatch.setattr(project_module, 'generalizePath', lambda path: path) + monkeypatch.setattr(project_module.orso, 'load_orso', lambda _path: 'orso-data') + + def _warn_and_return_none(_orso_data): + warnings.warn('Missing model in ORSO', stacklevel=1) + return None + + monkeypatch.setattr(project_module, 'load_orso_model', _warn_and_return_none) + + received = [] + project.sampleLoadWarning.connect(lambda msg: received.append(msg)) + + project.sampleLoad('sample.orso') + + assert project._logic.added_samples == [] + assert project._logic.replaced_samples == [] + assert received == ['Missing model in ORSO'] diff --git a/tests/test_py_status.py b/tests/test_py_status.py new file mode 100644 index 00000000..10d11a55 --- /dev/null +++ b/tests/test_py_status.py @@ -0,0 +1,37 @@ +from EasyReflectometryApp.Backends.Py import status as status_module + + +class StubStatusLogic: + def __init__(self, _project_lib): + self.project = 'Demo Project' + self.experiments_count = '4' + self.calculator = 'refnx' + self.minimizer = 'LeastSquares' + + +class StubParametersLogic: + def __init__(self, _project_lib): + self.as_status_string = '2 free / 10 total' + + +def test_status_wrapper_delegates_to_logic(monkeypatch, qcore_application): + monkeypatch.setattr(status_module, 'StatusLogic', StubStatusLogic) + monkeypatch.setattr(status_module, 'ParametersLogic', StubParametersLogic) + + status = status_module.Status(project_lib=object()) + + assert status.project == 'Demo Project' + assert status.experimentsCount == '4' + assert status.calculator == 'refnx' + assert status.minimizer == 'LeastSquares' + assert status.variables == '2 free / 10 total' + + +def test_status_wrapper_phase_count_is_none(monkeypatch, qcore_application): + monkeypatch.setattr(status_module, 'StatusLogic', StubStatusLogic) + monkeypatch.setattr(status_module, 'ParametersLogic', StubParametersLogic) + + status = status_module.Status(project_lib=object()) + + assert status.phaseCount is None + diff --git a/tests/test_workers_fitter_worker.py b/tests/test_workers_fitter_worker.py new file mode 100644 index 00000000..e2db5fcc --- /dev/null +++ b/tests/test_workers_fitter_worker.py @@ -0,0 +1,96 @@ +from EasyReflectometryApp.Backends.Py.workers.fitter_worker import FitterWorker +from tests.factories import make_worker_fitter + + +class SilentError(Exception): + def __str__(self): + return '' + + +def test_run_emits_finished_with_list_result(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result=[1, 2]), 'fit') + received = {'finished': None, 'failed': None} + + worker.finished.connect(lambda value: received.__setitem__('finished', value)) + worker.failed.connect(lambda value: received.__setitem__('failed', value)) + + worker.run() + + assert received['finished'] == [1, 2] + assert received['failed'] is None + + +def test_run_wraps_non_list_result(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + received = [] + worker.finished.connect(received.append) + + worker.run() + + assert received == [['ok']] + + +def test_run_emits_failed_when_stop_requested_before_start(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + received = [] + worker.failed.connect(received.append) + worker.stop() + + worker.run() + + assert received == ['Fitting cancelled before start'] + + +def test_run_emits_failed_for_missing_method(qcore_application): + worker = FitterWorker(object(), 'missing_method') + received = [] + worker.failed.connect(received.append) + + worker.run() + + assert received == ["Fitter has no method 'missing_method'"] + + +def test_run_emits_failed_for_exception_message(qcore_application): + worker = FitterWorker(make_worker_fitter(error=RuntimeError('boom')), 'fit') + received = [] + worker.failed.connect(received.append) + + worker.run() + + assert received == ['boom'] + + +def test_run_uses_fallback_message_for_empty_exception_string(qcore_application): + worker = FitterWorker(make_worker_fitter(error=SilentError()), 'fit') + received = [] + worker.failed.connect(received.append) + + worker.run() + + assert received == ['SilentError: Unknown error during fitting'] + + +def test_stop_sets_flag_without_terminating_idle_thread(qcore_application, monkeypatch): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + terminated = {'terminate': 0, 'wait': 0} + monkeypatch.setattr(worker, 'isRunning', lambda: False) + monkeypatch.setattr(worker, 'terminate', lambda: terminated.__setitem__('terminate', terminated['terminate'] + 1)) + monkeypatch.setattr(worker, 'wait', lambda: terminated.__setitem__('wait', terminated['wait'] + 1)) + + worker.stop() + + assert worker.stop_requested is True + assert terminated == {'terminate': 0, 'wait': 0} + + +def test_stop_terminates_running_thread(qcore_application, monkeypatch): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + terminated = {'terminate': 0, 'wait': 0} + monkeypatch.setattr(worker, 'isRunning', lambda: True) + monkeypatch.setattr(worker, 'terminate', lambda: terminated.__setitem__('terminate', terminated['terminate'] + 1)) + monkeypatch.setattr(worker, 'wait', lambda: terminated.__setitem__('wait', terminated['wait'] + 1)) + + worker.stop() + + assert terminated == {'terminate': 1, 'wait': 1}