Skip to content

Commit 8863ddb

Browse files
committed
Created python test suite. Remove obsolete test file test.strata from the repository.
1 parent 1dde1b4 commit 8863ddb

File tree

17 files changed

+333
-6097
lines changed

17 files changed

+333
-6097
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Example Tests
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
branches: [main, master]
8+
9+
jobs:
10+
example-tests:
11+
runs-on: ubuntu-22.04
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install Qt6 and dependencies
16+
run: |
17+
sudo apt-get update
18+
sudo apt-get install -y \
19+
cmake \
20+
ninja-build \
21+
libgsl-dev \
22+
qt6-base-dev \
23+
qt6-svg-dev \
24+
qt6-tools-dev \
25+
libgl1-mesa-dev \
26+
python3
27+
28+
- name: Cache Qwt-Qt6
29+
id: cache-qwt
30+
uses: actions/cache@v4
31+
with:
32+
path: ~/qwt-qt6-install
33+
key: ${{ runner.os }}-qwt-qt6-6.3.0
34+
35+
- name: Build Qwt-Qt6
36+
if: steps.cache-qwt.outputs.cache-hit != 'true'
37+
run: |
38+
cd ~
39+
wget https://downloads.sourceforge.net/project/qwt/qwt/6.3.0/qwt-6.3.0.tar.bz2
40+
tar xjf qwt-6.3.0.tar.bz2
41+
cd qwt-6.3.0
42+
sed -i 's|^QWT_INSTALL_PREFIX.*|QWT_INSTALL_PREFIX = $${HOME}/qwt-qt6-install|' qwtconfig.pri
43+
/usr/lib/qt6/bin/qmake qwt.pro
44+
make -j$(nproc)
45+
make install
46+
47+
- name: Build Strata
48+
run: |
49+
mkdir build && cd build
50+
cmake .. \
51+
-GNinja \
52+
-DCMAKE_BUILD_TYPE=Release \
53+
-DQWT_INCLUDE_DIR="$HOME/qwt-qt6-install/include" \
54+
-DQWT_LIBRARY="$HOME/qwt-qt6-install/lib/libqwt.so"
55+
cmake --build . --parallel
56+
57+
- name: Run example tests
58+
run: |
59+
python3 scripts/compare_examples.py \
60+
build/source/strata \
61+
example/

CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ add_subdirectory(resources)
9393
add_subdirectory(source)
9494
add_subdirectory(tests)
9595

96+
# Example regression tests using Python comparison script
97+
find_package(Python3 COMPONENTS Interpreter)
98+
if (Python3_FOUND)
99+
enable_testing()
100+
add_test(
101+
NAME example_regression
102+
COMMAND ${Python3_EXECUTABLE}
103+
${CMAKE_SOURCE_DIR}/scripts/compare_examples.py
104+
$<TARGET_FILE:strata>
105+
${CMAKE_SOURCE_DIR}/example
106+
)
107+
set_tests_properties(example_regression PROPERTIES TIMEOUT 600)
108+
endif()
96109
find_program(PDFLATEX_PATH pdflatex)
97110
if (PDFLATEX_PATH)
98111
add_subdirectory(manual)

