|
|
"""Tests for Gradio UI components and their interactions. |
|
|
|
|
|
This module tests the Mosaic Gradio UI components, including: |
|
|
- Settings validation |
|
|
- Analysis workflow |
|
|
""" |
|
|
|
|
|
import pytest |
|
|
import pandas as pd |
|
|
from unittest.mock import Mock, patch, MagicMock |
|
|
from pathlib import Path |
|
|
|
|
|
|
|
|
from mosaic.ui.app import ( |
|
|
analyze_slides, |
|
|
set_cancer_subtype_maps, |
|
|
) |
|
|
from mosaic.ui.utils import SETTINGS_COLUMNS |
|
|
|
|
|
|
|
|
class TestSettingsValidation: |
|
|
"""Test settings validation logic.""" |
|
|
|
|
|
@patch("mosaic.ui.utils.gr.Warning") |
|
|
def test_invalid_cancer_subtype_defaults_to_unknown( |
|
|
self, mock_warning, mock_cancer_subtype_maps |
|
|
): |
|
|
"""Test invalid cancer subtype generates warning and defaults to Unknown.""" |
|
|
from mosaic.ui.utils import validate_settings |
|
|
|
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
|
|
|
|
|
|
df = pd.DataFrame( |
|
|
{ |
|
|
"Slide": ["test.svs"], |
|
|
"Site Type": ["Primary"], |
|
|
"Sex": ["Male"], |
|
|
"Tissue Site": ["Unknown"], |
|
|
"Cancer Subtype": ["InvalidSubtype"], |
|
|
"IHC Subtype": [""], |
|
|
"Segmentation Config": ["Biopsy"], |
|
|
} |
|
|
) |
|
|
|
|
|
result = validate_settings( |
|
|
df, cancer_subtype_name_map, cancer_subtypes, reversed_map |
|
|
) |
|
|
|
|
|
|
|
|
assert result.iloc[0]["Cancer Subtype"] == "Unknown" |
|
|
|
|
|
assert mock_warning.called |
|
|
|
|
|
@patch("mosaic.ui.utils.gr.Warning") |
|
|
def test_invalid_site_type_defaults_to_primary( |
|
|
self, mock_warning, mock_cancer_subtype_maps |
|
|
): |
|
|
"""Test invalid site type generates warning and defaults to Primary.""" |
|
|
from mosaic.ui.utils import validate_settings |
|
|
|
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
|
|
|
df = pd.DataFrame( |
|
|
{ |
|
|
"Slide": ["test.svs"], |
|
|
"Site Type": ["InvalidSite"], |
|
|
"Sex": ["Male"], |
|
|
"Tissue Site": ["Unknown"], |
|
|
"Cancer Subtype": ["Unknown"], |
|
|
"IHC Subtype": [""], |
|
|
"Segmentation Config": ["Biopsy"], |
|
|
} |
|
|
) |
|
|
|
|
|
result = validate_settings( |
|
|
df, cancer_subtype_name_map, cancer_subtypes, reversed_map |
|
|
) |
|
|
|
|
|
assert result.iloc[0]["Site Type"] == "Primary" |
|
|
assert mock_warning.called |
|
|
|
|
|
|
|
|
class TestAnalysisWorkflow: |
|
|
"""Test analysis workflow with mocked analyze_slide.""" |
|
|
|
|
|
@patch("mosaic.ui.app.analyze_slide") |
|
|
@patch("mosaic.ui.app.create_user_directory") |
|
|
def test_single_slide_analysis_no_model_cache( |
|
|
self, |
|
|
mock_create_dir, |
|
|
mock_analyze, |
|
|
sample_files_single, |
|
|
mock_analyze_slide_results, |
|
|
mock_cancer_subtype_maps, |
|
|
temp_output_dir, |
|
|
): |
|
|
"""Test single slide analysis doesn't load model cache.""" |
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
set_cancer_subtype_maps(cancer_subtype_name_map, reversed_map, cancer_subtypes) |
|
|
|
|
|
|
|
|
mock_create_dir.return_value = temp_output_dir |
|
|
mock_analyze.return_value = mock_analyze_slide_results |
|
|
|
|
|
|
|
|
settings_df = pd.DataFrame( |
|
|
{ |
|
|
"Slide": ["test_slide_1.svs"], |
|
|
"Site Type": ["Primary"], |
|
|
"Sex": ["Male"], |
|
|
"Tissue Site": ["Unknown"], |
|
|
"Cancer Subtype": ["Unknown"], |
|
|
"IHC Subtype": [""], |
|
|
"Segmentation Config": ["Biopsy"], |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
gen = analyze_slides( |
|
|
sample_files_single, |
|
|
settings_df, |
|
|
"Primary", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"", |
|
|
"Biopsy", |
|
|
temp_output_dir, |
|
|
) |
|
|
|
|
|
|
|
|
results = list(gen) |
|
|
|
|
|
|
|
|
assert len(results) >= 1 |
|
|
|
|
|
|
|
|
assert mock_analyze.call_count == 1 |
|
|
|
|
|
|
|
|
call_kwargs = mock_analyze.call_args[1] |
|
|
assert call_kwargs["model_cache"] is None |
|
|
|
|
|
@patch("mosaic.ui.app.load_all_models") |
|
|
@patch("mosaic.ui.app.analyze_slide") |
|
|
@patch("mosaic.ui.app.create_user_directory") |
|
|
def test_batch_analysis_loads_model_cache_once( |
|
|
self, |
|
|
mock_create_dir, |
|
|
mock_analyze, |
|
|
mock_load_models, |
|
|
sample_files_multiple, |
|
|
mock_analyze_slide_results, |
|
|
mock_model_cache, |
|
|
mock_cancer_subtype_maps, |
|
|
temp_output_dir, |
|
|
): |
|
|
"""Test batch analysis loads models once and reuses cache.""" |
|
|
from PIL import Image |
|
|
|
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
set_cancer_subtype_maps(cancer_subtype_name_map, reversed_map, cancer_subtypes) |
|
|
|
|
|
|
|
|
def mock_analyze_side_effect(*args, **kwargs): |
|
|
mask = Image.new("RGB", (100, 100), color="red") |
|
|
aeon_results = pd.DataFrame( |
|
|
{"Cancer Subtype": ["LUAD"], "Confidence": [0.95]} |
|
|
) |
|
|
paladin_results = pd.DataFrame( |
|
|
{ |
|
|
"Cancer Subtype": ["LUAD", "LUAD", "LUAD"], |
|
|
"Biomarker": ["TP53", "KRAS", "EGFR"], |
|
|
"Score": [0.85, 0.72, 0.63], |
|
|
} |
|
|
) |
|
|
return (mask, aeon_results, paladin_results) |
|
|
|
|
|
mock_create_dir.return_value = temp_output_dir |
|
|
mock_load_models.return_value = mock_model_cache |
|
|
mock_analyze.side_effect = mock_analyze_side_effect |
|
|
|
|
|
|
|
|
settings_df = pd.DataFrame( |
|
|
{ |
|
|
"Slide": ["test_slide_1.svs", "test_slide_2.svs", "test_slide_3.svs"], |
|
|
"Site Type": ["Primary", "Primary", "Primary"], |
|
|
"Sex": ["Male", "Female", "Male"], |
|
|
"Tissue Site": ["Unknown", "Unknown", "Unknown"], |
|
|
"Cancer Subtype": ["Unknown", "Unknown", "Unknown"], |
|
|
"IHC Subtype": ["", "", ""], |
|
|
"Segmentation Config": ["Biopsy", "Biopsy", "Biopsy"], |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
gen = analyze_slides( |
|
|
sample_files_multiple, |
|
|
settings_df, |
|
|
"Primary", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"", |
|
|
"Biopsy", |
|
|
temp_output_dir, |
|
|
) |
|
|
|
|
|
|
|
|
results = list(gen) |
|
|
|
|
|
|
|
|
assert mock_load_models.call_count == 1 |
|
|
|
|
|
|
|
|
assert mock_analyze.call_count == 3 |
|
|
|
|
|
|
|
|
for call in mock_analyze.call_args_list: |
|
|
assert call[1]["model_cache"] == mock_model_cache |
|
|
|
|
|
|
|
|
assert mock_model_cache.cleanup.called |
|
|
|
|
|
@patch("mosaic.ui.app.create_user_directory") |
|
|
def test_no_slides_raises_error( |
|
|
self, mock_create_dir, mock_cancer_subtype_maps, temp_output_dir |
|
|
): |
|
|
"""Test that no slides uploaded raises gr.Error.""" |
|
|
import gradio as gr |
|
|
|
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
set_cancer_subtype_maps(cancer_subtype_name_map, reversed_map, cancer_subtypes) |
|
|
|
|
|
mock_create_dir.return_value = temp_output_dir |
|
|
|
|
|
|
|
|
gen = analyze_slides( |
|
|
None, |
|
|
None, |
|
|
"Primary", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"", |
|
|
"Biopsy", |
|
|
temp_output_dir, |
|
|
) |
|
|
|
|
|
|
|
|
with pytest.raises(gr.Error): |
|
|
next(gen) |
|
|
|
|
|
@patch("mosaic.ui.app.create_user_directory") |
|
|
def test_settings_mismatch_raises_error( |
|
|
self, |
|
|
mock_create_dir, |
|
|
sample_files_multiple, |
|
|
sample_settings_df, |
|
|
mock_cancer_subtype_maps, |
|
|
temp_output_dir, |
|
|
): |
|
|
"""Test that settings count mismatch raises gr.Error.""" |
|
|
import gradio as gr |
|
|
|
|
|
cancer_subtype_name_map, reversed_map, cancer_subtypes = ( |
|
|
mock_cancer_subtype_maps |
|
|
) |
|
|
set_cancer_subtype_maps(cancer_subtype_name_map, reversed_map, cancer_subtypes) |
|
|
|
|
|
mock_create_dir.return_value = temp_output_dir |
|
|
|
|
|
|
|
|
|
|
|
two_files = sample_files_multiple[:2] |
|
|
|
|
|
gen = analyze_slides( |
|
|
two_files, |
|
|
sample_settings_df, |
|
|
"Primary", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"Unknown", |
|
|
"", |
|
|
"Biopsy", |
|
|
temp_output_dir, |
|
|
) |
|
|
|
|
|
|
|
|
with pytest.raises(gr.Error): |
|
|
next(gen) |
|
|
|