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()