scripts/compare_examples.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env python3
2+
"""Compare Strata example output catalogs against reference results.
3+
4+
Usage:
5+
compare_examples.py <strata_binary> <example_dir> [--rtol=2e-2] [--atol=1e-6]
6+
compare_examples.py <strata_binary> <example_dir> --update
7+
8+
Workflow for each example-NN.json with existing results:
9+
1. Copy the file to a temporary directory
10+
2. Run strata in batch mode on the copy
11+
3. Compare the outputCatalog from the result against the original
12+
13+
Use --update to regenerate reference results in-place.
14+
"""
15+
16+
import argparse
17+
import glob
18+
import json
19+
import os
20+
import shutil
21+
import subprocess
22+
import sys
23+
import tempfile
24+
25+
26+
def compare_values(ref, actual, path, rtol, atol, errors):
27+
"""Recursively compare two JSON values, collecting mismatches."""
28+
if isinstance(ref, dict) and isinstance(actual, dict):
29+
for key in ref:
30+
if key == "log":
31+
# Skip log text — messages vary between runs
32+
continue
33+
if key not in actual:
34+
errors.append(f"{path}.{key}: missing in actual output")
35+
continue
36+
compare_values(ref[key], actual[key], f"{path}.{key}", rtol, atol, errors)
37+
return
38+
39+
if isinstance(ref, list) and isinstance(actual, list):
40+
if len(ref) != len(actual):
41+
errors.append(
42+
f"{path}: list length mismatch: expected {len(ref)}, got {len(actual)}"
43+
)
44+
return
45+
for i, (r, a) in enumerate(zip(ref, actual)):
46+
compare_values(r, a, f"{path}[{i}]", rtol, atol, errors)
47+
return
48+
49+
if isinstance(ref, (int, float)) and isinstance(actual, (int, float)):
50+
if ref == actual:
51+
return
52+
abs_err = abs(ref - actual)
53+
if abs_err <= atol:
54+
return
55+
denom = max(abs(ref), abs(actual), 1e-15)
56+
rel_err = abs_err / denom
57+
if rel_err > rtol:
58+
errors.append(
59+
f"{path}: value mismatch: expected {ref}, got {actual} "
60+
f"(rel_err={rel_err:.2e}, abs_err={abs_err:.2e})"
61+
)
62+
return
63+
64+
if isinstance(ref, str) and isinstance(actual, str):
65+
if ref != actual:
66+
errors.append(f"{path}: string mismatch: expected {ref!r}, got {actual!r}")
67+
return
68+
69+
if isinstance(ref, bool) and isinstance(actual, bool):
70+
if ref != actual:
71+
errors.append(f"{path}: bool mismatch: expected {ref}, got {actual}")
72+
return
73+
74+
if ref is None and actual is None:
75+
return
76+
77+
if type(ref) != type(actual):
78+
errors.append(
79+
f"{path}: type mismatch: expected {type(ref).__name__}, "
80+
f"got {type(actual).__name__}"
81+
)
82+
83+
84+
def extract_output_data(output_catalog):
85+
"""Extract only the result data fields from the outputCatalog for comparison."""
86+
data = {}
87+
for catalog_key in [
88+
"profilesOutputCatalog",
89+
"ratiosOutputCatalog",
90+
"soilTypesOutputCatalog",
91+
"spectraOutputCatalog",
92+
"timeSeriesOutputCatalog",
93+
]:
94+
entries = output_catalog.get(catalog_key, [])
95+
catalog_data = []
96+
for entry in entries:
97+
if entry.get("enabled") and entry.get("data"):
98+
catalog_data.append(
99+
{
100+
"className": entry.get("className"),
101+
"data": entry["data"],
102+
}
103+
)
104+
if catalog_data:
105+
data[catalog_key] = catalog_data
106+
return data
107+
108+
109+
def run_example(strata_bin, example_path, rtol, atol, update=False):
110+
"""Run strata on an example file and compare results. Returns (name, errors, skipped)."""
111+
name = os.path.basename(example_path)
112+
113+
# Load reference
114+
with open(example_path) as f:
115+
ref_doc = json.load(f)
116+
117+
if not update and not ref_doc.get("hasResults", False):
118+
return name, [], True # skip
119+
120+
ref_output = extract_output_data(ref_doc.get("outputCatalog", {}))
121+
if not update and not ref_output:
122+
return name, ["No enabled output data in reference file"], False
123+
124+
# Copy to temp directory and run
125+
with tempfile.TemporaryDirectory() as tmpdir:
126+
tmp_file = os.path.join(tmpdir, name)
127+
shutil.copy2(example_path, tmp_file)
128+
129+
# Also copy any motion files referenced (same directory)
130+
example_dir = os.path.dirname(example_path)
131+
motions_dir = os.path.join(example_dir, "motions")
132+
if os.path.isdir(motions_dir):
133+
shutil.copytree(motions_dir, os.path.join(tmpdir, "motions"))
134+
135+
# Run strata in batch mode
136+
try:
137+
result = subprocess.run(
138+
[strata_bin, "-b", tmp_file],
139+
capture_output=True,
140+
text=True,
141+
timeout=300,
142+
)
143+
except subprocess.TimeoutExpired:
144+
return name, ["Strata timed out after 300 seconds"], False
145+
146+
if result.returncode != 0:
147+
return (
148+
name,
149+
[f"Strata exited with code {result.returncode}: {result.stderr}"],
150+
False,
151+
)
152+
153+
# Load results
154+
with open(tmp_file) as f:
155+
actual_doc = json.load(f)
156+
157+
if not actual_doc.get("hasResults", False):
158+
return name, ["Strata did not produce results"], False
159+
160+
if update:
161+
# Overwrite the original file with new results
162+
shutil.copy2(tmp_file, example_path)
163+
return name, [], False
164+
165+
actual_output = extract_output_data(actual_doc.get("outputCatalog", {}))
166+
167+
# Compare
168+
errors = []
169+
compare_values(ref_output, actual_output, "outputCatalog", rtol, atol, errors)
170+
return name, errors, False
171+
172+
173+
def main():
174+
parser = argparse.ArgumentParser(
175+
description="Run Strata examples and compare output catalogs"
176+
)
177+
parser.add_argument("strata_binary", help="Path to the strata executable")
178+
parser.add_argument("example_dir", help="Path to the example directory")
179+
parser.add_argument(
180+
"--rtol",
181+
type=float,
182+
default=2e-2,
183+
help="Relative tolerance for numerical comparison (default: 2e-2)",
184+
)
185+
parser.add_argument(
186+
"--atol",
187+
type=float,
188+
default=1e-6,
189+
help="Absolute tolerance for numerical comparison (default: 1e-6)",
190+
)
191+
parser.add_argument(
192+
"--update",
193+
action="store_true",
194+
help="Regenerate reference results by running strata and saving output",
195+
)
196+
args = parser.parse_args()
197+
198+
strata_bin = os.path.abspath(args.strata_binary)
199+
example_dir = os.path.abspath(args.example_dir)
200+
201+
if not os.path.isfile(strata_bin):
202+
print(f"ERROR: Strata binary not found: {strata_bin}", file=sys.stderr)
203+
sys.exit(1)
204+
205+
# Find example files (example-NN.json pattern, excluding -test variants)
206+
pattern = os.path.join(example_dir, "example-[0-9][0-9].json")
207+
example_files = sorted(glob.glob(pattern))
208+
209+
if not example_files:
210+
print(f"ERROR: No example files found matching {pattern}", file=sys.stderr)
211+
sys.exit(1)
212+
213+
print(f"Found {len(example_files)} example file(s)")
214+
if args.update:
215+
print("Mode: updating reference results")
216+
else:
217+
print(f"Tolerances: rtol={args.rtol}, atol={args.atol}")
218+
print()
219+
220+
failed = []
221+
passed = []
222+
skipped = []
223+
224+
for example_path in example_files:
225+
name, errors, was_skipped = run_example(
226+
strata_bin, example_path, args.rtol, args.atol, update=args.update
227+
)
228+
if was_skipped:
229+
skipped.append(name)
230+
print(f" SKIP: {name} (no reference results)")
231+
elif args.update:
232+
if errors:
233+
failed.append((name, errors))
234+
print(f" FAIL: {name}")
235+
for err in errors:
236+
print(f" {err}")
237+
else:
238+
passed.append(name)
239+
print(f" UPDATED: {name}")
240+
elif errors:
241+
failed.append((name, errors))
242+
print(f" FAIL: {name}")
243+
for err in errors[:10]:
244+
print(f" {err}")
245+
if len(errors) > 10:
246+
print(f" ... and {len(errors) - 10} more errors")
247+
else:
248+
passed.append(name)
249+
print(f" PASS: {name}")
250+
251+
print()
252+
print(f"Results: {len(passed)} passed, {len(failed)} failed, {len(skipped)} skipped")
253+
254+
if failed:
255+
sys.exit(1)
256+
257+
258+
if __name__ == "__main__":
259+
main()

0 commit comments

Comments
 (0)