diff --git a/TempFile.h b/TempFile.h new file mode 100644 index 000000000..927f92ba5 --- /dev/null +++ b/TempFile.h @@ -0,0 +1,129 @@ +// +// Created by James Gallagher on 1/4/26. +// + +#ifndef LIBDAP_TEMPFILE_H +#define LIBDAP_TEMPFILE_H + +#include +#include +#include +#include // mkstemp, close, unlink +#include // std::exchange +#include + +namespace libdap { + +class TempFile { +public: + // pattern must contain at least 6 trailing 'X' characters, e.g. "/tmp/myapp-XXXXXX" + explicit TempFile(std::string pattern, + std::ios::openmode mode = std::ios::out | std::ios::binary | std::ios::trunc) { + create_and_open(std::move(pattern), mode); + } + + ~TempFile() noexcept { cleanup(); } + + TempFile(const TempFile &) = delete; + TempFile &operator=(const TempFile &) = delete; + + TempFile(TempFile &&other) noexcept + : path_(std::move(other.path_)), stream_(std::move(other.stream_)), + unlink_on_destroy_(std::exchange(other.unlink_on_destroy_, false)) { + other.path_.clear(); + } + + TempFile &operator=(TempFile &&other) noexcept { + if (this != &other) { + cleanup(); + path_ = std::move(other.path_); + stream_ = std::move(other.stream_); + unlink_on_destroy_ = std::exchange(other.unlink_on_destroy_, false); + other.path_.clear(); + } + return *this; + } + + const std::string &path() const noexcept { return path_; } + + std::ofstream &stream() noexcept { return stream_; } + const std::ofstream &stream() const noexcept { return stream_; } + + bool is_open() const noexcept { return stream_.is_open(); } + void flush() noexcept { stream_.flush(); } + + // Optional: close early (file will still be unlinked in destructor unless you call release()). + void close_stream() { + if (stream_.is_open()) + stream_.close(); + } + + // Optional: keep the file on disk after this object dies. + // Closes the stream and disables unlink-on-destroy, returning the path. + std::string release() noexcept { + close_stream(); + unlink_on_destroy_ = false; + return path_; + } + +private: + void create_and_open(std::string pattern, std::ios::openmode mode) { + // mkstemp mutates its template buffer. + std::vector buf(pattern.begin(), pattern.end()); + buf.push_back('\0'); + + int fd = ::mkstemp(buf.data()); + if (fd == -1) { + throw std::runtime_error("mkstemp failed"); + } + + std::string created_path = buf.data(); + + // We can't portably attach std::ofstream to an existing fd, so close it + // and reopen by path using iostreams. + if (::close(fd) != 0) { + ::unlink(created_path.c_str()); + throw std::runtime_error("close(mkstemp fd) failed"); + } + + std::ofstream ofs; + ofs.open(created_path, mode); + if (!ofs.is_open() || !ofs) { + ::unlink(created_path.c_str()); + throw std::runtime_error("ofstream open failed"); + } + + // Commit only after everything succeeded (strong exception safety). + path_ = std::move(created_path); + stream_ = std::move(ofs); + unlink_on_destroy_ = true; + } + + void cleanup() noexcept { + // Never throw from cleanup/destructor. + try { + if (stream_.is_open()) { + stream_.close(); + } + } catch (...) { + // swallow + } + + if (unlink_on_destroy_ && !path_.empty()) { + ::unlink(path_.c_str()); // ignore errors + } + + // Reset to a benign state. + unlink_on_destroy_ = false; + path_.clear(); + // stream_ is already closed (or never opened); leaving it is fine. + } + + std::string path_; + std::ofstream stream_; + bool unlink_on_destroy_ = false; +}; + +} // namespace libdap + +#endif // LIBDAP_TEMPFILE_H diff --git a/tests/cmake/das-tests.cmake b/tests/cmake/das-tests.cmake index 6b4909182..310919a9a 100644 --- a/tests/cmake/das-tests.cmake +++ b/tests/cmake/das-tests.cmake @@ -4,6 +4,10 @@ file(GLOB DAS_FILES RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/das-testsuite" "${CMAKE_CURRENT_SOURCE_DIR}/das-testsuite/*.das") +# Accumulate all per-test baseline targets into this list. Used to make the +# 'das-baselines' target that rebuilds all of the baselines. +set(_DAS_BASELINE_TARGETS "") + # Helper to register one DAS‐response test function(add_das_test das_filename) # # get "test.1.das" → fullname="test.1.das" @@ -20,17 +24,45 @@ function(add_das_test das_filename) set(baseline "${CMAKE_CURRENT_SOURCE_DIR}/das-testsuite/${das_filename}.base") set(output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") - # Add the CTest entry + # Put the command that runs the test in a variable so that the exact same command + # can be used with a custom target to build the test baseline. + # + # Assume das_filename has no spaces; no need to quote the variables in the + # shell command, which makes the command more readable. + set(the_test "$ -p < ${input} > ${output} 2>&1") + + # Add the CTest entry. add_test(NAME ${testname} - COMMAND /bin/sh "-c" - # 1) run das-test, redirect all output into a temp file - # 2) diff that file against the baseline" - "\"$\" -p < \"${input}\" > \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + COMMAND /bin/sh -c "${the_test}; diff -u -b -B ${baseline} ${output} && rm -f ${output}" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;das") + + set(staged_baseline "${CMAKE_CURRENT_BINARY_DIR}/baselines/${das_filename}.base") + set(baseline_tgt "baseline-${testname}") + add_custom_target(${baseline_tgt} + COMMAND ${CMAKE_COMMAND} -E make_directory "$" + COMMAND /bin/sh -c "${the_test}; ${CMAKE_COMMAND} -E copy ${output} ${staged_baseline} && rm -f ${output}" + BYPRODUCTS "${staged_baseline}" + COMMENT "Staging DAS baseline for ${das_filename} → ${staged_baseline}" + VERBATIM + ) + + # Share the target name with the parent scope so we can build an aggregate target + set(_DAS_BASELINE_TARGETS ${_DAS_BASELINE_TARGETS} ${baseline_tgt} PARENT_SCOPE) endfunction() -foreach(dfile IN LISTS DAS_FILES) - add_das_test(${dfile}) +# Register all tests (and per-test baseline targets). +# Use 'make test' or 'make check' to run these. See the top-level CMakeLists file +# for more ways to run the tests. +# +# Use 'make baseline-das_test_1' or 'cmake --build . --target baseline-das_test_1', ... +# build the baselines. Use 'ctest -R das_test_1' to run a single test. +foreach(das_file IN LISTS DAS_FILES) + add_das_test(${das_file}) endforeach() + +# Aggregate target: regenerate all DAS baselines in one shot. Use +# 'cmake --build . --target das-baselines' for that. +if(_DAS_BASELINE_TARGETS) + add_custom_target(das-baselines DEPENDS ${_DAS_BASELINE_TARGETS}) +endif() diff --git a/tests/cmake/dds-tests.cmake b/tests/cmake/dds-tests.cmake index 97a3ae3f3..4f102282e 100644 --- a/tests/cmake/dds-tests.cmake +++ b/tests/cmake/dds-tests.cmake @@ -9,41 +9,63 @@ file(GLOB DDS_FILES RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/dds-testsuite" "${CMAKE_CURRENT_SOURCE_DIR}/dds-testsuite/*.dds") +# Accumulate all per-test baseline targets into this list. Used to make the +# 'das-baselines' target that rebuilds all of the baselines. +set(_DDS_BASELINE_TARGETS "") + ## This function will take the name of a DDS file and use it as input to a DDS ## test. There are some tricks here as well. 7/8/25 jhrg function(add_dds_test dds_filename) # Here the name of the dds file is morphed into something that will work # as a cmake name (dots are not allowed in cmake names). 7/8/25 jhrg - # message(STATUS "testname: ${testname}") get_filename_component(fullname "${dds_filename}" NAME) - # strip just ".das" → raw="test.1" string(REGEX REPLACE "\\.dds$" "" raw "${fullname}") # sanitize; test.1 → test_1 string(REGEX REPLACE "[^A-Za-z0-9_]" "_" testname "dds_${raw}") - # message(STATUS "testname: ${testname}") # Paths set(input "${CMAKE_CURRENT_SOURCE_DIR}/dds-testsuite/${dds_filename}") set(baseline "${CMAKE_CURRENT_SOURCE_DIR}/dds-testsuite/${dds_filename}.base") set(output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") + set(the_test "$ -p < ${input} > ${output} 2>&1") + # Add the CTest entry. Here the shell is used so that we can employ redirection. # The extra double quotes are 'best practice' for cmake, but really not needed here # because we know that $ and the various variables (e.g. ${input}) # do not have spaces. The extra backslash characters make it harder to decipher # what is going on. 7/8/25 jhrg + # ...so removed them. jhrg 12/30/25 add_test(NAME ${testname} - COMMAND /bin/sh "-c" - "\"$\" -p < \"${input}\" > \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + COMMAND /bin/sh -c "${the_test}; diff -u -b -B ${baseline} ${output} && rm -f ${output}" ) # This makes it so we can run just these tests and also makes it easy to run the # unit tests _before_ the integration tests with a 'check' target. See the top-level # CMakeLists file. 7/8/25 jhrg set_tests_properties(${testname} PROPERTIES LABELS "integration;dds") + + set(staged_baseline "${CMAKE_CURRENT_BINARY_DIR}/baselines/${dds_filename}.base") + set(baseline_tgt "baseline-${testname}") + add_custom_target(${baseline_tgt} + COMMAND ${CMAKE_COMMAND} -E make_directory "$" + COMMAND /bin/sh -c "${the_test}; ${CMAKE_COMMAND} -E copy ${output} ${staged_baseline} && rm -f ${output}" + BYPRODUCTS "${staged_baseline}" + COMMENT "Staging DAS baseline for ${das_filename} → ${staged_baseline}" + VERBATIM + ) + + # Share the target name with the parent scope so we can build an aggregate target + set(_DDS_BASELINE_TARGETS ${_DDS_BASELINE_TARGETS} ${baseline_tgt} PARENT_SCOPE) + endfunction() ## Iterate over all of the DDS filed and make a cmake/ctest for each one. 7/8/25 jhrg -foreach(dfile IN LISTS DDS_FILES) - add_dds_test(${dfile}) +foreach(dds_file IN LISTS DDS_FILES) + add_dds_test(${dds_file}) endforeach() + +# Aggregate target: regenerate all DDS baselines in one shot. Use +# 'cmake --build . --target dds-baselines' for that. +if(_DDS_BASELINE_TARGETS) + add_custom_target(dds-baselines DEPENDS ${_DDS_BASELINE_TARGETS}) +endif() diff --git a/tests/cmake/dmr-tests.cmake b/tests/cmake/dmr-tests.cmake index 5a2425d18..20f3852b3 100644 --- a/tests/cmake/dmr-tests.cmake +++ b/tests/cmake/dmr-tests.cmake @@ -19,7 +19,7 @@ function(dmr_parse_ce_test test_number test_input ce test_baseline) # 1) run das-test, redirect all output into a temp file # 2) diff that file against the baseline" "\"$\" -x -p \"${input}\" -c \"${ce}\" > \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + diff -b -B \"${baseline}\" \"${output}\" && rm -f \"${output}\"" ) # This makes it so we can run just these tests and also makes it easy to run the # unit tests _before_ the integration tests with a 'check' target. See the top-leve @@ -107,7 +107,7 @@ function(dmr_intern_test number input baseline) add_test(NAME ${testname} COMMAND /bin/sh "-c" "\"$\" -x -i \"${input}\" > \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + diff -b -B \"${baseline}\" \"${output}\" && rm -f \"${output}\"" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;dmr;dmr-intern") @@ -171,7 +171,7 @@ function(dmr_trans_test number input ce func baseline byte_order) sed 's@[0-9a-f][0-9a-f]*@${checksum_replacement}@' \"${output}\" > \"${output}_univ\"; \ mv \"${output}_univ\" \"${output}\"; \ fi; \ - diff -b -B \"${baseline}\" \"${output}\";" + diff -b -B \"${baseline}\" \"${output}\" && rm -f \"${output}\"" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;dmr;trans") @@ -181,7 +181,8 @@ function(dmr_trans_test number input ce func baseline byte_order) # files with the same (i.e., conflicting) names. I tied using the # RESOURCE_LOCKS property, but it was slower and did not stop the collisions. # 7/12/25 jhrg - set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) + # set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) + # dmr-test now uses multi-process safe temp files. jhrg 1/5/26 endfunction() # DMR translation tests → dmr_trans_test(number, input, ce, func, baseline, byte_order) @@ -384,23 +385,41 @@ dmr_trans_test(146 vol_1_ce_10.xml "lat[10:11][10:11];lon[10:11][10:11]" "scale( ## of word order and they use CEs that have operators (!, <=, ...). Making a test name substituting ## those chars with '_' doesn't make unique test names. But, for this we can use the baseline ## names. Also, some of these tests are expected to fail. 7/14/25 jhrg + +# New version: We switched to test numbers a while back to avoid the hack of making names +# from the various arguments, and I updated the code so include baseline generation. For +# the latter, I switched to an external script to run the C++ CLI program 'dmr-test.' +# This adds a layer of indirection, but it makes the text passed to cmake's add_test() +# easier to understand/debug. jhrg 1/2/26 +# +# Notes about this new version: +# +# Run all the tests: +# ctest -V +# Update all the baselines (see the script 'run_dmr_series_tes.sh' for how this works). +# UPDATE_BASELINES=1 ctest -V +# Update just one baseline: +# UPDATE_BASELINES=1 ctest -R dmr_series_test_147 -V + function(dmr_series_test number input ce baseline xfail) set(testname "dmr_series_test_${number}") - set(input "${CMAKE_CURRENT_SOURCE_DIR}/dmr-testsuite/${input}") - set(baseline "${CMAKE_CURRENT_SOURCE_DIR}/dmr-testsuite/universal/${baseline}") - set(output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") + set(input_path "${CMAKE_CURRENT_SOURCE_DIR}/dmr-testsuite/${input}") + set(baseline_path "${CMAKE_CURRENT_SOURCE_DIR}/dmr-testsuite/universal/${baseline}") + set(output_path "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") + set(runner "${CMAKE_CURRENT_SOURCE_DIR}/dmr-testsuite/run_dmr_series_test.sh") add_test(NAME ${testname} - COMMAND /bin/sh "-c" - "\"$\" -C -x -e -t \"${input}\" -c \"${ce}\" > \"${output}\" 2>&1; \ - sed 's@[0-9a-f][0-9a-f]*@@' \"${output}\" > \"${output}_univ\"; \ - mv \"${output}_univ\" \"${output}\"; \ - diff -b -B \"${baseline}\" \"${output}\"" + COMMAND bash "${runner}" + "$" + "${input_path}" + "${ce}" + "${baseline_path}" + "${output_path}" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;dmr;dmr-series") - set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) + if("${xfail}" STREQUAL "xfail") set_tests_properties(${testname} PROPERTIES WILL_FAIL TRUE) endif() @@ -423,18 +442,18 @@ dmr_series_test(156 test_simple_7.xml "s|1024<=i1<=32768" test_simple_7.xml.f9.t dmr_series_test(157 test_simple_7.xml "s|i1>=1024.0" test_simple_7.xml.fa.trans_base "pass") ## \\\" --> \\ is a literal slash and \" is a literal double quote. 7/14/25 jhrg -dmr_series_test(158 test_simple_7.xml "s|s==\\\"Silly test string: 2\\\"" test_simple_7.xml.fs1.trans_base "pass") -dmr_series_test(159 test_simple_7.xml "s|s!=\\\"Silly test string: 2\\\"" test_simple_7.xml.fs2.trans_base "pass") -dmr_series_test(160 test_simple_7.xml "s|s<\\\"Silly test string: 2\\\"" test_simple_7.xml.fs3.trans_base "pass") -dmr_series_test(161 test_simple_7.xml "s|s<=\\\"Silly test string: 2\\\"" test_simple_7.xml.fs4.trans_base "pass") -dmr_series_test(162 test_simple_7.xml "s|s>\\\"Silly test string: 2\\\"" test_simple_7.xml.fs5.trans_base "pass") -dmr_series_test(163 test_simple_7.xml "s|s>=\\\"Silly test string: 2\\\"" test_simple_7.xml.fs6.trans_base "pass") -dmr_series_test(164 test_simple_7.xml "s|s~=\\\".*2\\\"" test_simple_7.xml.fs7.trans_base "pass") +dmr_series_test(158 test_simple_7.xml "s|s==\"Silly test string: 2\"" test_simple_7.xml.fs1.trans_base "pass") +dmr_series_test(159 test_simple_7.xml "s|s!=\"Silly test string: 2\"" test_simple_7.xml.fs2.trans_base "pass") +dmr_series_test(160 test_simple_7.xml "s|s<\"Silly test string: 2\"" test_simple_7.xml.fs3.trans_base "pass") +dmr_series_test(161 test_simple_7.xml "s|s<=\"Silly test string: 2\"" test_simple_7.xml.fs4.trans_base "pass") +dmr_series_test(162 test_simple_7.xml "s|s>\"Silly test string: 2\"" test_simple_7.xml.fs5.trans_base "pass") +dmr_series_test(163 test_simple_7.xml "s|s>=\"Silly test string: 2\"" test_simple_7.xml.fs6.trans_base "pass") +dmr_series_test(164 test_simple_7.xml "s|s~=\".*2\"" test_simple_7.xml.fs7.trans_base "pass") # Test filtering a sequence that has only one field projected, including filtering on the values # of a filed not projected. dmr_series_test(165 test_simple_7.xml "s{i1}|i1<32768" test_simple_7.xml.g1.trans_base "pass") -dmr_series_test(166 test_simple_7.xml "s{i1}|s<=\\\"Silly test string: 2\\\"" test_simple_7.xml.g1.trans_base "pass") +dmr_series_test(166 test_simple_7.xml "s{i1}|s<=\"Silly test string: 2\"" test_simple_7.xml.g1.trans_base "pass") # A nested sequence with floats in the outer sequence and the int, string combination in the inner dmr_series_test(167 test_simple_8.1.xml "outer" test_simple_8.1.xml.f1.trans_base "pass") diff --git a/tests/cmake/expr-tests.cmake b/tests/cmake/expr-tests.cmake index af94dba45..85ef545bd 100644 --- a/tests/cmake/expr-tests.cmake +++ b/tests/cmake/expr-tests.cmake @@ -8,14 +8,15 @@ function(expr_test test_num option input ce baseline xfail) add_test(NAME ${testname} COMMAND /bin/sh "-c" "\"$\" \"${option}\" \"${input}\" -k \"${ce}\" -f \"dummy\"> \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + diff -b -B \"${baseline}\" \"${output}\" && rm -f \"${output}\"" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;expr") - if(${option} STREQUAL "-w" OR ${option} STREQUAL "-bw") + # if(${option} STREQUAL "-w" OR ${option} STREQUAL "-bw") # until we fix HYRAX-1843 the whole-enchilada tests must be run serially. 7/17/25 jhrg - set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) - endif() + # Fixed. jhrg 1/5/26 + # set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) + # endif() if("${xfail}" STREQUAL "xfail") set_tests_properties(${testname} PROPERTIES WILL_FAIL TRUE) endif() @@ -216,16 +217,15 @@ function(expr_error_test test_num option input ce baseline xfail) set(testname "expr_test_${test_num}") set(input "${CMAKE_CURRENT_SOURCE_DIR}/expr-testsuite/${input}") set(baseline "${CMAKE_CURRENT_SOURCE_DIR}/expr-testsuite/${baseline}") - set(err_output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") + set(output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") add_test(NAME ${testname} COMMAND /bin/sh "-c" - "\"$\" \"${option}\" \"${input}\" -k \"${ce}\" > /dev/null 2> \"${err_output}\"; \ - diff -b -B \"${baseline}\" \"${err_output}\"" + "\"$\" \"${option}\" \"${input}\" -k \"${ce}\" > /dev/null 2> \"${output}\"; \ + diff -b -B \"${baseline}\" \"${output}\" && rm -f \"${output}\"" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;expr-error") - # set_tests_properties(${testname} PROPERTIES RUN_SERIAL TRUE) if("${xfail}" STREQUAL "xfail") set_tests_properties(${testname} PROPERTIES WILL_FAIL TRUE) endif() diff --git a/tests/cmake/getdap-tests.cmake b/tests/cmake/getdap-tests.cmake index 8ee85f5b1..11cb7ffbc 100644 --- a/tests/cmake/getdap-tests.cmake +++ b/tests/cmake/getdap-tests.cmake @@ -1,15 +1,13 @@ function(getdap_test test_num option url baseline xfail) set(testname "getdap_test_${test_num}") - set(baseline "${CMAKE_CURRENT_SOURCE_DIR}/getdap-testsuite/${baseline}") + set(baseline_path "${CMAKE_CURRENT_SOURCE_DIR}/getdap-testsuite/${baseline}") set(output "${CMAKE_CURRENT_BINARY_DIR}/${testname}.out") + set(the_test "$ ${option} ${url} > ${output} 2>&1") + add_test(NAME ${testname} - COMMAND /bin/sh "-c" - # 1) run das-test, redirect all output into a temp file - # 2) diff that file against the baseline" - "$ \"${option}\" \"${url}\" > \"${output}\" 2>&1; \ - diff -b -B \"${baseline}\" \"${output}\"" + COMMAND /bin/sh -c "${the_test}; diff -b -B ${baseline_path} ${output} && rm -f ${output}" ) set_tests_properties(${testname} PROPERTIES LABELS "integration;getdap") @@ -19,6 +17,16 @@ function(getdap_test test_num option url baseline xfail) set_tests_properties(${testname} PROPERTIES WILL_FAIL TRUE) endif() + set(staged_baseline "${CMAKE_CURRENT_BINARY_DIR}/baselines/${baseline}.base") + set(baseline_tgt "baseline-${testname}") + add_custom_target(${baseline_tgt} + COMMAND ${CMAKE_COMMAND} -E make_directory "$" + COMMAND /bin/sh -c "${the_test}; ${CMAKE_COMMAND} -E copy ${output} ${staged_baseline} && rm -f ${output}" + BYPRODUCTS "${staged_baseline}" + COMMENT "Staging baseline for ${testname} → ${staged_baseline}" + VERBATIM + ) + endfunction() getdap_test(1 "-d" "http://test.opendap.org/dap/data/nc/fnoc1.nc" "fnoc1.nc.dds" "pass") diff --git a/tests/dmr-test.cc b/tests/dmr-test.cc index 01d9e766b..d9eeca3ee 100644 --- a/tests/dmr-test.cc +++ b/tests/dmr-test.cc @@ -26,6 +26,7 @@ #include "config.h" +#include #include #include #include @@ -56,6 +57,8 @@ #include "mime_util.h" +#include "../TempFile.h" + int test_variable_sleep_interval = 0; // Used in Test* classes for testing timeouts. using namespace libdap; @@ -140,11 +143,13 @@ void set_series_values(DMR *dmr, bool state) { * * @param dataset * @param constraint + * @param function * @param series_values + * @param ce_parser_debug * @return The name of the file that hods the response. */ -string send_data(DMR *dataset, const string &constraint, const string &function, bool series_values, - bool ce_parser_debug) { +TempFile send_data(DMR *dataset, const string &constraint, const string &function, bool series_values, + bool ce_parser_debug) { set_series_values(dataset, series_values); // This will be used by the DMR that holds the results of running the functions. @@ -164,9 +169,9 @@ string send_data(DMR *dataset, const string &constraint, const string &function, if (ce_parser_debug) parser.set_trace_parsing(true); bool parse_ok = parser.parse(function); - if (!parse_ok) + if (!parse_ok) { throw Error("Function Expression failed to parse."); - else { + } else { if (ce_parser_debug) cerr << "Function Parse OK" << endl; @@ -180,9 +185,7 @@ string send_data(DMR *dataset, const string &constraint, const string &function, D4ResponseBuilder rb; rb.set_dataset_name(dataset->name()); - - string file_name = dataset->name() + "_data.bin"; - ofstream out(file_name.c_str(), ios::out | ios::trunc | ios::binary); + auto dmr_tmp_file = TempFile("/tmp/dmr_test.XXXXXX"); if (!constraint.empty()) { D4ConstraintEvaluator parser(dataset); @@ -197,10 +200,10 @@ string send_data(DMR *dataset, const string &constraint, const string &function, dataset->root()->set_send_p(true); } - rb.send_dap(out, *dataset, /*with mime headers*/ true, !constraint.empty()); - out.close(); + rb.send_dap(dmr_tmp_file.stream(), *dataset, /*with mime headers*/ true, !constraint.empty()); + dmr_tmp_file.flush(); - return file_name; + return dmr_tmp_file; } void intern_data(DMR *dataset, bool series_values) { @@ -417,18 +420,21 @@ int main(int argc, char *argv[]) { if (send) { DMR *dmr = test_dap4_parser(name, use_checksums, debug, print); - string file_name = send_data(dmr, ce, function, series_values, ce_parser_debug); - if (print) - cout << "Response file: " << file_name << endl; + auto dmr_temp_file = send_data(dmr, ce, function, series_values, ce_parser_debug); + if (print) { + cout << "Response file: " << dmr_temp_file.path() << endl; + dmr_temp_file.release(); + } + delete dmr; } if (trans) { DMR *dmr = test_dap4_parser(name, use_checksums, debug, print); - string file_name = send_data(dmr, ce, function, series_values, ce_parser_debug); + auto dmr_temp_file = send_data(dmr, ce, function, series_values, ce_parser_debug); delete dmr; - DMR *client = read_data_plain(file_name, use_checksums, debug); + DMR *client = read_data_plain(dmr_temp_file.path(), use_checksums, debug); if (print) { XMLWriter xml; @@ -446,7 +452,7 @@ int main(int argc, char *argv[]) { if (intern) { DMR *dmr = test_dap4_parser(name, use_checksums, debug, print); - intern_data(dmr, /*ce,*/ series_values); + intern_data(dmr, series_values); if (print) { XMLWriter xml; diff --git a/tests/dmr-testsuite/run_dmr_series_test.sh b/tests/dmr-testsuite/run_dmr_series_test.sh new file mode 100755 index 000000000..16131d0db --- /dev/null +++ b/tests/dmr-testsuite/run_dmr_series_test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -u + +# Pass the test program (a C++ program) in so that cmake can use the correct +# program - it will not be installed in a directory on the PATH +exe="$1" +input="$2" +ce="$3" +baseline="$4" +output="$5" + +# Optional: when set to 1/ON/TRUE, write baseline candidates instead of failing. +update="${UPDATE_BASELINES:-0}" + +# Run the tool and capture output +"$exe" -C -x -e -t "$input" -c "$ce" > "$output" 2>&1 + +# Normalize output (for portability, don't use 'sed -i') +tmp="${output}_univ" +sed 's@[0-9a-f][0-9a-f]*@@' "$output" >"$tmp" +mv "$tmp" "$output" + +# If asked to update baselines, write a candidate file for review. +# We write ".new" next to the committed baseline. +shopt -s nocasematch || true +if [[ "$update" == "1" || "$update" == "on" || "$update" == "true" || "$update" == "yes" ]]; then + new_baseline="${baseline}.new" + mkdir -p "$(dirname "$new_baseline")" + cp -f "$output" "$new_baseline" + + echo "Wrote baseline candidate: $new_baseline" >&2 + + rm -f "$output" + exit 0 +fi +shopt -u nocasematch || true + +# Normal mode: compare and return diff's exit code +diff -b -B "$baseline" "$output" +rc=$? + +rm -f "$output" +exit $rc diff --git a/tests/expr-test.cc b/tests/expr-test.cc index 49fa280aa..2b771936b 100644 --- a/tests/expr-test.cc +++ b/tests/expr-test.cc @@ -73,6 +73,8 @@ #include "parser.h" #include "util.h" +#include "../TempFile.h" + #include "debug.h" using namespace std; @@ -105,35 +107,34 @@ extern int ce_exprdebug; const string version = "version 1.12"; const string prompt = "expr-test: "; const string options = "sS:bdecvp:w:W:f:k:vx?"; -const string usage = "\ -\nexpr-test [-s [-S string] -d -c -v [-p dds-file]\ -\n[-e expr] [-w|-W dds-file] [-f data-file] [-k expr]]\ -\nTest the expression evaluation software.\ -\nOptions:\ -\n -s: Feed the input stream directly into the expression scanner, does\ -\n not parse.\ -\n -S: Scan the string as if it was standard input.\ -\n -d: Turn on expression parser debugging.\ -\n -c: Print the constrained DDS (the one that will be returned\ -\n prepended to a data transmission. Must also supply -p and -e \ -\n -V: Print the version of expr-test\ -\n -p: DDS-file: Read the DDS from DDS-file and create a DDS object,\ -\n then prompt for an expression and parse that expression, given\ -\n the DDS object.\ -\n -e: Evaluate the constraint expression. Must be used with -p.\ -\n -w: Do the whole enchilada. You don't need to supply -p, -e, ...\ -\n This prompts for the constraint expression and the optional\ -\n data file name. NOTE: The CE parser Error objects do not print\ -\n with this option.\ -\n -W: Similar to -w but uses the new (11/2007) intern_data() methods\ -\n in place of the serialize()/deserialize() combination.\ -\n -b: Use periodic/cyclic/changing values. For testing Sequence CEs.\ -\n -f: A file to use for data. Currently only used by -w for sequences.\ -\n -k: A constraint expression to use with the data. Works with -p,\ -\n -e, -t and -w\ -\n -x: Print declarations using the XML syntax. Does not work with the\ -\n data printouts.\ -\n -?: Print usage information"; +const string usage = R"(expr-test [-s [-S string] -d -c -v [-p dds-file]\ +[-e expr] [-w|-W dds-file] [-f data-file] [-k expr]] +Test the expression evaluation software. +Options: + -s: Feed the input stream directly into the expression scanner, does + not parse. + -S: Scan the string as if it was standard input. + -d: Turn on expression parser debugging. + -c: Print the constrained DDS (the one that will be returned + prepended to a data transmission. Must also supply -p and -e + -V: Print the version of expr-test + -p: DDS-file: Read the DDS from DDS-file and create a DDS object, + then prompt for an expression and parse that expression, given + the DDS object. + -e: Evaluate the constraint expression. Must be used with -p. + -w: Do the whole enchilada. You don't need to supply -p, -e, ... + This prompts for the constraint expression and the optional + data file name. NOTE: The CE parser Error objects do not print + with this option. + -W: Similar to -w but uses the new (11/2007) intern_data() methods + in place of the serialize()/deserialize() combination. + -b: Use periodic/cyclic/changing values. For testing Sequence CEs. + -f: A file to use for data. Currently only used by -w for sequences. + -k: A constraint expression to use with the data. Works with -p, + -e, -t and -w + -x: Print declarations using the XML syntax. Does not work with the + data printouts. + -?: Print usage information)"; int main(int argc, char *argv[]) { GetOpt getopt(argc, argv, options.c_str()); @@ -484,12 +485,12 @@ void constrained_trans(const string &dds_name, const bool constraint_expr, const df.set_ce(ce); df.set_dataset_name(dds_name); - ofstream out("expr-test-data.bin", ios::out | ios::trunc | ios::binary); - df.send_data(out, server, eval, true); - out.close(); + auto expr_temp_file = TempFile("expr-test-data.XXXXXX"); + df.send_data(expr_temp_file.stream(), server, eval, true); + expr_temp_file.flush(); // Now do what Connect::request_data() does: - FILE *fp = fopen("expr-test-data.bin", "r"); + FILE *fp = fopen(expr_temp_file.path().c_str(), "r"); Response r(fp, 400); Connect c("http://dummy_argument"); diff --git a/unit-tests/CMakeLists.txt b/unit-tests/CMakeLists.txt index 65b5b8357..87f8a91b3 100644 --- a/unit-tests/CMakeLists.txt +++ b/unit-tests/CMakeLists.txt @@ -20,7 +20,7 @@ set(TESTS_DAP_ONLY D4DimensionsTest.cc D4EnumDefsTest.cc D4GroupTest.cc D4ParserSax2Test.cc D4AttributesTest.cc D4EnumTest.cc chunked_iostream_test.cc D4AsyncDocTest.cc DMRTest.cc DmrRoundTripTest.cc DmrToDap2Test.cc D4FilterClauseTest.cc - IsDap4ProjectedTest.cc MarshallerFutureTest.cc + IsDap4ProjectedTest.cc MarshallerFutureTest.cc TempFileTest.cc ) # BigArrayTest.cc seems to break things. jhrg 6/12/25 diff --git a/unit-tests/TempFileTest.cc b/unit-tests/TempFileTest.cc new file mode 100644 index 000000000..749571353 --- /dev/null +++ b/unit-tests/TempFileTest.cc @@ -0,0 +1,167 @@ +// +// Created by James Gallagher on 1/5/26. +// + +#include +#include + +#include +#include + +#include // stat +#include // access, unlink + +#include "TempFile.h" + +#include "run_tests_cppunit.h" + +// --- If TempFile is in the same translation unit for testing, remove this include and paste class above. --- + +namespace libdap { +bool file_exists(const std::string &path) { return !path.empty() && (::access(path.c_str(), F_OK) == 0); } + +std::string read_all(const std::string &path) { + std::ifstream in(path, std::ios::in | std::ios::binary); + CPPUNIT_ASSERT_MESSAGE("Failed to open file for reading: " + path, in.is_open()); + + std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + return contents; +} + +class TempFileTest : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TempFileTest); + CPPUNIT_TEST(testCreatesAndPathExists); + CPPUNIT_TEST(testWriteAndReadBack); + CPPUNIT_TEST(testDestructorUnlinksFile); + CPPUNIT_TEST(testReleaseKeepsFile); + CPPUNIT_TEST(testMoveConstructorTransfersOwnership); + CPPUNIT_TEST(testMoveAssignmentTransfersOwnershipAndCleansOld); + CPPUNIT_TEST_SUITE_END(); + +public: + void testCreatesAndPathExists() { + TempFile tf("/tmp/tempfiletest-XXXXXX"); + CPPUNIT_ASSERT(!tf.path().empty()); + CPPUNIT_ASSERT(tf.is_open()); + CPPUNIT_ASSERT_MESSAGE("Temp file should exist on disk", file_exists(tf.path())); + } + + void testWriteAndReadBack() { + std::string path; + { + TempFile tf("/tmp/tempfiletest-XXXXXX", std::ios::out | std::ios::binary | std::ios::trunc); + path = tf.path(); + + tf.stream() << "hello"; + tf.stream() << "\n"; + tf.stream() << "world"; + tf.stream().flush(); + CPPUNIT_ASSERT(tf.stream().good()); + + // Close early to ensure data is fully written before we read it. + tf.close_stream(); + CPPUNIT_ASSERT_MESSAGE("File should still exist before destructor", file_exists(path)); + + const std::string got = read_all(path); + CPPUNIT_ASSERT_EQUAL(std::string("hello\nworld"), got); + + // TempFile destructor will unlink. + } + CPPUNIT_ASSERT_MESSAGE("Temp file should be unlinked after scope exit", !file_exists(path)); + } + + void testDestructorUnlinksFile() { + std::string path; + { + TempFile tf("/tmp/tempfiletest-XXXXXX"); + path = tf.path(); + CPPUNIT_ASSERT(file_exists(path)); + } + CPPUNIT_ASSERT_MESSAGE("Temp file should be unlinked after destruction", !file_exists(path)); + } + + void testReleaseKeepsFile() { + std::string path; + { + TempFile tf("/tmp/tempfiletest-XXXXXX"); + path = tf.path(); + CPPUNIT_ASSERT(file_exists(path)); + + tf.stream() << "persist"; + tf.stream().flush(); + + // Prevent unlink-on-destroy. + std::string released = tf.release(); + CPPUNIT_ASSERT_EQUAL(path, released); + } + + CPPUNIT_ASSERT_MESSAGE("File should still exist after release()", file_exists(path)); + CPPUNIT_ASSERT_EQUAL(std::string("persist"), read_all(path)); + + // Clean up to avoid leaving junk behind. + ::unlink(path.c_str()); + CPPUNIT_ASSERT(!file_exists(path)); + } + + void testMoveConstructorTransfersOwnership() { + std::string path; + { + TempFile a("/tmp/tempfiletest-XXXXXX"); + path = a.path(); + CPPUNIT_ASSERT(file_exists(path)); + + a.stream() << "moved"; + a.stream().flush(); + + TempFile b(std::move(a)); + CPPUNIT_ASSERT_EQUAL(path, b.path()); + CPPUNIT_ASSERT(b.is_open()); + + // moved-from object should no longer own the path + CPPUNIT_ASSERT(a.path().empty()); + + // On scope exit: b should unlink, a should do nothing. + } + CPPUNIT_ASSERT_MESSAGE("File should be unlinked after moved-to object destruction", !file_exists(path)); + } + + void testMoveAssignmentTransfersOwnershipAndCleansOld() { + std::string path1, path2; + + { + TempFile a("/tmp/tempfiletest-XXXXXX"); + a.stream() << "first"; + a.stream().flush(); + path1 = a.path(); + CPPUNIT_ASSERT(file_exists(path1)); + + TempFile b("/tmp/tempfiletest-XXXXXX"); + b.stream() << "second"; + b.stream().flush(); + path2 = b.path(); + CPPUNIT_ASSERT(file_exists(path2)); + + // Move-assign b into a: + // - a should clean up its current file (unlink path1) + // - a should take ownership of b's file (path2) + // - b should become empty/non-owning + a = std::move(b); + + CPPUNIT_ASSERT_MESSAGE("Old file owned by a should be cleaned up during move assignment", + !file_exists(path1)); + + CPPUNIT_ASSERT_EQUAL(path2, a.path()); + CPPUNIT_ASSERT(b.path().empty()); + CPPUNIT_ASSERT(file_exists(path2)); + } + + // After leaving scope, a should unlink path2. + CPPUNIT_ASSERT_MESSAGE("Final owned file should be unlinked at scope exit", !file_exists(path2)); + } +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TempFileTest); + +} // namespace libdap + +int main(int argc, char *argv[]) { return run_tests(argc, argv) ? 0 : 1; }