diff --git a/docs/source/reference/release_gate_geotiff.rst b/docs/source/reference/release_gate_geotiff.rst index 4f98cd3f..03877908 100644 --- a/docs/source/reference/release_gate_geotiff.rst +++ b/docs/source/reference/release_gate_geotiff.rst @@ -228,7 +228,7 @@ Local GeoTIFF read and write - experimental - ``attrs['gdal_metadata_xml']`` is escaped before serialization and does not corrupt the IFD when round-tripped. - - ``xrspatial/geotiff/tests/test_gdal_metadata_xml_escape_1614.py`` + - ``xrspatial/geotiff/tests/unit/test_safe_xml.py`` - `#2340`_ * - ``writer.extra_tags`` - experimental diff --git a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR5.md b/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR5.md deleted file mode 100644 index c7f6c9be..00000000 --- a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR5.md +++ /dev/null @@ -1,126 +0,0 @@ -# CLUSTER_AUDIT_PR5.md — Attrs contract consolidation - -Temporary audit table tracking every old `file::test` and where it -lands in `attrs/test_contract.py`. Deleted on the final commit on the -branch before approval (epic #2390 contract). - -## Source files folded - -- `test_attrs_contract_canonical_1984.py` -- `test_attrs_contract_aliases_1984.py` -- `test_attrs_contract_passthrough_1984.py` -- `test_attrs_contract_version_1984.py` - -All four deleted in the same commit that added `attrs/test_contract.py`. -File count delta: `-4 +1 = -3`. - -## Canonical tier mapping - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_attrs_contract_canonical_1984.py::test_every_canonical_key_present` | `attrs/test_contract.py::test_canonical_every_key_present` | Renamed for the new prefix scheme; logic identical. | -| `test_attrs_contract_canonical_1984.py::test_crs_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[crs]]` | Folded into the per-key parametrize; assertion unchanged. | -| `test_attrs_contract_canonical_1984.py::test_crs_wkt_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[crs_wkt]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_transform_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[transform]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_nodata_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[GDAL_NODATA]]` | Folded; id renamed to surface the underlying TIFF tag. | -| `test_attrs_contract_canonical_1984.py::test_extra_tags_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[extra_tags]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_gdal_metadata_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[gdal_metadata]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_gdal_metadata_xml_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[gdal_metadata_xml]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_resolution_group_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[resolution_group]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_contract_version_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[contract_version]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_georef_status_roundtrip` | `attrs/test_contract.py::test_canonical_value_roundtrip[canonical[georef_status]]` | Folded. | -| `test_attrs_contract_canonical_1984.py::test_canonical_keys_present_per_backend[eager-numpy]` | `attrs/test_contract.py::test_canonical_keys_present_per_backend[canonical[eager-numpy]]` | Param IDs renamed to the dimensional scheme; marker now `requires_gpu` from `_helpers/markers.py`. | -| `test_attrs_contract_canonical_1984.py::test_canonical_keys_present_per_backend[dask-numpy]` | `attrs/test_contract.py::test_canonical_keys_present_per_backend[canonical[dask-numpy]]` | Same. | -| `test_attrs_contract_canonical_1984.py::test_canonical_keys_present_per_backend[gpu]` | `attrs/test_contract.py::test_canonical_keys_present_per_backend[canonical[gpu]]` | Same; marker swapped to `requires_gpu`. | -| `test_attrs_contract_canonical_1984.py::test_canonical_keys_present_per_backend[dask-gpu]` | `attrs/test_contract.py::test_canonical_keys_present_per_backend[canonical[dask-gpu]]` | Same. | -| `test_attrs_contract_canonical_1984.py::test_raster_type_area_omitted_on_roundtrip` | `attrs/test_contract.py::test_canonical_raster_type_area_omitted_on_roundtrip` | Renamed for prefix consistency. | -| `test_attrs_contract_canonical_1984.py::test_raster_type_point_roundtrip` | `attrs/test_contract.py::test_canonical_raster_type_point_roundtrip` | Renamed. | - -## Aliases tier mapping - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_attrs_contract_aliases_1984.py::test_read_uses_nodatavals_when_nodata_absent` | `attrs/test_contract.py::test_alias_read_used_when_nodata_absent[alias[nodatavals->nodata]]` | Folded into the two-alias parametrize. | -| `test_attrs_contract_aliases_1984.py::test_read_uses_fill_value_when_nodata_absent` | `attrs/test_contract.py::test_alias_read_used_when_nodata_absent[alias[_FillValue->nodata]]` | Folded. | -| `test_attrs_contract_aliases_1984.py::test_canonical_nodata_wins_over_aliases_at_resolver` | `attrs/test_contract.py::test_alias_canonical_nodata_wins_at_resolver` | Renamed for prefix. | -| `test_attrs_contract_aliases_1984.py::test_canonical_nodata_wins_over_aliases_at_write` | `attrs/test_contract.py::test_alias_canonical_nodata_wins_at_write` | Renamed; `ConflictingNodataError` import moved to module top. | -| `test_attrs_contract_aliases_1984.py::test_write_does_not_emit_aliases_when_canonical_present` | `attrs/test_contract.py::test_alias_write_does_not_emit_aliases_when_canonical_present` | Renamed. | -| `test_attrs_contract_aliases_1984.py::test_nan_in_nodatavals_is_skipped` | `attrs/test_contract.py::test_alias_nan_in_nodatavals_is_skipped` | Renamed. | - -## Passthrough tier mapping - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_attrs_contract_passthrough_1984.py::test_passthrough_cases_cover_all_keys` | `attrs/test_contract.py::test_passthrough_cases_cover_all_keys` | Verbatim. | -| `test_attrs_contract_passthrough_1984.py::test_passthrough_key_roundtrip[image_description]` | `attrs/test_contract.py::test_passthrough_key_roundtrip[passthrough[image_description]]` | Param ID prefixed with the tier. | -| `test_attrs_contract_passthrough_1984.py::test_passthrough_key_roundtrip[extra_samples]` | `attrs/test_contract.py::test_passthrough_key_roundtrip[passthrough[extra_samples]]` | Same. | -| `test_attrs_contract_passthrough_1984.py::test_passthrough_key_roundtrip[colormap]` | `attrs/test_contract.py::test_passthrough_key_roundtrip[passthrough[colormap]]` | Same. | -| `test_attrs_contract_passthrough_1984.py::test_passthrough_dropped_when_no_crs` | `attrs/test_contract.py::test_passthrough_dropped_when_no_crs` | Verbatim. | -| `test_attrs_contract_passthrough_1984.py::test_passthrough_does_not_promote_to_canonical` | `attrs/test_contract.py::test_passthrough_does_not_promote_to_canonical` | Verbatim. | -| `test_attrs_contract_passthrough_1984.py::test_removed_attrs_not_emitted` | `attrs/test_contract.py::test_passthrough_removed_attrs_not_emitted` | Renamed for prefix. | -| `test_attrs_contract_passthrough_1984.py::test_removed_attrs_absent_after_roundtrip` | `attrs/test_contract.py::test_passthrough_removed_attrs_absent_after_roundtrip` | Renamed. | -| `test_attrs_contract_passthrough_1984.py::test_contract_version_is_current` | dropped | Redundant with `canonical[contract_version]` (which exercises the same stamp on a richer fixture) and the per-backend version-section tests. Removed during review-round fixes. | - -## Version tier mapping - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_attrs_contract_version_1984.py::test_attrs_contract_version_constant_is_current` | `attrs/test_contract.py::test_version_constant_is_current` | Renamed for prefix. | -| `test_attrs_contract_version_1984.py::test_attrs_module_docstring_version_matches_constant` | `attrs/test_contract.py::test_version_module_docstring_matches_constant` | Renamed. | -| `test_attrs_contract_version_1984.py::test_eager_numpy_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_tiff_backend[version[eager-numpy]]` | Folded into TIFF-backend parametrize. | -| `test_attrs_contract_version_1984.py::test_dask_numpy_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_tiff_backend[version[dask-numpy]]` | Folded. | -| `test_attrs_contract_version_1984.py::test_gpu_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_tiff_backend[version[gpu]]` | Folded; marker swapped to `requires_gpu`. | -| `test_attrs_contract_version_1984.py::test_dask_gpu_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_tiff_backend[version[dask-gpu]]` | Folded. | -| `test_attrs_contract_version_1984.py::test_vrt_eager_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_vrt_backend[version[vrt-eager]]` | Folded into VRT-backend parametrize. | -| `test_attrs_contract_version_1984.py::test_vrt_chunked_stamps_contract_version` | `attrs/test_contract.py::test_version_stamp_present_per_vrt_backend[version[vrt-chunked]]` | Folded. | - -## Coverage parity check - -Old surface (deduplicated functions/params): -- Canonical: 1 presence test + 10 per-key value tests + 4 per-backend - params + 2 raster_type tests = 17 cases. -- Aliases: 6 tests (2 read-side, 2 precedence, 1 write-side, 1 NaN). -- Passthrough: 1 covers-all + 3 param cases + 5 misc = 9 cases. -- Version: 2 constant/docstring + 4 TIFF backends + 2 VRT backends = 8. -- Total old: 40 cases. - -New surface: -- Canonical: 1 + 10 (parametrize) + 4 (parametrize) + 2 = 17 cases. -- Aliases: 2 (parametrize) + 4 = 6 cases. -- Passthrough: 1 + 3 (parametrize) + 4 = 8 cases. (The redundant - contract-version stamp test was dropped during review; the same - assertion is exercised by `canonical[contract_version]` and the - per-backend version section.) -- Version: 2 + 4 (parametrize) + 2 (parametrize) = 8 cases. -- Total new: 39 cases. One intentional drop documented above. - -## Out of scope (intentionally untouched) - -- `test_attrs_finalization_parity_2211.py` — parity-flavoured (PR 4). -- `test_attrs_parity_1548.py` — parity (PR 4). -- `test_attrs_kwarg_parity_1561.py` — parity (PR 4). -- `test_release_gate_attrs_contract.py` — release-gate registry (PR 10). -- `test_nodata_attr_aliases_1582.py` — finer-grained alias regression - tests; the original aliases file noted these as a sibling, not as - contract scope. Stays for now; can fold into a later cluster if it - fits. - -## Roundtrip slice deferral - -The epic mentions a future `attrs/test_roundtrip.py`. The four source -files do not contain a clean roundtrip slice that would naturally fall -out during this consolidation: every roundtrip case here is bound to a -specific tier (canonical key presence, alias promotion, passthrough -reconstruction) and is exercised inside that tier's section. Adding a -standalone `test_roundtrip.py` now would duplicate the canonical -fixture without adding coverage. Leaving for a follow-up so it can be -designed against a real coverage gap. - -The natural shape for the deferred file would be a single fixture that -writes every canonical key (plus the two aliases and the three -reconstructible passthrough keys), reads it back, and asserts -structural equality of the read-back attrs dict against a golden -expected dict. The current per-key value checks would stay where they -are (one failure points at one key); the roundtrip file would catch -dict-shape drift (extra keys leaking in, ordering issues in tuple-valued -attrs, etc.). Worth picking up alongside any future contract bump. diff --git a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR8.md b/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR8.md deleted file mode 100644 index 02fa2fc6..00000000 --- a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR8.md +++ /dev/null @@ -1,193 +0,0 @@ -# CLUSTER_AUDIT_PR8.md — Reader-path tests - -Temporary audit table mapping every old `file::test` to its new home in -the `read/` cluster. Deleted in a follow-up commit on the same branch -before merge, per the epic #2390 contract. - -## Cluster split - -PR 8 owns the reader-side cluster. The following eight files land under -`xrspatial/geotiff/tests/read/`: - -- `read/test_basic.py` — minimal read paths, band validation. -- `read/test_dtypes.py` — reader dtype handling (eager / dask / GPU). -- `read/test_compression.py` — decompression-codec round-trips and - bomb caps (DEFLATE / LZW / ZSTD / PACKBITS / LZ4 / LERC / JPEG2000 / - JPEG). -- `read/test_tiling.py` — tile / strip byte-count cap on CPU and GPU. -- `read/test_endianness.py` — big-endian multi-byte read paths. -- `read/test_nodata.py` — nodata propagation on read (GPU helper). -- `read/test_coords.py` — descending / ascending coord round-trip. -- `read/test_streaming.py` — streaming BigTIFF threshold (folds in - `xrspatial/tests/test_geotiff_streaming_bigtiff_threshold_1785.py`). - -PR 3's `read/test_crs.py` (rotated / dropped / missing CRS) is the -parallel sibling and is left for that PR. - -## Folded files - -### `test_band_validation_1673.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_band_validation_1673.py::test_read_to_array_negative_band_rejected` | `read/test_basic.py::TestBandValidationLocal::test_negative_band_rejected` | Renamed, class-grouped. Same assertion. | -| `test_band_validation_1673.py::test_read_to_array_band_equal_to_samples_rejected` | `read/test_basic.py::TestBandValidationLocal::test_band_equal_to_samples_rejected` | Same. | -| `test_band_validation_1673.py::test_read_to_array_band_far_above_samples_rejected` | `read/test_basic.py::TestBandValidationLocal::test_band_far_above_samples_rejected` | Same. | -| `test_band_validation_1673.py::test_read_to_array_valid_band_still_works` | `read/test_basic.py::TestBandValidationLocal::test_valid_band_still_works` | Same. | -| `test_band_validation_1673.py::test_read_to_array_band_none_still_returns_all_bands` | `read/test_basic.py::TestBandValidationLocal::test_band_none_returns_all_bands` | Same. | -| `test_band_validation_1673.py::test_backend_parity_negative_band` | `read/test_basic.py::TestBandValidationBackendParity::test_negative_band` | Class-grouped. | -| `test_band_validation_1673.py::test_backend_parity_band_equal_to_samples` | `read/test_basic.py::TestBandValidationBackendParity::test_band_equal_to_samples` | Class-grouped. | -| (fixture) `multiband_tiff_path` | same fixture in `read/test_basic.py` | Filename in tmp_path renamed `mb_1673.tif` -> `mb_band_validation.tif`. | - -### `test_dtype_read.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_dtype_read.py::TestDtypeEager::*` | `read/test_dtypes.py::TestDtypeEager::*` | Verbatim. Fixture filenames renamed `test_1083_*.tif` -> `dtype_*.tif`. | -| `test_dtype_read.py::TestDtypeDask::*` | `read/test_dtypes.py::TestDtypeDask::*` | Same. | - -### `test_float16_read_1941.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestDtypeMap::*` | `read/test_dtypes.py::TestFloat16DtypeMap::*` | Renamed to disambiguate from generic dtype map tests. Body unchanged. | -| `TestEagerFloat16Read::*` | `read/test_dtypes.py::TestEagerFloat16Read::*` | Verbatim. | -| `TestPredictor3Float16::*` | `read/test_dtypes.py::TestPredictor3Float16::*` | Verbatim. | -| `TestRegressionGuards::*` | `read/test_dtypes.py::TestFloat16RegressionGuards::*` | Class renamed (no name collisions with other regression-guard classes). | - -### `test_float16_read_gpu_1941.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestEagerGPUReadFloat16::*` | `read/test_dtypes.py::TestEagerGPUReadFloat16::*` | Body unchanged. Module-level `pytestmark` skip replaced with per-method `@_gpu_only` since the consolidated file mixes GPU and non-GPU tests. | -| `TestGPUWindowedFloat16::*` | `read/test_dtypes.py::TestGPUWindowedFloat16::*` | Same. | -| `TestDaskGPUFloat16::*` | `read/test_dtypes.py::TestDaskGPUFloat16::*` | Same. | -| `TestGDSPathGatedOffForFloat16::*` | `read/test_dtypes.py::TestGDSPathGatedOffForFloat16::*` | Same. | -| `TestBackendParityFloat16::*` | `read/test_dtypes.py::TestBackendParityFloat16::*` | Same. | -| `TestPredictor3Float16GPU::*` | `read/test_dtypes.py::TestPredictor3Float16GPU::*` | Same. | - -### `test_compression.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestDeflate::*` | `read/test_compression.py::TestDeflate::*` | Verbatim. | -| `TestLZW::*` | `read/test_compression.py::TestLZW::*` | Verbatim. | -| `TestPredictor::*` | `read/test_compression.py::TestPredictor::*` | Verbatim. | -| `TestDispatch::*` | `read/test_compression.py::TestDispatch::*` | Verbatim. | - -### `test_decompression_caps.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestCodecDirect::*` | `read/test_compression.py::TestCodecDirect::*` | Verbatim. | -| `TestZstdDirect::*` | `read/test_compression.py::TestZstdDirect::*` | Verbatim. | -| `TestLz4Direct::*` | `read/test_compression.py::TestLz4Direct::*` | Verbatim. | -| `test_deflate_bomb_rejected` | `read/test_compression.py::test_deflate_bomb_rejected` | Verbatim. | -| `test_zstd_bomb_rejected` | `read/test_compression.py::test_zstd_bomb_rejected` | Verbatim. | -| `test_lz4_bomb_rejected` | `read/test_compression.py::test_lz4_bomb_rejected` | Verbatim. | -| `test_packbits_bomb_rejected` | `read/test_compression.py::test_packbits_bomb_rejected` | Verbatim. | -| `test_legitimate_high_compression_passes` | `read/test_compression.py::test_legitimate_high_compression_passes` | Verbatim. | -| `test_cap_includes_metadata_margin` | `read/test_compression.py::test_cap_includes_metadata_margin` | Verbatim. | -| `TestLercDirect::*` | `read/test_compression.py::TestLercDirect::*` | Verbatim. | -| `TestJpeg2000Direct::*` | `read/test_compression.py::TestJpeg2000Direct::*` | Verbatim. | -| `TestJpegDirect::*` | `read/test_compression.py::TestJpegDirect::*` | Verbatim. | - -### `test_local_tile_byte_cap_1664.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestLocalTileByteCap::*` | `read/test_tiling.py::TestLocalTileByteCap::*` | Verbatim. Fixture filenames renamed `forged_local_*_1664.tif` -> `forged_*.tif`. | -| `TestLocalStripByteCap::*` | `read/test_tiling.py::TestLocalStripByteCap::*` | Same. | -| `test_max_tile_bytes_env_negative_falls_back` | `read/test_tiling.py::test_max_tile_bytes_env_negative_falls_back` | Verbatim. | -| `test_max_tile_bytes_env_zero_falls_back` | `read/test_tiling.py::test_max_tile_bytes_env_zero_falls_back` | Verbatim. | -| `test_max_tile_bytes_env_garbage_falls_back` | `read/test_tiling.py::test_max_tile_bytes_env_garbage_falls_back` | Verbatim. | -| Import: `from ._helpers.tiff_surgery import ...` | `from .._helpers.tiff_surgery import ...` | One-level deeper under `read/`. | - -### `test_gpu_tile_byte_cap_2026_05_18.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestGpuTileByteCap::*` | `read/test_tiling.py::TestGpuTileByteCap::*` | Verbatim. Shares `_build_forged_tiled_cog` helper with the CPU class via a `basename` parameter so the two CPU-vs-GPU forged-tile groups do not collide on `tmp_path`. | -| `TestGpuChunkedTileByteCap::*` | `read/test_tiling.py::TestGpuChunkedTileByteCap::*` | Verbatim. | - -### `test_gpu_byteswap_1508.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_read_geotiff_gpu_big_endian_multibyte[*]` | `read/test_endianness.py::test_read_geotiff_gpu_big_endian_multibyte[*]` | Verbatim. | -| `test_read_geotiff_gpu_big_endian_uncompressed` | `read/test_endianness.py::test_read_geotiff_gpu_big_endian_uncompressed` | Verbatim. | -| `test_xp_byteswap_preserves_dtype` | `read/test_endianness.py::test_xp_byteswap_preserves_dtype` | Verbatim. | -| `test_xp_byteswap_uint8_passthrough` | `read/test_endianness.py::test_xp_byteswap_uint8_passthrough` | Verbatim. | - -### `test_apply_nodata_mask_gpu_inplace_1934.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_apply_nodata_mask_gpu_float_masks_sentinel_to_nan_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_float_masks_sentinel_to_nan` | Issue number dropped from name. Body unchanged. | -| `test_apply_nodata_mask_gpu_float_in_place_no_copy_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_float_in_place_no_copy` | Same. | -| `test_apply_nodata_mask_gpu_float_alloc_count_unchanged_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_float_alloc_count_unchanged` | Same. | -| `test_apply_nodata_mask_gpu_int_promotes_and_masks_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_int_promotes_and_masks` | Same. | -| `test_apply_nodata_mask_gpu_int_no_extra_buffer_after_astype_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_int_no_extra_buffer_after_astype` | Same. | -| `test_apply_nodata_mask_gpu_float_nan_sentinel_noop_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_float_nan_sentinel_noop` | Same. | -| `test_apply_nodata_mask_gpu_none_nodata_passthrough_1934` | `read/test_nodata.py::test_apply_nodata_mask_gpu_none_nodata_passthrough` | Same. | - -### `test_apply_nodata_mask_gpu_with_presence_removed_2208.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_apply_nodata_mask_gpu_with_presence_not_importable_2208` | `read/test_nodata.py::test_apply_nodata_mask_gpu_with_presence_not_importable` | Issue number dropped. Same `ImportError` assertion. | -| `test_apply_nodata_mask_gpu_still_present_2208` | `read/test_nodata.py::test_apply_nodata_mask_gpu_still_present` | Same. | - -### `test_descending_coords_1716.py` (deleted) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_descending_x_roundtrip` | `read/test_coords.py::TestDescendingCoordsRoundTrip::test_descending_x_roundtrip` | Class-grouped. tmp_path filenames renamed (`tmp_1716_desc_x.tif` -> `desc_x.tif`). | -| `test_ascending_y_roundtrip` | `read/test_coords.py::TestDescendingCoordsRoundTrip::test_ascending_y_roundtrip` | Same. | -| `test_descending_x_and_ascending_y_roundtrip` | `read/test_coords.py::TestDescendingCoordsRoundTrip::test_descending_x_and_ascending_y_roundtrip` | Same. | -| `test_north_up_still_uses_pixel_scale_and_tiepoint` | `read/test_coords.py::TestOrientationTagSelection::test_north_up_uses_pixel_scale_and_tiepoint` | Class-grouped, name slimmed. | -| `test_descending_x_uses_transformation_tag` | `read/test_coords.py::TestOrientationTagSelection::test_descending_x_uses_transformation_tag` | Same. | -| `test_ascending_y_uses_transformation_tag` | `read/test_coords.py::TestOrientationTagSelection::test_ascending_y_uses_transformation_tag` | Same. | - -### `xrspatial/tests/test_geotiff_streaming_bigtiff_threshold_1785.py` (deleted — cross-directory move) - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `TestShouldUseBigTIFFStreaming::*` | `read/test_streaming.py::TestShouldUseBigTIFFStreaming::*` | Verbatim. | -| `TestStreamingBigTIFFUserOverride::*` | `read/test_streaming.py::TestStreamingBigTIFFUserOverride::*` | Verbatim. Fixture filenames renamed `*_1785.tif` -> issue-number-free. | - -## Files NOT folded in (justified) - -Several files in the prompt's "key examples" list turned out to be -writer-side or unit-level on inspection and would conflict with another -PR's surface. They are left in place for their natural cluster: - -| File | Reason left in place | -|---|---| -| `test_accuracy_1081.py` | Mixed read/write numerical accuracy with parity surface area; folding into `read/test_basic.py` would expand PR scope beyond the reader-only contract. Defer to PR 11 unit-cleanup. | -| `test_ambiguous_metadata_hooks_1987.py` | Metadata contract / parity surface — overlaps with PR 4 (parity) and PR 5 (attrs contract). | -| `test_assemble_layout_no_bytes_copy_1756.py` | Tests `_assemble_standard_layout`, `_assemble_cog_layout`, `_assemble_tiff` — writer internals. Belongs to PR 7. | -| `test_bytesio_source.py` | Mixed BytesIO read/write; round-trip surface area is large and the file already groups its own concerns coherently. Defer to PR 11. | -| `test_chunked_gpu_declared_dtype_1909.py` | Mixed dtype/dask coverage that overlaps with the parity matrix (PR 4). | -| `test_compression_docstring_1644.py` | Tests `write_geotiff_gpu` docstring + GPU writer codec acceptance — writer-side. Belongs to PR 7. | -| `test_compression_level.py` | Tests `to_geotiff(compression_level=...)` — writer-side. Belongs to PR 7. | -| `test_conflicting_crs_write_1987.py` | Writer-side (CRS conflict on write). Belongs to PR 7. | -| `test_coord_regularity_1720.py` | Tests `_coords_to_transform` validation on the writer path. Belongs to PR 7. | -| `test_coords_1813.py` | Unit tests of `xrspatial.geotiff._coords` helpers — fits `unit/` (PR 11). | -| `test_coords_to_transform_3d_1643.py` | Writer-side coord-to-transform. Belongs to PR 7. | -| `test_predictor2_big_endian.py` / `test_predictor2_big_endian_gpu_1517.py` / `test_predictor3_big_endian.py` / `test_predictor3_int_dtype*` / `test_predictor_fp_write_*` | Predictor coverage overlaps with the writer codec matrix (PR 7) and the parity matrix (PR 4). Defer to a future endianness/predictor sub-cluster rather than risk colliding mid-PR. | - -## Verification - -- 134 tests collected in `xrspatial/geotiff/tests/read/` after PR 8 (8 - modules, including PR 3's `test_crs.py` once that PR lands). -- Total `test_*.py` files removed across the PR: 13 (12 inside - `geotiff/tests/`, plus the one cross-directory move from - `xrspatial/tests/test_geotiff_streaming_bigtiff_threshold_1785.py`). -- New `test_*.py` files added under `read/`: 8 (plus the empty - `__init__.py`). -- Net delta inside `geotiff/tests/`: -12 + 8 = -4 `test_*.py` files - (`find xrspatial/geotiff/tests -name 'test_*.py' | wc -l` goes from - 352 to 348). -- Net delta inside `xrspatial/tests/`: -1 `test_*.py` file. -- Total PR-wide `test_*.py` delta: -5. diff --git a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR9.md b/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR9.md deleted file mode 100644 index 985a7ea1..00000000 --- a/xrspatial/geotiff/tests/CLUSTER_AUDIT_PR9.md +++ /dev/null @@ -1,246 +0,0 @@ -# CLUSTER_AUDIT_PR9.md - -PR 9 of the GeoTIFF test consolidation epic (#2390): fold the -integration / HTTP / dask-pipeline cluster into three files under -`xrspatial/geotiff/tests/integration/`. - -This file is deleted on the final commit on this branch before the PR -is approved (epic convention). - -Each old file lands as one named section inside the consolidated module. -Helper functions, fixtures, and classes are suffixed with the section id -(`_
`) so cross-section names cannot collide. Top-level -`autouse=True` fixtures from each source file lose their autouse flag and -apply via an explicit `@pytest.mark.usefixtures(...)` marker on the tests -and classes of that section, so a fixture that monkey-patches a global -like `XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS=1` no longer leaks to tests -that need the production default (the `scheme_case` SSRF rejection tests). - -Issue-number suffixes on test names (`_2266`, `_2026_05_15`, `_issue_A`) -are stripped per epic convention. Issue numbers are preserved in git log -and PR descriptions. - -For parametrised tests, the "New `file::test_id`" column lists the first -collected parametrize variant. A single row in this table can therefore -cover several parametrize variants of the same test function -- the -original test moved as one unit and pytest expands the matrix from the -preserved `@pytest.mark.parametrize` decorators. - -## HTTP sources -> `integration/test_http_sources.py` - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_http_band_validation_1695.py::test_http_negative_band_rejected` | `integration/test_http_sources.py::test_http_negative_band_rejected` | | -| `test_http_band_validation_1695.py::test_http_negative_band_rejected_via_low_level` | `integration/test_http_sources.py::test_http_negative_band_rejected_via_low_level` | | -| `test_http_band_validation_1695.py::test_http_band_equal_to_samples_rejected` | `integration/test_http_sources.py::test_http_band_equal_to_samples_rejected` | | -| `test_http_band_validation_1695.py::test_http_band_far_above_samples_rejected` | `integration/test_http_sources.py::test_http_band_far_above_samples_rejected` | | -| `test_http_band_validation_1695.py::test_http_nonzero_band_on_single_band_rejected` | `integration/test_http_sources.py::test_http_nonzero_band_on_single_band_rejected` | | -| `test_http_band_validation_1695.py::test_http_band_zero_on_single_band_still_works` | `integration/test_http_sources.py::test_http_band_zero_on_single_band_still_works` | | -| `test_http_band_validation_1695.py::test_http_band_none_returns_all_bands` | `integration/test_http_sources.py::test_http_band_none_returns_all_bands` | | -| `test_http_band_validation_1695.py::test_local_and_http_negative_band_parity` | `integration/test_http_sources.py::test_local_and_http_negative_band_parity` | | -| `test_http_band_validation_1695.py::test_local_and_http_band_equal_to_samples_parity` | `integration/test_http_sources.py::test_local_and_http_band_equal_to_samples_parity` | | -| `test_http_band_validation_1695.py::test_local_and_http_single_band_nonzero_parity` | `integration/test_http_sources.py::test_local_and_http_single_band_nonzero_parity` | | -| `test_http_band_validation_1695.py::test_open_geotiff_http_negative_band_rejected` | `integration/test_http_sources.py::test_open_geotiff_http_negative_band_rejected` | | -| `test_http_cog_coalesce.py::test_coalesce_empty_input` | `integration/test_http_sources.py::test_coalesce_empty_input` | | -| `test_http_cog_coalesce.py::test_coalesce_single_range` | `integration/test_http_sources.py::test_coalesce_single_range` | | -| `test_http_cog_coalesce.py::test_coalesce_merges_adjacent_ranges` | `integration/test_http_sources.py::test_coalesce_merges_adjacent_ranges` | | -| `test_http_cog_coalesce.py::test_coalesce_does_not_merge_when_gap_exceeds_threshold` | `integration/test_http_sources.py::test_coalesce_does_not_merge_when_gap_exceeds_threshold` | | -| `test_http_cog_coalesce.py::test_coalesce_with_unsorted_input` | `integration/test_http_sources.py::test_coalesce_with_unsorted_input` | | -| `test_http_cog_coalesce.py::test_coalesce_negative_threshold_disables_merging` | `integration/test_http_sources.py::test_coalesce_negative_threshold_disables_merging` | | -| `test_http_cog_coalesce.py::test_coalesce_split_recovers_per_tile_bytes` | `integration/test_http_sources.py::test_coalesce_split_recovers_per_tile_bytes` | | -| `test_http_cog_coalesce.py::test_coalesce_caps_merged_range_size_2266` | `integration/test_http_sources.py::test_coalesce_caps_merged_range_size` | | -| `test_http_cog_coalesce.py::test_coalesce_cap_round_trips_bytes_2266` | `integration/test_http_sources.py::test_coalesce_cap_round_trips_bytes` | | -| `test_http_cog_coalesce.py::test_coalesce_default_cap_bounds_adversarial_input_2266` | `integration/test_http_sources.py::test_coalesce_default_cap_bounds_adversarial_input` | | -| `test_http_cog_coalesce.py::test_coalesce_cap_zero_disables_size_check_2266` | `integration/test_http_sources.py::test_coalesce_cap_zero_disables_size_check` | | -| `test_http_cog_coalesce.py::test_coalesce_cap_does_not_split_legitimate_back_to_back_2266` | `integration/test_http_sources.py::test_coalesce_cap_does_not_split_legitimate_back_to_back` | | -| `test_http_cog_coalesce.py::test_coalesce_cap_respects_env_override_2266` | `integration/test_http_sources.py::test_coalesce_cap_respects_env_override` | | -| `test_http_cog_coalesce.py::test_coalesce_cap_preserves_oversized_single_input_2266` | `integration/test_http_sources.py::test_coalesce_cap_preserves_oversized_single_input` | | -| `test_http_cog_coalesce.py::test_http_source_read_ranges_coalesced_respects_cap_2266` | `integration/test_http_sources.py::test_http_source_read_ranges_coalesced_respects_cap` | | -| `test_http_cog_coalesce.py::test_read_cog_http_uses_coalesced_fetches` | `integration/test_http_sources.py::test_read_cog_http_uses_coalesced_fetches` | | -| `test_http_cog_coalesce.py::test_read_cog_http_perf_with_mock_rtt` | `integration/test_http_sources.py::test_read_cog_http_perf_with_mock_rtt` | | -| `test_http_cog_coalesce.py::test_dask_local_correctness` | `integration/test_http_sources.py::test_dask_local_correctness` | | -| `test_http_cog_coalesce.py::test_dask_http_parses_ifds_once` | `integration/test_http_sources.py::test_dask_http_parses_ifds_once` | | -| `test_http_cog_range_contract_2286.py::test_windowed_tile_read_bounded_bytes_and_range_count` | `integration/test_http_sources.py::test_windowed_tile_read_bounded_bytes_and_range_count` | | -| `test_http_cog_range_contract_2286.py::test_windowed_multi_tile_read_range_count_bounded` | `integration/test_http_sources.py::test_windowed_multi_tile_read_range_count_bounded` | | -| `test_http_cog_range_contract_2286.py::test_overview_read_does_not_fetch_full_resolution_pixels` | `integration/test_http_sources.py::test_overview_read_does_not_fetch_full_resolution_pixels` | | -| `test_http_cog_range_contract_2286.py::test_band_selection_multiband_chunky_bounded_reads` | `integration/test_http_sources.py::test_band_selection_multiband_chunky_bounded_reads` | | -| `test_http_cog_range_contract_2286.py::test_band_selection_with_window_bounded_range_count` | `integration/test_http_sources.py::test_band_selection_with_window_bounded_range_count` | | -| `test_http_cog_range_contract_2286.py::test_dask_read_parses_ifds_once_across_chunks` | `integration/test_http_sources.py::test_dask_read_parses_ifds_once_across_chunks` | | -| `test_http_cog_range_contract_2286.py::test_dask_header_gets_independent_of_chunk_count` | `integration/test_http_sources.py::test_dask_header_gets_independent_of_chunk_count` | | -| `test_http_cog_range_contract_2286.py::test_truncated_cog_closes_http_source` | `integration/test_http_sources.py::test_truncated_cog_closes_http_source` | | -| `test_http_cog_range_contract_2286.py::test_malformed_ifd_chain_closes_http_source` | `integration/test_http_sources.py::test_malformed_ifd_chain_closes_http_source` | | -| `test_http_cog_range_contract_2286.py::test_short_body_during_pixel_fetch_closes_source` | `integration/test_http_sources.py::test_short_body_during_pixel_fetch_closes_source` | | -| `test_http_cog_range_contract_2286.py::test_coalesce_does_not_silently_exceed_explicit_cap` | `integration/test_http_sources.py::test_coalesce_does_not_silently_exceed_explicit_cap` | | -| `test_http_cog_range_contract_2286.py::test_coalesce_default_cap_bounds_adversarial_input` | `integration/test_http_sources.py::test_coalesce_default_cap_bounds_adversarial_input` | | -| `test_http_cog_range_contract_2286.py::test_coalesced_get_size_capped_on_real_http_source` | `integration/test_http_sources.py::test_coalesced_get_size_capped_on_real_http_source` | | -| `test_http_cog_range_contract_2286.py::test_split_coalesced_bytes_round_trips_under_cap` | `integration/test_http_sources.py::test_split_coalesced_bytes_round_trips_under_cap` | | -| `test_http_cog_range_contract_2286.py::test_loopback_end_to_end_windowed_byte_budget` | `integration/test_http_sources.py::test_loopback_end_to_end_windowed_byte_budget` | | -| `test_http_dask_allow_rotated_2130.py::test_http_dask_rotated_default_raises` | `integration/test_http_sources.py::test_http_dask_rotated_default_raises` | | -| `test_http_dask_allow_rotated_2130.py::test_http_dask_rotated_allow_rotated_reads` | `integration/test_http_sources.py::test_http_dask_rotated_allow_rotated_reads` | | -| `test_http_dask_orientation_1794.py::test_http_dask_read_rejects_non_default_orientation` | `integration/test_http_sources.py::test_http_dask_read_rejects_non_default_orientation` | | -| `test_http_meta_buffer_1718.py::test_small_cog_uses_single_initial_read` | `integration/test_http_sources.py::test_small_cog_uses_single_initial_read` | | -| `test_http_meta_buffer_1718.py::test_ifd_chain_past_64kib_resolves` | `integration/test_http_sources.py::test_ifd_chain_past_64kib_resolves` | | -| `test_http_meta_buffer_1718.py::test_end_to_end_http_read_with_big_metadata` | `integration/test_http_sources.py::test_end_to_end_http_read_with_big_metadata` | | -| `test_http_meta_buffer_1718.py::test_cap_raises_clear_error_on_excessive_chain` | `integration/test_http_sources.py::test_cap_raises_clear_error_on_excessive_chain` | | -| `test_http_no_stdlib_fallback_2050.py::test_urllib3_is_importable` | `integration/test_http_sources.py::test_urllib3_is_importable` | | -| `test_http_no_stdlib_fallback_2050.py::test_reader_imports_urllib3_at_module_level` | `integration/test_http_sources.py::test_reader_imports_urllib3_at_module_level` | | -| `test_http_no_stdlib_fallback_2050.py::test_get_http_pool_returns_a_pool_manager` | `integration/test_http_sources.py::test_get_http_pool_returns_a_pool_manager` | | -| `test_http_no_stdlib_fallback_2050.py::test_stdlib_opener_helper_is_removed` | `integration/test_http_sources.py::test_stdlib_opener_helper_is_removed` | | -| `test_http_no_stdlib_fallback_2050.py::test_validating_redirect_handler_is_removed` | `integration/test_http_sources.py::test_validating_redirect_handler_is_removed` | | -| `test_http_no_stdlib_fallback_2050.py::test_reader_does_not_import_urllib_request` | `integration/test_http_sources.py::test_reader_does_not_import_urllib_request` | | -| `test_http_no_stdlib_fallback_2050.py::test_read_range_source_has_no_stdlib_branch` | `integration/test_http_sources.py::test_read_range_source_has_no_stdlib_branch` | | -| `test_http_no_stdlib_fallback_2050.py::test_read_all_source_has_no_stdlib_branch` | `integration/test_http_sources.py::test_read_all_source_has_no_stdlib_branch` | | -| `test_http_no_stdlib_fallback_2050.py::test_read_range_uses_urllib3_pool` | `integration/test_http_sources.py::test_read_range_uses_urllib3_pool` | | -| `test_http_no_stdlib_fallback_2050.py::test_read_all_uses_urllib3_pool` | `integration/test_http_sources.py::test_read_all_uses_urllib3_pool` | | -| `test_http_no_stdlib_fallback_2050.py::test_read_range_short_circuits_zero_length` | `integration/test_http_sources.py::test_read_range_short_circuits_zero_length` | | -| `test_http_no_stdlib_fallback_2050.py::test_install_requires_lists_urllib3` | `integration/test_http_sources.py::test_install_requires_lists_urllib3` | | -| `test_http_orientation_1717.py::test_http_full_read_matches_local_for_orientation` | `integration/test_http_sources.py::test_http_full_read_matches_local_for_orientation[2]` | | -| `test_http_orientation_1717.py::test_http_windowed_read_rejects_non_default_orientation` | `integration/test_http_sources.py::test_http_windowed_read_rejects_non_default_orientation[5]` | | -| `test_http_orientation_1717.py::test_http_default_orientation_still_works` | `integration/test_http_sources.py::test_http_default_orientation_still_works` | | -| `test_http_range_validation_1735.py::test_range_request_ignored_for_nonzero_start_raises` | `integration/test_http_sources.py::test_range_request_ignored_for_nonzero_start_raises` | | -| `test_http_range_validation_1735.py::test_range_request_wrong_content_range_raises` | `integration/test_http_sources.py::test_range_request_wrong_content_range_raises` | | -| `test_http_range_validation_1735.py::test_range_request_short_body_raises` | `integration/test_http_sources.py::test_range_request_short_body_raises` | | -| `test_http_range_validation_1735.py::test_range_request_well_formed_succeeds` | `integration/test_http_sources.py::test_range_request_well_formed_succeeds` | | -| `test_http_range_validation_1735.py::test_read_range_zero_length_returns_empty_without_request` | `integration/test_http_sources.py::test_read_range_zero_length_returns_empty_without_request` | | -| `test_http_range_validation_1735.py::test_range_ignored_200_oversize_rejected_via_content_length` | `integration/test_http_sources.py::test_range_ignored_200_oversize_rejected_via_content_length` | | -| `test_http_range_validation_1735.py::test_range_ignored_200_full_object_sliced_within_cap` | `integration/test_http_sources.py::test_range_ignored_200_full_object_sliced_within_cap` | | -| `test_http_range_validation_1735.py::test_range_ignored_200_short_body_returned_as_is` | `integration/test_http_sources.py::test_range_ignored_200_short_body_returned_as_is` | | -| `test_http_range_validation_1735.py::test_range_ignored_200_no_content_length_is_streamed_and_capped` | `integration/test_http_sources.py::test_range_ignored_200_no_content_length_is_streamed_and_capped` | | -| `test_http_range_validation_1735.py::test_range_request_uses_streaming_response` | `integration/test_http_sources.py::test_range_request_uses_streaming_response` | | -| `test_http_read_all_bounded_2051.py::test_budget_uses_max_strip_end_plus_slack` | `integration/test_http_sources.py::test_budget_uses_max_strip_end_plus_slack` | | -| `test_http_read_all_bounded_2051.py::test_budget_empty_strip_table_falls_back_to_per_strip_cap` | `integration/test_http_sources.py::test_budget_empty_strip_table_falls_back_to_per_strip_cap` | | -| `test_http_read_all_bounded_2051.py::test_budget_all_sparse_falls_back_to_per_strip_cap` | `integration/test_http_sources.py::test_budget_all_sparse_falls_back_to_per_strip_cap` | | -| `test_http_read_all_bounded_2051.py::test_read_all_no_budget_returns_full_body` | `integration/test_http_sources.py::test_read_all_no_budget_returns_full_body` | | -| `test_http_read_all_bounded_2051.py::test_read_all_rejects_oversized_content_length` | `integration/test_http_sources.py::test_read_all_rejects_oversized_content_length` | | -| `test_http_read_all_bounded_2051.py::test_read_all_truncates_when_server_lies_about_content_length_small` | `integration/test_http_sources.py::test_read_all_truncates_when_server_lies_about_content_length_small` | | -| `test_http_read_all_bounded_2051.py::test_read_all_catches_missing_content_length` | `integration/test_http_sources.py::test_read_all_catches_missing_content_length` | | -| `test_http_read_all_bounded_2051.py::test_read_all_passes_when_body_fits_budget` | `integration/test_http_sources.py::test_read_all_passes_when_body_fits_budget` | | -| `test_http_read_all_bounded_2051.py::test_full_image_http_read_still_works_for_legitimate_cog` | `integration/test_http_sources.py::test_full_image_http_read_still_works_for_legitimate_cog` | | -| `test_http_read_all_bounded_2051.py::test_full_image_http_read_rejects_padded_body` | `integration/test_http_sources.py::test_full_image_http_read_rejects_padded_body` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_http_schemes_match` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_http_schemes_match[HTTPS://example.com/x.tif]` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_non_http_schemes_do_not_match` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_non_http_schemes_do_not_match[C:\\windows\\file.tif]` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_non_string_does_not_match` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_non_string_does_not_match[42]` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_empty_string_does_not_match` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_empty_string_does_not_match` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_scheme_only_prefix_does_not_match` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_scheme_only_prefix_does_not_match` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_scheme_colon_no_slashes_classifies_as_http` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_scheme_colon_no_slashes_classifies_as_http` | | -| `test_http_scheme_case_2321.py::TestIsHttpSourceHelper::test_open_source_http_colon_no_hostname_raises` | `integration/test_http_sources.py::TestIsHttpSourceHelper_http_scheme_case::test_open_source_http_colon_no_hostname_raises` | | -| `test_http_scheme_case_2321.py::TestOpenSourceRoutesUppercase::test_uppercase_http_routes_to_http_source` | `integration/test_http_sources.py::TestOpenSourceRoutesUppercase_http_scheme_case::test_uppercase_http_routes_to_http_source` | | -| `test_http_scheme_case_2321.py::TestOpenSourceRoutesUppercase::test_uppercase_https_routes_to_http_source` | `integration/test_http_sources.py::TestOpenSourceRoutesUppercase_http_scheme_case::test_uppercase_https_routes_to_http_source` | | -| `test_http_scheme_case_2321.py::TestOpenSourceRoutesUppercase::test_mixed_case_routes_to_http_source` | `integration/test_http_sources.py::TestOpenSourceRoutesUppercase_http_scheme_case::test_mixed_case_routes_to_http_source` | | -| `test_http_scheme_case_2321.py::TestDispatchBooleansAreCaseInsensitive::test_helper_recognizes_uppercase` | `integration/test_http_sources.py::TestDispatchBooleansAreCaseInsensitive_http_scheme_case::test_helper_recognizes_uppercase[HTTPS://example.com/x.tif]` | | -| `test_http_scheme_case_2321.py::TestDispatchBooleansAreCaseInsensitive::test_is_fsspec_uri_excludes_uppercase_http` | `integration/test_http_sources.py::TestDispatchBooleansAreCaseInsensitive_http_scheme_case::test_is_fsspec_uri_excludes_uppercase_http` | | -| `test_http_scheme_case_2321.py::TestDispatchBooleansAreCaseInsensitive::test_writer_is_fsspec_uri_excludes_uppercase_http` | `integration/test_http_sources.py::TestDispatchBooleansAreCaseInsensitive_http_scheme_case::test_writer_is_fsspec_uri_excludes_uppercase_http` | | -| `test_http_scheme_case_2321.py::TestDispatchBooleansAreCaseInsensitive::test_sidecar_helper_is_case_insensitive` | `integration/test_http_sources.py::TestDispatchBooleansAreCaseInsensitive_http_scheme_case::test_sidecar_helper_is_case_insensitive` | | -| `test_http_scheme_case_2321.py::TestUppercaseSchemeStillRejectsPrivateHosts::test_private_host_rejected_regardless_of_scheme_case` | `integration/test_http_sources.py::TestUppercaseSchemeStillRejectsPrivateHosts_http_scheme_case::test_private_host_rejected_regardless_of_scheme_case[127.0.0.1-HTTPS]` | | -| `test_http_scheme_case_2321.py::TestUppercaseSchemeStillRejectsPrivateHosts::test_localhost_rejected_regardless_of_scheme_case` | `integration/test_http_sources.py::TestUppercaseSchemeStillRejectsPrivateHosts_http_scheme_case::test_localhost_rejected_regardless_of_scheme_case[HTTPS]` | | -| `test_http_scheme_case_2321.py::TestUppercaseSchemeStillRejectsPrivateHosts::test_uppercase_scheme_to_127_literal_rejected` | `integration/test_http_sources.py::TestUppercaseSchemeStillRejectsPrivateHosts_http_scheme_case::test_uppercase_scheme_to_127_literal_rejected[HTTP]` | | -| `test_http_scheme_case_2321.py::TestUppercaseSchemeStillRejectsPrivateHosts::test_open_source_uppercase_private_host_raises` | `integration/test_http_sources.py::TestUppercaseSchemeStillRejectsPrivateHosts_http_scheme_case::test_open_source_uppercase_private_host_raises` | | -| `test_http_scheme_case_2321.py::TestWriterRejectsHttpTargets::test_write_bytes_rejects_http` | `integration/test_http_sources.py::TestWriterRejectsHttpTargets_http_scheme_case::test_write_bytes_rejects_http[HTTP://example.com/x.tif]` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_stripped_http_fetches_only_intersecting_strips` | `integration/test_http_sources.py::test_windowed_stripped_http_fetches_only_intersecting_strips` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_max_pixels_honoured_for_stripped_http_read` | `integration/test_http_sources.py::test_windowed_max_pixels_honoured_for_stripped_http_read` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_max_pixels_too_small_raises` | `integration/test_http_sources.py::test_windowed_max_pixels_too_small_raises` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_full_stripped_http_read_honours_caller_max_pixels` | `integration/test_http_sources.py::test_full_stripped_http_read_honours_caller_max_pixels` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_stripped_http_matches_full_read` | `integration/test_http_sources.py::test_windowed_stripped_http_matches_full_read[window2]` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_strip_byte_cap_skips_unrelated_oversized_strip` | `integration/test_http_sources.py::test_windowed_strip_byte_cap_skips_unrelated_oversized_strip` | | -| `test_http_stripped_window_max_pixels_issue_A_1842.py::test_windowed_strip_decoded_dim_guard_rejects_oversized_strip` | `integration/test_http_sources.py::test_windowed_strip_decoded_dim_guard_rejects_oversized_strip` | | -| `test_http_window_band_planar_1669.py::test_http_window_parity_single_band` | `integration/test_http_sources.py::test_http_window_parity_single_band` | | -| `test_http_window_band_planar_1669.py::test_http_window_parity_full_tile_aligned` | `integration/test_http_sources.py::test_http_window_parity_full_tile_aligned` | | -| `test_http_window_band_planar_1669.py::test_http_window_via_read_to_array_low_level` | `integration/test_http_sources.py::test_http_window_via_read_to_array_low_level` | | -| `test_http_window_band_planar_1669.py::test_http_window_via_low_level_read_cog_http` | `integration/test_http_sources.py::test_http_window_via_low_level_read_cog_http` | | -| `test_http_window_band_planar_1669.py::test_http_window_out_of_bounds_rejected` | `integration/test_http_sources.py::test_http_window_out_of_bounds_rejected` | | -| `test_http_window_band_planar_1669.py::test_http_band_parity_multi_band` | `integration/test_http_sources.py::test_http_band_parity_multi_band` | | -| `test_http_window_band_planar_1669.py::test_http_band_parity_via_read_to_array` | `integration/test_http_sources.py::test_http_band_parity_via_read_to_array` | | -| `test_http_window_band_planar_1669.py::test_http_window_and_band_combined` | `integration/test_http_sources.py::test_http_window_and_band_combined` | | -| `test_http_window_band_planar_1669.py::test_http_planar2_full_read` | `integration/test_http_sources.py::test_http_planar2_full_read` | | -| `test_http_window_band_planar_1669.py::test_http_planar2_windowed` | `integration/test_http_sources.py::test_http_planar2_windowed` | | -| `test_http_window_band_planar_1669.py::test_http_planar2_band_selection` | `integration/test_http_sources.py::test_http_planar2_band_selection` | | -| `test_http_window_band_planar_1669.py::test_http_window_on_oriented_tiff_rejected` | `integration/test_http_sources.py::test_http_window_on_oriented_tiff_rejected` | | -| `test_cog_http_close_on_error_1816.py::test_http_source_closed_on_success` | `integration/test_http_sources.py::test_http_source_closed_on_success` | | -| `test_cog_http_close_on_error_1816.py::test_http_source_closed_when_tile_fetch_raises` | `integration/test_http_sources.py::test_http_source_closed_when_tile_fetch_raises` | | -| `test_cog_http_close_on_error_1816.py::test_http_source_closed_when_post_processing_raises` | `integration/test_http_sources.py::test_http_source_closed_when_post_processing_raises` | | -| `test_cog_http_concurrent.py::test_read_ranges_returns_results_in_input_order` | `integration/test_http_sources.py::test_read_ranges_returns_results_in_input_order` | | -| `test_cog_http_concurrent.py::test_read_ranges_empty_list` | `integration/test_http_sources.py::test_read_ranges_empty_list` | | -| `test_cog_http_concurrent.py::test_read_ranges_single_request_skips_pool` | `integration/test_http_sources.py::test_read_ranges_single_request_skips_pool` | | -| `test_cog_http_concurrent.py::test_read_ranges_dispatches_concurrently` | `integration/test_http_sources.py::test_read_ranges_dispatches_concurrently` | | -| `test_cog_http_concurrent.py::test_cog_http_round_trip_matches_local_read` | `integration/test_http_sources.py::test_cog_http_round_trip_matches_local_read` | | -| `test_cog_http_concurrent.py::test_read_to_array_dispatches_to_http` | `integration/test_http_sources.py::test_read_to_array_dispatches_to_http` | | -| `test_cog_http_parallel_decode_2026_05_15.py::test_parallel_decode_matches_reference` | `integration/test_http_sources.py::test_parallel_decode_matches_reference` | | -| `test_cog_http_parallel_decode_2026_05_15.py::test_serial_decode_matches_reference` | `integration/test_http_sources.py::test_serial_decode_matches_reference` | | -| `test_cog_http_parallel_decode_2026_05_15.py::test_parallel_pool_used_above_threshold` | `integration/test_http_sources.py::test_parallel_pool_used_above_threshold` | | -| `test_cog_http_parallel_decode_2026_05_15.py::test_serial_path_below_threshold` | `integration/test_http_sources.py::test_serial_path_below_threshold` | | -| `test_cog_http_parallel_decode_2026_05_15.py::test_each_tile_decoded_once` | `integration/test_http_sources.py::test_each_tile_decoded_once` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_sentinel_returns_default` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_sentinel_returns_default` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_none_disables_check` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_none_disables_check` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_int_kwarg_wins` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_int_kwarg_wins` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_env_override` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_env_override` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_kwarg_overrides_env` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_kwarg_overrides_env` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_invalid_env_falls_back_to_default` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_invalid_env_falls_back_to_default` | | -| `test_cloud_read_byte_limit_1928.py::TestResolveMaxCloudBytes::test_zero_or_negative_env_falls_back` | `integration/test_http_sources.py::TestResolveMaxCloudBytes_cloud_read_byte_limit::test_zero_or_negative_env_falls_back` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_small_cloud_object_under_budget_reads` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_small_cloud_object_under_budget_reads` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_oversized_cloud_object_rejected_before_read` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_oversized_cloud_object_rejected_before_read` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_none_disables_limit` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_none_disables_limit` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_env_var_threshold_applied` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_env_var_threshold_applied` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_open_geotiff_plumbs_max_cloud_bytes` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_open_geotiff_plumbs_max_cloud_bytes` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_local_file_unaffected` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_local_file_unaffected` | | -| `test_cloud_read_byte_limit_1928.py::TestCloudByteLimit::test_http_path_unaffected` | `integration/test_http_sources.py::TestCloudByteLimit_cloud_read_byte_limit::test_http_path_unaffected` | | - -## Dask pipeline + accessor -> `integration/test_dask_pipeline.py` - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_dask_chunk_tile_misalignment.py::test_chunk_smaller_than_tile` | `integration/test_dask_pipeline.py::test_chunk_smaller_than_tile` | | -| `test_dask_chunk_tile_misalignment.py::test_chunk_larger_than_tile_nonmultiple` | `integration/test_dask_pipeline.py::test_chunk_larger_than_tile_nonmultiple` | | -| `test_dask_chunk_tile_misalignment.py::test_chunk_tuple_doubly_unaligned` | `integration/test_dask_pipeline.py::test_chunk_tuple_doubly_unaligned` | | -| `test_dask_int_nodata_chunks_1597.py::test_eager_promotes_to_float64_and_masks` | `integration/test_dask_pipeline.py::test_eager_promotes_to_float64_and_masks` | | -| `test_dask_int_nodata_chunks_1597.py::test_dask_chunks_4_matches_eager` | `integration/test_dask_pipeline.py::test_dask_chunks_4_matches_eager` | | -| `test_dask_int_nodata_chunks_1597.py::test_dask_chunks_2_per_chunk_dtype_uniform` | `integration/test_dask_pipeline.py::test_dask_chunks_2_per_chunk_dtype_uniform` | | -| `test_dask_int_nodata_chunks_1597.py::test_dask_keeps_dtype_for_out_of_range_sentinel` | `integration/test_dask_pipeline.py::test_dask_keeps_dtype_for_out_of_range_sentinel` | | -| `test_dask_int_nodata_chunks_1597.py::test_dask_float_input_with_sentinel_in_one_chunk` | `integration/test_dask_pipeline.py::test_dask_float_input_with_sentinel_in_one_chunk` | | -| `test_dask_max_pixels_default_guard_1838.py::test_default_max_pixels_guard_fires_for_full_region` | `integration/test_dask_pipeline.py::test_default_max_pixels_guard_fires_for_full_region` | | -| `test_dask_max_pixels_default_guard_1838.py::test_explicit_max_pixels_still_enforced` | `integration/test_dask_pipeline.py::test_explicit_max_pixels_still_enforced` | | -| `test_dask_max_pixels_default_guard_1838.py::test_small_region_unaffected` | `integration/test_dask_pipeline.py::test_small_region_unaffected` | | -| `test_dask_no_op_astype_1624.py::test_uint16_mask_path_still_promotes` | `integration/test_dask_pipeline.py::test_uint16_mask_path_still_promotes` | | -| `test_dask_no_op_astype_1624.py::test_astype_skipped_when_dtypes_match` | `integration/test_dask_pipeline.py::test_astype_skipped_when_dtypes_match` | | -| `test_dask_no_op_astype_1624.py::test_caller_supplied_dtype_still_casts` | `integration/test_dask_pipeline.py::test_caller_supplied_dtype_still_casts` | | -| `test_dask_overview_level.py::test_dask_overview_level_zero_matches_full_res` | `integration/test_dask_pipeline.py::test_dask_overview_level_zero_matches_full_res` | | -| `test_dask_overview_level.py::test_dask_overview_level_one_returns_half_res` | `integration/test_dask_pipeline.py::test_dask_overview_level_one_returns_half_res` | | -| `test_dask_overview_level.py::test_dask_overview_level_two_returns_quarter_res` | `integration/test_dask_pipeline.py::test_dask_overview_level_two_returns_quarter_res` | | -| `test_dask_overview_level.py::test_dask_overview_level_none_returns_full_res` | `integration/test_dask_pipeline.py::test_dask_overview_level_none_returns_full_res` | | -| `test_dask_planar_multiband.py::test_dask_planar_multiband_matches_numpy` | `integration/test_dask_pipeline.py::test_dask_planar_multiband_matches_numpy[uint8-4-False-separate]` | | -| `test_dask_planar_multiband.py::test_dask_planar_separate_chunks_tuple` | `integration/test_dask_pipeline.py::test_dask_planar_separate_chunks_tuple` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1x1::test_1x1_chunk_matches_shape` | `integration/test_dask_pipeline.py::TestStreamingWrite1x1_dask_streaming_write_degenerate::test_1x1_chunk_matches_shape` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1x1::test_1x1_with_nodata_attr` | `integration/test_dask_pipeline.py::TestStreamingWrite1x1_dask_streaming_write_degenerate::test_1x1_with_nodata_attr` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1x1::test_1x1_uint16` | `integration/test_dask_pipeline.py::TestStreamingWrite1x1_dask_streaming_write_degenerate::test_1x1_uint16` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1xN::test_1xN_single_chunk` | `integration/test_dask_pipeline.py::TestStreamingWrite1xN_dask_streaming_write_degenerate::test_1xN_single_chunk` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1xN::test_1xN_chunks_split_columns` | `integration/test_dask_pipeline.py::TestStreamingWrite1xN_dask_streaming_write_degenerate::test_1xN_chunks_split_columns` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWrite1xN::test_1xN_wide_segmented_by_buffer` | `integration/test_dask_pipeline.py::TestStreamingWrite1xN_dask_streaming_write_degenerate::test_1xN_wide_segmented_by_buffer` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteNx1::test_Nx1_single_chunk` | `integration/test_dask_pipeline.py::TestStreamingWriteNx1_dask_streaming_write_degenerate::test_Nx1_single_chunk` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteNx1::test_Nx1_chunks_split_rows` | `integration/test_dask_pipeline.py::TestStreamingWriteNx1_dask_streaming_write_degenerate::test_Nx1_chunks_split_rows` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteAllNan::test_all_nan_with_sentinel` | `integration/test_dask_pipeline.py::TestStreamingWriteAllNan_dask_streaming_write_degenerate::test_all_nan_with_sentinel` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteAllNan::test_all_nan_default_nodata` | `integration/test_dask_pipeline.py::TestStreamingWriteAllNan_dask_streaming_write_degenerate::test_all_nan_default_nodata` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteMixedNanInf::test_mixed_nan_plus_minus_inf` | `integration/test_dask_pipeline.py::TestStreamingWriteMixedNanInf_dask_streaming_write_degenerate::test_mixed_nan_plus_minus_inf` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteAllInf::test_all_plus_inf` | `integration/test_dask_pipeline.py::TestStreamingWriteAllInf_dask_streaming_write_degenerate::test_all_plus_inf` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteAllInf::test_all_minus_inf` | `integration/test_dask_pipeline.py::TestStreamingWriteAllInf_dask_streaming_write_degenerate::test_all_minus_inf` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteFloatPredictor::test_predictor3_float32_round_trip` | `integration/test_dask_pipeline.py::TestStreamingWriteFloatPredictor_dask_streaming_write_degenerate::test_predictor3_float32_round_trip` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteFloatPredictor::test_predictor3_float64_round_trip` | `integration/test_dask_pipeline.py::TestStreamingWriteFloatPredictor_dask_streaming_write_degenerate::test_predictor3_float64_round_trip` | | -| `test_dask_streaming_write_degenerate_2026_05_15.py::TestStreamingWriteFloatPredictor::test_predictor3_int_input_rejected` | `integration/test_dask_pipeline.py::TestStreamingWriteFloatPredictor_dask_streaming_write_degenerate::test_predictor3_int_input_rejected` | | -| `test_accessor_io.py::TestDataArrayToGeotiff::test_round_trip` | `integration/test_dask_pipeline.py::TestDataArrayToGeotiff_accessor_io::test_round_trip` | | -| `test_accessor_io.py::TestDataArrayToGeotiff::test_with_kwargs` | `integration/test_dask_pipeline.py::TestDataArrayToGeotiff_accessor_io::test_with_kwargs` | | -| `test_accessor_io.py::TestDataArrayToGeotiff::test_preserves_crs` | `integration/test_dask_pipeline.py::TestDataArrayToGeotiff_accessor_io::test_preserves_crs` | | -| `test_accessor_io.py::TestDatasetToGeotiff::test_round_trip` | `integration/test_dask_pipeline.py::TestDatasetToGeotiff_accessor_io::test_round_trip` | | -| `test_accessor_io.py::TestDatasetToGeotiff::test_explicit_var` | `integration/test_dask_pipeline.py::TestDatasetToGeotiff_accessor_io::test_explicit_var` | | -| `test_accessor_io.py::TestDatasetToGeotiff::test_no_yx_raises` | `integration/test_dask_pipeline.py::TestDatasetToGeotiff_accessor_io::test_no_yx_raises` | | -| `test_accessor_io.py::TestDatasetOpenGeotiff::test_windowed_read` | `integration/test_dask_pipeline.py::TestDatasetOpenGeotiff_accessor_io::test_windowed_read` | | -| `test_accessor_io.py::TestDatasetOpenGeotiff::test_full_extent_returns_all` | `integration/test_dask_pipeline.py::TestDatasetOpenGeotiff_accessor_io::test_full_extent_returns_all` | | -| `test_accessor_io.py::TestDatasetOpenGeotiff::test_no_coords_raises` | `integration/test_dask_pipeline.py::TestDatasetOpenGeotiff_accessor_io::test_no_coords_raises` | | -| `test_accessor_io.py::TestDatasetOpenGeotiff::test_kwargs_forwarded` | `integration/test_dask_pipeline.py::TestDatasetOpenGeotiff_accessor_io::test_kwargs_forwarded` | | - -## GPU pipeline -> `integration/test_gpu_pipeline.py` - -| Old `file::test` | New `file::test_id` | Notes | -|---|---|---| -| `test_dask_cupy_combined.py::test_open_geotiff_gpu_chunks_int_round_trip` | `integration/test_gpu_pipeline.py::test_open_geotiff_gpu_chunks_int_round_trip` | | -| `test_dask_cupy_combined.py::test_read_geotiff_gpu_chunks_tuple_round_trip` | `integration/test_gpu_pipeline.py::test_read_geotiff_gpu_chunks_tuple_round_trip` | | -| `test_dask_cupy_combined.py::test_open_geotiff_gpu_chunks_multiband` | `integration/test_gpu_pipeline.py::test_open_geotiff_gpu_chunks_multiband` | | -| `test_dask_cupy_combined.py::test_open_geotiff_gpu_chunks_partial_last_chunk` | `integration/test_gpu_pipeline.py::test_open_geotiff_gpu_chunks_partial_last_chunk` | | -| `test_dask_cupy_combined.py::test_open_geotiff_gpu_chunks_preserves_geo_attrs` | `integration/test_gpu_pipeline.py::test_open_geotiff_gpu_chunks_preserves_geo_attrs` | | diff --git a/xrspatial/geotiff/tests/conftest.py b/xrspatial/geotiff/tests/conftest.py index d8786efa..b31c7870 100644 --- a/xrspatial/geotiff/tests/conftest.py +++ b/xrspatial/geotiff/tests/conftest.py @@ -1,12 +1,11 @@ """Shared fixtures for geotiff tests. -The TIFF builder, markers, and capability probes now live under +The TIFF builder, markers, and capability probes live under ``_helpers/``. This module re-exports them so legacy imports such as ``from .conftest import make_minimal_tiff`` or ``from xrspatial.geotiff.tests.conftest import requires_gpu`` keep -working. PR 11 of the consolidation epic (#2390) drops the -``pytest_collection_modifyitems`` socketserver hack below; until then -it stays in place to keep the existing loopback-skip behaviour. +working without touching every test file. New tests should import the +symbols directly from ``_helpers`` instead. """ from __future__ import annotations @@ -32,61 +31,6 @@ ] -def pytest_collection_modifyitems(config, items): - """Auto-skip tests that stand up a loopback HTTP server when the - sandbox denies socket bind. - - A test needs loopback iff its function body or any fixture in its - closure references ``socketserver.TCPServer(`` or invokes the - file-local ``_serve(`` helper. This is finer-grained than skipping - every test in a module that imports ``socketserver``: mixed files - (e.g. ``test_miniswhite_backend_parity_1797.py`` has both HTTP and - a local-file GPU test) keep their non-HTTP coverage in restricted - sandboxes. - """ - if loopback_available(): - return - - import inspect - - def _source_of(obj) -> str: - try: - return inspect.getsource(obj) - except (OSError, TypeError): - return '' - - def _references_loopback(src: str) -> bool: - return 'socketserver.TCPServer(' in src or '_serve(' in src - - skip_marker = pytest.mark.skip( - reason="loopback bind unavailable in this environment" - ) - for item in items: - needs_skip = False - - func = getattr(item, 'function', None) - if func is not None and _references_loopback(_source_of(func)): - needs_skip = True - - if not needs_skip: - fixtureinfo = getattr(item, '_fixtureinfo', None) - if fixtureinfo is not None: - name2defs = getattr(fixtureinfo, 'name2fixturedefs', {}) - for fname in getattr(fixtureinfo, 'names_closure', ()): - for fdef in name2defs.get(fname, ()): - ffunc = getattr(fdef, 'func', None) - if ffunc is not None and _references_loopback( - _source_of(ffunc) - ): - needs_skip = True - break - if needs_skip: - break - - if needs_skip: - item.add_marker(skip_marker) - - @pytest.fixture def simple_float32_tiff(): """4x4 float32 stripped TIFF with sequential values.""" diff --git a/xrspatial/geotiff/tests/parity/test_backend_matrix.py b/xrspatial/geotiff/tests/parity/test_backend_matrix.py index ed636ec0..0d7d59e7 100644 --- a/xrspatial/geotiff/tests/parity/test_backend_matrix.py +++ b/xrspatial/geotiff/tests/parity/test_backend_matrix.py @@ -77,7 +77,7 @@ from xrspatial.geotiff import open_geotiff, read_vrt, to_geotiff, write_vrt from xrspatial.geotiff._errors import RotatedTransformError -from .._helpers.markers import gpu_available, requires_gpu +from .._helpers.markers import gpu_available, requires_gpu, requires_loopback # --------------------------------------------------------------------------- # Environment gating @@ -841,6 +841,7 @@ def _resolve_source( # The single matrix test entry point # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("spec", _fixture_params()) @pytest.mark.parametrize("backend", _backend_params()) def test_backend_parity_matrix( @@ -997,6 +998,7 @@ def _resolve(spec: _ErrorFixtureSpec) -> Path: return _resolve +@requires_loopback @pytest.mark.parametrize("error_spec", _ERROR_FIXTURES, ids=lambda s: s.fix_id) @pytest.mark.parametrize("backend", _backend_params()) diff --git a/xrspatial/geotiff/tests/parity/test_pixel_equality.py b/xrspatial/geotiff/tests/parity/test_pixel_equality.py index 8790ca6e..0ffcb31f 100644 --- a/xrspatial/geotiff/tests/parity/test_pixel_equality.py +++ b/xrspatial/geotiff/tests/parity/test_pixel_equality.py @@ -33,7 +33,7 @@ from xrspatial.geotiff import (open_geotiff, read_geotiff_dask, read_geotiff_gpu, read_vrt, to_geotiff, write_vrt) -from .._helpers.markers import gpu_available, requires_gpu +from .._helpers.markers import gpu_available, requires_gpu, requires_loopback # --------------------------------------------------------------------------- # Environment gating @@ -622,6 +622,7 @@ def miniswhite_http_url(tmp_path, monkeypatch): httpd.server_close() +@requires_loopback def test_miniswhite_http_matches_local_reader(miniswhite_http_url): """HTTP read of a MinIsWhite TIFF returns the inverted pixel domain.""" url, stored = miniswhite_http_url @@ -629,6 +630,7 @@ def test_miniswhite_http_matches_local_reader(miniswhite_http_url): np.testing.assert_array_equal(got.values, np.iinfo(stored.dtype).max - stored) +@requires_loopback def test_miniswhite_http_dask_matches_local_reader(miniswhite_http_url): """Dask HTTP read agrees with the eager HTTP read on inversion.""" url, stored = miniswhite_http_url diff --git a/xrspatial/geotiff/tests/test_golden_corpus_http_1930.py b/xrspatial/geotiff/tests/test_golden_corpus_http_1930.py index 6a7b1c8d..96aa99d9 100644 --- a/xrspatial/geotiff/tests/test_golden_corpus_http_1930.py +++ b/xrspatial/geotiff/tests/test_golden_corpus_http_1930.py @@ -39,6 +39,10 @@ from xrspatial.geotiff import open_geotiff # noqa: E402 +from ._helpers.markers import requires_loopback # noqa: E402 + +pytestmark = requires_loopback + # Golden-corpus fixtures span every codec/tier, including the # experimental and internal-only ones gated by epic #2340 PR 4. Opting # in here lets the parity check exercise the full corpus; the per-codec diff --git a/xrspatial/geotiff/tests/test_packbits_jit_2048.py b/xrspatial/geotiff/tests/test_packbits_jit_2048.py deleted file mode 100644 index 4d23e86e..00000000 --- a/xrspatial/geotiff/tests/test_packbits_jit_2048.py +++ /dev/null @@ -1,169 +0,0 @@ -"""PackBits decode JIT kernel coverage (issue #2048). - -``packbits_decompress`` was reworked from a pure-Python ``while`` loop into a -numba ``@ngjit`` kernel wrapped by a thin bytes-in / bytes-out shim. These -tests pin the kernel against PackBits' boundary conditions: the 128-byte run -length boundary that switches literal/replicate encodings, max-length runs, -the ``-128`` no-op sentinel, empty input, and the decompression-bomb cap. - -The pre-existing PackBits coverage (``test_features.py`` round-trips and -``test_decompression_caps.py`` bomb guard) keeps passing too; this file just -fills in the bit-exact edge cases that the JIT rewrite is most likely to -regress on. -""" -from __future__ import annotations - -import pytest - -from xrspatial.geotiff._compression import packbits_compress, packbits_decompress - -# -- Bit-exact decode against known PackBits encodings ----------------------- - - -def test_packbits_decode_empty_input_returns_empty_bytes(): - assert packbits_decompress(b"") == b"" - - -def test_packbits_decode_single_literal_byte(): - # Header 0x00 -> "copy next 1 byte literally". - assert packbits_decompress(bytes([0x00, 0x42])) == b"\x42" - - -def test_packbits_decode_single_replicate_pair(): - # Header 0xFF (signed -1) -> "repeat next byte 2 times". - assert packbits_decompress(bytes([0xFF, 0x42])) == b"\x42\x42" - - -def test_packbits_decode_noop_sentinel_is_skipped(): - # Header 0x80 (signed -128) is a no-op marker; surrounding data still decodes. - assert packbits_decompress(bytes([0x80, 0x00, 0x42])) == b"\x42" - - -def test_packbits_decode_wikipedia_canonical_example(): - # From the PackBits Wikipedia article, normalized to a self-contained vector. - encoded = bytes( - [0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A, 0xFD, 0xAA, - 0x03, 0x80, 0x00, 0x2A, 0x22, 0xF7, 0xAA] - ) - expected = ( - b"\xAA" * 3 - + b"\x80\x00\x2A" - + b"\xAA" * 4 - + b"\x80\x00\x2A\x22" - + b"\xAA" * 10 - ) - assert packbits_decompress(encoded) == expected - - -# -- Run-length boundary cases (128-byte switch) ----------------------------- - - -def test_packbits_decode_max_literal_run_is_128_bytes(): - # Header 0x7F (127) -> 128 literal bytes follow. - literal = bytes(range(128)) - encoded = bytes([0x7F]) + literal - assert packbits_decompress(encoded) == literal - - -def test_packbits_decode_max_replicate_run_is_128_bytes(): - # Header 0x81 (signed -127) -> repeat next byte 128 times. - encoded = bytes([0x81, 0x42]) - assert packbits_decompress(encoded) == b"\x42" * 128 - - -def test_packbits_decode_back_to_back_max_runs(): - # Two back-to-back max-length runs land on the 128-byte boundary - # and exercise the literal -> replicate switch with no gap. - literal = bytes(range(128)) - encoded = bytes([0x7F]) + literal + bytes([0x81, 0x99]) - assert packbits_decompress(encoded) == literal + b"\x99" * 128 - - -# -- Round-trip parity with the (untouched) compressor ----------------------- - - -@pytest.mark.parametrize( - "payload", - [ - b"", - b"A", - b"AAAA", - b"ABCD" * 64, - bytes(range(256)), - b"\x00" * 1024, - (b"long literal stretch with no runs at all 1234567890" * 17), - ], -) -def test_packbits_roundtrip(payload): - assert packbits_decompress(packbits_compress(payload)) == payload - - -# -- Decompression-bomb cap stays intact across the rewrite ------------------ - - -def test_packbits_cap_rejects_oversized_expansion(): - # 0x81 0x42 produces 128 bytes from a 2-byte input; cap of 4 must reject. - with pytest.raises(ValueError, match="packbits decode exceeded expected size"): - packbits_decompress(bytes([0x81, 0x42]), expected_size=4) - - -def test_packbits_cap_allows_within_margin(): - # Cap is expected_size * 1.05 + 1 = 5; a 4-byte decode must pass. - out = packbits_decompress(bytes([0xFF, 0x42, 0xFF, 0x43]), expected_size=4) - assert out == b"BBCC" - - -@pytest.mark.parametrize( - "expected_size, payload_bytes, should_pass", - [ - # Cap = int(expected_size * 1.05) + 1. - # expected_size=1 -> cap=2; 2-byte legitimate decode lands on the cap. - (1, 2, True), - # expected_size=100 -> cap=106; 106-byte decode equals the cap. - (100, 106, True), - # expected_size=100 -> cap=106; 107-byte decode trips the guard. - (100, 107, False), - ], -) -def test_packbits_cap_boundary(expected_size, payload_bytes, should_pass): - # Encode `payload_bytes` zeros using replicate runs of 128 plus a tail. - full_runs, tail = divmod(payload_bytes, 128) - encoded = bytes([0x81, 0x00]) * full_runs - if tail: - # Replicate run of `tail` bytes: header = 257 - tail (for tail >= 2), - # or a single literal byte (header 0x00) for tail == 1. - if tail == 1: - encoded += bytes([0x00, 0x00]) - else: - encoded += bytes([257 - tail, 0x00]) - if should_pass: - out = packbits_decompress(encoded, expected_size=expected_size) - assert out == b"\x00" * payload_bytes - else: - with pytest.raises(ValueError, match="packbits decode exceeded"): - packbits_decompress(encoded, expected_size=expected_size) - - -def test_packbits_no_cap_when_expected_size_is_zero(): - # expected_size=0 disables the cap (backward-compat path). - out = packbits_decompress(bytes([0x81, 0x42])) - assert out == b"\x42" * 128 - - -# -- Truncated input must not read past src ---------------------------------- - - -def test_packbits_truncated_literal_run_stops_at_src_end(): - # Header claims 4 literal bytes but only 2 follow. Decoder must not - # read past the end of src. - encoded = bytes([0x03, 0x01, 0x02]) - out = packbits_decompress(encoded) - assert out == b"\x01\x02" - - -def test_packbits_truncated_replicate_header_stops_cleanly(): - # Replicate header without its data byte: decoder must terminate - # rather than reading off the end. - encoded = bytes([0xFE]) - out = packbits_decompress(encoded) - assert out == b"" diff --git a/xrspatial/geotiff/tests/test_packbits_jit_2049.py b/xrspatial/geotiff/tests/test_packbits_jit_2049.py deleted file mode 100644 index 3f4ab9df..00000000 --- a/xrspatial/geotiff/tests/test_packbits_jit_2049.py +++ /dev/null @@ -1,150 +0,0 @@ -"""JIT'd PackBits encoder: round-trip and edge-case coverage. - -See issue #2049. ``packbits_compress`` was pure-Python; this file pins the -contract of the ``@ngjit`` rewrite against the decoder. -""" -from __future__ import annotations - -import numpy as np -import pytest - -from xrspatial.geotiff._compression import (_packbits_encode_kernel, packbits_compress, - packbits_decompress) - - -def _roundtrip(data: bytes) -> None: - assert packbits_decompress(packbits_compress(data)) == data - - -class TestPackBitsJITRoundTrip: - """Encode-decode parity across the regime boundaries of PackBits.""" - - def test_empty(self): - _roundtrip(b'') - - def test_length_one(self): - _roundtrip(b'\x42') - - def test_length_two_same(self): - _roundtrip(b'\xAA\xAA') - - def test_length_two_different(self): - _roundtrip(b'\x00\xFF') - - def test_length_128_all_same(self): - # Hits the inner run cap exactly. - _roundtrip(b'\x55' * 128) - - def test_length_129_all_same(self): - # Forces a second header byte after the 128-byte run cap. - _roundtrip(b'\x55' * 129) - - def test_length_128_alternating(self): - # Hits the literal cap exactly; no run ever forms. - _roundtrip(bytes([i & 1 for i in range(128)])) - - def test_length_129_alternating(self): - # Forces a second literal header after the 128-byte literal cap. - _roundtrip(bytes([i & 1 for i in range(129)])) - - def test_alternating_short(self): - _roundtrip(b'\x00\xFF\x00\xFF\x00\xFF') - - def test_run_of_three_at_end(self): - # Boundary between literal scan and run detection. - _roundtrip(b'\x01\x02\x03\xAA\xAA\xAA') - - def test_run_of_three_at_start(self): - _roundtrip(b'\xAA\xAA\xAA\x01\x02\x03') - - def test_runs_and_literals_interleaved(self): - data = b'\x00' * 100 + b'\xFF' * 50 + bytes(range(200)) - _roundtrip(data) - - @pytest.mark.parametrize("seed", [0, 1, 42, 12345]) - def test_random_bytes(self, seed): - rng = np.random.default_rng(seed) - data = rng.integers(0, 256, size=1024, dtype=np.uint8).tobytes() - _roundtrip(data) - - def test_random_with_runs(self): - # Mix runs of varying length with random literals. - rng = np.random.default_rng(7) - chunks = [] - for _ in range(20): - if rng.random() < 0.5: - val = int(rng.integers(0, 256)) - length = int(rng.integers(1, 200)) - chunks.append(bytes([val]) * length) - else: - length = int(rng.integers(1, 200)) - chunks.append(rng.integers(0, 256, size=length, dtype=np.uint8).tobytes()) - _roundtrip(b''.join(chunks)) - - def test_all_zeros_large(self): - data = b'\x00' * 10_000 - compressed = packbits_compress(data) - # 10_000 bytes at run-cap 128 -> ceil(10_000 / 128) = 79 runs, - # each 2 bytes => 158 bytes total. - assert len(compressed) < len(data) // 50 - assert packbits_decompress(compressed) == data - - -class TestPackBitsJITKernel: - """The kernel itself is callable; sanity-check buffer mechanics.""" - - def test_kernel_returns_length(self): - src = np.array([1, 1, 1, 1, 1], dtype=np.uint8) - dst = np.empty(2 * len(src) + 1, dtype=np.uint8) - n = _packbits_encode_kernel(src, len(src), dst, len(dst)) - # 5 identical bytes -> one run: 2 bytes (header + value) - assert n == 2 - # Header is the signed int8 (1 - 5) = -4, stored as 256 - 4 = 252 - assert dst[0] == 252 - assert dst[1] == 1 - - def test_kernel_empty_input(self): - src = np.empty(0, dtype=np.uint8) - dst = np.empty(1, dtype=np.uint8) - n = _packbits_encode_kernel(src, 0, dst, 1) - assert n == 0 - - def test_kernel_literal_golden(self): - # Three distinct bytes encode as a single literal header (lit_len-1=2) - # followed by the payload. - src = np.array([0x10, 0x20, 0x30], dtype=np.uint8) - dst = np.empty(2 * len(src) + 1, dtype=np.uint8) - n = _packbits_encode_kernel(src, len(src), dst, len(dst)) - assert n == 4 - assert dst[0] == 2 - assert list(dst[1:4]) == [0x10, 0x20, 0x30] - - -class TestPackBitsJITBufferCap: - """Output must always fit inside the worst-case allocation.""" - - @pytest.mark.parametrize( - "data", - [ - b'', - b'\x00', - b'\x00\xFF', - b'\x55' * 128, - b'\x55' * 129, - bytes([i & 1 for i in range(256)]), - bytes(range(256)), - ], - ) - def test_output_within_cap(self, data): - compressed = packbits_compress(data) - # The wrapper allocates 2 * src_len + 1 bytes for the encode buffer; - # the actual output must never exceed that bound. - assert len(compressed) <= 2 * len(data) + 1 - - def test_random_output_within_cap(self): - rng = np.random.default_rng(2049) - for _ in range(8): - length = int(rng.integers(0, 4096)) - data = rng.integers(0, 256, size=length, dtype=np.uint8).tobytes() - compressed = packbits_compress(data) - assert len(compressed) <= 2 * length + 1 diff --git a/xrspatial/geotiff/tests/test_parallel_strip_decode_2100.py b/xrspatial/geotiff/tests/test_parallel_strip_decode_2100.py index f185fad4..6347d8b2 100644 --- a/xrspatial/geotiff/tests/test_parallel_strip_decode_2100.py +++ b/xrspatial/geotiff/tests/test_parallel_strip_decode_2100.py @@ -25,6 +25,8 @@ from xrspatial.geotiff import to_geotiff from xrspatial.geotiff._reader import read_to_array +from ._helpers.markers import requires_loopback + def _make_stripped_uint16(height: int, width: int, *, compression: str = "deflate") -> bytes: @@ -179,6 +181,7 @@ def _start_server(blob: bytes): return server, port +@requires_loopback class TestHttpStripParallelDecode: def test_parallel_decode_matches_serial(self, monkeypatch): monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1") @@ -323,6 +326,7 @@ def test_windowed_planar2_parallel(self, tmp_path): expected = np.moveaxis(arr, 0, -1)[100:900, 100:900] np.testing.assert_array_equal(par, expected) + @requires_loopback def test_http_windowed_planar2_parallel(self, monkeypatch): """HTTP windowed strip path on planar=2 multi-band: pins the per-band strip-job loop inside diff --git a/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py b/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py index e6eb8abb..3fe17db1 100644 --- a/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py +++ b/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py @@ -49,6 +49,8 @@ # under test do not depend on rasterio at runtime. rasterio = pytest.importorskip("rasterio") +from ._helpers.markers import requires_loopback # noqa: E402 + from xrspatial.geotiff import _decode as _decode_mod # noqa: E402 from xrspatial.geotiff import _reader as _reader_mod # noqa: E402 from xrspatial.geotiff._reader import read_to_array # noqa: E402 @@ -291,6 +293,7 @@ def test_planar2_sparse_parallel_matches_serial(self, tmp_path): # HTTP COG strip sparse coverage ------------------------------------------- +@requires_loopback class TestHttpStripsSparseParallel: """``_fetch_decode_cog_http_strips`` with sparse strips. diff --git a/xrspatial/geotiff/tests/test_read_geotiff_gpu_url_eager_2161.py b/xrspatial/geotiff/tests/test_read_geotiff_gpu_url_eager_2161.py index 168d519f..d78b7177 100644 --- a/xrspatial/geotiff/tests/test_read_geotiff_gpu_url_eager_2161.py +++ b/xrspatial/geotiff/tests/test_read_geotiff_gpu_url_eager_2161.py @@ -29,6 +29,8 @@ import numpy as np import pytest +from ._helpers.markers import requires_loopback + def _gpu_available() -> bool: if importlib.util.find_spec("cupy") is None: @@ -129,6 +131,7 @@ def test_local_path_still_returns_cupy(small_tif_bytes_2161): # --------------------------------------------------------------------------- @_gpu_only +@requires_loopback def test_http_url_returns_cupy_matching_cpu(small_tif_bytes_2161, monkeypatch): """HTTP URLs route through the CPU decode + GPU upload helper; the @@ -204,6 +207,7 @@ def test_memory_fsspec_uri_returns_cupy_matching_cpu(small_tif_bytes_2161): # --------------------------------------------------------------------------- @_gpu_only +@requires_loopback def test_unreachable_http_url_does_not_raise_filenotfound(monkeypatch): """Before the fix, ``read_geotiff_gpu("https://example.invalid/x.tif")`` raised ``FileNotFoundError`` whose message was the URL itself @@ -264,6 +268,7 @@ def test_unreachable_http_url_does_not_raise_filenotfound(monkeypatch): # --------------------------------------------------------------------------- @_gpu_only +@requires_loopback def test_chunked_url_path_still_uses_chunked_helper(small_tif_bytes_2161, monkeypatch): """``chunks=`` on a URL must still go through diff --git a/xrspatial/geotiff/tests/test_remote_sidecar_byte_order_2314.py b/xrspatial/geotiff/tests/test_remote_sidecar_byte_order_2314.py index 1e62ea49..1a9c821d 100644 --- a/xrspatial/geotiff/tests/test_remote_sidecar_byte_order_2314.py +++ b/xrspatial/geotiff/tests/test_remote_sidecar_byte_order_2314.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import io import os import uuid @@ -32,6 +31,8 @@ tifffile = pytest.importorskip("tifffile") +from ._helpers.markers import requires_loopback # noqa: E402 + # --------------------------------------------------------------------------- # Fixture builders. Each call writes a fresh base + sidecar pair so @@ -156,6 +157,7 @@ def test_local_eager_mixed_endian_sidecar(tmp_path, base_bo, side_bo): # sidecar URL. The fix returns the sidecar's header from # ``_parse_cog_http_meta`` when ``used_sidecar=True``. # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("base_bo,side_bo", [ ('<', '>'), ('>', '<'), @@ -189,6 +191,7 @@ def test_http_eager_mixed_endian_sidecar(tmp_path, monkeypatch, # ``_parse_cog_http_meta``: when the sidecar is in use, ``http_header`` # is the sidecar's header. # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("base_bo,side_bo", [ ('<', '>'), ('>', '<'), @@ -262,6 +265,7 @@ def test_fsspec_chunked_mixed_endian_sidecar(tmp_path, base_bo, side_bo): # Run one parametrized case here so a future regression that decouples # the two paths gets caught. # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("base_bo,side_bo", [ ('<', '>'), ('>', '<'), @@ -297,6 +301,7 @@ def test_http_eager_mixed_endian_sidecar_tiled(tmp_path, monkeypatch, # different field could silently regress without tripping the # end-to-end tests above. # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("base_bo,side_bo", [ ('<', '>'), ('>', '<'), diff --git a/xrspatial/geotiff/tests/test_remote_sidecar_chunked_2239.py b/xrspatial/geotiff/tests/test_remote_sidecar_chunked_2239.py index d4c8d003..bf88245e 100644 --- a/xrspatial/geotiff/tests/test_remote_sidecar_chunked_2239.py +++ b/xrspatial/geotiff/tests/test_remote_sidecar_chunked_2239.py @@ -28,6 +28,8 @@ import numpy as np import pytest +from ._helpers.markers import requires_loopback + _FIXTURE = ( pathlib.Path(__file__).resolve().parent / "golden_corpus" @@ -183,6 +185,7 @@ def test_fsspec_chunked_open_resolves_sidecar_overview( # fsspec test, but exercises the HTTP discovery + load + tile-fetch path # in ``_read_cog_http`` and ``_backends/dask.py``. # --------------------------------------------------------------------------- +@requires_loopback @pytest.mark.parametrize("overview_level", [1, 2]) def test_http_chunked_open_resolves_sidecar_overview( _http_with_sidecar, overview_level): @@ -195,6 +198,7 @@ def test_http_chunked_open_resolves_sidecar_overview( np.testing.assert_array_equal(chunked.values, eager.values) +@requires_loopback def test_http_eager_reads_sidecar_overview(_http_with_sidecar): """The eager HTTP path also needs to honour sidecars (issue #2239).""" from xrspatial.geotiff import open_geotiff @@ -208,6 +212,7 @@ def test_http_eager_reads_sidecar_overview(_http_with_sidecar): assert da16.shape == (16, 16) +@requires_loopback def test_http_eager_vs_local_parity(_http_with_sidecar): """Eager HTTP reads should match the eager local read byte-for-byte.""" from xrspatial.geotiff import open_geotiff @@ -258,6 +263,7 @@ def test_fsspec_chunked_open_rejects_overview_past_sidecar( open_geotiff(uri, chunks=16, overview_level=3) +@requires_loopback def test_http_chunked_open_rejects_overview_past_sidecar(_http_with_sidecar): from xrspatial.geotiff import open_geotiff url = _http_with_sidecar diff --git a/xrspatial/geotiff/tests/test_sidecar_max_cloud_bytes_2121.py b/xrspatial/geotiff/tests/test_sidecar_max_cloud_bytes_2121.py index 84aa6f55..94fcdd30 100644 --- a/xrspatial/geotiff/tests/test_sidecar_max_cloud_bytes_2121.py +++ b/xrspatial/geotiff/tests/test_sidecar_max_cloud_bytes_2121.py @@ -30,6 +30,8 @@ from xrspatial.geotiff._reader import CloudSizeLimitError from xrspatial.geotiff._sidecar import load_sidecar +from ._helpers.markers import requires_loopback + _FIXTURE = ( pathlib.Path(__file__).resolve().parent / "golden_corpus" @@ -134,6 +136,7 @@ def test_fsspec_sidecar_max_cloud_bytes_none_is_unbounded(tmp_path): # HTTP: load_sidecar translates the underlying budget OSError into # CloudSizeLimitError so both cloud transports raise the same type. # --------------------------------------------------------------------------- +@requires_loopback def test_http_sidecar_rejects_when_exceeds_max_cloud_bytes( tmp_path, monkeypatch): """Streaming download aborts when the body exceeds ``max_cloud_bytes``.""" @@ -160,6 +163,7 @@ def test_http_sidecar_rejects_when_exceeds_max_cloud_bytes( httpd.shutdown() +@requires_loopback def test_http_sidecar_succeeds_when_under_max_cloud_bytes( tmp_path, monkeypatch): monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1") @@ -176,6 +180,7 @@ def test_http_sidecar_succeeds_when_under_max_cloud_bytes( httpd.shutdown() +@requires_loopback def test_http_sidecar_max_cloud_bytes_none_is_unbounded( tmp_path, monkeypatch): """``max_cloud_bytes=None`` preserves the pre-#2121 unbounded read.""" diff --git a/xrspatial/geotiff/tests/test_sidecar_ovr_2112.py b/xrspatial/geotiff/tests/test_sidecar_ovr_2112.py index 3f4ade5d..a0b04c9b 100644 --- a/xrspatial/geotiff/tests/test_sidecar_ovr_2112.py +++ b/xrspatial/geotiff/tests/test_sidecar_ovr_2112.py @@ -31,6 +31,8 @@ from xrspatial.geotiff._reader import read_to_array from xrspatial.geotiff._sidecar import find_sidecar, load_sidecar +from ._helpers.markers import requires_loopback + _FIXTURE = ( pathlib.Path(__file__).resolve().parent / "golden_corpus" @@ -252,6 +254,7 @@ def __init__(self, *a, **kw): return httpd, httpd.server_address[1] +@requires_loopback def test_find_sidecar_http_probe_returns_url_when_present( tmp_path, monkeypatch): # The sidecar probe now routes through ``_HTTPSource``, which @@ -272,6 +275,7 @@ def test_find_sidecar_http_probe_returns_url_when_present( httpd.shutdown() +@requires_loopback def test_find_sidecar_http_probe_returns_none_when_missing( tmp_path, monkeypatch): monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1") @@ -286,6 +290,7 @@ def test_find_sidecar_http_probe_returns_none_when_missing( httpd.shutdown() +@requires_loopback def test_find_sidecar_http_probe_rejects_loopback_without_env_override( tmp_path): """Without ``XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS=1``, the SSRF @@ -303,6 +308,7 @@ def test_find_sidecar_http_probe_rejects_loopback_without_env_override( httpd.shutdown() +@requires_loopback def test_load_sidecar_http_returns_ifds(tmp_path, monkeypatch): monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1") src = _fixture_or_skip() diff --git a/xrspatial/geotiff/tests/unit/__init__.py b/xrspatial/geotiff/tests/unit/__init__.py new file mode 100644 index 00000000..4e7edb46 --- /dev/null +++ b/xrspatial/geotiff/tests/unit/__init__.py @@ -0,0 +1,7 @@ +"""Pure-unit tests for low-level GeoTIFF helpers. + +Each module here tests a single helper or family of helpers in +isolation, without standing up a full read/write pipeline. The split +keeps the long-running integration suite separate from the fast +helper-level coverage. +""" diff --git a/xrspatial/geotiff/tests/unit/test_compression.py b/xrspatial/geotiff/tests/unit/test_compression.py new file mode 100644 index 00000000..88eb518a --- /dev/null +++ b/xrspatial/geotiff/tests/unit/test_compression.py @@ -0,0 +1,337 @@ +"""Pure-unit coverage for the codec helpers in ``_compression``. + +Two clusters share this file because they fail in the same ways: + +* PackBits decoder JIT kernel boundary cases (the 128-byte run boundary, + the ``-128`` no-op sentinel, the decompression-bomb cap, truncated + inputs). +* PackBits encoder JIT kernel round-trips against the decoder, kernel + buffer mechanics, and worst-case output cap. + +The matrix of write/read round-trips against the public ``to_geotiff`` / +``open_geotiff`` API lives separately under ``test_features.py`` and the +codec-specific files (``test_lz4.py``, ``test_lerc.py``, etc.); this file +exercises only the bytes-in / bytes-out kernels. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from xrspatial.geotiff._compression import (_packbits_encode_kernel, packbits_compress, + packbits_decompress) + + +# --------------------------------------------------------------------------- +# Decoder: bit-exact decode against known PackBits encodings +# --------------------------------------------------------------------------- + + +def test_packbits_decode_empty_input_returns_empty_bytes(): + assert packbits_decompress(b"") == b"" + + +def test_packbits_decode_single_literal_byte(): + # Header 0x00 -> "copy next 1 byte literally". + assert packbits_decompress(bytes([0x00, 0x42])) == b"\x42" + + +def test_packbits_decode_single_replicate_pair(): + # Header 0xFF (signed -1) -> "repeat next byte 2 times". + assert packbits_decompress(bytes([0xFF, 0x42])) == b"\x42\x42" + + +def test_packbits_decode_noop_sentinel_is_skipped(): + # Header 0x80 (signed -128) is a no-op marker; surrounding data still decodes. + assert packbits_decompress(bytes([0x80, 0x00, 0x42])) == b"\x42" + + +def test_packbits_decode_wikipedia_canonical_example(): + # From the PackBits Wikipedia article, normalized to a self-contained vector. + encoded = bytes( + [0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A, 0xFD, 0xAA, + 0x03, 0x80, 0x00, 0x2A, 0x22, 0xF7, 0xAA] + ) + expected = ( + b"\xAA" * 3 + + b"\x80\x00\x2A" + + b"\xAA" * 4 + + b"\x80\x00\x2A\x22" + + b"\xAA" * 10 + ) + assert packbits_decompress(encoded) == expected + + +# --------------------------------------------------------------------------- +# Decoder: run-length boundary cases (128-byte switch) +# --------------------------------------------------------------------------- + + +def test_packbits_decode_max_literal_run_is_128_bytes(): + # Header 0x7F (127) -> 128 literal bytes follow. + literal = bytes(range(128)) + encoded = bytes([0x7F]) + literal + assert packbits_decompress(encoded) == literal + + +def test_packbits_decode_max_replicate_run_is_128_bytes(): + # Header 0x81 (signed -127) -> repeat next byte 128 times. + encoded = bytes([0x81, 0x42]) + assert packbits_decompress(encoded) == b"\x42" * 128 + + +def test_packbits_decode_back_to_back_max_runs(): + # Two back-to-back max-length runs land on the 128-byte boundary + # and exercise the literal -> replicate switch with no gap. + literal = bytes(range(128)) + encoded = bytes([0x7F]) + literal + bytes([0x81, 0x99]) + assert packbits_decompress(encoded) == literal + b"\x99" * 128 + + +# --------------------------------------------------------------------------- +# Decoder: round-trip parity with the compressor +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "payload", + [ + b"", + b"A", + b"AAAA", + b"ABCD" * 64, + bytes(range(256)), + b"\x00" * 1024, + (b"long literal stretch with no runs at all 1234567890" * 17), + ], +) +def test_packbits_roundtrip(payload): + assert packbits_decompress(packbits_compress(payload)) == payload + + +# --------------------------------------------------------------------------- +# Decoder: decompression-bomb cap +# --------------------------------------------------------------------------- + + +def test_packbits_cap_rejects_oversized_expansion(): + # 0x81 0x42 produces 128 bytes from a 2-byte input; cap of 4 must reject. + with pytest.raises(ValueError, match="packbits decode exceeded expected size"): + packbits_decompress(bytes([0x81, 0x42]), expected_size=4) + + +def test_packbits_cap_allows_within_margin(): + # Cap is expected_size * 1.05 + 1 = 5; a 4-byte decode must pass. + out = packbits_decompress(bytes([0xFF, 0x42, 0xFF, 0x43]), expected_size=4) + assert out == b"BBCC" + + +@pytest.mark.parametrize( + "expected_size, payload_bytes, should_pass", + [ + # Cap = int(expected_size * 1.05) + 1. + # expected_size=1 -> cap=2; 2-byte legitimate decode lands on the cap. + (1, 2, True), + # expected_size=100 -> cap=106; 106-byte decode equals the cap. + (100, 106, True), + # expected_size=100 -> cap=106; 107-byte decode trips the guard. + (100, 107, False), + ], +) +def test_packbits_cap_boundary(expected_size, payload_bytes, should_pass): + # Encode `payload_bytes` zeros using replicate runs of 128 plus a tail. + full_runs, tail = divmod(payload_bytes, 128) + encoded = bytes([0x81, 0x00]) * full_runs + if tail: + # Replicate run of `tail` bytes: header = 257 - tail (for tail >= 2), + # or a single literal byte (header 0x00) for tail == 1. + if tail == 1: + encoded += bytes([0x00, 0x00]) + else: + encoded += bytes([257 - tail, 0x00]) + if should_pass: + out = packbits_decompress(encoded, expected_size=expected_size) + assert out == b"\x00" * payload_bytes + else: + with pytest.raises(ValueError, match="packbits decode exceeded"): + packbits_decompress(encoded, expected_size=expected_size) + + +def test_packbits_no_cap_when_expected_size_is_zero(): + # expected_size=0 disables the cap (backward-compat path). + out = packbits_decompress(bytes([0x81, 0x42])) + assert out == b"\x42" * 128 + + +# --------------------------------------------------------------------------- +# Decoder: truncated input handling +# --------------------------------------------------------------------------- + + +def test_packbits_truncated_literal_run_stops_at_src_end(): + # Header claims 4 literal bytes but only 2 follow. Decoder must not + # read past the end of src. + encoded = bytes([0x03, 0x01, 0x02]) + out = packbits_decompress(encoded) + assert out == b"\x01\x02" + + +def test_packbits_truncated_replicate_header_stops_cleanly(): + # Replicate header without its data byte: decoder must terminate + # rather than reading off the end. + encoded = bytes([0xFE]) + out = packbits_decompress(encoded) + assert out == b"" + + +# --------------------------------------------------------------------------- +# Encoder: round-trip against the decoder +# --------------------------------------------------------------------------- + + +def _roundtrip(data: bytes) -> None: + assert packbits_decompress(packbits_compress(data)) == data + + +class TestPackBitsEncodeRoundTrip: + """Encode-decode parity across the regime boundaries of PackBits.""" + + def test_empty(self): + _roundtrip(b'') + + def test_length_one(self): + _roundtrip(b'\x42') + + def test_length_two_same(self): + _roundtrip(b'\xAA\xAA') + + def test_length_two_different(self): + _roundtrip(b'\x00\xFF') + + def test_length_128_all_same(self): + # Hits the inner run cap exactly. + _roundtrip(b'\x55' * 128) + + def test_length_129_all_same(self): + # Forces a second header byte after the 128-byte run cap. + _roundtrip(b'\x55' * 129) + + def test_length_128_alternating(self): + # Hits the literal cap exactly; no run ever forms. + _roundtrip(bytes([i & 1 for i in range(128)])) + + def test_length_129_alternating(self): + # Forces a second literal header after the 128-byte literal cap. + _roundtrip(bytes([i & 1 for i in range(129)])) + + def test_alternating_short(self): + _roundtrip(b'\x00\xFF\x00\xFF\x00\xFF') + + def test_run_of_three_at_end(self): + # Boundary between literal scan and run detection. + _roundtrip(b'\x01\x02\x03\xAA\xAA\xAA') + + def test_run_of_three_at_start(self): + _roundtrip(b'\xAA\xAA\xAA\x01\x02\x03') + + def test_runs_and_literals_interleaved(self): + data = b'\x00' * 100 + b'\xFF' * 50 + bytes(range(200)) + _roundtrip(data) + + @pytest.mark.parametrize("seed", [0, 1, 42, 12345]) + def test_random_bytes(self, seed): + rng = np.random.default_rng(seed) + data = rng.integers(0, 256, size=1024, dtype=np.uint8).tobytes() + _roundtrip(data) + + def test_random_with_runs(self): + # Mix runs of varying length with random literals. + rng = np.random.default_rng(7) + chunks = [] + for _ in range(20): + if rng.random() < 0.5: + val = int(rng.integers(0, 256)) + length = int(rng.integers(1, 200)) + chunks.append(bytes([val]) * length) + else: + length = int(rng.integers(1, 200)) + chunks.append(rng.integers(0, 256, size=length, dtype=np.uint8).tobytes()) + _roundtrip(b''.join(chunks)) + + def test_all_zeros_large(self): + data = b'\x00' * 10_000 + compressed = packbits_compress(data) + # 10_000 bytes at run-cap 128 -> ceil(10_000 / 128) = 79 runs, + # each 2 bytes => 158 bytes total. + assert len(compressed) < len(data) // 50 + assert packbits_decompress(compressed) == data + + +# --------------------------------------------------------------------------- +# Encoder: low-level kernel surface +# --------------------------------------------------------------------------- + + +class TestPackBitsEncodeKernel: + """The kernel itself is callable; sanity-check buffer mechanics.""" + + def test_kernel_returns_length(self): + src = np.array([1, 1, 1, 1, 1], dtype=np.uint8) + dst = np.empty(2 * len(src) + 1, dtype=np.uint8) + n = _packbits_encode_kernel(src, len(src), dst, len(dst)) + # 5 identical bytes -> one run: 2 bytes (header + value) + assert n == 2 + # Header is the signed int8 (1 - 5) = -4, stored as 256 - 4 = 252 + assert dst[0] == 252 + assert dst[1] == 1 + + def test_kernel_empty_input(self): + src = np.empty(0, dtype=np.uint8) + dst = np.empty(1, dtype=np.uint8) + n = _packbits_encode_kernel(src, 0, dst, 1) + assert n == 0 + + def test_kernel_literal_golden(self): + # Three distinct bytes encode as a single literal header (lit_len-1=2) + # followed by the payload. + src = np.array([0x10, 0x20, 0x30], dtype=np.uint8) + dst = np.empty(2 * len(src) + 1, dtype=np.uint8) + n = _packbits_encode_kernel(src, len(src), dst, len(dst)) + assert n == 4 + assert dst[0] == 2 + assert list(dst[1:4]) == [0x10, 0x20, 0x30] + + +# --------------------------------------------------------------------------- +# Encoder: worst-case output cap +# --------------------------------------------------------------------------- + + +class TestPackBitsEncodeBufferCap: + """Output must always fit inside the worst-case allocation.""" + + @pytest.mark.parametrize( + "data", + [ + b'', + b'\x00', + b'\x00\xFF', + b'\x55' * 128, + b'\x55' * 129, + bytes([i & 1 for i in range(256)]), + bytes(range(256)), + ], + ) + def test_output_within_cap(self, data): + compressed = packbits_compress(data) + # The wrapper allocates 2 * src_len + 1 bytes for the encode buffer; + # the actual output must never exceed that bound. + assert len(compressed) <= 2 * len(data) + 1 + + def test_random_output_within_cap(self): + rng = np.random.default_rng(2049) + for _ in range(8): + length = int(rng.integers(0, 4096)) + data = rng.integers(0, 256, size=length, dtype=np.uint8).tobytes() + compressed = packbits_compress(data) + assert len(compressed) <= 2 * length + 1 diff --git a/xrspatial/geotiff/tests/test_geotags.py b/xrspatial/geotiff/tests/unit/test_geotags.py similarity index 99% rename from xrspatial/geotiff/tests/test_geotags.py rename to xrspatial/geotiff/tests/unit/test_geotags.py index 1f12c625..e5644c37 100644 --- a/xrspatial/geotiff/tests/test_geotags.py +++ b/xrspatial/geotiff/tests/unit/test_geotags.py @@ -12,7 +12,7 @@ build_geo_tags, extract_geo_info) from xrspatial.geotiff._header import parse_all_ifds, parse_header -from .conftest import make_minimal_tiff +from ..conftest import make_minimal_tiff class TestGeoTransform: diff --git a/xrspatial/geotiff/tests/test_header.py b/xrspatial/geotiff/tests/unit/test_header.py similarity index 99% rename from xrspatial/geotiff/tests/test_header.py rename to xrspatial/geotiff/tests/unit/test_header.py index 50be42db..cc6585d6 100644 --- a/xrspatial/geotiff/tests/test_header.py +++ b/xrspatial/geotiff/tests/unit/test_header.py @@ -10,7 +10,7 @@ from xrspatial.geotiff._header import (IFD, TAG_IMAGE_WIDTH, TAG_X_RESOLUTION, TAG_Y_RESOLUTION, _read_value, parse_all_ifds, parse_header, parse_ifd) -from .conftest import make_minimal_tiff +from ..conftest import make_minimal_tiff class TestParseHeader: diff --git a/xrspatial/geotiff/tests/test_gdal_metadata_xml_escape_1614.py b/xrspatial/geotiff/tests/unit/test_safe_xml.py similarity index 100% rename from xrspatial/geotiff/tests/test_gdal_metadata_xml_escape_1614.py rename to xrspatial/geotiff/tests/unit/test_safe_xml.py diff --git a/xrspatial/geotiff/tests/write/test_cog.py b/xrspatial/geotiff/tests/write/test_cog.py index 3d290bea..a4b22574 100644 --- a/xrspatial/geotiff/tests/write/test_cog.py +++ b/xrspatial/geotiff/tests/write/test_cog.py @@ -14,7 +14,7 @@ import numpy as np import pytest import xarray as xr -from .._helpers.markers import gpu_available +from .._helpers.markers import gpu_available, requires_loopback import os import importlib.util import io @@ -1851,6 +1851,7 @@ def test_row4_golden_cog_xrspatial_local(): # Row 5: golden/rasterio COG fixture -> xrspatial HTTP range read # --------------------------------------------------------------------------- +@requires_loopback def test_row5_golden_cog_xrspatial_http(golden_cog_http): """xrspatial's HTTP range reader returns the same pixels as the local read. @@ -1900,6 +1901,7 @@ def test_row5_golden_cog_xrspatial_http(golden_cog_http): # Row 6: golden/rasterio COG fixture -> xrspatial dask HTTP range read # --------------------------------------------------------------------------- +@requires_loopback def test_row6_golden_cog_xrspatial_dask_http(golden_cog_http): """The dask HTTP path returns the same pixels as the local read.