Skip to content

Commit d3aa06b

Browse files
authored
Gitc 6903 Adding Unit Test Suite (#83)
* GITC-6903 unit tests * GITC-6903 unit tests infrastructure * GITC-6903 unit tests mrf insert update * Update test_mrf_insert.py * Update test_join.py * Create test_join.md * Update test_join.md * Create TEST_MRF_INSERT.md * Rename test_join.md to TEST_JOIN.md * Create test_jxl.py * Create test_tiles2mrf.py * Create test_mrf_size.py * Create test_read_data.py * Create test_read_idx.py * Update MRF_UTILITIES_TEST_SUITE.md * Update MRF_UTILITIES_TEST_SUITE.md * Create Makefile.lcl * Update requirements.txt * Update test_join.py * Update test_jxl.py * Update test_read_data.py * Update test_read_idx.py * Rename TEST_JOIN.md to test_join.md * Rename TEST_MRF_INSERT.md to test_mrf_insert.md * Rename doc/Test Suite/test_join.md to tests/test_join.md * Rename doc/Test Suite/test_mrf_insert.md to tests/test_mrf_insert.md * Update Makefile.lcl * Rename MRF_UTILITIES_TEST_SUITE.md to MRF_Utilities_Test_Suite.md * Update Dockerfile * Update Dockerfile * Update Dockerfile * Create Dockerfile * Update MRF_Utilities_Test_Suite.md * Update Dockerfile * Update Dockerfile * Refactored Dockerfiles, fixed unit test bugs, also made changes to utilities in mrf_apps * Update Dockerfile * Update Dockerfile * Update Dockerfile * Update Dockerfile Resolved missing GDAL Python Bindings * Update mrf_size.py Reverted change based on review by the dev team * Update test_mrf_size.py Fix for test_vrt_creation_multi_band because test was incorrectly asserting bands==3 * Update test_mrf_size.py * Update MRF_Utilities_Test_Suite.md
1 parent 9868d6f commit d3aa06b

21 files changed

+1490
-4
lines changed

Dockerfile

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# mrf/Dockerfile
2+
3+
# =====================================================================
4+
# Stage 1: Install all development tools, compile the C++ utilities,
5+
# and create the Python virtual environment.
6+
# =====================================================================
7+
FROM almalinux:10 AS builder
8+
9+
# Build Arguments for the el9 GDAL RPM
10+
ARG GDAL_VERSION=3.6.4
11+
ARG GIBS_GDAL_RELEASE=3 #
12+
ARG ALMALINUX_VERSION=10 #
13+
14+
# Install Build-time Dependencies
15+
RUN dnf install -y epel-release dnf-plugins-core && \
16+
dnf config-manager --set-enabled crb && \
17+
dnf groupinstall -y "Development Tools" && \
18+
dnf install -y --allowerasing \
19+
cmake \
20+
git \
21+
python3-pip \
22+
python3-devel \
23+
libtiff-devel \
24+
sqlite-devel \
25+
wget \
26+
curl \
27+
geos \
28+
proj && \
29+
dnf clean all
30+
31+
# Install Pre-compiled GIBS GDAL for el10
32+
RUN wget -P /tmp/ https://github.com/nasa-gibs/gibs-gdal/releases/download/v${GDAL_VERSION}-${GIBS_GDAL_RELEASE}/gibs-gdal-${GDAL_VERSION}-${GIBS_GDAL_RELEASE}.el${ALMALINUX_VERSION}.x86_64.rpm && \
33+
dnf install -y /tmp/gibs-gdal-${GDAL_VERSION}-${GIBS_GDAL_RELEASE}.el${ALMALINUX_VERSION}.x86_64.rpm && \
34+
# Point ldconfig to /usr/lib (where libgdal.so is)
35+
# AND also /usr/local/lib for Brunsli
36+
echo "/usr/lib" > /etc/ld.so.conf.d/gdal-custom.conf && \
37+
echo "/usr/local/lib" > /etc/ld.so.conf.d/gdal-brunsli.conf && \
38+
ldconfig && \
39+
rm -rf /tmp/*
40+
41+
# Download the missing private marfa.h header
42+
RUN curl -L "https://raw.githubusercontent.com/OSGeo/gdal/v${GDAL_VERSION}/frmts/mrf/marfa.h" -o /usr/local/include/marfa.h
43+
44+
WORKDIR /app
45+
COPY requirements.txt .
46+
# Create the venv and install packages
47+
RUN python3 -m venv /app/venv
48+
ENV PATH="/app/venv/bin:$PATH"
49+
RUN pip install --no-cache-dir -r requirements.txt
50+
# Fix outdated packaging lib before building GDAL
51+
RUN pip install --force-reinstall 'packaging>=25.0'
52+
53+
# Install build dependencies needed when using --no-build-isolation
54+
RUN pip install setuptools wheel
55+
56+
# Install the Python bindings with correct build flags
57+
RUN NP_INCLUDE=$(python -c "import numpy; print(numpy.get_include())") && \
58+
VENV_SITE_PACKAGES=$(python -c "import site; print(site.getsitepackages()[0])") && \
59+
CFLAGS="$(gdal-config --cflags) -I${NP_INCLUDE}" \
60+
# Override LDFLAGS to use /usr/lib
61+
LDFLAGS="-L/usr/lib -lgdal" \
62+
PYTHONPATH="${VENV_SITE_PACKAGES}:${PYTHONPATH:-}" \
63+
pip install \
64+
--no-build-isolation \
65+
--no-cache-dir \
66+
--no-binary :all: \
67+
GDAL==$(gdal-config --version)
68+
69+
# Copy the rest of the project files
70+
COPY . .
71+
72+
# Build the C++ utilities
73+
RUN cd mrf_apps && make
74+
75+
# Install the project itself into the venv
76+
RUN pip install -e .
77+
78+
# =====================================================================
79+
# Stage 2: Minimal, distributable image.
80+
# =====================================================================
81+
FROM almalinux:10
82+
83+
# Install only Runtime Dependencies
84+
RUN dnf install -y epel-release dnf-plugins-core && \
85+
dnf config-manager --set-enabled crb && \
86+
dnf install -y --allowerasing python3 wget geos proj && \
87+
dnf clean all
88+
89+
# Install the el10 GDAL RPM for its runtime libraries
90+
ARG GDAL_VERSION=3.6.4
91+
ARG GIBS_GDAL_RELEASE=3
92+
ARG ALMALINUX_VERSION=10
93+
RUN wget -P /tmp/ https://github.com/nasa-gibs/gibs-gdal/releases/download/v${GDAL_VERSION}-${GIBS_GDAL_RELEASE}/gibs-gdal-${GDAL_VERSION}-${GIBS_GDAL_RELEASE}.el${ALMALINUX_VERSION}.x86_64.rpm && \
94+
dnf install -y /tmp/gibs-gdal-${GDAL_VERSION}-${GIBS_GDAL_RELEASE}.el${ALMALINUX_VERSION}.x86_64.rpm && \
95+
# Point ldconfig to /usr/lib (where libgdal.so is)
96+
# AND also /usr/local/lib for Brunsli
97+
echo "/usr/lib" > /etc/ld.so.conf.d/gdal-custom.conf && \
98+
echo "/usr/local/lib" > /etc/ld.so.conf.d/gdal-brunsli.conf && \
99+
# Update the shared library cache
100+
ldconfig && \
101+
rm -rf /tmp/*
102+
103+
WORKDIR /app
104+
105+
# Copy Artifacts from the "builder" Stage
106+
COPY --from=builder /app/mrf_apps/can /usr/local/bin/
107+
COPY --from=builder /app/mrf_apps/jxl /usr/local/bin/
108+
COPY --from=builder /app/mrf_apps/mrf_insert /usr/local/bin/
109+
COPY --from=builder /app/venv /app/venv
110+
COPY mrf_apps/ ./mrf_apps/
111+
COPY pyproject.toml .
112+
COPY README.md .
113+
114+
# Set Final Environment Variables
115+
ENV PATH="/app/venv/bin:$PATH"
116+
ENV GDAL_DATA="/usr/local/share/gdal"

MRF_Utilities_Test_Suite.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
## **MRF Utilities Test Suite**
2+
3+
This document outlines the unit tests for the Meta Raster Format (MRF) utilities. The tests are written in Python using the `unittest` framework and are designed to be run with a test runner like `pytest`. The suite is structured into separate files for each utility to ensure maintainability and clarity.
4+
5+
A shared test helper, `tests/helpers.py`, provides a base class that handles the setup and teardown of a temporary testing directory and includes methods for creating mock MRF files (`.mrf`, `.idx`, `.dat`). This approach minimizes code duplication and standardizes test environments.
6+
7+
8+
### Docker-Based Testing Environment
9+
10+
Using Docker is the recommended method for running this test suite. It creates an environment with all the necessary C++, GDAL, and Python dependencies pre-installed, resolving any platform-specific issues and ensuring the tests run in this isolated environment. This workflow uses a two stage building approach: first creating a base application image, and then building a lightweight test runner image from it.
11+
12+
#### Prerequisites
13+
14+
Ensure Docker installed and running on your system.
15+
16+
#### Building and Running the Tests
17+
18+
**Step 1: Build the Base Application Image**
19+
Navigate to the project's root directory and run the following command. This builds the main application image, compiling all C++ utilities and installing dependencies. It is tagged as `mrf-app:latest`.
20+
21+
```bash
22+
docker build --platform linux/amd64 -t mrf-app:latest -f Dockerfile .
23+
```
24+
25+
> **Note**: The `--platform linux/amd64` flag is required if you are building on an ARM-based machine (like an Apple Silicon Mac) to ensure compatibility with the pre-compiled `x86_64` GDAL RPM used in the build.
26+
27+
**Step 2: Build the Test Suite Image**
28+
Next, build the dedicated test runner image. This build uses the `mrf-app` image from the previous step as its base.
29+
30+
```bash
31+
docker build --platform linux/amd64 -t mrf-test-suite -f tests/Dockerfile .
32+
```
33+
34+
**Step 3: Run the Test Suite**
35+
Finally, run the tests using the `mrf-test-suite` image. This command starts a container, executes `pytest`, and automatically removes the container (`--rm`) when finished.
36+
37+
```bash
38+
docker run --rm mrf-test-suite pytest -rs
39+
```
40+
41+
You should see output from `pytest`, culminating in a summary showing tests passing or failing or skipping.
42+
43+
44+
### `can` Utility Tests
45+
46+
**File**: `tests/test_can.py`
47+
48+
These tests validate the `can` C++ command-line utility, which is used for compressing and decompressing sparse MRF index files.
49+
50+
* **`test_can_uncan_cycle`**: Verifies the round-trip integrity of the canning process. It creates a large, sparse mock index file (`.idx`), runs `can` to compress it to a canned index (`.ix`), and then runs it with the `-u` flag to decompress it back to an `.idx` file. The test passes if the final index file is identical to the original.
51+
52+
53+
### `jxl` Utility Tests
54+
55+
**File**: `tests/test_jxl.py`
56+
57+
These tests validate the `jxl` C++ utility, which converts MRF data files and single images between JPEG (JFIF) and JPEG XL (Brunsli) formats.
58+
59+
* **`test_jxl_mrf_round_trip`**: Verifies the primary MRF conversion. It converts a mock MRF data file (`.pjg`) and its index to JXL format and then back to JPEG, confirming the final files are identical to the originals and that the JXL file is smaller.
60+
* **`test_jxl_single_file_round_trip`**: Validates the single-file mode (`-s`). It performs a round-trip conversion on a standalone JPEG file and confirms data integrity.
61+
* **`test_jxl_bundle_mode` (Placeholder)**: A placeholder test for Esri bundle mode (`-b`) that is skipped, as creating a valid mock bundle file is non-trivial.
62+
63+
64+
### `mrf_clean.py` Tests
65+
66+
**File**: `tests/test_clean.py`
67+
68+
These tests validate `mrf_clean.py`, a script used to optimize MRF storage by removing unused space.
69+
70+
* **`test_mrf_clean_copy`**: Checks the default "copy" mode. It verifies that the script creates a new, smaller data file with slack space removed and that the new index file has correctly updated, contiguous tile offsets.
71+
* **`test_mrf_clean_trim`**: Validates the in-place "trim" mode. It confirms that the original data file is truncated to the correct size and its index file is overwritten with updated offsets.
72+
73+
74+
### `mrf_insert` Utility Tests
75+
76+
**File**: `tests/test_mrf_insert.py`
77+
78+
These tests validate the `mrf_insert` C++ utility, which is used to patch a smaller raster into a larger MRF.
79+
80+
* **`test_mrf_insert_simple_patch`**: Validates the core functionality. It creates an empty target MRF and a smaller source raster, executes `mrf_insert`, and uses GDAL to verify the patched region was written correctly while unpatched regions remain unaffected.
81+
* **`test_mrf_insert_with_overviews`**: Tests that inserting a patch with the `-r` flag correctly regenerates the affected overview tiles.
82+
* **`test_mrf_insert_partial_tile_overlap`**: Confirms that inserting a source that only partially covers a target tile correctly merges the new data while preserving the uncovered portions of the original tile.
83+
84+
85+
### `mrf_join.py` Tests
86+
87+
**File**: `tests/test_join.py`
88+
89+
These tests validate `mrf_join.py`, a script that merges or appends multiple MRF files.
90+
91+
* **`test_mrf_join_simple_merge`**: Checks the script's ability to merge two sparse MRFs, verifying that the final data file is a concatenation of inputs and the final index correctly combines entries with updated offsets.
92+
* **`test_mrf_join_overwrite`**: Confirms the "last-one-wins" logic by joining two MRFs that provide data for the same tile and verifying that the final index points to the data from the last-processed input.
93+
* **`test_mrf_append_z_dimension`**: Validates the ability to stack 2D MRFs into a single 3D MRF, checking that the Z dimension is correctly set in the metadata and that the index layout is correct for multiple slices.
94+
* **`test_mrf_append_with_overviews`**: Tests the scenario of appending MRFs that contain overviews, ensuring the final interleaved index structure is correctly assembled.
95+
96+
### `mrf_read_data.py` Tests
97+
98+
**File**: `tests/test_read_data.py`
99+
100+
These tests validate `mrf_read_data.py`, which extracts a specific tile or data segment from an MRF data file.
101+
102+
* **`test_read_with_offset_and_size`**: Validates the direct read mode by using `--offset` and `--size` to extract a specific data segment and confirming the output is correct.
103+
* **`test_read_with_index_and_tile`**: Validates the index-based read mode by using `--index` and `--tile` to retrieve a specific tile and verifying its content.
104+
* **`test_read_with_little_endian_index`**: Ensures the `--little-endian` flag functions correctly by reading from an index file with a different byte order.
105+
106+
107+
### `mrf_read_idx.py` Tests
108+
109+
**File**: `tests/test_read_idx.py`
110+
111+
These tests validate `mrf_read_idx.py`, which converts a binary MRF index file into a CSV.
112+
113+
* **`test_read_simple_index`**: Validates the script's core functionality with a standard, big-endian index file, verifying the output CSV has the correct headers and data.
114+
* **`test_read_little_endian_index`**: Confirms that the `--little-endian` flag works by parsing an index with a different byte order and checking for correctly interpreted values.
115+
* **`test_read_empty_index`**: Handles the edge case of an empty input file, ensuring the script produces a CSV with only the header row.
116+
117+
118+
### `mrf_size.py` Tests
119+
120+
**File**: `tests/test_mrf_size.py`
121+
122+
These tests validate `mrf_size.py`, which generates a GDAL VRT to visualize the tile sizes from an MRF index.
123+
124+
* **`test_vrt_creation_single_band`**: Checks VRT generation for a single-band MRF, verifying the VRT's dimensions, GeoTransform, and raw band parameters.
125+
* **`test_vrt_creation_multi_band`**: Validates handling of multi-band MRFs, ensuring the VRT contains the correct number of bands with correctly calculated offsets.
126+
* **`test_vrt_default_pagesize`**: Ensures the script correctly applies a default 512x512 page size when it's not specified in the MRF metadata.
127+
128+
129+
### `tiles2mrf.py` Tests
130+
131+
**File**: `tests/test_tiles2mrf.py`
132+
133+
These tests validate `tiles2mrf.py`, which assembles an MRF from a directory of individual tiles.
134+
135+
* **`test_simple_conversion`**: Validates basic functionality by assembling a 2x2 grid of tiles and verifying the concatenated data file and sequential index offsets.
136+
* **`test_with_overviews_and_padding`**: Checks the creation of a multi-level pyramid, ensuring the script correctly processes all levels and adds necessary padding records to the index.
137+
* **`test_blank_tile_handling`**: Validates the `--blank-tile` feature, confirming that blank tiles are omitted from the data file and are represented by a zero-record in the index.
138+
139+
140+
### Conditional Test Skipping
141+
142+
The test suite is designed to be run primarily within the provided Docker container, where all dependencies are guaranteed to be met. However, the tests include conditional skipping logic to fail gracefully if run in a local environment that is not fully configured.
143+
144+
* **C++ Executable Tests**: The tests for **`can`**, **`jxl`**, and **`mrf_insert`** will be skipped if their respective compiled executables are not found in the system's PATH.
145+
* **GDAL Python Dependency**: The test for `mrf_insert` requires the GDAL Python bindings to create test files. It will be skipped if the `osgeo.gdal` library cannot be imported.

Makefile.lcl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Makefile.lcl
2+
# Local configuration file that provides the system-specific paths needed to compile the C++ utilities.
3+
4+
# Set the base directory where the GDAL RPM installed its files.
5+
PREFIX=/usr/local
6+
7+
# Set the root for GDAL headers, as expected by the mrf_apps/Makefile.
8+
# This points to the same location as PREFIX/include but satisfies the variable requirement.
9+
GDAL_ROOT=/usr/local/include
10+
11+
# Override the library directory to point to lib64
12+
LIBDIR = $(PREFIX)/lib64

mrf_apps/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# PREFIX=/home/ec2-user
55
# GDAL_ROOT=$(PREFIX)/src/gdal/gdal
66
#
7-
include Makefile.lcl
7+
include ../Makefile.lcl
88

99
TARGETS = can mrf_insert jxl
1010
GDAL_INCLUDE = -I $(PREFIX)/include -I $(GDAL_ROOT)

mrf_apps/mrf_join.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ def mrf_append(inputs, output, outsize, startidx = 0):
205205
assert os.path.splitext(f)[1] == ext,\
206206
"All input files should have the same extension as the output"
207207
# Get the template mrf information from the first input
208-
mrfinfo, tree = getmrfinfo(os.path.splitext(inputs[1])[0] + ".mrf", ofname + ".mrf")
208+
# Use the first input file (inputs[0]) as template for the output MRF
209+
mrfinfo, tree = getmrfinfo(os.path.splitext(inputs[0])[0] + ".mrf")
209210

210211
# Create the output .mrf if it doesn't exist
211212
if not os.path.isfile(ofname + ".mrf"):

mrf_apps/tiles2mrf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ def option_error(parser, msg):
4040
sys.exit(1)
4141

4242
def half(val):
43-
'Divide by two with roundup, returns at least 1'
44-
return 1 + (val - 1 )/2
43+
'Divide by two with roundup, returns integer value at least 1'
44+
return 1 + (val - 1 ) // 2
4545

4646
def hash_tile(tile):
4747
h = hashlib.sha256()

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "mrf_utilities"
3+
version = "0.1.0"
4+
5+
[tool.setuptools]
6+
packages = ["mrf_apps", "tests"]

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#requirements.txt
2+
pytest
3+
numpy
4+
Pillow

tests/Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# mrf/tests/Dockerfile
2+
# This file builds the test runner image.
3+
4+
# Start from application image mrf/Dockerfile
5+
# Need to make sure the tag in the build step is matching.
6+
FROM mrf-app:latest
7+
8+
# The WORKDIR and ENV variables are inherited from the base image.
9+
10+
# Copy the test directory into the image.
11+
# This assumes the `docker build` command was run from the project root.
12+
COPY tests/ ./tests/
13+
14+
# The source code and tests were copied in the base image,
15+
# so we just defining the command to run the tests.
16+
CMD ["pytest"]

0 commit comments

Comments
 (0)