diff --git a/pyproject.toml b/pyproject.toml index 687ac4a..9bbce79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jabs-postprocess" -version = "0.3.0" +version = "0.4.0" description = "A python library for JABS postprocessing utilities." readme = "README.md" license = "LicenseRef-PLATFORM-LICENSE-AGREEMENT-FOR-NON-COMMERCIAL-USE" diff --git a/src/jabs_postprocess/cli/main.py b/src/jabs_postprocess/cli/main.py index b663059..4f6863e 100644 --- a/src/jabs_postprocess/cli/main.py +++ b/src/jabs_postprocess/cli/main.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Annotated, List, Optional +import pandas as pd import numpy as np import typer @@ -329,5 +330,144 @@ def heuristic_classify( ) +@app.command() +def merge_tables( + input_tables: Annotated[ + List[Path], + typer.Option( + help="Paths to behavior table files to merge (must be same behavior and table type)" + ), + ], + output_prefix: Annotated[ + str, + typer.Option(help="File prefix for merged output table"), + ] = "merged_behavior", + overwrite: Annotated[bool, typer.Option(help="Overwrites output files")] = False, +): + """Merge multiple behavior tables of the same type and behavior. + + This command merges behavior tables that contain the same behavior data, + combining them into a single consolidated table while preserving header information. + """ + if not input_tables: + typer.echo("Error: No input tables provided.") + raise typer.Exit(1) + + # Validate all input files exist + for table_path in input_tables: + if not table_path.exists(): + typer.echo(f"Error: Input table not found: {table_path}") + raise typer.Exit(1) + + try: + output_file, _ = generate_behavior_tables.merge_behavior_tables( + input_tables=input_tables, + output_prefix=output_prefix, + overwrite=overwrite, + ) + + typer.echo(f"Successfully merged {len(input_tables)} tables:") + for table in input_tables: + typer.echo(f" - {table}") + typer.echo(f"Output saved to: {output_file}") + + except FileExistsError as e: + typer.echo(f"Error: {str(e)}") + typer.echo("Use --overwrite to force overwrite.") + raise typer.Exit(1) + except ValueError as e: + typer.echo(f"Error: {str(e)}") + raise typer.Exit(1) + except Exception as e: + typer.echo(f"Unexpected error: {str(e)}") + raise typer.Exit(1) + + +@app.command() +def merge_multiple_tables( + table_folder: Annotated[ + Path, + typer.Option( + help="Folder containing behavior table files to merge, grouped by behavior" + ), + ], + behaviors: Annotated[ + Optional[List[str]], + typer.Option(help="Specific behaviors to merge (default: auto-detect all)"), + ] = None, + table_pattern: Annotated[ + str, + typer.Option(help="File pattern to match behavior tables"), + ] = "*.csv", + output_prefix: Annotated[ + str, + typer.Option(help="File prefix for merged output tables"), + ] = "merged_behavior", + overwrite: Annotated[bool, typer.Option(help="Overwrites output files")] = False, +): + """Merge multiple sets of behavior tables, automatically grouping by behavior. + + This command scans a folder for behavior table files, groups them by behavior name, + and merges each group separately. Useful for combining results from multiple experiments. + """ + if not table_folder.exists(): + typer.echo(f"Error: Table folder not found: {table_folder}") + raise typer.Exit(1) + + # Find all table files matching the pattern + table_files = list(table_folder.glob(table_pattern)) + if not table_files: + typer.echo( + f"Error: No table files found matching pattern '{table_pattern}' in {table_folder}" + ) + raise typer.Exit(1) + + # Group tables by behavior (extract from filename or header) + table_groups = {} + for table_file in table_files: + try: + header_data = pd.read_csv(table_file, nrows=1) + behavior_name = header_data["Behavior"][0] + + # Filter by requested behaviors if specified + if behaviors and behavior_name not in behaviors: + continue + + if behavior_name not in table_groups: + table_groups[behavior_name] = [] + table_groups[behavior_name].append(table_file) + + except (KeyError, pd.errors.EmptyDataError, Exception): + typer.echo(f"Warning: Could not read behavior from {table_file}, skipping.") + continue + + if not table_groups: + typer.echo("Error: No valid behavior tables found to merge.") + raise typer.Exit(1) + + try: + results = generate_behavior_tables.merge_multiple_behavior_tables( + table_groups=table_groups, + output_prefix=output_prefix, + overwrite=overwrite, + ) + + typer.echo(f"Successfully merged tables for {len(results)} behaviors:") + for behavior_name, (bout_file, bin_file) in results.items(): + typer.echo(f" {behavior_name}:") + if bout_file: + typer.echo(f" Bout table: {bout_file}") + if bin_file: + typer.echo(f" Bin table: {bin_file}") + + except FileExistsError as e: + typer.echo(f"Error: {str(e)}") + typer.echo("Use --overwrite to force overwrite.") + raise typer.Exit(1) + except Exception as e: + typer.echo(f"Unexpected error: {str(e)}") + raise typer.Exit(1) + + if __name__ == "__main__": app() diff --git a/src/jabs_postprocess/generate_behavior_tables.py b/src/jabs_postprocess/generate_behavior_tables.py index e6eb26d..8c2e103 100644 --- a/src/jabs_postprocess/generate_behavior_tables.py +++ b/src/jabs_postprocess/generate_behavior_tables.py @@ -3,7 +3,13 @@ from typing import Dict, List, Optional, Tuple from pathlib import Path -from jabs_postprocess.utils.project_utils import ClassifierSettings, JabsProject +import pandas as pd +from jabs_postprocess.utils.project_utils import ( + ClassifierSettings, + JabsProject, + BoutTable, + BinTable, +) def process_behavior_tables( @@ -111,3 +117,137 @@ def process_multiple_behaviors( results.append((bout_path, bin_path)) return results + + +def merge_behavior_tables( + input_tables: List[Path], + output_prefix: str = "merged_behavior", + overwrite: bool = False, +) -> Tuple[str, str]: + """Merge multiple behavior tables for the same behavior. + + Args: + input_tables: List of paths to behavior table files to merge + output_prefix: Prefix for output filenames + overwrite: Whether to overwrite existing files + + Returns: + Tuple[str, str]: (merged_bout_table_path, merged_bin_table_path) - Paths to the created files + + Raises: + FileNotFoundError: If any input table file doesn't exist + ValueError: If tables have different behaviors or incompatible headers + FileExistsError: If output files exist and overwrite is False + """ + if not input_tables: + raise ValueError("No input tables provided") + + # Validate all input files exist + for table_path in input_tables: + if not Path(table_path).exists(): + raise FileNotFoundError(f"Input table not found: {table_path}") + + # Read the first table to determine if it's a bout or bin table and get behavior info + first_table = BoutTable.from_file(input_tables[0]) + behavior_name = first_table.settings.behavior + table_type = "bout" + + # Try to determine table type by checking columns + if "bout_behavior" in first_table.data.columns: + # This is likely a bin table + first_table = BinTable.from_file(input_tables[0]) + table_type = "bin" + + # Load all tables and validate they're compatible + tables = [] + for table_path in input_tables: + if table_type == "bout": + table = BoutTable.from_file(table_path) + else: + table = BinTable.from_file(table_path) + + # Validate same behavior + if table.settings.behavior != behavior_name: + raise ValueError( + f"Incompatible behaviors: {behavior_name} vs {table.settings.behavior} in {table_path}" + ) + + tables.append(table) + + # Merge the tables using the existing combine_data method + if table_type == "bout": + merged_table = BoutTable.combine_data(tables) + output_file = f"{output_prefix}_{behavior_name}_bouts_merged.csv" + else: + merged_table = BinTable.combine_data(tables) + output_file = f"{output_prefix}_{behavior_name}_summaries_merged.csv" + + # Write the merged table + merged_table.to_file(output_file, overwrite) + + return ( + output_file, + output_file, + ) # Return same file for both since we only merged one type + + +def merge_multiple_behavior_tables( + table_groups: Dict[str, List[Path]], + output_prefix: str = "merged_behavior", + overwrite: bool = False, +) -> Dict[str, Tuple[str, str]]: + """Merge multiple sets of behavior tables grouped by behavior. + + Args: + table_groups: Dictionary mapping behavior names to lists of table file paths + output_prefix: Prefix for output filenames + overwrite: Whether to overwrite existing files + + Returns: + Dictionary mapping behavior names to (bout_table_path, bin_table_path) tuples + + Raises: + ValueError: If any behavior group is empty + FileNotFoundError: If any input table file doesn't exist + FileExistsError: If output files exist and overwrite is False + """ + results = {} + + for behavior_name, table_paths in table_groups.items(): + if not table_paths: + raise ValueError(f"No tables provided for behavior: {behavior_name}") + + # Group tables by type (bout vs bin) for this behavior + bout_tables = [] + bin_tables = [] + + for table_path in table_paths: + data_sample = pd.read_csv(table_path, skiprows=2, nrows=1) + + if data_sample.empty: + continue # Skip empty tables + + # Check if it's a bin table (has bout_behavior column) or bout table + full_data = pd.read_csv(table_path, skiprows=2) + if "bout_behavior" in full_data.columns: + bin_tables.append(table_path) + else: + bout_tables.append(table_path) + + # Merge bout tables if any exist + bout_output = None + if bout_tables: + bout_output, _ = merge_behavior_tables( + bout_tables, f"{output_prefix}_{behavior_name}_bouts", overwrite + ) + + # Merge bin tables if any exist + bin_output = None + if bin_tables: + bin_output, _ = merge_behavior_tables( + bin_tables, f"{output_prefix}_{behavior_name}_summaries", overwrite + ) + + results[behavior_name] = (bout_output, bin_output) + + return results diff --git a/src/jabs_postprocess/utils/project_utils.py b/src/jabs_postprocess/utils/project_utils.py index 06b8402..58e7761 100644 --- a/src/jabs_postprocess/utils/project_utils.py +++ b/src/jabs_postprocess/utils/project_utils.py @@ -526,7 +526,7 @@ def data(self): return self._data @classmethod - def combine_data(cls, data_list: List(Table)): + def combine_data(cls, data_list: List[Table]): """Combines multiple data tables together. Args: @@ -1138,7 +1138,7 @@ def from_no_prediction( return cls(settings, bout_df, video_metadata) @classmethod - def combine_data(cls, data_list: List(Table)): + def combine_data(cls, data_list: List[Table]): """Combines multiple prediction tables together. Args: diff --git a/tests/test_generate_behavior_tables.py b/tests/test_generate_behavior_tables.py index cead62a..4d6f909 100644 --- a/tests/test_generate_behavior_tables.py +++ b/tests/test_generate_behavior_tables.py @@ -1,7 +1,7 @@ """Unit tests for the generate_behavior_tables module. This test module validates the functionality of the behavior table generation process -in JABS (Just Another Behavior Scorer). The main functions under test are: +in JABS. It currently tests two functions: 1. process_behavior_tables - Processes a single behavior, extracting bout information and creating summary tables with configurable parameters @@ -16,10 +16,13 @@ from unittest.mock import MagicMock, patch import pytest +from pathlib import Path from jabs_postprocess.generate_behavior_tables import ( process_behavior_tables, process_multiple_behaviors, + merge_behavior_tables, + merge_multiple_behavior_tables, ) @@ -78,13 +81,13 @@ def test_process_behavior_tables_default_params(mock_project): ): # Act result = process_behavior_tables( - project_folder="/path/to/project", behavior="grooming" + project_folder=Path("/path/to/project"), behavior="grooming" ) # Assert mock_settings.assert_called_once_with("grooming", None, None, None) mock_from_folder.assert_called_once_with( - "/path/to/project", mock_settings.return_value, None + Path("/path/to/project"), mock_settings.return_value, None ) mock_project.get_bouts.assert_called_once() mock_project.get_bouts.return_value.to_file.assert_called_once_with( @@ -122,11 +125,11 @@ def test_process_behavior_tables_custom_params(mock_project): ): # Act result = process_behavior_tables( - project_folder="/path/to/project", + project_folder=Path("/path/to/project"), behavior="walking", out_prefix="custom", out_bin_size=120, - feature_folder="/custom/features", + feature_folder=Path("/custom/features"), interpolate_size=5, stitch_gap=3, min_bout_length=10, @@ -136,7 +139,9 @@ def test_process_behavior_tables_custom_params(mock_project): # Assert mock_settings.assert_called_once_with("walking", 5, 3, 10) mock_from_folder.assert_called_once_with( - "/path/to/project", mock_settings.return_value, "/custom/features" + Path("/path/to/project"), + mock_settings.return_value, + Path("/custom/features"), ) mock_project.get_bouts.assert_called_once() mock_project.get_bouts.return_value.to_file.assert_called_once_with( @@ -185,7 +190,7 @@ def test_process_behavior_tables_param_combinations( ): # Act process_behavior_tables( - project_folder="/path/to/project", + project_folder=Path("/path/to/project"), behavior="grooming", interpolate_size=interpolate_size, stitch_gap=stitch_gap, @@ -216,7 +221,7 @@ def test_process_behavior_tables_empty_project_folder(): with pytest.raises( ValueError, match="Project folder is empty or does not exist" ): - process_behavior_tables(project_folder="", behavior="grooming") + process_behavior_tables(project_folder=Path(""), behavior="grooming") def test_process_behavior_tables_error_during_processing(mock_project): @@ -236,7 +241,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): # Act & Assert with pytest.raises(RuntimeError, match="Failed to process bouts"): process_behavior_tables( - project_folder="/path/to/project", behavior="grooming" + project_folder=Path("/path/to/project"), behavior="grooming" ) @@ -248,7 +253,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): [{"behavior": "grooming"}], [ { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "grooming", "out_prefix": "behavior", "out_bin_size": 60, @@ -268,7 +273,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): ], [ { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "grooming", "out_prefix": "behavior", "out_bin_size": 60, @@ -279,7 +284,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): "overwrite": False, }, { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "walking", "out_prefix": "behavior", "out_bin_size": 60, @@ -302,7 +307,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): ], [ { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "grooming", "out_prefix": "behavior", "out_bin_size": 60, @@ -313,7 +318,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): "overwrite": False, }, { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "walking", "out_prefix": "behavior", "out_bin_size": 60, @@ -324,7 +329,7 @@ def test_process_behavior_tables_error_during_processing(mock_project): "overwrite": False, }, { - "project_folder": "/path/to/project", + "project_folder": Path("/path/to/project"), "behavior": "feeding", "out_prefix": "behavior", "out_bin_size": 60, @@ -364,7 +369,7 @@ def test_process_multiple_behaviors(behaviors, expected_calls, mock_find_behavio # Act result = process_multiple_behaviors( - project_folder="/path/to/project", behaviors=behaviors + project_folder=Path("/path/to/project"), behaviors=behaviors ) # Assert @@ -409,11 +414,11 @@ def test_process_multiple_behaviors_custom_params(mock_find_behaviors): # Act result = process_multiple_behaviors( - project_folder="/path/to/project", + project_folder=Path("/path/to/project"), behaviors=behaviors, out_prefix="custom", out_bin_size=120, - feature_folder="/custom/features", + feature_folder=Path("/custom/features"), overwrite=True, ) @@ -421,16 +426,14 @@ def test_process_multiple_behaviors_custom_params(mock_find_behaviors): assert mock_process.call_count == 2 for i, behavior in enumerate(behaviors): - assert ( - mock_process.call_args_list[i][1]["project_folder"] - == "/path/to/project" + assert mock_process.call_args_list[i][1]["project_folder"] == Path( + "/path/to/project" ) assert mock_process.call_args_list[i][1]["behavior"] == behavior["behavior"] assert mock_process.call_args_list[i][1]["out_prefix"] == "custom" assert mock_process.call_args_list[i][1]["out_bin_size"] == 120 - assert ( - mock_process.call_args_list[i][1]["feature_folder"] - == "/custom/features" + assert mock_process.call_args_list[i][1]["feature_folder"] == Path( + "/custom/features" ) assert mock_process.call_args_list[i][1]["overwrite"] is True @@ -456,7 +459,7 @@ def test_process_multiple_behaviors_behavior_not_found(mock_find_behaviors): # Act & Assert with pytest.raises(ValueError, match="invalid_behavior not in experiment folder"): - process_multiple_behaviors("/path/to/project", behaviors) + process_multiple_behaviors(Path("/path/to/project"), behaviors) def test_process_multiple_behaviors_missing_behavior_key(): @@ -473,7 +476,7 @@ def test_process_multiple_behaviors_missing_behavior_key(): # Act & Assert with pytest.raises(KeyError, match="Behavior name required"): - process_multiple_behaviors("/path/to/project", behaviors) + process_multiple_behaviors(Path("/path/to/project"), behaviors) def test_process_multiple_behaviors_error_propagation(mock_find_behaviors): @@ -502,7 +505,7 @@ def side_effect(**kwargs): # Act & Assert with pytest.raises(RuntimeError, match="Failed to process walking behavior"): - process_multiple_behaviors("/path/to/project", behaviors) + process_multiple_behaviors(Path("/path/to/project"), behaviors) def test_process_multiple_behaviors_no_available_behaviors(): @@ -524,7 +527,7 @@ def test_process_multiple_behaviors_no_available_behaviors(): # Act & Assert with pytest.raises(ValueError, match="grooming not in experiment folder"): - process_multiple_behaviors("/path/to/project", behaviors) + process_multiple_behaviors(Path("/path/to/project"), behaviors) def test_process_multiple_behaviors_empty_list(mock_find_behaviors): @@ -542,8 +545,228 @@ def test_process_multiple_behaviors_empty_list(mock_find_behaviors): "jabs_postprocess.generate_behavior_tables.process_behavior_tables" ) as mock_process: # Act - result = process_multiple_behaviors("/path/to/project", behaviors) + result = process_multiple_behaviors(Path("/path/to/project"), behaviors) # Assert assert mock_process.call_count == 0 assert result == [] + + +# Tests for merge functionality + + +@pytest.fixture +def mock_bout_table(): + """Create a mock BoutTable.""" + mock = MagicMock() + mock.settings.behavior = "grooming" + mock.data.columns = ["animal_idx", "start", "duration", "is_behavior"] + return mock + + +@pytest.fixture +def mock_bin_table(): + """Create a mock BinTable.""" + mock = MagicMock() + mock.settings.behavior = "grooming" + mock.data.columns = ["longterm_idx", "time", "bout_behavior"] + return mock + + +def test_merge_behavior_tables_bout_tables(): + """Test merging multiple bout tables.""" + table_paths = [Path("/path/table1.csv"), Path("/path/table2.csv")] + + with ( + patch("jabs_postprocess.generate_behavior_tables.Path") as mock_path, + patch( + "jabs_postprocess.generate_behavior_tables.BoutTable" + ) as mock_bout_table_class, + ): + # Mock Path.exists() to return True for all tables + mock_path.return_value.exists.return_value = True + + # Create mock table instances + mock_table1 = MagicMock() + mock_table1.settings.behavior = "grooming" + mock_table1.data.columns = ["animal_idx", "start", "duration", "is_behavior"] + + mock_table2 = MagicMock() + mock_table2.settings.behavior = "grooming" + mock_table2.data.columns = ["animal_idx", "start", "duration", "is_behavior"] + + mock_merged = MagicMock() + + # Mock the from_file method to return our mock tables + mock_bout_table_class.from_file.side_effect = [ + mock_table1, + mock_table1, + mock_table2, + ] + mock_bout_table_class.combine_data.return_value = mock_merged + + # Act + result = merge_behavior_tables(table_paths, "test_output", False) + + # Assert + mock_bout_table_class.from_file.assert_called() + mock_bout_table_class.combine_data.assert_called_once() + mock_merged.to_file.assert_called_once_with( + "test_output_grooming_bouts_merged.csv", False + ) + assert result == ( + "test_output_grooming_bouts_merged.csv", + "test_output_grooming_bouts_merged.csv", + ) + + +def test_merge_behavior_tables_bin_tables(): + """Test merging multiple bin tables.""" + table_paths = [Path("/path/table1.csv"), Path("/path/table2.csv")] + + with ( + patch("jabs_postprocess.generate_behavior_tables.Path") as mock_path, + patch( + "jabs_postprocess.generate_behavior_tables.BoutTable" + ) as mock_bout_table_class, + patch( + "jabs_postprocess.generate_behavior_tables.BinTable" + ) as mock_bin_table_class, + ): + # Mock Path.exists() to return True for all tables + mock_path.return_value.exists.return_value = True + + # First call determines table type - mock as bin table + mock_bout_table1 = MagicMock() + mock_bout_table1.settings.behavior = "grooming" + mock_bout_table1.data.columns = ["longterm_idx", "time", "bout_behavior"] + + # Create bin table mocks + mock_table1 = MagicMock() + mock_table1.settings.behavior = "grooming" + + mock_table2 = MagicMock() + mock_table2.settings.behavior = "grooming" + + mock_merged = MagicMock() + + # Mock the from_file methods + mock_bout_table_class.from_file.return_value = mock_bout_table1 + # Need 3 calls to from_file: one for each table in the loop + mock_bin_table_class.from_file.side_effect = [ + mock_table1, + mock_table2, + mock_table1, + ] + mock_bin_table_class.combine_data.return_value = mock_merged + + # Act + result = merge_behavior_tables(table_paths, "test_output", False) + + # Assert + mock_bin_table_class.combine_data.assert_called_once() + mock_merged.to_file.assert_called_once_with( + "test_output_grooming_summaries_merged.csv", False + ) + assert result == ( + "test_output_grooming_summaries_merged.csv", + "test_output_grooming_summaries_merged.csv", + ) + + +def test_merge_behavior_tables_empty_list(): + """Test merge_behavior_tables with empty input list.""" + with pytest.raises(ValueError, match="No input tables provided"): + merge_behavior_tables([]) + + +def test_merge_behavior_tables_nonexistent_file(): + """Test merge_behavior_tables with non-existent file.""" + table_paths = [Path("/path/nonexistent.csv")] + + with patch("jabs_postprocess.generate_behavior_tables.Path") as mock_path: + mock_path.return_value.exists.return_value = False + + with pytest.raises(FileNotFoundError, match="Input table not found"): + merge_behavior_tables(table_paths) + + +def test_merge_behavior_tables_incompatible_behaviors(): + """Test merge_behavior_tables with tables having different behaviors.""" + table_paths = [Path("/path/table1.csv"), Path("/path/table2.csv")] + + with ( + patch("jabs_postprocess.generate_behavior_tables.Path") as mock_path, + patch( + "jabs_postprocess.generate_behavior_tables.BoutTable" + ) as mock_bout_table_class, + ): + mock_path.return_value.exists.return_value = True + + # Create mock tables with different behaviors + mock_table1 = MagicMock() + mock_table1.settings.behavior = "grooming" + mock_table1.data.columns = ["animal_idx", "start", "duration", "is_behavior"] + + mock_table2 = MagicMock() + mock_table2.settings.behavior = "walking" + mock_table2.data.columns = ["animal_idx", "start", "duration", "is_behavior"] + + mock_bout_table_class.from_file.side_effect = [ + mock_table1, + mock_table1, + mock_table2, + ] + + with pytest.raises(ValueError, match="Incompatible behaviors"): + merge_behavior_tables(table_paths) + + +def test_merge_multiple_behavior_tables(): + """Test merging multiple sets of behavior tables grouped by behavior.""" + table_groups = { + "grooming": [Path("/path/groom1.csv"), Path("/path/groom2.csv")], + "walking": [Path("/path/walk1.csv")], + } + + with ( + patch("jabs_postprocess.generate_behavior_tables.pd.read_csv") as mock_read_csv, + patch( + "jabs_postprocess.generate_behavior_tables.merge_behavior_tables" + ) as mock_merge, + ): + # Mock pandas read_csv to simulate table type detection + def read_csv_side_effect(path, **kwargs): + mock_df = MagicMock() + if "bout_behavior" in str(path): + mock_df.columns = ["longterm_idx", "time", "bout_behavior"] + else: + mock_df.columns = ["animal_idx", "start", "duration", "is_behavior"] + mock_df.empty = False + return mock_df + + mock_read_csv.side_effect = read_csv_side_effect + + # Mock merge_behavior_tables to return expected outputs + def merge_side_effect(tables, prefix, overwrite): + behavior = prefix.split("_")[1] + return f"merged_{behavior}_output.csv", f"merged_{behavior}_output.csv" + + mock_merge.side_effect = merge_side_effect + + # Act + result = merge_multiple_behavior_tables(table_groups, "merged", False) + + # Assert + assert len(result) == 2 + assert "grooming" in result + assert "walking" in result + assert mock_merge.call_count >= 2 # At least one call per behavior + + +def test_merge_multiple_behavior_tables_empty_group(): + """Test merge_multiple_behavior_tables with empty table group.""" + table_groups = {"grooming": []} + + with pytest.raises(ValueError, match="No tables provided for behavior: grooming"): + merge_multiple_behavior_tables(table_groups) diff --git a/uv.lock b/uv.lock index 17ade3f..414a513 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "jabs-postprocess" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "black" },