diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 0626505..fc2ea85 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -732,6 +732,135 @@ def _open_file(self, actionable, file_path, sep, mode): file_path, mode, sep ) + def _validate_yaml_structure(self, yaml_content): + """Validate PEtab YAML structure before attempting to load files. + + Parameters + ---------- + yaml_content : dict + The parsed YAML content. + + Returns + ------- + tuple + (is_valid: bool, errors: list[str]) + """ + errors = [] + + # Check format version + if "format_version" not in yaml_content: + errors.append("Missing 'format_version' field") + + # Check problems array + if "problems" not in yaml_content: + errors.append("Missing 'problems' field") + return False, errors + + if ( + not isinstance(yaml_content["problems"], list) + or not yaml_content["problems"] + ): + errors.append("'problems' must be a non-empty list") + return False, errors + + problem = yaml_content["problems"][0] + + # Optional but recommended fields + if ( + "visualization_files" not in problem + or not problem["visualization_files"] + ): + errors.append("Warning: No visualization_files specified") + + # Required fields in problem + for field in [ + "sbml_files", + "measurement_files", + "observable_files", + "condition_files", + ]: + if field not in problem or not problem[field]: + errors.append("Problem must contain at least one SBML file") + + # Check parameter_file (at root level) + if "parameter_file" not in yaml_content: + errors.append("Missing 'parameter_file' at root level") + + return len([e for e in errors if "Warning" not in e]) == 0, errors + + def _validate_files_exist(self, yaml_dir, yaml_content): + """Validate that all files referenced in YAML exist. + + Parameters + ---------- + yaml_dir : Path + The directory containing the YAML file. + yaml_content : dict + The parsed YAML content. + + Returns + ------- + tuple + (all_exist: bool, missing_files: list[str]) + """ + missing_files = [] + problem = yaml_content["problems"][0] + + # Check SBML files + for sbml_file in problem.get("sbml_files", []): + if not (yaml_dir / sbml_file).exists(): + missing_files.append(str(sbml_file)) + + # Check measurement files + for meas_file in problem.get("measurement_files", []): + if not (yaml_dir / meas_file).exists(): + missing_files.append(str(meas_file)) + + # Check observable files + for obs_file in problem.get("observable_files", []): + if not (yaml_dir / obs_file).exists(): + missing_files.append(str(obs_file)) + + # Check condition files + for cond_file in problem.get("condition_files", []): + if not (yaml_dir / cond_file).exists(): + missing_files.append(str(cond_file)) + + # Check parameter file + if "parameter_file" in yaml_content: + param_file = yaml_content["parameter_file"] + if not (yaml_dir / param_file).exists(): + missing_files.append(str(param_file)) + + # Check visualization files (optional) + for vis_file in problem.get("visualization_files", []): + if not (yaml_dir / vis_file).exists(): + missing_files.append(str(vis_file)) + + return len(missing_files) == 0, missing_files + + def _load_file_list(self, controller, file_list, file_type, yaml_dir): + """Load multiple files for a given controller. + + Parameters + ---------- + controller : object + The controller to load files into (e.g., measurement_controller). + file_list : list[str] + List of file names to load. + file_type : str + Human-readable file type for logging (e.g., "measurement"). + yaml_dir : Path + The directory containing the YAML and data files. + """ + for i, file_name in enumerate(file_list): + file_mode = "overwrite" if i == 0 else "append" + controller.open_table(yaml_dir / file_name, mode=file_mode) + self.logger.log_message( + f"Loaded {file_type} file ({i + 1}/{len(file_list)}): {file_name}", + color="blue", + ) + def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"): """Open files from a YAML configuration. @@ -749,62 +878,149 @@ def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"): if controller == self.sbml_controller: continue controller.release_completers() + # Load the YAML content - with open(yaml_path) as file: + with open(yaml_path, encoding="utf-8") as file: yaml_content = yaml.safe_load(file) + # Validate PEtab version if (major := get_major_version(yaml_content)) != 1: raise ValueError( f"Only PEtab v1 problems are currently supported. " f"Detected version: {major}.x." ) + # Validate YAML structure + is_valid, errors = self._validate_yaml_structure(yaml_content) + if not is_valid: + error_msg = "Invalid YAML structure:\n - " + "\n - ".join( + [e for e in errors if "Warning" not in e] + ) + self.logger.log_message(error_msg, color="red") + QMessageBox.critical( + self.view, "Invalid PEtab YAML", error_msg + ) + return + + # Log warnings but continue + warnings = [e for e in errors if "Warning" in e] + for warning in warnings: + self.logger.log_message(warning, color="orange") + # Resolve the directory of the YAML file to handle relative paths yaml_dir = Path(yaml_path).parent - # Upload SBML model - sbml_file_path = ( - yaml_dir / yaml_content["problems"][0]["sbml_files"][0] + # Validate file existence + all_exist, missing_files = self._validate_files_exist( + yaml_dir, yaml_content ) - self.sbml_controller.overwrite_sbml(sbml_file_path) - self.measurement_controller.open_table( - yaml_dir / yaml_content["problems"][0]["measurement_files"][0] - ) - self.observable_controller.open_table( - yaml_dir / yaml_content["problems"][0]["observable_files"][0] - ) - self.parameter_controller.open_table( - yaml_dir / yaml_content["parameter_file"] - ) - self.condition_controller.open_table( - yaml_dir / yaml_content["problems"][0]["condition_files"][0] - ) - # Visualization is optional - vis_path = yaml_content["problems"][0].get("visualization_files") - if vis_path: - self.visualization_controller.open_table( - yaml_dir / vis_path[0] + if not all_exist: + error_msg = ( + "The following files referenced in the YAML are missing:\n - " + + "\n - ".join(missing_files) + ) + self.logger.log_message(error_msg, color="red") + QMessageBox.critical(self.view, "Missing Files", error_msg) + return + + problem = yaml_content["problems"][0] + + # Load SBML model (required, single file) + sbml_files = problem.get("sbml_files", []) + if sbml_files: + sbml_file_path = yaml_dir / sbml_files[0] + self.sbml_controller.overwrite_sbml(sbml_file_path) + self.logger.log_message( + f"Loaded SBML file: {sbml_files[0]}", color="blue" + ) + + # Load measurement files (multiple allowed) + measurement_files = problem.get("measurement_files", []) + if measurement_files: + self._load_file_list( + self.measurement_controller, + measurement_files, + "measurement", + yaml_dir, + ) + + # Load observable files (multiple allowed) + observable_files = problem.get("observable_files", []) + if observable_files: + self._load_file_list( + self.observable_controller, + observable_files, + "observable", + yaml_dir, + ) + + # Load condition files (multiple allowed) + condition_files = problem.get("condition_files", []) + if condition_files: + self._load_file_list( + self.condition_controller, + condition_files, + "condition", + yaml_dir, + ) + + # Load parameter file (required, single file at root level) + if "parameter_file" in yaml_content: + param_file = yaml_content["parameter_file"] + self.parameter_controller.open_table(yaml_dir / param_file) + self.logger.log_message( + f"Loaded parameter file: {param_file}", color="blue" + ) + + # Load visualization files (optional, multiple allowed) + visualization_files = problem.get("visualization_files", []) + if visualization_files: + self._load_file_list( + self.visualization_controller, + visualization_files, + "visualization", + yaml_dir, ) else: self.visualization_controller.clear_table() + # Simulation should be cleared self.simulation_controller.clear_table() + self.logger.log_message( "All files opened successfully from the YAML configuration.", color="green", ) self.check_model() - # rerun the completers + + # Rerun the completers for controller in self.controllers: if controller == self.sbml_controller: continue controller.setup_completers() self.unsaved_changes_change(False) + except FileNotFoundError as e: + error_msg = f"File not found: {e.filename if hasattr(e, 'filename') else str(e)}" + self.logger.log_message(error_msg, color="red") + QMessageBox.warning(self.view, "File Not Found", error_msg) + except KeyError as e: + error_msg = f"Missing required field in YAML: {str(e)}" + self.logger.log_message(error_msg, color="red") + QMessageBox.warning(self.view, "Invalid YAML", error_msg) + except ValueError as e: + error_msg = f"Invalid YAML structure: {str(e)}" + self.logger.log_message(error_msg, color="red") + QMessageBox.warning(self.view, "Invalid YAML", error_msg) + except yaml.YAMLError as e: + error_msg = f"YAML parsing error: {str(e)}" + self.logger.log_message(error_msg, color="red") + QMessageBox.warning(self.view, "YAML Parsing Error", error_msg) except Exception as e: - self.logger.log_message( - f"Failed to open files from YAML: {str(e)}", color="red" - ) + error_msg = f"Unexpected error loading YAML: {str(e)}" + self.logger.log_message(error_msg, color="red") + logging.exception("Full traceback for YAML loading error:") + QMessageBox.critical(self.view, "Error", error_msg) def open_omex_and_load_files(self, omex_path=None): """Opens a petab problem from a COMBINE Archive.""" diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index 7e38e0a..7327a8a 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -216,18 +216,34 @@ def append_df(self, new_df: pd.DataFrame): 1. Columns are the union of both DataFrame columns. 2. Rows are the union of both DataFrame rows (duplicates removed) """ + self.proxy_model.setSourceModel(None) self.model.beginResetModel() - combined_df = pd.concat([self.model.get_df(), new_df], axis=0) - combined_df = combined_df[~combined_df.index.duplicated(keep="first")] + current_df = self.model.get_df() + + # For tables without a named index (measurement, visualization, simulation), + # ignore the index to avoid removing appended data due to index conflicts + if self.model.table_type in [ + "measurement", + "visualization", + "simulation", + ]: + combined_df = pd.concat( + [current_df, new_df], axis=0, ignore_index=True + ) + else: + # For tables with named indices, concatenate and remove duplicate indices + combined_df = pd.concat([current_df, new_df], axis=0) + combined_df = combined_df[ + ~combined_df.index.duplicated(keep="first") + ] + self.model._data_frame = combined_df - self.proxy_model.setSourceModel(None) - self.proxy_model.setSourceModel(self.model) self.model.endResetModel() self.logger.log_message( f"Appended the {self.model.table_type} table with new data.", color="green", ) - # test: overwrite the new model as source model + self.proxy_model.setSourceModel(self.model) self.overwritten_df.emit() def clear_table(self): diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 806c49b..16b86e7 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -1095,7 +1095,7 @@ class MeasurementModel(PandasTableModel): possibly_new_observable = Signal(str) # Signal for new observable def __init__(self, data_frame, type: str = "measurement", parent=None): - allowed_columns = COLUMNS[type] + allowed_columns = COLUMNS[type].copy() super().__init__( data_frame=data_frame, allowed_columns=allowed_columns, @@ -1128,7 +1128,7 @@ class ObservableModel(IndexedPandasTableModel): def __init__(self, data_frame, parent=None): super().__init__( data_frame=data_frame, - allowed_columns=COLUMNS["observable"], + allowed_columns=COLUMNS["observable"].copy(), table_type="observable", parent=parent, ) @@ -1140,7 +1140,7 @@ class ParameterModel(IndexedPandasTableModel): def __init__(self, data_frame, parent=None, sbml_model=None): super().__init__( data_frame=data_frame, - allowed_columns=COLUMNS["parameter"], + allowed_columns=COLUMNS["parameter"].copy(), table_type="parameter", parent=parent, ) @@ -1153,9 +1153,11 @@ class ConditionModel(IndexedPandasTableModel): """Table model for the condition data.""" def __init__(self, data_frame, parent=None): + # Use a copy to avoid mutating the global COLUMNS constant + condition_columns = COLUMNS["condition"].copy() super().__init__( data_frame=data_frame, - allowed_columns=COLUMNS["condition"], + allowed_columns=condition_columns, table_type="condition", parent=parent, ) @@ -1206,7 +1208,7 @@ class VisualizationModel(PandasTableModel): def __init__(self, data_frame, parent=None): super().__init__( data_frame=data_frame, - allowed_columns=COLUMNS["visualization"], + allowed_columns=COLUMNS["visualization"].copy(), table_type="visualization", parent=parent, ) diff --git a/tests/test_upload.py b/tests/test_upload.py new file mode 100644 index 0000000..8d9422e --- /dev/null +++ b/tests/test_upload.py @@ -0,0 +1,735 @@ +"""Tests for file upload functionality in mother_controller.py.""" + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +import yaml + +# Add the src directory to the Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from petab_gui.controllers.mother_controller import MainController +from petab_gui.models import PEtabModel +from petab_gui.views import MainWindow + +# Try to import QApplication for Qt tests +try: + from PySide6.QtWidgets import QApplication + + _QT_AVAILABLE = True +except ImportError: + _QT_AVAILABLE = False + + +# Create a module-level QApplication instance if Qt is available +_qapp = None +if _QT_AVAILABLE: + _qapp = QApplication.instance() + if _qapp is None: + _qapp = QApplication([]) + + +class TestYAMLValidation(unittest.TestCase): + """Test YAML structure validation functions.""" + + def setUp(self): + """Set up test fixtures.""" + # Skip tests if Qt is not available + if not _QT_AVAILABLE: + self.skipTest("Qt not available") + + # Create real application components + self.view = MainWindow() + self.model = PEtabModel() + self.controller = MainController(self.view, self.model) + self.view.controller = self.controller + + def tearDown(self): + """Clean up test fixtures.""" + if hasattr(self, "view"): + self.view.close() + self.view.deleteLater() + + def test_validate_yaml_structure_valid_minimal(self): + """Test validation with minimal valid YAML structure.""" + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertTrue(is_valid) + # Should have no critical errors, only potential warnings + critical_errors = [e for e in errors if "Warning" not in e] + self.assertEqual(len(critical_errors), 0) + + def test_validate_yaml_structure_missing_format_version(self): + """Test validation fails when format_version is missing.""" + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("Missing 'format_version' field", errors) + + def test_validate_yaml_structure_missing_problems(self): + """Test validation fails when problems field is missing.""" + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("Missing 'problems' field", errors) + + def test_validate_yaml_structure_empty_problems(self): + """Test validation fails when problems list is empty.""" + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("'problems' must be a non-empty list", errors) + + def test_validate_yaml_structure_missing_sbml_files(self): + """Test validation fails when sbml_files is missing.""" + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("Problem must contain at least one SBML file", errors) + + def test_validate_yaml_structure_empty_sbml_files(self): + """Test validation fails when sbml_files is empty.""" + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": [], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("Problem must contain at least one SBML file", errors) + + def test_validate_yaml_structure_missing_parameter_file(self): + """Test validation fails when parameter_file is missing.""" + yaml_content = { + "format_version": "1.0", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + self.assertFalse(is_valid) + self.assertIn("Missing 'parameter_file' at root level", errors) + + def test_validate_yaml_structure_warnings_for_optional_fields(self): + """Test validation generates warnings for missing optional fields.""" + yaml_content = { + "format_version": "1.0", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + is_valid, errors = self.controller._validate_yaml_structure( + yaml_content + ) + + # Should be valid despite warnings + self.assertTrue(is_valid) + + # Should have warnings for missing optional fields + warnings = [e for e in errors if "Warning" in e] + self.assertGreater(len(warnings), 0) + + warning_text = " ".join(warnings) + self.assertIn("visualization_files", warning_text) + + +class TestFileExistenceValidation(unittest.TestCase): + """Test file existence validation.""" + + def setUp(self): + """Set up test fixtures.""" + # Skip tests if Qt is not available + if not _QT_AVAILABLE: + self.skipTest("Qt not available") + + # Create real application components + self.view = MainWindow() + self.model = PEtabModel() + self.controller = MainController(self.view, self.model) + self.view.controller = self.controller + + def tearDown(self): + """Clean up test fixtures.""" + if hasattr(self, "view"): + self.view.close() + self.view.deleteLater() + + def test_validate_files_exist_all_present(self): + """Test validation passes when all files exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files + (temp_path / "model.xml").touch() + (temp_path / "measurements.tsv").touch() + (temp_path / "observables.tsv").touch() + (temp_path / "conditions.tsv").touch() + (temp_path / "parameters.tsv").touch() + + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + all_exist, missing = self.controller._validate_files_exist( + temp_path, yaml_content + ) + + self.assertTrue(all_exist) + self.assertEqual(len(missing), 0) + + def test_validate_files_exist_missing_sbml(self): + """Test validation detects missing SBML file.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files (except SBML) + (temp_path / "measurements.tsv").touch() + (temp_path / "observables.tsv").touch() + (temp_path / "conditions.tsv").touch() + (temp_path / "parameters.tsv").touch() + + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + all_exist, missing = self.controller._validate_files_exist( + temp_path, yaml_content + ) + + self.assertFalse(all_exist) + self.assertIn("model.xml", missing) + + def test_validate_files_exist_missing_multiple(self): + """Test validation detects multiple missing files.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create only some test files + (temp_path / "model.xml").touch() + (temp_path / "parameters.tsv").touch() + + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + all_exist, missing = self.controller._validate_files_exist( + temp_path, yaml_content + ) + + self.assertFalse(all_exist) + self.assertIn("measurements.tsv", missing) + self.assertIn("observables.tsv", missing) + self.assertIn("conditions.tsv", missing) + self.assertEqual(len(missing), 3) + + def test_validate_files_exist_multiple_files_per_category(self): + """Test validation with multiple files per category.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files + (temp_path / "model.xml").touch() + (temp_path / "measurements1.tsv").touch() + (temp_path / "measurements2.tsv").touch() + (temp_path / "observables1.tsv").touch() + (temp_path / "observables2.tsv").touch() + (temp_path / "conditions.tsv").touch() + (temp_path / "parameters.tsv").touch() + + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": [ + "measurements1.tsv", + "measurements2.tsv", + ], + "observable_files": [ + "observables1.tsv", + "observables2.tsv", + ], + "condition_files": ["conditions.tsv"], + } + ], + } + + all_exist, missing = self.controller._validate_files_exist( + temp_path, yaml_content + ) + + self.assertTrue(all_exist) + self.assertEqual(len(missing), 0) + + def test_validate_files_exist_missing_one_of_multiple(self): + """Test validation detects missing file when multiple files listed.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files (missing measurements2.tsv) + (temp_path / "model.xml").touch() + (temp_path / "measurements1.tsv").touch() + # measurements2.tsv intentionally not created + (temp_path / "observables.tsv").touch() + (temp_path / "conditions.tsv").touch() + (temp_path / "parameters.tsv").touch() + + yaml_content = { + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": [ + "measurements1.tsv", + "measurements2.tsv", + ], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + + all_exist, missing = self.controller._validate_files_exist( + temp_path, yaml_content + ) + + self.assertFalse(all_exist) + self.assertIn("measurements2.tsv", missing) + self.assertEqual(len(missing), 1) + + +class TestMultiFileYAMLLoading(unittest.TestCase): + """Test multi-file YAML loading functionality.""" + + def setUp(self): + """Set up test fixtures.""" + # Skip tests if Qt is not available + if not _QT_AVAILABLE: + self.skipTest("Qt not available") + + # Create real application components + self.view = MainWindow() + self.model = PEtabModel() + self.controller = MainController(self.view, self.model) + self.view.controller = self.controller + + # Add spies to track method calls on real controllers + self.controller.sbml_controller.overwrite_sbml = Mock( + wraps=self.controller.sbml_controller.overwrite_sbml + ) + self.controller.measurement_controller.open_table = Mock( + wraps=self.controller.measurement_controller.open_table + ) + self.controller.observable_controller.open_table = Mock( + wraps=self.controller.observable_controller.open_table + ) + self.controller.condition_controller.open_table = Mock( + wraps=self.controller.condition_controller.open_table + ) + self.controller.parameter_controller.open_table = Mock( + wraps=self.controller.parameter_controller.open_table + ) + + def tearDown(self): + """Clean up test fixtures.""" + if hasattr(self, "view"): + self.view.close() + self.view.deleteLater() + + def test_single_file_yaml_backward_compatibility(self): + """Test that single-file YAML loading still works (backward compatibility).""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files + (temp_path / "model.xml").write_text( + "" + ) + (temp_path / "measurements.tsv").write_text( + "observableId\ttime\tmeasurement\n" + ) + (temp_path / "observables.tsv").write_text( + "observableId\tobservableFormula\n" + ) + (temp_path / "conditions.tsv").write_text("conditionId\n") + (temp_path / "parameters.tsv").write_text("parameterId\n") + + # Create YAML file + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + yaml_file = temp_path / "problem.yaml" + with open(yaml_file, "w") as f: + yaml.dump(yaml_content, f) + + # Mock get_major_version to return 1 + with patch( + "petab_gui.controllers.mother_controller.get_major_version", + return_value=1, + ): + # Call the method + self.controller.open_yaml_and_load_files(str(yaml_file)) + + # Verify SBML was loaded once + self.controller.sbml_controller.overwrite_sbml.assert_called_once() + + # Verify each table was loaded once in overwrite mode + self.controller.measurement_controller.open_table.assert_called_once() + self.controller.observable_controller.open_table.assert_called_once() + self.controller.condition_controller.open_table.assert_called_once() + self.controller.parameter_controller.open_table.assert_called_once() + + def test_multi_file_yaml_loading(self): + """Test loading YAML with multiple files per category.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files + (temp_path / "model.xml").write_text( + "" + ) + (temp_path / "measurements1.tsv").write_text( + "observableId\ttime\tmeasurement\n" + ) + (temp_path / "measurements2.tsv").write_text( + "observableId\ttime\tmeasurement\n" + ) + (temp_path / "observables1.tsv").write_text( + "observableId\tobservableFormula\n" + ) + (temp_path / "observables2.tsv").write_text( + "observableId\tobservableFormula\n" + ) + (temp_path / "conditions1.tsv").write_text("conditionId\n") + (temp_path / "conditions2.tsv").write_text("conditionId\n") + (temp_path / "parameters.tsv").write_text("parameterId\n") + + # Create YAML file with multiple files per category + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": [ + "measurements1.tsv", + "measurements2.tsv", + ], + "observable_files": [ + "observables1.tsv", + "observables2.tsv", + ], + "condition_files": [ + "conditions1.tsv", + "conditions2.tsv", + ], + } + ], + } + yaml_file = temp_path / "problem.yaml" + with open(yaml_file, "w") as f: + yaml.dump(yaml_content, f) + + # Mock get_major_version to return 1 + with patch( + "petab_gui.controllers.mother_controller.get_major_version", + return_value=1, + ): + # Call the method + self.controller.open_yaml_and_load_files(str(yaml_file)) + + # Verify measurement files were loaded (once in overwrite, once in append) + self.assertEqual( + self.controller.measurement_controller.open_table.call_count, 2 + ) + + # Check that first call was with mode='overwrite' + first_call = self.controller.measurement_controller.open_table.call_args_list[ + 0 + ] + self.assertEqual(first_call[1].get("mode"), "overwrite") + + # Check that second call was with mode='append' + second_call = self.controller.measurement_controller.open_table.call_args_list[ + 1 + ] + self.assertEqual(second_call[1].get("mode"), "append") + + # Verify observable files were loaded + self.assertEqual( + self.controller.observable_controller.open_table.call_count, 2 + ) + + # Verify condition files were loaded + self.assertEqual( + self.controller.condition_controller.open_table.call_count, 2 + ) + + +class TestErrorHandling(unittest.TestCase): + """Test error handling in YAML loading.""" + + def setUp(self): + """Set up test fixtures.""" + # Skip tests if Qt is not available + if not _QT_AVAILABLE: + self.skipTest("Qt not available") + + # Create real application components + self.view = MainWindow() + self.model = PEtabModel() + self.controller = MainController(self.view, self.model) + self.view.controller = self.controller + + def tearDown(self): + """Clean up test fixtures.""" + if hasattr(self, "view"): + self.view.close() + self.view.deleteLater() + + def test_invalid_yaml_structure_shows_error(self): + """Test that invalid YAML structure shows appropriate error.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create YAML with invalid structure (missing problems) + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + } + yaml_file = temp_path / "problem.yaml" + with open(yaml_file, "w") as f: + yaml.dump(yaml_content, f) + + # Mock QMessageBox to capture error display + with ( + patch( + "petab_gui.controllers.mother_controller.QMessageBox" + ) as mock_msgbox, + patch( + "petab_gui.controllers.mother_controller.get_major_version", + return_value=1, + ), + ): + # Call the method + self.controller.open_yaml_and_load_files(str(yaml_file)) + + # Verify error message box was shown + mock_msgbox.critical.assert_called_once() + call_args = mock_msgbox.critical.call_args + self.assertIn("Invalid YAML structure", str(call_args)) + + def test_missing_files_shows_error(self): + """Test that missing files show appropriate error.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create YAML but don't create the files it references + yaml_content = { + "format_version": "1.0", + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + yaml_file = temp_path / "problem.yaml" + with open(yaml_file, "w") as f: + yaml.dump(yaml_content, f) + + # Mock QMessageBox to capture error display + with ( + patch( + "petab_gui.controllers.mother_controller.QMessageBox" + ) as mock_msgbox, + patch( + "petab_gui.controllers.mother_controller.get_major_version", + return_value=1, + ), + ): + # Call the method + self.controller.open_yaml_and_load_files(str(yaml_file)) + + # Verify error message box was shown + mock_msgbox.critical.assert_called_once() + call_args = mock_msgbox.critical.call_args + self.assertIn("Missing Files", str(call_args)) + + def test_unsupported_petab_version_shows_error(self): + """Test that unsupported PEtab version shows appropriate error.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create valid YAML + yaml_content = { + "format_version": "2.0", # Version 2 + "parameter_file": "parameters.tsv", + "problems": [ + { + "sbml_files": ["model.xml"], + "measurement_files": ["measurements.tsv"], + "observable_files": ["observables.tsv"], + "condition_files": ["conditions.tsv"], + } + ], + } + yaml_file = temp_path / "problem.yaml" + with open(yaml_file, "w") as f: + yaml.dump(yaml_content, f) + + # Create a spy to track log messages + logged_messages = [] + original_log = self.controller.logger.log_message + + def log_spy(message, color="black"): + logged_messages.append(message) + original_log(message, color) + + self.controller.logger.log_message = log_spy + + # Mock get_major_version to return 2 + with ( + patch( + "petab_gui.controllers.mother_controller.get_major_version", + return_value=2, + ), + patch("petab_gui.controllers.mother_controller.QMessageBox"), + ): + self.controller.open_yaml_and_load_files(str(yaml_file)) + + # Restore original method + self.controller.logger.log_message = original_log + + # Check that an error about PEtab version was logged + version_errors = [ + msg for msg in logged_messages if "PEtab v1" in msg + ] + self.assertGreater(len(version_errors), 0) + + +if __name__ == "__main__": + unittest.main()