|
| 1 | +"""Test for duplicate mask loading bug fix in S2Product._load_masks. |
| 2 | +
|
| 3 | +This test verifies that when loading a mask with multiple associated bands, |
| 4 | +the mask file is only opened/processed ONCE per associated band, not N times |
| 5 | +(where N is the number of associated bands). |
| 6 | +
|
| 7 | +Bug context: |
| 8 | +In `_load_masks`, when iterating over `associated_bands[band]`, the code was |
| 9 | +appending `band` to `bands_to_load` once per associated band when the mask file |
| 10 | +wasn't on disk. For example, with `associated_bands = {DETFOO: [RED, GREEN, BLUE]}`, |
| 11 | +`bands_to_load` would become `[DETFOO, DETFOO, DETFOO]`, causing each associated |
| 12 | +band mask to be opened 3 times. |
| 13 | +
|
| 14 | +Fix: |
| 15 | +The fix ensures `band` is only appended to `bands_to_load` once, by checking |
| 16 | +`if band not in bands_to_load` before appending. |
| 17 | +""" |
| 18 | + |
| 19 | +from unittest.mock import MagicMock, patch |
| 20 | + |
| 21 | +import pytest |
| 22 | + |
| 23 | +from eoreader.bands import S2MaskBandNames |
| 24 | +from eoreader.keywords import ASSOCIATED_BANDS |
| 25 | +from eoreader.products.optical.s2_product import S2Product |
| 26 | + |
| 27 | + |
| 28 | +class TestS2LoadMasksNoDuplicateLoading: |
| 29 | + """Test that S2Product._load_masks doesn't load the same mask multiple times.""" |
| 30 | + |
| 31 | + @pytest.fixture |
| 32 | + def mock_s2_product(self): |
| 33 | + """Create a mock S2Product instance with necessary attributes mocked.""" |
| 34 | + with patch.object(S2Product, "__init__", lambda self, *args, **kwargs: None): |
| 35 | + prod = S2Product.__new__(S2Product) |
| 36 | + |
| 37 | + # Set required attributes |
| 38 | + prod._processing_baseline = 4.0 |
| 39 | + |
| 40 | + # Mock _sanitized_associated_bands to return our test data |
| 41 | + prod._sanitized_associated_bands = MagicMock() |
| 42 | + |
| 43 | + # Mock _get_band_key to return a predictable key |
| 44 | + def get_band_key(band, assoc_band, **kw): |
| 45 | + band_name = band.name if hasattr(band, "name") else str(band) |
| 46 | + assoc_name = ( |
| 47 | + assoc_band.name |
| 48 | + if hasattr(assoc_band, "name") and assoc_band |
| 49 | + else str(assoc_band) |
| 50 | + if assoc_band |
| 51 | + else "None" |
| 52 | + ) |
| 53 | + return f"{band_name}_{assoc_name}" |
| 54 | + |
| 55 | + prod._get_band_key = MagicMock(side_effect=get_band_key) |
| 56 | + |
| 57 | + # Mock get_band_path to return a path that doesn't exist (forces loading) |
| 58 | + mock_path = MagicMock() |
| 59 | + mock_path.is_file.return_value = False |
| 60 | + prod.get_band_path = MagicMock(return_value=mock_path) |
| 61 | + |
| 62 | + # Mock _open_masks to track what it receives |
| 63 | + prod._open_masks = MagicMock(return_value={}) |
| 64 | + |
| 65 | + yield prod |
| 66 | + |
| 67 | + def test_load_masks_no_duplicates_with_multiple_associated_bands( |
| 68 | + self, mock_s2_product |
| 69 | + ): |
| 70 | + """Verify _open_masks receives bands_to_load without duplicates. |
| 71 | +
|
| 72 | + When a mask (e.g., DETFOO) has multiple associated bands (e.g., [RED, GREEN, BLUE]), |
| 73 | + the band should appear only once in bands_to_load passed to _open_masks. |
| 74 | + """ |
| 75 | + prod = mock_s2_product |
| 76 | + |
| 77 | + # DETFOO mask with 3 associated bands |
| 78 | + associated_bands = {S2MaskBandNames.DETFOO: ["RED", "GREEN", "BLUE"]} |
| 79 | + |
| 80 | + # Mock returns the same associated_bands that we pass as input |
| 81 | + prod._sanitized_associated_bands.return_value = associated_bands |
| 82 | + |
| 83 | + # Call the actual _load_masks method |
| 84 | + prod._load_masks( |
| 85 | + bands=[S2MaskBandNames.DETFOO], |
| 86 | + pixel_size=10, |
| 87 | + size=None, |
| 88 | + **{ASSOCIATED_BANDS: associated_bands}, |
| 89 | + ) |
| 90 | + |
| 91 | + # Verify _open_masks was called |
| 92 | + assert prod._open_masks.called, "_open_masks should have been called" |
| 93 | + |
| 94 | + # Get the bands_to_load argument passed to _open_masks |
| 95 | + call_args = prod._open_masks.call_args |
| 96 | + bands_to_load = call_args[0][0] # First positional argument |
| 97 | + |
| 98 | + # THE KEY ASSERTION: DETFOO should appear exactly once, not 3 times |
| 99 | + detfoo_count = bands_to_load.count(S2MaskBandNames.DETFOO) |
| 100 | + assert detfoo_count == 1, ( |
| 101 | + f"S2MaskBandNames.DETFOO should appear exactly once in bands_to_load, " |
| 102 | + f"but appears {detfoo_count} times. bands_to_load = {bands_to_load}" |
| 103 | + ) |
| 104 | + |
| 105 | + def test_load_masks_multiple_masks_with_associated_bands(self, mock_s2_product): |
| 106 | + """Test with multiple masks, each having multiple associated bands.""" |
| 107 | + prod = mock_s2_product |
| 108 | + |
| 109 | + # DETFOO with 3 associated bands, SATURA with 2 associated bands |
| 110 | + associated_bands = { |
| 111 | + S2MaskBandNames.DETFOO: ["RED", "GREEN", "BLUE"], |
| 112 | + S2MaskBandNames.SATURA: ["RED", "GREEN"], |
| 113 | + } |
| 114 | + |
| 115 | + # Mock returns the same associated_bands that we pass as input |
| 116 | + prod._sanitized_associated_bands.return_value = associated_bands |
| 117 | + |
| 118 | + # Call the actual _load_masks method |
| 119 | + prod._load_masks( |
| 120 | + bands=[S2MaskBandNames.DETFOO, S2MaskBandNames.SATURA], |
| 121 | + pixel_size=10, |
| 122 | + size=None, |
| 123 | + **{ASSOCIATED_BANDS: associated_bands}, |
| 124 | + ) |
| 125 | + |
| 126 | + # Get the bands_to_load argument |
| 127 | + call_args = prod._open_masks.call_args |
| 128 | + bands_to_load = call_args[0][0] |
| 129 | + |
| 130 | + # Each mask should appear exactly once |
| 131 | + assert bands_to_load.count(S2MaskBandNames.DETFOO) == 1 |
| 132 | + assert bands_to_load.count(S2MaskBandNames.SATURA) == 1 |
| 133 | + assert len(bands_to_load) == 2 |
0 commit comments