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'
No samples available.
' + assert logic._all_experiments_section_html() == '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('