From 03641be7410c262e6eb373ac75a2b4b2c222ebbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Narti=C5=A1s?= Date: Thu, 2 Oct 2025 15:47:55 +0300 Subject: [PATCH 1/3] tests: implement pytest tests for disk and sqlite cache backends --- .github/workflows/build-linux.yml | 16 +- .gitignore | 1 + tests/data/mapcache_backend_template.xml | 72 +++++++ tests/mcpython/generate_synthetic_geotiff.py | 98 +++++++++ tests/mcpython/requirements.txt | 2 + tests/mcpython/test_disk_cache_pytest.py | 180 +++++++++++++++++ tests/mcpython/test_sqlite_cache_pytest.py | 193 ++++++++++++++++++ tests/mcpython/verification_core.py | 198 +++++++++++++++++++ 8 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 tests/data/mapcache_backend_template.xml create mode 100644 tests/mcpython/generate_synthetic_geotiff.py create mode 100644 tests/mcpython/requirements.txt create mode 100644 tests/mcpython/test_disk_cache_pytest.py create mode 100644 tests/mcpython/test_sqlite_cache_pytest.py create mode 100644 tests/mcpython/verification_core.py diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index ac7ad108..6b90da95 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -26,13 +26,17 @@ jobs: if [[ 'default,maximal' =~ ${{ matrix.option }} ]] then sudo apt-get install -y libgdal-dev libfcgi-dev libpixman-1-dev - sudo apt-get install -y gdal-bin libxml2-utils + sudo apt-get install -y gdal-bin libxml2-utils python3-pip python3-gdal python3-pytest fi if [[ 'maximal' =~ ${{ matrix.option }} ]] then sudo apt-get install -y libhiredis-dev libdb-dev libmapserver-dev libpcre2-dev fi + - name: Install python dependencies + run: | + pip install -r ${{ github.workspace }}/tests/mcpython/requirements.txt + - name: Build MapCache run: | if [[ 'minimal' == ${{ matrix.option }} ]] @@ -80,3 +84,13 @@ jobs: else echo No test performed on this target fi + + - name: Run python tests + run: | + if [[ 'ubuntu-latest' == ${{ matrix.os }} ]] \ + && [[ 'default' == ${{ matrix.option }} ]] + then + pytest ${{ github.workspace }}/tests/mcpython/ + else + echo No python test performed on this target + fi diff --git a/.gitignore b/.gitignore index b1d61cfe..8f80078a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ nbproject/ /build/ /build_vagrant/ /.vagrant/ +__pycache__ diff --git a/tests/data/mapcache_backend_template.xml b/tests/data/mapcache_backend_template.xml new file mode 100644 index 00000000..b7b982ed --- /dev/null +++ b/tests/data/mapcache_backend_template.xml @@ -0,0 +1,72 @@ + + + + NEAREST + SYNTHETIC_GEOTIFF_PATH_PLACEHOLDER + + + -500000 -500000 500000 500000 + EPSG:3857 + m + top-left + 256 256 + + 1000 + 500 + 250 + 125 + 62.5 + 31.25 + 15.625 + 7.8125 + 3.90625 + 1.953125 + 0.9765625 + 0.48828125 + 0.244140625 + 0.1220703125 + 0.06103515625 + 0.030517578125 + 0.0152587890625 + 0.00762939453125 + + + + TILE_CACHE_BASE_DIR/disk + + + disk + synthetic-source + synthetic_grid + PNG + NEAREST + 1 1 + + + + TILE_CACHE_BASE_DIR/cache.sqlite + + + sqlite + synthetic-source + synthetic_grid + PNG + NEAREST + 1 1 + + + + debug + diff --git a/tests/mcpython/generate_synthetic_geotiff.py b/tests/mcpython/generate_synthetic_geotiff.py new file mode 100644 index 00000000..cdf4bc56 --- /dev/null +++ b/tests/mcpython/generate_synthetic_geotiff.py @@ -0,0 +1,98 @@ +# Project: MapCache +# Purpose: Generates a GeoTIFF with a predictable content to serve as a reference +# Author: Maris Nartiss +# +# ***************************************************************************** +# Copyright (c) 2025 Regents of the University of Minnesota. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# ****************************************************************************/ + +import numpy as np +import logging + +from osgeo import gdal, osr + + +def generate_synthetic_geotiff( + output_filename="synthetic_test_data.tif", width=256, height=256 +): + """ + Generates a synthetic GeoTIFF with unique pixel values based on their coordinates. + Each pixel value encodes its row and column index, allowing for detection of + shift and rotation errors. + """ + osr.DontUseExceptions() + # Define image properties + wm_min_x = -500000 + wm_max_x = 500000 + wm_min_y = -500000 + wm_max_y = 500000 + + pixel_width = (wm_max_x - wm_min_x) / width + pixel_height = (wm_min_y - wm_max_y) / height # Negative for north-up image + + # GeoTransform: [top-left x, pixel width, 0, top-left y, 0, pixel height] + # Top-left corner is (wm_min_x, wm_max_y) + geotransform = [wm_min_x, pixel_width, 0, wm_max_y, 0, pixel_height] + + # Spatial Reference System (Web Mercator) + srs = osr.SpatialReference() + srs.ImportFromEPSG(3857) + + # Determine appropriate data type based on image dimensions + # For 3-band output, we'll use uint8 for each band. + gdal_datatype = gdal.GDT_Byte + numpy_datatype = np.uint8 + num_bands = 3 + + # Create the GeoTIFF file + driver = gdal.GetDriverByName("GTiff") + dataset = driver.Create(output_filename, width, height, num_bands, gdal_datatype) + + if dataset is None: + logging.error(f"Error: Could not create {output_filename}") + return + + dataset.SetGeoTransform(geotransform) + dataset.SetSpatialRef(srs) + + # Create NumPy arrays to hold the pixel data for each band + data_band1 = np.zeros((height, width), dtype=numpy_datatype) + data_band2 = np.zeros((height, width), dtype=numpy_datatype) + data_band3 = np.zeros((height, width), dtype=numpy_datatype) + + # Generate unique pixel values based on row and column index for each band + # Band 1 (Red): row % 256 + # Band 2 (Green): col % 256 + # Band 3 (Blue): (row + col) % 256 + for row in range(height): + for col in range(width): + data_band1[row, col] = row % 256 + data_band2[row, col] = col % 256 + data_band3[row, col] = (row + col) % 256 + + # Write the data to each band + dataset.GetRasterBand(1).WriteArray(data_band1) + dataset.GetRasterBand(2).WriteArray(data_band2) + dataset.GetRasterBand(3).WriteArray(data_band3) + + # Close the dataset + dataset = None + logging.info(f"Successfully created synthetic GeoTIFF: {output_filename}") diff --git a/tests/mcpython/requirements.txt b/tests/mcpython/requirements.txt new file mode 100644 index 00000000..1cc18fc7 --- /dev/null +++ b/tests/mcpython/requirements.txt @@ -0,0 +1,2 @@ +numpy +pytest diff --git a/tests/mcpython/test_disk_cache_pytest.py b/tests/mcpython/test_disk_cache_pytest.py new file mode 100644 index 00000000..0be1e127 --- /dev/null +++ b/tests/mcpython/test_disk_cache_pytest.py @@ -0,0 +1,180 @@ +# Project: MapCache +# Purpose: Test MapCache disk based storage backend +# Author: Maris Nartiss +# +# ***************************************************************************** +# Copyright (c) 2025 Regents of the University of Minnesota. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# ****************************************************************************/ + +import os +import pytest +import numpy as np +import logging + +from osgeo import gdal + +# Import the GeoTIFF generation function +from generate_synthetic_geotiff import generate_synthetic_geotiff + +# Import generic verification functions and constants +from verification_core import ( + TILE_SIZE, + TILE_CACHE_BASE_DIR, + TEMP_MAPCACHE_CONFIG_DIR, + calculate_expected_tile_data, + compare_tile_arrays, + cleanup, + run_seeder, + create_temp_mapcache_config, +) + +# --- Configuration --- # +SYNTHETIC_GEOTIFF_FILENAME = os.path.join( + TEMP_MAPCACHE_CONFIG_DIR, "synthetic_test_data.tif" +) +GEOTIFF_WIDTH = 512 +GEOTIFF_HEIGHT = 512 +MAPCACHE_TEMPLATE_CONFIG = "../data/mapcache_backend_template.xml" + +# --- Grid Parameters --- # +INITIAL_RESOLUTION = 1000 +ORIGIN_X = -500000 +ORIGIN_Y = 500000 + + +def read_tile(tile_path, tile_size=TILE_SIZE): + if not os.path.exists(tile_path): + logging.error(f"Error: Actual tile not found at {tile_path}") + return None + + actual_ds = gdal.Open(tile_path, gdal.GA_ReadOnly) + if actual_ds is None: + logging.error(f"Error: Could not open actual tile {tile_path}") + return None + + # Read all bands from the actual tile + actual_tile_data = np.zeros( + (tile_size, tile_size, actual_ds.RasterCount), dtype=np.uint8 + ) + for i in range(actual_ds.RasterCount): + actual_tile_data[:, :, i] = actual_ds.GetRasterBand(i + 1).ReadAsArray() + + actual_ds = None # Close the dataset + + # Mapcache might output 4 bands (RGBA) even if source is 3 bands. Handle this. + # If actual_tile_data has 4 bands, ignore the alpha band for comparison. + if actual_tile_data.shape[2] == 4: + actual_tile_data_rgb = actual_tile_data[:, :, :3] # Take only RGB bands + elif actual_tile_data.shape[2] == 3: + actual_tile_data_rgb = actual_tile_data + else: + logging.error( + f"Error: Unexpected number of bands in actual tile: {actual_tile_data.shape[2]}" + ) + return None + + return actual_tile_data_rgb + + +def run_mapcache_test(zoom, x, y, geotiff_path, initial_resolution, origin_x, origin_y): + logging.info(f"Running MapCache test for tile Z{zoom}-X{x}-Y{y}...") + + # Calculate expected tile data using generic function + expected_tile_data = calculate_expected_tile_data( + zoom, + x, + y, + geotiff_path, + initial_resolution, + origin_x, + origin_y, + ) + if expected_tile_data is None: + return False + + # --- Read Actual Tile Data --- + actual_tile_path = os.path.join( + TILE_CACHE_BASE_DIR, + "disk", + "disk-tileset", + "synthetic_grid", + f"{zoom:02d}", + f"{x // 1000000:03d}", + f"{(x // 1000) % 1000:03d}", + f"{x % 1000:03d}", + f"{y // 1000000:03d}", + f"{(y // 1000) % 1000:03d}", + f"{y % 1000:03d}.png", + ) + + logging.info(f"Reading tile {actual_tile_path}") + actual_tile_data_rgb = read_tile(actual_tile_path, TILE_SIZE) + if actual_tile_data_rgb is None: + return False + + # --- Compare --- + return compare_tile_arrays(expected_tile_data, actual_tile_data_rgb, zoom, x, y) + + +@pytest.fixture(scope="module") +def setup_test_environment(request): + cleanup() + logging.info("Testing disk storage backend...") + os.makedirs(TEMP_MAPCACHE_CONFIG_DIR, exist_ok=True) + generate_synthetic_geotiff( + output_filename=SYNTHETIC_GEOTIFF_FILENAME, + width=GEOTIFF_WIDTH, + height=GEOTIFF_HEIGHT, + ) + create_temp_mapcache_config( + SYNTHETIC_GEOTIFF_FILENAME, + MAPCACHE_TEMPLATE_CONFIG, + ) + run_seeder("disk-tileset", "0,1") + + def teardown(): + cleanup() + logging.info("Cleanup complete.") + + request.addfinalizer(teardown) + + +def test_disk_tiles(setup_test_environment): + ok0 = run_mapcache_test( + 0, + 0, + 0, + SYNTHETIC_GEOTIFF_FILENAME, + INITIAL_RESOLUTION, + ORIGIN_X, + ORIGIN_Y, + ) + ok1 = run_mapcache_test( + 1, + 1, + 2, + SYNTHETIC_GEOTIFF_FILENAME, + INITIAL_RESOLUTION, + ORIGIN_X, + ORIGIN_Y, + ) + assert ok0 + assert ok1 diff --git a/tests/mcpython/test_sqlite_cache_pytest.py b/tests/mcpython/test_sqlite_cache_pytest.py new file mode 100644 index 00000000..8da8638b --- /dev/null +++ b/tests/mcpython/test_sqlite_cache_pytest.py @@ -0,0 +1,193 @@ +# Project: MapCache +# Purpose: Test MapCache SQLite based storage backend +# Author: Maris Nartiss +# +# ***************************************************************************** +# Copyright (c) 2025 Regents of the University of Minnesota. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# ****************************************************************************/ + +import os +import pytest +import numpy as np +import sqlite3 +import logging + +from osgeo import gdal + +# Import the GeoTIFF generation function +from generate_synthetic_geotiff import generate_synthetic_geotiff + +# Import generic verification functions and constants +from verification_core import ( + TILE_SIZE, + TILE_CACHE_BASE_DIR, + TEMP_MAPCACHE_CONFIG_DIR, + calculate_expected_tile_data, + compare_tile_arrays, + cleanup, + run_seeder, + create_temp_mapcache_config, +) + +# --- Configuration --- # +SYNTHETIC_GEOTIFF_FILENAME = os.path.join( + TEMP_MAPCACHE_CONFIG_DIR, "synthetic_test_data.tif" +) +GEOTIFF_WIDTH = 512 +GEOTIFF_HEIGHT = 512 +MAPCACHE_TEMPLATE_CONFIG = "../data/mapcache_backend_template.xml" + +# --- Grid Parameters --- # +INITIAL_RESOLUTION = 1000 +ORIGIN_X = -500000 +ORIGIN_Y = 500000 + + +def read_tile(zoom, x, y, tile_size=TILE_SIZE): + db_file = os.path.join(TILE_CACHE_BASE_DIR, "cache.sqlite") + tmp_tile = os.path.join(TILE_CACHE_BASE_DIR, "temp_tile_sqlite.png") + if not os.path.exists(db_file): + logging.error(f"Error: Database file not found at {db_file}") + return None + + try: + con = sqlite3.connect(db_file) + cur = con.cursor() + cur.execute( + "SELECT data FROM tiles WHERE tileset='sqlite-tileset' AND " + "grid='synthetic_grid' AND x=? AND y=? AND z=?", + (x, y, zoom), + ) + row = cur.fetchone() + if row is None: + logging.error(f"Error: Tile not found in database for Z{zoom}-X{x}-Y{y}") + return None + + # Assume SQLite returned raw PNG data + with open(tmp_tile, "wb") as f: + f.write(row[0]) + + actual_ds = gdal.Open(tmp_tile, gdal.GA_ReadOnly) + if actual_ds is None: + logging.error( + f"Error: Could not open tile from database data. Tile: {tmp_tile}" + ) + return None + + # Read all bands from the actual tile + actual_tile_data = np.zeros( + (tile_size, tile_size, actual_ds.RasterCount), dtype=np.uint8 + ) + for i in range(actual_ds.RasterCount): + actual_tile_data[:, :, i] = actual_ds.GetRasterBand(i + 1).ReadAsArray() + + actual_ds = None # Close the dataset + + # Mapcache might output 4 bands (RGBA) even if source is 3 bands. Handle this. + if actual_tile_data.shape[2] == 4: + actual_tile_data_rgb = actual_tile_data[:, :, :3] # Take only RGB bands + elif actual_tile_data.shape[2] == 3: + actual_tile_data_rgb = actual_tile_data + else: + logging.error( + f"Error: Unexpected number of bands in actual tile: {actual_tile_data.shape[2]}" + ) + return None + + return actual_tile_data_rgb + + except sqlite3.Error as e: + logging.error(f"Database error: {e}") + return None + finally: + if con: + con.close() + + +def run_mapcache_test(zoom, x, y, geotiff_path, initial_resolution, origin_x, origin_y): + logging.info(f"Running MapCache test for tile Z{zoom}-X{x}-Y{y}...") + + # Calculate expected tile data using generic function + expected_tile_data = calculate_expected_tile_data( + zoom, + x, + y, + geotiff_path, + initial_resolution, + origin_x, + origin_y, + ) + if expected_tile_data is None: + return False + + # --- Read Actual Tile Data --- + actual_tile_data_rgb = read_tile(zoom, x, y, TILE_SIZE) + if actual_tile_data_rgb is None: + return False + + # --- Compare --- + return compare_tile_arrays(expected_tile_data, actual_tile_data_rgb, zoom, x, y) + + +@pytest.fixture(scope="module") +def setup_test_environment(request): + cleanup() + logging.info("Testing sqlite storage backend...") + os.makedirs(TEMP_MAPCACHE_CONFIG_DIR, exist_ok=True) + generate_synthetic_geotiff( + output_filename=SYNTHETIC_GEOTIFF_FILENAME, + width=GEOTIFF_WIDTH, + height=GEOTIFF_HEIGHT, + ) + create_temp_mapcache_config( + SYNTHETIC_GEOTIFF_FILENAME, + MAPCACHE_TEMPLATE_CONFIG, + ) + run_seeder("sqlite-tileset", "0,1") + + def teardown(): + cleanup() + logging.info("Cleanup complete.") + + request.addfinalizer(teardown) + + +def test_sqlite_tiles(setup_test_environment): + ok0 = run_mapcache_test( + 0, + 0, + 0, + SYNTHETIC_GEOTIFF_FILENAME, + INITIAL_RESOLUTION, + ORIGIN_X, + ORIGIN_Y, + ) + ok1 = run_mapcache_test( + 1, + 1, + 2, + SYNTHETIC_GEOTIFF_FILENAME, + INITIAL_RESOLUTION, + ORIGIN_X, + ORIGIN_Y, + ) + assert ok0 + assert ok1 diff --git a/tests/mcpython/verification_core.py b/tests/mcpython/verification_core.py new file mode 100644 index 00000000..f7c12c5c --- /dev/null +++ b/tests/mcpython/verification_core.py @@ -0,0 +1,198 @@ +# Project: MapCache +# Purpose: Common code for various MapCache storage backend tests +# Author: Maris Nartiss +# +# ***************************************************************************** +# Copyright (c) 2025 Regents of the University of Minnesota. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# ****************************************************************************/ + +import os +import shutil +import math +import subprocess +import numpy as np +import logging + +from osgeo import gdal + +TILE_SIZE = 256 +TEMP_MAPCACHE_CONFIG_DIR = "/tmp/mc_test" +TEMP_MAPCACHE_CONFIG_FILE = os.path.join(TEMP_MAPCACHE_CONFIG_DIR, "mapcache.xml") +TILE_CACHE_BASE_DIR = os.path.join(TEMP_MAPCACHE_CONFIG_DIR, "cache_data") + + +def calculate_expected_tile_data( + zoom, x, y, geotiff_path, initial_resolution, origin_x, origin_y +): + """ + Calculates the expected pixel data for a given tile (zoom, x, y) + by reading directly from the source GeoTIFF using GDAL, + thus matching any resampling done by MapCache. + Returns a 3-band NumPy array (uint8) or None on error. + """ + # Calculate resolution for the current zoom level + resolution = initial_resolution / (2**zoom) + + # Calculate geographic bounds of the tile + min_x_tile = origin_x + x * TILE_SIZE * resolution + max_y_tile = origin_y - y * TILE_SIZE * resolution + max_x_tile = min_x_tile + TILE_SIZE * resolution + min_y_tile = max_y_tile - TILE_SIZE * resolution + + logging.info( + "Tile geographic bounds (Web Mercator):\n " + f"MinX: {min_x_tile}, MinY: {min_y_tile}\n " + f"MaxX: {max_x_tile}, MaxY: {max_y_tile}" + ) + + expected_tile_data = np.zeros((TILE_SIZE, TILE_SIZE, 3), dtype=np.uint8) + + # Open the source GeoTIFF + src_ds = gdal.Open(geotiff_path, gdal.GA_ReadOnly) + if src_ds is None: + logging.error(f"Error: Could not open source GeoTIFF {geotiff_path}") + return None + + geotiff_width = src_ds.RasterXSize + geotiff_height = src_ds.RasterYSize + + src_gt = src_ds.GetGeoTransform() + + # Get the source bands + src_bands = [src_ds.GetRasterBand(i + 1) for i in range(src_ds.RasterCount)] + + # Iterate over each pixel in the output tile + for py in range(TILE_SIZE): + for px in range(TILE_SIZE): + # Calculate geographic coordinates (Web Mercator) of the center of the pixel in the tile + map_x = min_x_tile + (px + 0.5) * resolution + map_y = max_y_tile - (py + 0.5) * resolution + + # Map Web Mercator (x, y) to pixel (col, row) in the source GeoTIFF + src_col = math.floor((map_x - src_gt[0]) / src_gt[1]) + src_row = math.floor((map_y - src_gt[3]) / src_gt[5]) + + # Read pixel value directly from the source GeoTIFF + if 0 <= src_row < geotiff_height and 0 <= src_col < geotiff_width: + for band_idx in range(3): # Assuming 3 bands (RGB) + # ReadRaster(xoff, yoff, xsize, ysize, buf_xsize, buf_ysize, buf_type) + # Read a single pixel + val = src_bands[band_idx].ReadRaster( + src_col, src_row, 1, 1, 1, 1, gdal.GDT_Byte + ) + expected_tile_data[py, px, band_idx] = np.frombuffer( + val, dtype=np.uint8 + )[0] + else: + # If the coordinate falls outside the source GeoTIFF, set to 0 (black) + expected_tile_data[py, px, :] = 0 + + src_ds = None # Close the source dataset + return expected_tile_data + + +def compare_tile_arrays(expected_data, actual_data, zoom, x, y): + """ + Compares two NumPy arrays representing tile data and reports discrepancies. + Returns True if arrays are equal, False otherwise. + """ + if actual_data is None: + return False + + if np.array_equal(expected_data, actual_data): + logging.info(f"SUCCESS: Tile Z{zoom}-X{x}-Y{y} matches expected data.") + return True + else: + logging.error(f"FAILURE: Tile Z{zoom}-X{x}-Y{y} does NOT match expected data.") + diff = expected_data.astype(np.int16) - actual_data.astype(np.int16) + logging.error("Differences (Expected - Actual):\n%s", diff) + diff_coords = np.argwhere(diff != 0) + if len(diff_coords) > 0: + logging.error("First 10 differing pixel coordinates and values:") + for coord in diff_coords[:10]: + py, px, band_idx = coord + logging.error( + f" Pixel ({px}, {py}), Band {band_idx}: " + f"Expected={expected_data[py, px, band_idx]}, " + f"Actual={actual_data[py, px, band_idx]}" + ) + return False + + +def cleanup(): + if os.path.exists(TEMP_MAPCACHE_CONFIG_DIR): + shutil.rmtree(TEMP_MAPCACHE_CONFIG_DIR) + + +def create_temp_mapcache_config(geotiff_path, mapcache_template_config): + """ + Replace dynamic parts of config file with actual values + """ + + os.makedirs(TEMP_MAPCACHE_CONFIG_DIR, exist_ok=True) + + with open(mapcache_template_config, "r") as f: + template_content = f.read() + + content = template_content.replace( + "SYNTHETIC_GEOTIFF_PATH_PLACEHOLDER", geotiff_path + ) + content = content.replace("TILE_CACHE_BASE_DIR", TILE_CACHE_BASE_DIR) + + with open(TEMP_MAPCACHE_CONFIG_FILE, "w") as f: + f.write(content) + + logging.info(f"Created temporary mapcache config: {TEMP_MAPCACHE_CONFIG_FILE}") + + +def run_seeder(tileset, zoomlevels): + """ + Prepopulate storage backend with tiles + Tileset is a tileset name + Zoomlevels – a string with zoomlevels to seed e.g. "0,2" + """ + + logging.info("Running mapcache seeder...") + seeder_command = [ + "mapcache_seed", + "-c", + TEMP_MAPCACHE_CONFIG_FILE, + "-t", + tileset, + "--force", + "-z", + zoomlevels, + ] + try: + result = subprocess.run( + seeder_command, check=True, capture_output=True, text=True + ) + logging.info("Seeder stdout: %s", result.stdout) + if result.stderr: + logging.error("Seeder stderr: %s", result.stderr) + logging.info("Mapcache seeder finished.") + except subprocess.CalledProcessError as e: + logging.error( + f"Error running mapcache_seed: {e} Temporary files in: {TEMP_MAPCACHE_CONFIG_DIR}" + ) + logging.error("Stdout: %s", e.stdout) + logging.error("Stderr: %s", e.stderr) + exit(1) From 62fa344c270a8573a984635306f40d0c813b9e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Narti=C5=A1s?= Date: Thu, 2 Oct 2025 15:59:48 +0300 Subject: [PATCH 2/3] tests: use absolute path to configuration file --- tests/mcpython/test_disk_cache_pytest.py | 4 +++- tests/mcpython/test_sqlite_cache_pytest.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/mcpython/test_disk_cache_pytest.py b/tests/mcpython/test_disk_cache_pytest.py index 0be1e127..1fea9a13 100644 --- a/tests/mcpython/test_disk_cache_pytest.py +++ b/tests/mcpython/test_disk_cache_pytest.py @@ -52,7 +52,9 @@ ) GEOTIFF_WIDTH = 512 GEOTIFF_HEIGHT = 512 -MAPCACHE_TEMPLATE_CONFIG = "../data/mapcache_backend_template.xml" +MAPCACHE_TEMPLATE_CONFIG = os.path.join( + os.path.dirname(__file__), "..", "data", "mapcache_backend_template.xml" +) # --- Grid Parameters --- # INITIAL_RESOLUTION = 1000 diff --git a/tests/mcpython/test_sqlite_cache_pytest.py b/tests/mcpython/test_sqlite_cache_pytest.py index 8da8638b..9351ca1a 100644 --- a/tests/mcpython/test_sqlite_cache_pytest.py +++ b/tests/mcpython/test_sqlite_cache_pytest.py @@ -53,7 +53,9 @@ ) GEOTIFF_WIDTH = 512 GEOTIFF_HEIGHT = 512 -MAPCACHE_TEMPLATE_CONFIG = "../data/mapcache_backend_template.xml" +MAPCACHE_TEMPLATE_CONFIG = os.path.join( + os.path.dirname(__file__), "..", "data", "mapcache_backend_template.xml" +) # --- Grid Parameters --- # INITIAL_RESOLUTION = 1000 From 37e53048772ddb06f717c6f294b6103a5ed7b1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Narti=C5=A1s?= Date: Fri, 3 Oct 2025 15:42:06 +0300 Subject: [PATCH 3/3] tests: rename test files --- tests/mcpython/{test_disk_cache_pytest.py => test_disk_cache.py} | 0 .../{test_sqlite_cache_pytest.py => test_sqlite_cache.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/mcpython/{test_disk_cache_pytest.py => test_disk_cache.py} (100%) rename tests/mcpython/{test_sqlite_cache_pytest.py => test_sqlite_cache.py} (100%) diff --git a/tests/mcpython/test_disk_cache_pytest.py b/tests/mcpython/test_disk_cache.py similarity index 100% rename from tests/mcpython/test_disk_cache_pytest.py rename to tests/mcpython/test_disk_cache.py diff --git a/tests/mcpython/test_sqlite_cache_pytest.py b/tests/mcpython/test_sqlite_cache.py similarity index 100% rename from tests/mcpython/test_sqlite_cache_pytest.py rename to tests/mcpython/test_sqlite_cache.py