From 4dcbec34d9a766ebb9587e096e6b10bd8aa36209 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 13:38:39 +0200 Subject: [PATCH 1/6] Expose the reusable test harness --- CMakeLists.txt | 49 +++++++++- cmake/GecodeSources.cmake | 14 ++- test/public-int-smoke.cpp | 131 ++++++++++++++++++++++++++ test/public-runner-smoke.cpp | 172 +++++++++++++++++++++++++++++++++++ test/test-main.cpp | 51 +++++++++++ test/test.cpp | 82 ++++++++--------- test/test.hh | 15 +-- 7 files changed, 458 insertions(+), 56 deletions(-) create mode 100644 test/public-int-smoke.cpp create mode 100644 test/public-runner-smoke.cpp create mode 100644 test/test-main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e9d0600f6b..055773f1ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1354,8 +1354,49 @@ if(GECODE_ENABLE_FLATZINC) endif() if(BUILD_TESTING) - set(GECODE_CAN_BUILD_TESTS TRUE) - foreach(required search int minimodel driver) + set(GECODE_CAN_BUILD_TEST_HARNESS TRUE) + foreach(required search int) + if(NOT TARGET gecode${required}) + set(GECODE_CAN_BUILD_TEST_HARNESS FALSE) + endif() + endforeach() + + if(GECODE_CAN_BUILD_TEST_HARNESS) + add_library(gecodetest STATIC ${GECODE_TEST_CORE_SOURCES}) + target_compile_features(gecodetest PUBLIC cxx_std_17) + target_include_directories(gecodetest + PUBLIC + $ + $) + if(GECODE_VISIBILITY_COMPILE_OPTION) + target_compile_options(gecodetest PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) + endif() + target_link_libraries(gecodetest PUBLIC gecodesearch gecodeint) + + add_library(gecodetestint STATIC ${GECODE_TEST_INT_SOURCES}) + target_compile_features(gecodetestint PUBLIC cxx_std_17) + target_include_directories(gecodetestint + PUBLIC + $ + $) + if(GECODE_VISIBILITY_COMPILE_OPTION) + target_compile_options(gecodetestint PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) + endif() + target_link_libraries(gecodetestint PUBLIC gecodetest) + + add_executable(public-runner-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_RUNNER_SMOKE_SOURCE}) + target_link_libraries(public-runner-smoke PRIVATE gecodetest) + add_test(NAME public-runner-smoke COMMAND public-runner-smoke) + + add_executable(public-int-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_INT_SMOKE_SOURCE}) + target_link_libraries(public-int-smoke PRIVATE gecodetestint) + add_test(NAME public-int-smoke COMMAND public-int-smoke) + else() + message(WARNING "Skipping public test harness targets because required modules are disabled") + endif() + + set(GECODE_CAN_BUILD_TESTS ${GECODE_CAN_BUILD_TEST_HARNESS}) + foreach(required minimodel driver) if(NOT TARGET gecode${required}) set(GECODE_CAN_BUILD_TESTS FALSE) endif() @@ -1393,8 +1434,8 @@ if(BUILD_TESTING) endif() if(GECODE_CAN_BUILD_TESTS) - add_executable(gecode-test EXCLUDE_FROM_ALL ${GECODE_TEST_SOURCES_SELECTED}) - set(GECODE_TEST_LINK_LIBS gecodeminimodel) + add_executable(gecode-test EXCLUDE_FROM_ALL ${GECODE_TEST_MAIN_SOURCE} ${GECODE_TEST_SOURCES_SELECTED}) + set(GECODE_TEST_LINK_LIBS gecodetestint gecodeminimodel) if(GECODE_ENABLE_FLATZINC) list(APPEND GECODE_TEST_LINK_LIBS gecodeflatzinc) endif() diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index 0c9defc0f1..1ddd513014 100644 --- a/cmake/GecodeSources.cmake +++ b/cmake/GecodeSources.cmake @@ -244,6 +244,18 @@ set(GECODE_FLATZINC_SOURCES gecode/flatzinc/registry.cpp ) +set(GECODE_TEST_CORE_SOURCES + test/test.cpp +) + +set(GECODE_TEST_INT_SOURCES + test/int.cpp +) + +set(GECODE_TEST_MAIN_SOURCE test/test-main.cpp) +set(GECODE_TEST_PUBLIC_RUNNER_SMOKE_SOURCE test/public-runner-smoke.cpp) +set(GECODE_TEST_PUBLIC_INT_SMOKE_SOURCE test/public-int-smoke.cpp) + set(GECODE_TEST_SOURCES test/afc.cpp test/array.cpp @@ -376,7 +388,6 @@ set(GECODE_TEST_SOURCES test/float/transcendental.cpp test/float/trigonometric.cpp test/groups.cpp - test/int.cpp test/int/arithmetic.cpp test/int/basic.cpp test/int/bin-packing.cpp @@ -427,7 +438,6 @@ set(GECODE_TEST_SOURCES test/set/rel-op.cpp test/set/rel.cpp test/set/sequence.cpp - test/test.cpp ) set(GECODE_TEST_BLACKBOX_EXEC_SOURCE test/flatzinc/blackbox-exec.cpp) diff --git a/test/public-int-smoke.cpp b/test/public-int-smoke.cpp new file mode 100644 index 0000000000..8539a6acef --- /dev/null +++ b/test/public-int-smoke.cpp @@ -0,0 +1,131 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Christian Schulte + * + * Contributing authors: + * Mikael Lagerkvist + * + * Copyright: + * Christian Schulte, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.org + * + * 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 or substantial portions of the 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. + * + */ + +#include "test/int.hh" + +#include +#include +#include +#include +#include +#include + +namespace { + + int abs_runs = 0; + + class AbsSmokeTest : public Test::Int::Test { + public: + AbsSmokeTest(void) + : ::Test::Int::Test("Public::Smoke::Abs",2,-2,2) {} + + bool solution(const ::Test::Int::Assignment& x) const override { + const int abs_x0 = (x[0] < 0) ? -x[0] : x[0]; + return abs_x0 == x[1]; + } + + void post(Gecode::Space& home, Gecode::IntVarArray& x) override { + abs_runs++; + Gecode::abs(home, x[0], x[1]); + } + } abs_smoke_test; + + bool require(bool condition, const std::string& message) { + if (!condition) { + std::cerr << "public-int-smoke: " << message << std::endl; + return false; + } + return true; + } + + int run_and_capture(std::initializer_list args, std::string& output) { + std::vector argv; + argv.reserve(args.size()); + for (const char* arg : args) + argv.push_back(const_cast(arg)); + + std::ostringstream captured; + std::streambuf* old = std::cout.rdbuf(captured.rdbuf()); + const int rc = Test::run_registered_tests(static_cast(argv.size()), argv.data()); + std::cout.rdbuf(old); + output = captured.str(); + return rc; + } + +} + +int +main(void) { + const std::string test_name = "Int::Public::Smoke::Abs"; + + std::string list_output; + if (!require(run_and_capture({"public-int-smoke", "-list"}, list_output) == EXIT_SUCCESS, + "-list should succeed")) { + return EXIT_FAILURE; + } + if (!require(list_output.find(test_name) != std::string::npos, + "-list output missing integer smoke test")) { + return EXIT_FAILURE; + } + if (!require(abs_runs == 0, + "-list should not execute integer smoke tests")) { + return EXIT_FAILURE; + } + + std::string run_output; + if (!require(run_and_capture({"public-int-smoke", "-test", "Int::Public::Smoke::Abs", + "-iter", "1", "-stop", "true"}, + run_output) == EXIT_SUCCESS, + "filtered integer smoke run should succeed")) { + return EXIT_FAILURE; + } + if (!require(run_output.find(test_name) != std::string::npos, + "filtered integer smoke run did not print the selected test")) { + return EXIT_FAILURE; + } + if (!require(run_output.find('+') != std::string::npos, + "filtered integer smoke run did not report success")) { + return EXIT_FAILURE; + } + if (!require(abs_runs > 0, + "filtered integer smoke run did not execute the helper-backed test")) { + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} + +// STATISTICS: test-int diff --git a/test/public-runner-smoke.cpp b/test/public-runner-smoke.cpp new file mode 100644 index 0000000000..e359bc3ce2 --- /dev/null +++ b/test/public-runner-smoke.cpp @@ -0,0 +1,172 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Christian Schulte + * + * Contributing authors: + * Mikael Lagerkvist + * + * Copyright: + * Christian Schulte, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.org + * + * 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 or substantial portions of the 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. + * + */ + +#include "test/test.hh" + +#include +#include +#include +#include +#include +#include + +namespace { + + int passing_runs = 0; + int failing_runs = 0; + + class PassingSmokeTest : public Test::Base { + public: + PassingSmokeTest(void) + : Test::Base("Smoke::A-Pass") {} + + bool run(void) override { + passing_runs++; + return true; + } + } passing_smoke_test; + + class FailingSmokeTest : public Test::Base { + public: + FailingSmokeTest(void) + : Test::Base("Smoke::B-Fail") {} + + bool run(void) override { + failing_runs++; + return false; + } + } failing_smoke_test; + + bool require(bool condition, const std::string& message) { + if (!condition) { + std::cerr << "public-runner-smoke: " << message << std::endl; + return false; + } + return true; + } + + int run_and_capture(std::initializer_list args, std::string& output) { + std::vector argv; + argv.reserve(args.size()); + for (const char* arg : args) { + argv.push_back(const_cast(arg)); + } + + std::ostringstream captured; + std::streambuf* old = std::cout.rdbuf(captured.rdbuf()); + const int rc = Test::run_registered_tests(static_cast(argv.size()), argv.data()); + std::cout.rdbuf(old); + output = captured.str(); + return rc; + } + +} + +int +main(void) { + std::string list_output; + if (!require(run_and_capture({"public-runner-smoke", "-list"}, list_output) == EXIT_SUCCESS, + "-list should succeed")) { + return EXIT_FAILURE; + } + const std::string pass_name = "Smoke::A-Pass"; + const std::string fail_name = "Smoke::B-Fail"; + const std::size_t pass_pos = list_output.find(pass_name); + const std::size_t fail_pos = list_output.find(fail_name); + if (!require(pass_pos != std::string::npos, "-list output missing pass test")) { + return EXIT_FAILURE; + } + if (!require(fail_pos != std::string::npos, "-list output missing fail test")) { + return EXIT_FAILURE; + } + if (!require(pass_pos < fail_pos, "-list output is not alphabetically ordered")) { + return EXIT_FAILURE; + } + if (!require(passing_runs == 0 && failing_runs == 0, + "-list should not execute any registered tests")) { + return EXIT_FAILURE; + } + + std::string pass_output; + if (!require(run_and_capture({"public-runner-smoke", "-test", "Smoke::A-Pass", "-iter", "1", "-stop", "true"}, + pass_output) == EXIT_SUCCESS, + "filtered passing run should succeed")) { + return EXIT_FAILURE; + } + if (!require(pass_output.find(pass_name) != std::string::npos, + "filtered passing run did not print the selected test")) { + return EXIT_FAILURE; + } + if (!require(pass_output.find(fail_name) == std::string::npos, + "filtered passing run executed an unselected test")) { + return EXIT_FAILURE; + } + if (!require(pass_output.find('+') != std::string::npos, + "filtered passing run did not report success")) { + return EXIT_FAILURE; + } + if (!require(passing_runs == 1 && failing_runs == 0, + "filtered passing run counts are wrong")) { + return EXIT_FAILURE; + } + + std::string fail_output; + if (!require(run_and_capture({"public-runner-smoke", "-test", "Smoke::B-Fail", "-iter", "1", "-stop", "true"}, + fail_output) == EXIT_FAILURE, + "filtered failing run should fail")) { + return EXIT_FAILURE; + } + if (!require(fail_output.find(fail_name) != std::string::npos, + "filtered failing run did not print the selected test")) { + return EXIT_FAILURE; + } + if (!require(fail_output.find("Options: -seed ") != std::string::npos, + "filtered failing run did not preserve seed diagnostics")) { + return EXIT_FAILURE; + } + if (!require(fail_output.find("-test Smoke::B-Fail") != std::string::npos, + "filtered failing run did not preserve test diagnostics")) { + return EXIT_FAILURE; + } + if (!require(passing_runs == 1 && failing_runs == 1, + "filtered failing run counts are wrong")) { + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} + +// STATISTICS: test-core diff --git a/test/test-main.cpp b/test/test-main.cpp new file mode 100644 index 0000000000..e1451d93fa --- /dev/null +++ b/test/test-main.cpp @@ -0,0 +1,51 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Christian Schulte + * + * Contributing authors: + * Mikael Lagerkvist + * + * Copyright: + * Christian Schulte, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.org + * + * 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 or substantial portions of the 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. + * + */ + +#include "test/test.hh" + +#ifdef GECODE_HAS_MTRACE +#include +#endif + +int +main(int argc, char* argv[]) { +#ifdef GECODE_HAS_MTRACE + mtrace(); +#endif + return Test::run_registered_tests(argc, argv); +} + +// STATISTICS: test-core diff --git a/test/test.cpp b/test/test.cpp index 5d808d8dd2..4912fa558a 100644 --- a/test/test.cpp +++ b/test/test.cpp @@ -46,7 +46,6 @@ #include #include #include -#include namespace Test { @@ -94,7 +93,7 @@ namespace Test { Options opt; - void report_error(const std::string& name, unsigned int seed, Options& options, std::ostream& ostream) { + void report_error(const std::string& name, unsigned int seed, const Options& options, std::ostream& ostream) { ostream << "Options: -seed " << seed; if (options.fixprob != Test::Options::deffixprob) ostream << " -fixprob " << options.fixprob; @@ -207,7 +206,7 @@ namespace Test { exit(EXIT_FAILURE); } - bool Options::is_test_name_matching(const std::string& test_name) { + bool Options::is_test_name_matching(const std::string& test_name) const { if (!testpat.empty()) { bool positive_patterns = false; bool match_found = false; @@ -260,7 +259,7 @@ namespace Test { ostream.flush(); } else { ostream << "-" << std::endl; - report_error(test->name(), seed, opt, ostream); + report_error(test->name(), seed, options, ostream); return false; } } @@ -268,9 +267,9 @@ namespace Test { return true; } catch (Gecode::Exception& e) { ostream << "Exception in \"Gecode::" << e.what() - << "." << std::endl - << "Stopping..." << std::endl; - report_error(test->name(), options.seed, opt, ostream); + << "." << std::endl + << "Stopping..." << std::endl; + report_error(test->name(), options.seed, options, ostream); return false; } } @@ -282,7 +281,7 @@ namespace Test { for (auto test : tests) { unsigned int test_seed = seed_sequence.next(); if (!run_test(test, test_seed, options, std::cout)) { - if (opt.stop) { + if (options.stop) { return EXIT_FAILURE; } else { result = EXIT_FAILURE; @@ -439,59 +438,56 @@ namespace Test { } }; - /// Run all the tests with the supplied options i parallel. + /// Run all the tests with the supplied options in parallel. int run_tests_parallel(const std::vector& tests, const Options& options) { using namespace Gecode::Support; RandomGenerator seed_sequence(options.seed); - TestExecutionControl tec(tests, options, opt.threads); + TestExecutionControl tec(tests, options, options.threads); - for (unsigned int i = 0; i < opt.threads; ++i) { + for (unsigned int i = 0; i < options.threads; ++i) { Thread::run(new TestExecutor(tec, seed_sequence.next())); } tec.await_test_runners_completed(); return tec.get_result(); } -} - -int -main(int argc, char* argv[]) { - using namespace Test; -#ifdef GECODE_HAS_MTRACE - mtrace(); -#endif - - opt.parse(argc, argv); - - Base::sort(); - - if (opt.list) { - for (Base* t = Base::tests() ; t != nullptr; t = t->next() ) { - std::cout << t->name() << std::endl; + std::vector select_tests(const Options& options) { + std::vector tests; + bool started = options.start_from == nullptr; + for (Base* t = Base::tests(); t != nullptr; t = t->next()) { + if (!started) { + if (t->name().find(options.start_from) != std::string::npos) { + started = true; + } else { + continue; + } + } + if (options.is_test_name_matching(t->name())) { + tests.emplace_back(t); + } } - exit(EXIT_SUCCESS); + return tests; } - std::vector tests; - bool started = opt.start_from == nullptr ? true : false; - for (Base* t = Base::tests() ; t != nullptr; t = t->next() ) { - if (!started) { - if (t->name().find(opt.start_from) != std::string::npos) { - started = true; - } else { - continue; + int run_registered_tests(int argc, char* argv[]) { + opt = Options(); + opt.parse(argc, argv); + + Base::sort(); + + if (opt.list) { + for (Base* t = Base::tests(); t != nullptr; t = t->next()) { + std::cout << t->name() << std::endl; } + return EXIT_SUCCESS; } - if (opt.is_test_name_matching(t->name())) { - tests.emplace_back(t); - } - } - if (opt.threads > 1) { - return run_tests_parallel(tests, opt); - } else { + const std::vector tests = select_tests(opt); + if (opt.threads > 1) { + return run_tests_parallel(tests, opt); + } return run_tests(tests, opt); } } diff --git a/test/test.hh b/test/test.hh index 9092165615..a805770429 100755 --- a/test/test.hh +++ b/test/test.hh @@ -45,6 +45,7 @@ #include #include #include +#include /// General test support namespace Test { @@ -111,7 +112,7 @@ namespace Test { void parse(int argc, char* argv[]); /// True iff a test name should be executed according to the patterns. With no patterns, always true. - bool is_test_name_matching(const std::string& test_name); + bool is_test_name_matching(const std::string& test_name) const; }; /// The options @@ -163,16 +164,16 @@ namespace Test { /// Random number generator mutable Gecode::Support::RandomGenerator _rand; }; + + /** + * \brief Run all currently registered tests through the shared runner + * \relates Test::Base + */ + int run_registered_tests(int argc, char* argv[]); //@} } -/** - * \brief Main function - * \relates Test::Base - */ -int main(int argc, char* argv[]); - /** * \brief Print indentation * \relates Test::ind From 6855d731bfc63e7a85590845771c92197a0d6eb8 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 13:40:05 +0200 Subject: [PATCH 2/6] Install the CMake test component --- .github/workflows/build.yml | 22 ++ CMakeLists.txt | 25 +- cmake/GecodeConfig.cmake.in | 48 +++- docs/cmake-build.md | 19 +- docs/public-test-harness.md | 215 ++++++++++++++ .../public-test-component/CMakeLists.txt | 19 ++ .../public-test-component/consumer-smoke.cpp | 25 ++ .../verify-installed-test-component.py | 265 ++++++++++++++++++ 8 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 docs/public-test-harness.md create mode 100644 test/package/public-test-component/CMakeLists.txt create mode 100644 test/package/public-test-component/consumer-smoke.cpp create mode 100644 test/package/verify-installed-test-component.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8457a5e7f..6d1576c973 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,6 +148,12 @@ jobs: set -euxo pipefail cmake --build . --config $BUILD_TYPE --target check + - name: Build installed test component targets + if: matrix.os == 'ubuntu-latest' + working-directory: ${{github.workspace}}/build + shell: bash + run: cmake --build . --config $BUILD_TYPE --target gecodetest gecodetestint + - name: Install CMake package working-directory: ${{github.workspace}}/build shell: bash @@ -169,6 +175,22 @@ jobs: core fi + - name: Verify installed test component + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + set -euxo pipefail + python test/package/verify-installed-test-component.py \ + --source "$GITHUB_WORKSPACE" \ + --build-root "$RUNNER_TEMP/s03-package-proof" \ + --prefix "$GITHUB_WORKSPACE/install" + + python test/package/verify-installed-test-component.py \ + --source "$GITHUB_WORKSPACE" \ + --build-root "$RUNNER_TEMP/s03-package-proof-missing-component" \ + --prefix "$GITHUB_WORKSPACE/install" \ + --expect-missing-component-failure + - name: Package version checks shell: bash run: bash .github/ci/cmake-package-version-checks.sh "$GITHUB_WORKSPACE/install" diff --git a/CMakeLists.txt b/CMakeLists.txt index 055773f1ca..414a75bc16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1367,22 +1367,35 @@ if(BUILD_TESTING) target_include_directories(gecodetest PUBLIC $ - $) + $ + $) if(GECODE_VISIBILITY_COMPILE_OPTION) target_compile_options(gecodetest PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) endif() target_link_libraries(gecodetest PUBLIC gecodesearch gecodeint) + list(APPEND GECODE_INSTALL_TARGETS gecodetest) + list(APPEND GECODE_EXPORT_TARGETS gecodetest) add_library(gecodetestint STATIC ${GECODE_TEST_INT_SOURCES}) target_compile_features(gecodetestint PUBLIC cxx_std_17) target_include_directories(gecodetestint PUBLIC $ - $) + $ + $) if(GECODE_VISIBILITY_COMPILE_OPTION) target_compile_options(gecodetestint PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) endif() target_link_libraries(gecodetestint PUBLIC gecodetest) + list(APPEND GECODE_INSTALL_TARGETS gecodetestint) + list(APPEND GECODE_EXPORT_TARGETS gecodetestint) + if(GECODE_INSTALL) + foreach(gecode_install_component IN LISTS GECODE_LIBRARY_COMPONENTS) + if(TARGET gecode${gecode_install_component}_${GECODE_DEFAULT_LINK_VARIANT}) + add_dependencies(gecodetest gecode${gecode_install_component}_${GECODE_DEFAULT_LINK_VARIANT}) + endif() + endforeach() + endif() add_executable(public-runner-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_RUNNER_SMOKE_SOURCE}) target_link_libraries(public-runner-smoke PRIVATE gecodetest) @@ -1684,6 +1697,14 @@ if(GECODE_INSTALL) install(FILES ${GECODE_VAR_TYPE_HPP} ${GECODE_VAR_IMP_HPP} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/gecode/kernel/) endif() + if(TARGET gecodetest AND TARGET gecodetestint) + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/test/test.hh + ${CMAKE_CURRENT_SOURCE_DIR}/test/test.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/test/int.hh + ${CMAKE_CURRENT_SOURCE_DIR}/test/int.hpp + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/test) + endif() if(GECODE_ENABLE_FLATZINC) install(DIRECTORY gecode/flatzinc/mznlib/ diff --git a/cmake/GecodeConfig.cmake.in b/cmake/GecodeConfig.cmake.in index 4099c3b2bd..c694f2e2db 100644 --- a/cmake/GecodeConfig.cmake.in +++ b/cmake/GecodeConfig.cmake.in @@ -12,8 +12,14 @@ set(_gecode_supported_components minimodel driver flatzinc - gist) + gist + test) set(_gecode_built_components "@GECODE_LIBRARY_COMPONENTS@") +if("@BUILD_TESTING@" STREQUAL "ON" AND + search IN_LIST _gecode_built_components AND + int IN_LIST _gecode_built_components) + list(APPEND _gecode_built_components test) +endif() foreach(_gecode_component IN LISTS _gecode_supported_components) if(_gecode_component IN_LIST _gecode_built_components) @@ -23,27 +29,54 @@ foreach(_gecode_component IN LISTS _gecode_supported_components) endif() set(Gecode_${_gecode_component}_FOUND ${_gecode_component_found}) set(Gecode_gecode${_gecode_component}_FOUND ${_gecode_component_found}) + if(_gecode_component STREQUAL test) + set(Gecode_gecodetestint_FOUND ${_gecode_component_found}) + endif() endforeach() set(_gecode_dependency_roots) set(Gecode_LIBRARIES) +set(_gecode_missing_components) +set(_gecode_unsupported_components) if(Gecode_FIND_COMPONENTS) foreach(_gecode_requested_component IN LISTS Gecode_FIND_COMPONENTS) set(_gecode_canonical_component "${_gecode_requested_component}") if(_gecode_canonical_component MATCHES "^gecode(.+)$") set(_gecode_canonical_component "${CMAKE_MATCH_1}") endif() + if(_gecode_canonical_component STREQUAL testint) + set(_gecode_canonical_component test) + endif() if(_gecode_canonical_component IN_LIST _gecode_supported_components) if(_gecode_canonical_component IN_LIST _gecode_built_components) list(APPEND _gecode_dependency_roots ${_gecode_canonical_component}) - list(APPEND Gecode_LIBRARIES "Gecode::gecode${_gecode_canonical_component}") + if(_gecode_canonical_component STREQUAL test) + list(APPEND Gecode_LIBRARIES Gecode::gecodetest Gecode::gecodetestint) + else() + list(APPEND Gecode_LIBRARIES "Gecode::gecode${_gecode_canonical_component}") + endif() + else() + list(APPEND _gecode_missing_components ${_gecode_requested_component}) + endif() + else() + list(APPEND _gecode_unsupported_components ${_gecode_requested_component}) + if(NOT DEFINED Gecode_${_gecode_requested_component}_FOUND) + set(Gecode_${_gecode_requested_component}_FOUND FALSE) endif() - elseif(NOT DEFINED Gecode_${_gecode_requested_component}_FOUND) - set(Gecode_${_gecode_requested_component}_FOUND FALSE) endif() endforeach() list(REMOVE_DUPLICATES _gecode_dependency_roots) list(REMOVE_DUPLICATES Gecode_LIBRARIES) + if(_gecode_unsupported_components) + list(JOIN _gecode_unsupported_components ", " _gecode_unsupported_components_text) + list(JOIN _gecode_supported_components ", " _gecode_supported_components_text) + set(Gecode_NOT_FOUND_MESSAGE + "Unsupported Gecode components requested: ${_gecode_unsupported_components_text}. Supported components: ${_gecode_supported_components_text}") + elseif(_gecode_missing_components) + list(JOIN _gecode_missing_components ", " _gecode_missing_components_text) + set(Gecode_NOT_FOUND_MESSAGE + "Requested Gecode components are unavailable in this installation: ${_gecode_missing_components_text}") + endif() else() set(_gecode_dependency_roots ${_gecode_built_components}) set(Gecode_LIBRARIES Gecode::gecode) @@ -78,6 +111,8 @@ while(_gecode_dependency_queue) set(_gecode_component_dependencies int search minimodel gist) elseif(_gecode_dependency_component STREQUAL flatzinc) set(_gecode_component_dependencies minimodel driver gist) + elseif(_gecode_dependency_component STREQUAL test) + set(_gecode_component_dependencies search int) endif() foreach(_gecode_component_dependency IN LISTS _gecode_component_dependencies) if(_gecode_component_dependency IN_LIST _gecode_built_components) @@ -187,8 +222,13 @@ unset(_gecode_dependency_component) unset(_gecode_dependency_queue) unset(_gecode_dependency_roots) unset(_gecode_needs_qt) +unset(_gecode_missing_components) +unset(_gecode_missing_components_text) unset(_gecode_package_prefix_dir) unset(_gecode_requested_component) unset(_gecode_supported_components) +unset(_gecode_supported_components_text) +unset(_gecode_unsupported_components) +unset(_gecode_unsupported_components_text) check_required_components(Gecode) diff --git a/docs/cmake-build.md b/docs/cmake-build.md index 9dff49d775..977624effe 100644 --- a/docs/cmake-build.md +++ b/docs/cmake-build.md @@ -169,7 +169,7 @@ target_link_libraries(app PRIVATE Gecode::gecode) Canonical component names: - `support`, `kernel`, `search`, `int`, `set`, `float`, `minimodel`, `driver`, - `flatzinc`, `gist` + `flatzinc`, `gist`, `test` Example: @@ -179,6 +179,22 @@ add_executable(app main.cpp) target_link_libraries(app PRIVATE Gecode::gecodedriver) ``` +### Installed test component for downstream custom tests + +If the installed Gecode package was built with `BUILD_TESTING=ON`, it also exports a +`test` component for downstream consumers that want to register and run custom tests +through the public harness. + +Quick reference: + +- Imported targets: `Gecode::gecodetest`, `Gecode::gecodetestint` +- Installed public headers: `test/test.hh`, `test/test.hpp`, `test/int.hh`, `test/int.hpp` +- Public runner entrypoint: `Test::run_registered_tests(argc, argv)` +- Canonical proof path: `python test/package/verify-installed-test-component.py ...` + +For the full downstream contract, minimal consumer example, verifier workflow, +and support boundaries, see [`docs/public-test-harness.md`](./public-test-harness.md). + Legacy component spellings are also accepted in `COMPONENTS`: - `gecodesupport`, `gecodekernel`, `gecodesearch`, `gecodeint`, `gecodeset`, @@ -234,6 +250,7 @@ Deprecation horizon: - `find_package(Gecode COMPONENTS ...)` fails: - Verify requested component is enabled in the installed build. - For optional modules (`flatzinc`, `gist`, `float` with MPFR), ensure dependencies were available. + - The `test` component is only exported when the installed package was built with `BUILD_TESTING=ON`. - Qt/Gist issues: - `GECODE_ENABLE_QT=ON` and `GECODE_ENABLE_GIST=ON` are requirement modes; use `AUTO` when dependency discovery should be best-effort. diff --git a/docs/public-test-harness.md b/docs/public-test-harness.md new file mode 100644 index 0000000000..a85a70e078 --- /dev/null +++ b/docs/public-test-harness.md @@ -0,0 +1,215 @@ +# Public Test Harness Guide + +This document describes Gecode's supported public testing surface. +It is for downstream users who want to write Gecode-style tests for custom propagators +without developing inside the Gecode source tree. + +For general build, install, and package-consumption setup, see +[`docs/cmake-build.md`](./cmake-build.md). + +## What this gives you + +If Gecode was built and installed with `BUILD_TESTING=ON`, the installed CMake package +exports a `test` component with: + +- `Gecode::gecodetest` — core test registration and runner support +- `Gecode::gecodetestint` — integer-test helpers layered over the core runner + +The supported installed header surface is intentionally narrow: + +- `test/test.hh` +- `test/test.hpp` +- `test/int.hh` +- `test/int.hpp` + +The public runner entrypoint is: + +```c++ +int Test::run_registered_tests(int argc, char* argv[]); +``` + +This is the same runner seam used by Gecode's own `gecode-test` executable. + +## Supported first-release scope + +The first public release is intentionally focused. + +Supported: + +- The core process-global test registry and runner from `test/test.hh` +- Integer-test helpers based on `Test::Int::Test` +- Installed CMake consumption through `find_package(Gecode CONFIG REQUIRED COMPONENTS test)` +- A downstream executable that registers tests and forwards `main(...)` into + `Test::run_registered_tests(argc, argv)` + +Not yet part of the supported installed surface: + +- Public installation of the wider helper families under `test/` such as `set`, `float`, + `assign`, `branch`, or `flatzinc` +- Non-CMake downstream consumption paths +- A separate replacement framework or a redesigned runner model + +## Prerequisite: install Gecode with the test component enabled + +The `test` component is only exported when the installed package was built with +`BUILD_TESTING=ON` and the required harness dependencies were available. + +Typical flow: + +```bash +cmake -S . -B build -DBUILD_TESTING=ON +cmake --build build --target gecodetest gecodetestint gecode-test +cmake --install build --prefix /path/to/install +``` + +For full build/install details, platform notes, and package-location hints, see +[`docs/cmake-build.md`](./cmake-build.md). + +## Minimal downstream CMake consumer + +```cmake +cmake_minimum_required(VERSION 3.21) +project(gecode_public_test_consumer LANGUAGES CXX) + +find_package(Gecode CONFIG REQUIRED COMPONENTS test) + +add_executable(consumer-smoke consumer-smoke.cpp) +target_compile_features(consumer-smoke PRIVATE cxx_std_17) +target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestint) +``` + +If CMake does not find the package automatically, point it at the install prefix: + +- `-DCMAKE_PREFIX_PATH=/path/to/install` +- or `-DGecode_ROOT=/path/to/install` +- or `-DGecode_DIR=/path/to/install/lib/cmake/Gecode` + +## Minimal downstream test + +```c++ +#include + +#include + +namespace { + +class ConsumerSmoke final : public ::Test::Int::Test { +public: + ConsumerSmoke() + : ::Test::Int::Test("Package::ConsumerSmoke", 1, 0, 1) {} + + bool solution(const ::Test::Int::Assignment& assignment) const override { + return assignment[0] >= 0; + } + + void post(Gecode::Space& home, Gecode::IntVarArray& x) override { + Gecode::rel(home, x[0], Gecode::IRT_GQ, 0); + } +} consumer_smoke; + +} // namespace + +int main(int argc, char* argv[]) { + return Test::run_registered_tests(argc, argv); +} +``` + +This mirrors the maintained sample consumer at +`test/package/public-test-component/consumer-smoke.cpp`. + +## Running the downstream test executable + +List registered tests: + +```bash +./consumer-smoke -list +``` + +Run just one test: + +```bash +./consumer-smoke -test Package::ConsumerSmoke -iter 1 -stop true +``` + +The runner uses the same option model as Gecode's own `gecode-test` binary. +The supported public seam is the runner function, not a separate alternate CLI. + +## Package component behavior + +The installed `GecodeConfig.cmake` recognizes `test` as a supported component. +The package treats the component as available only when both of these imported +targets are present: + +- `Gecode::gecodetest` +- `Gecode::gecodetestint` + +If a consumer requests an unsupported component, the package emits an explicit +configure-time diagnostic rather than failing later at link time. + +## Supported proof path + +The canonical downstream verifier is: + +```bash +python test/package/verify-installed-test-component.py \ + --source . \ + --build-root build/package-proof \ + --prefix /path/to/install +``` + +What it checks: + +- the installed `test/` headers are present and limited to the supported public set +- the package exports `Gecode::gecodetest` and `Gecode::gecodetestint` +- the downstream consumer configures through the installed package metadata +- consumer compile flags use the installed prefix instead of source-tree include leakage +- the consumer binary builds successfully +- `-list` discovers the registered downstream test +- a filtered run executes the selected test successfully + +There is also a negative-path mode that confirms unsupported-component diagnostics: + +```bash +python test/package/verify-installed-test-component.py \ + --source . \ + --build-root build/package-proof-missing \ + --prefix /path/to/install \ + --expect-missing-component-failure +``` + +## How this relates to Gecode's own tests + +Inside the repo, `gecode-test` now links through the same public harness seam: + +- `gecodetest` +- `gecodetestint` + +That means the supported downstream path is not a sidecar proof-only API. The +same runner/library boundary is used both for in-tree testing and installed +package consumption. + +## Troubleshooting + +### `find_package(Gecode CONFIG REQUIRED COMPONENTS test)` fails + +Check: + +- the installation was built with `BUILD_TESTING=ON` +- the package lookup points at the intended install prefix +- the install tree actually contains `lib/cmake/Gecode/GecodeConfig.cmake` + +### `Gecode::gecodetestint` is missing + +The `test` component is only considered available when both `Gecode::gecodetest` +and `Gecode::gecodetestint` are exported. Treat a missing companion target as an +installation/configuration issue, not as something to patch around in the consumer. + +### The consumer builds but seems to use source-tree headers + +Run the verifier. It checks `compile_commands.json` for include-leakage and is the +maintained proof path used by CI as well. + +### I need non-integer helper families + +That is outside the supported first-release installed surface. The installed public +contract currently stops at the core runner plus integer-test helpers. diff --git a/test/package/public-test-component/CMakeLists.txt b/test/package/public-test-component/CMakeLists.txt new file mode 100644 index 0000000000..d7802be382 --- /dev/null +++ b/test/package/public-test-component/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.21) + +project(gecode_public_test_component_consumer LANGUAGES CXX) + +option(REQUIRE_UNKNOWN_COMPONENT "Request an unsupported Gecode component to verify diagnostics" OFF) + +set(CMAKE_FIND_PACKAGE_NO_PACKAGE_REGISTRY ON) +set(CMAKE_FIND_USE_PACKAGE_REGISTRY OFF) + +set(_gecode_components test) +if(REQUIRE_UNKNOWN_COMPONENT) + list(APPEND _gecode_components unsupported_component) +endif() + +find_package(Gecode CONFIG REQUIRED COMPONENTS ${_gecode_components}) + +add_executable(consumer-smoke consumer-smoke.cpp) +target_compile_features(consumer-smoke PRIVATE cxx_std_17) +target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestint) diff --git a/test/package/public-test-component/consumer-smoke.cpp b/test/package/public-test-component/consumer-smoke.cpp new file mode 100644 index 0000000000..943671f51b --- /dev/null +++ b/test/package/public-test-component/consumer-smoke.cpp @@ -0,0 +1,25 @@ +#include + +#include + +namespace { + +class ConsumerSmoke final : public ::Test::Int::Test { +public: + ConsumerSmoke() + : ::Test::Int::Test("Package::ConsumerSmoke", 1, 0, 1) {} + + bool solution(const ::Test::Int::Assignment& assignment) const override { + return assignment[0] >= 0; + } + + void post(Gecode::Space& home, Gecode::IntVarArray& x) override { + Gecode::rel(home, x[0], Gecode::IRT_GQ, 0); + } +} consumer_smoke; + +} // namespace + +int main(int argc, char* argv[]) { + return Test::run_registered_tests(argc, argv); +} diff --git a/test/package/verify-installed-test-component.py b/test/package/verify-installed-test-component.py new file mode 100644 index 0000000000..43822283cd --- /dev/null +++ b/test/package/verify-installed-test-component.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +EXPECTED_HEADERS = [ + "include/test/int.hh", + "include/test/int.hpp", + "include/test/test.hh", + "include/test/test.hpp", +] +EXPECTED_METADATA = [ + "lib/cmake/Gecode/GecodeConfig.cmake", + "lib/cmake/Gecode/GecodeTargets.cmake", +] +EXPECTED_TEST_NAME = "Int::Package::ConsumerSmoke" +VERIFIER_PREFIX = "[verify-installed-test-component]" + + +def format_command(command: list[str]) -> str: + return shlex.join(command) + + +def write_stream(stream, text: str) -> None: + if text: + stream.write(text) + if not text.endswith("\n"): + stream.write("\n") + + +def fail_phase( + phase: str, + message: str, + *, + command: list[str] | None = None, + cwd: Path | None = None, + result: subprocess.CompletedProcess[str] | None = None, +) -> "NoReturn": + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") + if command is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: command: {format_command(command)}\n") + if cwd is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: cwd: {cwd}\n") + if result is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: exit: {result.returncode}\n") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stdout:\n{result.stdout}") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stderr:\n{result.stderr}") + raise SystemExit(1) + + +def run_phase( + phase: str, + command: list[str], + *, + cwd: Path | None = None, + expect_success: bool = True, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + cwd=cwd, + text=True, + capture_output=True, + ) + if expect_success and result.returncode != 0: + fail_phase(phase, "command failed", command=command, cwd=cwd, result=result) + if not expect_success and result.returncode == 0: + fail_phase(phase, "command unexpectedly succeeded", command=command, cwd=cwd, result=result) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + return result + + +def assert_phase(condition: bool, phase: str, message: str) -> None: + if not condition: + fail_phase(phase, message) + + +def assert_prefix_surface(prefix: Path) -> None: + phase = "prefix-surface" + for rel in [*EXPECTED_HEADERS, *EXPECTED_METADATA]: + path = prefix / rel + assert_phase(path.exists(), phase, f"missing installed path: {path}") + + installed_test_headers = sorted( + path.relative_to(prefix).as_posix() + for path in (prefix / "include" / "test").glob("*") + if path.is_file() + ) + assert_phase(installed_test_headers == EXPECTED_HEADERS, phase, f"unexpected installed test headers: {installed_test_headers}") + + targets = (prefix / "lib/cmake/Gecode/GecodeTargets.cmake").read_text() + assert_phase("Gecode::gecodetest" in targets, phase, "missing Gecode::gecodetest export") + assert_phase("Gecode::gecodetestint" in targets, phase, "missing Gecode::gecodetestint export") + + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + + +def iter_include_dirs(entry: dict[str, object]) -> list[Path]: + directory = Path(str(entry["directory"])).resolve() + arguments = entry.get("arguments") + if arguments is None: + arguments = shlex.split(str(entry["command"])) + + include_dirs: list[Path] = [] + i = 0 + while i < len(arguments): + argument = str(arguments[i]) + include_path: str | None = None + if argument == "-I" or argument == "-isystem": + i += 1 + include_path = str(arguments[i]) + elif argument.startswith("-I") and argument != "-I": + include_path = argument[2:] + elif argument.startswith("-isystem") and argument != "-isystem": + include_path = argument[len("-isystem") :] + + if include_path: + path = Path(include_path) + include_dirs.append((directory / path).resolve() if not path.is_absolute() else path.resolve()) + i += 1 + + return include_dirs + + +def assert_no_source_tree_include_leakage(source: Path, consumer_build: Path, prefix: Path) -> None: + phase = "include-leakage" + compile_commands_path = consumer_build / "compile_commands.json" + assert_phase(compile_commands_path.exists(), phase, f"missing compile commands: {compile_commands_path}") + + compile_commands = json.loads(compile_commands_path.read_text()) + assert_phase(bool(compile_commands), phase, "expected compile commands for installed-package consumer") + + source_root = source.resolve() + consumer_build = consumer_build.resolve() + prefix = prefix.resolve() + prefix_include = (prefix / "include").resolve() + + leaked_include_dirs: set[str] = set() + saw_prefix_include = False + for entry in compile_commands: + for include_dir in iter_include_dirs(entry): + if include_dir == prefix_include or include_dir.is_relative_to(prefix_include): + saw_prefix_include = True + if ( + include_dir.is_relative_to(source_root) + and not include_dir.is_relative_to(consumer_build) + and not include_dir.is_relative_to(prefix) + ): + leaked_include_dirs.add(include_dir.as_posix()) + + assert_phase(saw_prefix_include, phase, "consumer compile flags never referenced the installed prefix include directory") + assert_phase(not leaked_include_dirs, phase, f"source-tree include leakage detected: {sorted(leaked_include_dirs)}") + + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + + +def configure_consumer(source: Path, build_root: Path, prefix: Path, *, require_unknown_component: bool) -> tuple[Path, Path, subprocess.CompletedProcess[str]]: + consumer_source = source / "test/package/public-test-component" + consumer_build = build_root / "consumer" + cmake_dir = prefix / "lib/cmake/Gecode" + + shutil.rmtree(build_root, ignore_errors=True) + consumer_build.mkdir(parents=True, exist_ok=True) + + configure_command = [ + "cmake", + "-S", + str(consumer_source), + "-B", + str(consumer_build), + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + f"-DGecode_DIR={cmake_dir}", + f"-DCMAKE_PREFIX_PATH={prefix}", + ] + if require_unknown_component: + configure_command.append("-DREQUIRE_UNKNOWN_COMPONENT=ON") + + result = run_phase( + "configure-missing-component" if require_unknown_component else "configure", + configure_command, + expect_success=not require_unknown_component, + ) + return consumer_source, consumer_build, result + + +def assert_missing_component_failure(result: subprocess.CompletedProcess[str]) -> None: + phase = "configure-missing-component" + combined_output = f"{result.stdout}\n{result.stderr}" + assert_phase( + "Unsupported Gecode components requested: unsupported_component" in combined_output, + phase, + "missing unsupported-component diagnostic in configure failure output", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: diagnostic ok\n") + + +def build_consumer(consumer_build: Path) -> Path: + run_phase("build", ["cmake", "--build", str(consumer_build), "--target", "consumer-smoke"]) + consumer_binary = consumer_build / "consumer-smoke" + if not consumer_binary.exists(): + consumer_binary = consumer_build / "consumer-smoke.exe" + assert_phase(consumer_binary.exists(), "build", f"missing built consumer binary: {consumer_binary}") + sys.stdout.write(f"{VERIFIER_PREFIX} build: binary={consumer_binary}\n") + return consumer_binary + + +def run_list_phase(consumer_binary: Path) -> None: + result = run_phase("list", [str(consumer_binary), "-list"]) + assert_phase(EXPECTED_TEST_NAME in result.stdout, "list", f"-list output missing {EXPECTED_TEST_NAME!r}") + sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={EXPECTED_TEST_NAME}\n") + + +def run_filtered_phase(consumer_binary: Path) -> None: + result = run_phase( + "filtered-run", + [str(consumer_binary), "-test", EXPECTED_TEST_NAME, "-iter", "1", "-stop", "true"], + ) + assert_phase(EXPECTED_TEST_NAME in result.stdout, "filtered-run", "filtered run did not print the selected downstream test") + assert_phase("+" in result.stdout, "filtered-run", "filtered run did not report success") + sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={EXPECTED_TEST_NAME}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--source", required=True) + parser.add_argument("--build-root", required=True) + parser.add_argument("--prefix", required=True) + parser.add_argument("--expect-missing-component-failure", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + source = Path(args.source).resolve() + build_root = Path(args.build_root).resolve() + prefix = Path(args.prefix).resolve() + + assert_prefix_surface(prefix) + + _, consumer_build, configure_result = configure_consumer( + source, + build_root, + prefix, + require_unknown_component=args.expect_missing_component_failure, + ) + + if args.expect_missing_component_failure: + assert_missing_component_failure(configure_result) + return 0 + + assert_no_source_tree_include_leakage(source, consumer_build, prefix) + consumer_binary = build_consumer(consumer_build) + run_list_phase(consumer_binary) + run_filtered_phase(consumer_binary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4bebc72eb80f2a211e0953ddcc09230e42e34a45 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 13:41:27 +0200 Subject: [PATCH 3/6] Install the Autoconf test libraries --- .github/workflows/build.yml | 30 ++ Makefile.in | 222 ++++++++- docs/public-test-harness.md | 103 +++- .../verify-installed-legacy-test-component.py | 439 ++++++++++++++++++ test/verify-legacy-check-selection.py | 77 +++ test/verify-legacy-install-surface.py | 167 +++++++ test/verify-legacy-selfhost.py | 154 ++++++ 7 files changed, 1162 insertions(+), 30 deletions(-) create mode 100644 test/package/verify-installed-legacy-test-component.py create mode 100644 test/verify-legacy-check-selection.py create mode 100644 test/verify-legacy-install-surface.py create mode 100644 test/verify-legacy-selfhost.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d1576c973..32b0980817 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,6 +79,36 @@ jobs: shell: bash run: make check + - name: Install Autoconf test-library prefix + if: runner.os == 'Linux' + shell: bash + run: | + set -euxo pipefail + build_root="$RUNNER_TEMP/legacy-test-component-proof" + prefix="$build_root/prefix" + rm -rf "$build_root" + mkdir -p "$prefix" + make install prefix="$prefix" + + - name: Verify installed legacy test consumer + if: runner.os == 'Linux' + shell: bash + run: | + set -euxo pipefail + build_root="$RUNNER_TEMP/legacy-test-component-proof" + prefix="$build_root/prefix" + + python test/package/verify-installed-legacy-test-component.py \ + --source "$GITHUB_WORKSPACE" \ + --build-root "$build_root" \ + --prefix "$prefix" + + python test/package/verify-installed-legacy-test-component.py \ + --source "$GITHUB_WORKSPACE" \ + --build-root "$build_root" \ + --prefix "$prefix" \ + --mode unsupported-header + - name: Autoconf install smoke shell: bash run: bash .github/ci/autoconf-install-smoke.sh "$RUNNER_TEMP/gecode-autoconf-install" diff --git a/Makefile.in b/Makefile.in index b446405125..5645a0acaf 100755 --- a/Makefile.in +++ b/Makefile.in @@ -1087,12 +1087,13 @@ else STATICTARGETS= endif -export LIBTARGETS = $(DLLTARGETS) $(STATICTARGETS) +export LIBTARGETS = $(DLLTARGETS) $(STATICTARGETS) $(TESTPUBLICINSTALLLIBTARGETS) ifeq "@need_soname@" "yes" export LIBLINKTARGETS = \ $(DLLTARGETS:%$(DLLSUFFIX)=%$(SOSUFFIX)) \ - $(DLLTARGETS:%$(DLLSUFFIX)=%$(SOLINKSUFFIX)) + $(DLLTARGETS:%$(DLLSUFFIX)=%$(SOLINKSUFFIX)) \ + $(TESTPUBLICINSTALLLIBLINKTARGETS) else export LIBLINKTARGETS = endif @@ -1100,8 +1101,8 @@ endif PDBS = $(ALLLIB:%$(LIBSUFFIX)=%$(PDBSUFFIX)) ifeq "@INSTALLLIBS@" "yes" -LIBLIBTARGETS = $(ALLLIB) -PDBTARGETS = $(PDBS) +LIBLIBTARGETS = $(ALLLIB) $(TESTPUBLICINSTALLLIBLIBTARGETS) +PDBTARGETS = $(PDBS) $(TESTPUBLICINSTALLPDBTARGETS) else LIBLIBTARGETS = PDBTARGETS = @@ -1276,7 +1277,19 @@ BLACKBOXEXECSRC = test/flatzinc/blackbox-exec.cpp BLACKBOXDLLSRC = test/flatzinc/blackbox-dll.cpp BLACKBOXSRC = $(BLACKBOXEXECSRC) $(BLACKBOXDLLSRC) -TESTSRC0 = test/test.cpp test/afc.cpp test/ldsb.cpp test/region.cpp \ +TESTCORESRC = test/test.cpp +TESTCOREOBJ = $(TESTCORESRC:%.cpp=%$(OBJSUFFIX)) +TESTMAINSRC = test/test-main.cpp +TESTMAINOBJ = $(TESTMAINSRC:%.cpp=%$(OBJSUFFIX)) +TESTINTSEAMSRC = test/int.cpp +TESTINTSEAMOBJ = $(TESTINTSEAMSRC:%.cpp=%$(OBJSUFFIX)) +TESTPUBLICRUNNERSMOKESRC = test/public-runner-smoke.cpp +TESTPUBLICRUNNERSMOKEOBJ = $(TESTPUBLICRUNNERSMOKESRC:%.cpp=%$(OBJSUFFIX)) +TESTPUBLICINTSMOKESRC = test/public-int-smoke.cpp +TESTPUBLICINTSMOKEOBJ = $(TESTPUBLICINTSMOKESRC:%.cpp=%$(OBJSUFFIX)) +TESTAUXOBJ = $(TESTMAINOBJ) $(TESTPUBLICRUNNERSMOKEOBJ) $(TESTPUBLICINTSMOKEOBJ) + +TESTSRC0 = $(TESTCORESRC) test/afc.cpp test/ldsb.cpp test/region.cpp \ test/groups.cpp # FailPoint is CMake-only; keep the Autoconf test executable fault-free. @@ -1289,6 +1302,87 @@ TESTHDR0 = \ test.hh test.hpp int.hh int.hpp set.hh set.hpp float.hh float.hpp \ branch.hh assign.hh flatzinc.hh TESTHDR = $(TESTHDR0:%=test/%) + +TESTPUBLICINSTALLHDR = +TESTPUBLICINSTALLLIBTARGETS = +TESTPUBLICINSTALLLIBLINKTARGETS = +TESTPUBLICINSTALLLIBLIBTARGETS = +TESTPUBLICINSTALLPDBTARGETS = + +ifeq "@enable_search@" "yes" +ifeq "@enable_int_vars@" "yes" +TESTPUBLICINSTALLHDR = test/test.hh test/test.hpp test/int.hh test/int.hpp + +export TESTCOREDLL = $(LIBPREFIX)test$(DLLSUFFIX) +export TESTCORELIB = $(LIBPREFIX)test$(LIBSUFFIX) +ifeq "@need_soname@" "yes" +export TESTCORESONAME = \ + @WLSONAME@$(LIBPREFIX)test$(SOSUFFIX) +else +export TESTCORESONAME = +endif +export TESTCORESTATICLIB = $(LIBPREFIX)test$(STATICLIBSUFFIX) +export LINKTESTCORE = $(LINKPREFIX)test$(LINKSUFFIX) +ifeq "@enable_resource@" "yes" +export TESTCORERC = $(LIBPREFIX)test$(RCSUFFIX) +export TESTCORERES = $(LIBPREFIX)test$(RESSUFFIX) +else +export TESTCORERES = +export TESTCORERC = +endif + +export TESTINTDLL = $(LIBPREFIX)testint$(DLLSUFFIX) +export TESTINTLIB = $(LIBPREFIX)testint$(LIBSUFFIX) +ifeq "@need_soname@" "yes" +export TESTINTSONAME = \ + @WLSONAME@$(LIBPREFIX)testint$(SOSUFFIX) +else +export TESTINTSONAME = +endif +export TESTINTSTATICLIB = $(LIBPREFIX)testint$(STATICLIBSUFFIX) +export LINKTESTINT = $(LINKPREFIX)testint$(LINKSUFFIX) +ifeq "@enable_resource@" "yes" +export TESTINTRC = $(LIBPREFIX)testint$(RCSUFFIX) +export TESTINTRES = $(LIBPREFIX)testint$(RESSUFFIX) +else +export TESTINTRES = +export TESTINTRC = +endif + +TESTPUBLICINSTALLLIBTARGETS += $(TESTCORESTATICLIB) $(TESTINTSTATICLIB) +else +export TESTCOREDLL = +export TESTCORELIB = +export TESTCORESONAME = +export TESTCORESTATICLIB = +export LINKTESTCORE = +export TESTCORERES = +export TESTCORERC = +export TESTINTDLL = +export TESTINTLIB = +export TESTINTSONAME = +export TESTINTSTATICLIB = +export LINKTESTINT = +export TESTINTRES = +export TESTINTRC = +endif +else +export TESTCOREDLL = +export TESTCORELIB = +export TESTCORESONAME = +export TESTCORESTATICLIB = +export LINKTESTCORE = +export TESTCORERES = +export TESTCORERC = +export TESTINTDLL = +export TESTINTLIB = +export TESTINTSONAME = +export TESTINTSTATICLIB = +export LINKTESTINT = +export TESTINTRES = +export TESTINTRC = +endif + TESTOBJ = $(INTTESTOBJ) $(SETTESTOBJ) $(FLOATTESTOBJ) \ $(FLATZINCTESTOBJ:%.cpp=%$(OBJSUFFIX)) \ $(BRANCHTESTSRC0:%.cpp=%$(OBJSUFFIX)) \ @@ -1297,6 +1391,8 @@ TESTOBJ = $(INTTESTOBJ) $(SETTESTOBJ) $(FLOATTESTOBJ) \ $(TESTSRC0:%.cpp=%$(OBJSUFFIX)) TESTSBJ = $(TESTOBJ:%$(OBJSUFFIX)=%$(SBJSUFFIX)) TESTEXE = test/test$(EXESUFFIX) +TESTPUBLICRUNNERSMOKEEXE = test/public-runner-smoke$(EXESUFFIX) +TESTPUBLICINTSMOKEEXE = test/public-int-smoke$(EXESUFFIX) BLACKBOXEXECOBJ = $(BLACKBOXEXECSRC:%.cpp=%$(OBJSUFFIX)) BLACKBOXDLLOBJ = $(BLACKBOXDLLSRC:%.cpp=%$(OBJSUFFIX)) BLACKBOXEXEC = test/flatzinc/blackbox-exec$(EXESUFFIX) @@ -1345,7 +1441,6 @@ test: mkcompiledirs $(BLACKBOXFIXTURES) @$(MAKE) $(VARIMP) $(TESTEXE) CHECKTESTS = Branch::Int::Dense::3 \ - FlatZinc::magic_square \ Int::Arithmetic::Abs \ Int::Arithmetic::ArgMax \ Int::Arithmetic::Max::Nary \ @@ -1356,14 +1451,18 @@ CHECKTESTS = Branch::Int::Dense::3 \ Int::Linear::Bool::Int::Lq \ Int::MiniModel::LinExpr::Bool::352 \ NoGoods::Queens \ - Search::DFS::Sol::Binary::Nary::Binary::1::1::1 \ + Search::DFS::Sol::Binary::Nary::Binary::1::1::1 + +ifeq "@enable_set_vars@" "yes" +CHECKTESTS += \ Set::Dom::Dom::Gr \ Set::RelOp::ConstSSI::Union \ Set::Sequence::SeqU1 \ Set::Wait +endif ifeq "@enable_flatzinc@" "yes" -CHECKTESTS += FlatZinc::Options FlatZinc::blackbox +CHECKTESTS += FlatZinc::magic_square FlatZinc::Options FlatZinc::blackbox BLACKBOXCHECKENV = \ GECODE_TEST_BLACKBOX_EXEC=$(abspath $(BLACKBOXEXEC)) \ GECODE_TEST_BLACKBOX_DLL=$(abspath $(BLACKBOXDLL)) \ @@ -1438,12 +1537,12 @@ VIS = @ALLVIS@ VISDEP = $(VIS) \ $(top_srcdir)/misc/genvarimp.py Makefile -gecode/kernel/var-type.hpp: $(VISDEP) +gecode/kernel/var-type.hpp: $(VISDEP) | mkcompiledirs $(UVRUN) $(top_srcdir)/misc/genvarimp.py -typehpp $(VIS) > $@ -gecode/kernel/var-imp.hpp: $(VISDEP) +gecode/kernel/var-imp.hpp: $(VISDEP) | mkcompiledirs $(UVRUN) $(top_srcdir)/misc/genvarimp.py -header $(VIS) > $@ -$(ALLOBJ) $(ALLSBJ) $(TESTOBJ) $(TESTSBJ): $(VARIMP) +$(ALLOBJ) $(ALLSBJ) $(TESTOBJ) $(TESTSBJ) $(TESTAUXOBJ): $(VARIMP) | mkcompiledirs # # Object targets @@ -1555,27 +1654,27 @@ $(GISTMOCSRC:%.cpp=%$(SBJSUFFIX)): gecode/gist/moc_%$(SBJSUFFIX): gecode/gist/mo @COMPILESBJ@$@ @CXXIN@$< .PRECIOUS: gecode/gist/moc_%.cpp -gecode/gist/moc_%.cpp: $(top_srcdir)/gecode/gist/%.hh +gecode/gist/moc_%.cpp: $(top_srcdir)/gecode/gist/%.hh | mkcompiledirs $(MOC) $(CPPFLAGS) -pgecode/gist $< -o $@ .PRECIOUS: gecode/flatzinc/%.cpp gecode/flatzinc/%.hh ifeq "@HAVE_FLEXBISON@" "yes" gecode/flatzinc/lexer.yy.cpp: \ $(top_srcdir)/gecode/flatzinc/lexer.lxx \ - $(top_srcdir)/gecode/flatzinc/parser.tab.hpp + $(top_srcdir)/gecode/flatzinc/parser.tab.hpp | mkcompiledirs flex -ogecode/flatzinc/lexer.yy.cpp \ $(top_srcdir)/gecode/flatzinc/lexer.lxx gecode/flatzinc/parser.tab.hpp gecode/flatzinc/parser.tab.cpp: \ - $(top_srcdir)/gecode/flatzinc/parser.yxx + $(top_srcdir)/gecode/flatzinc/parser.yxx | mkcompiledirs bison -t -o gecode/flatzinc/parser.tab.cpp -d $< else ifneq "$(top_srcdir)" "." -gecode/flatzinc/lexer.yy.cpp: $(top_srcdir)/gecode/flatzinc/lexer.yy.cpp +gecode/flatzinc/lexer.yy.cpp: $(top_srcdir)/gecode/flatzinc/lexer.yy.cpp | mkcompiledirs cp $(@:%=$(top_srcdir)/%) $@ -gecode/flatzinc/parser.tab.hpp: $(top_srcdir)/gecode/flatzinc/parser.tab.hpp +gecode/flatzinc/parser.tab.hpp: $(top_srcdir)/gecode/flatzinc/parser.tab.hpp | mkcompiledirs cp $(@:%=$(top_srcdir)/%) $@ -gecode/flatzinc/parser.tab.cpp: $(top_srcdir)/gecode/flatzinc/parser.tab.cpp +gecode/flatzinc/parser.tab.cpp: $(top_srcdir)/gecode/flatzinc/parser.tab.cpp | mkcompiledirs cp $(@:%=$(top_srcdir)/%) $@ endif endif @@ -1680,6 +1779,18 @@ $(FLATZINCDLL): $(FLATZINCOBJ) $(SUPPORTDLL) $(KERNELDLL) $(SEARCHDLL) \ $(LINKDL) @LINKOUTPUT@$(FLATZINCDLL) $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOLINKSUFFIX)) $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOSUFFIX)) +$(TESTCOREDLL): $(TESTCOREOBJ) $(SUPPORTDLL) $(KERNELDLL) $(SEARCHDLL) $(INTDLL) + $(CXX) $(DLLFLAGS) $(TESTCOREOBJ) $(TESTCORESONAME) \ + @DLLPATH@ $(LINKSUPPORT) $(LINKKERNEL) $(LINKSEARCH) $(LINKINT) \ + @LINKOUTPUT@$(TESTCOREDLL) + $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOLINKSUFFIX)) + $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOSUFFIX)) +$(TESTINTDLL): $(TESTINTSEAMOBJ) $(TESTCOREDLL) + $(CXX) $(DLLFLAGS) $(TESTINTSEAMOBJ) $(TESTINTSONAME) \ + @DLLPATH@ $(LINKTESTCORE) \ + @LINKOUTPUT@$(TESTINTDLL) + $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOLINKSUFFIX)) + $(CREATELINK) $@ $(@:%$(DLLSUFFIX)=%$(SOSUFFIX)) else export LINKALL = @@ -1789,6 +1900,25 @@ $(FLATZINCDLL) $(FLATZINCLIB): $(FLATZINCOBJ) $(FLATZINCRES) \ $(FIXMANIFEST) $(FLATZINCDLL).manifest $(MANIFEST) -manifest $(FLATZINCDLL).manifest \ -outputresource:$(FLATZINCDLL)\;2 +ifeq "@enable_resource@" "yes" +$(TESTCORERC): + $(RCGEN) $(TESTCOREDLL) $(TESTCORESRC) test/test.hh test/test.hpp > $@ +$(TESTINTRC): + $(RCGEN) $(TESTINTDLL) $(TESTINTSEAMSRC) test/test.hh test/test.hpp test/int.hh test/int.hpp > $@ +endif +$(TESTCOREDLL) $(TESTCORELIB): $(TESTCOREOBJ) $(TESTCORERES) \ + $(SUPPORTDLL) $(KERNELDLL) $(SEARCHDLL) $(INTDLL) + $(CXX) $(DLLFLAGS) $(TESTCOREOBJ) $(TESTCORERES) \ + @DLLPATH@ @LINKOUTPUT@$(TESTCOREDLL) $(GLDFLAGS) + $(FIXMANIFEST) $(TESTCOREDLL).manifest + $(MANIFEST) -manifest $(TESTCOREDLL).manifest \ + -outputresource:$(TESTCOREDLL)\;2 +$(TESTINTDLL) $(TESTINTLIB): $(TESTINTSEAMOBJ) $(TESTINTRES) $(TESTCOREDLL) + $(CXX) $(DLLFLAGS) $(TESTINTSEAMOBJ) $(TESTINTRES) \ + @DLLPATH@ @LINKOUTPUT@$(TESTINTDLL) $(GLDFLAGS) + $(FIXMANIFEST) $(TESTINTDLL).manifest + $(MANIFEST) -manifest $(TESTINTDLL).manifest \ + -outputresource:$(TESTINTDLL)\;2 endif # @@ -1825,6 +1955,12 @@ $(GISTSTATICLIB): $(GISTOBJ) $(FLATZINCSTATICLIB): $(FLATZINCOBJ) $(AR) $(ARFLAGS) $@ $(FLATZINCOBJ) $(RANLIB) $@ +$(TESTCORESTATICLIB): $(TESTCOREOBJ) + $(AR) $(ARFLAGS) $@ $(TESTCOREOBJ) + $(RANLIB) $@ +$(TESTINTSTATICLIB): $(TESTINTSEAMOBJ) + $(AR) $(ARFLAGS) $@ $(TESTINTSEAMOBJ) + $(RANLIB) $@ # # Mac OS X Framework @@ -1881,18 +2017,41 @@ endif ifeq "@enable_resource@" "yes" TESTRES = $(TESTEXE).res +TESTPUBLICRUNNERSMOKERES = $(TESTPUBLICRUNNERSMOKEEXE).res +TESTPUBLICINTSMOKERES = $(TESTPUBLICINTSMOKEEXE).res $(TESTEXE).rc: - $(RCGEN) $(TESTEXE) $(TESTSRC) $(TESTHDR) > $@ + $(RCGEN) $(TESTEXE) $(TESTSRC) $(TESTHDR) $(TESTMAINSRC) > $@ +$(TESTPUBLICRUNNERSMOKEEXE).rc: + $(RCGEN) $(TESTPUBLICRUNNERSMOKEEXE) $(TESTPUBLICRUNNERSMOKESRC) test/test.hh test/test.hpp > $@ +$(TESTPUBLICINTSMOKEEXE).rc: + $(RCGEN) $(TESTPUBLICINTSMOKEEXE) $(TESTPUBLICINTSMOKESRC) test/test.hh test/test.hpp test/int.hh test/int.hpp > $@ +$(TESTEXE).rc $(TESTPUBLICRUNNERSMOKEEXE).rc $(TESTPUBLICINTSMOKEEXE).rc: | mkcompiledirs else TESTRES = +TESTPUBLICRUNNERSMOKERES = +TESTPUBLICINTSMOKERES = endif -$(TESTEXE): $(TESTOBJ) $(TESTRES) $(ALLLIB) - $(CXX) @EXEOUTPUT@$@ $(TESTOBJ) $(TESTRES) $(DLLPATH) $(CXXFLAGS) \ +$(TESTEXE): $(TESTMAINOBJ) $(TESTOBJ) $(TESTRES) $(ALLLIB) + $(CXX) @EXEOUTPUT@$@ $(TESTMAINOBJ) $(TESTOBJ) $(TESTRES) $(DLLPATH) $(CXXFLAGS) \ $(LINKALL) $(GLDFLAGS) $(LINKQT) $(LINKCPPROFILER) $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 -$(BLACKBOXEXECOBJ) $(BLACKBOXDLLOBJ): | mkcompiledirs +$(TESTOBJ) $(TESTAUXOBJ) $(BLACKBOXEXECOBJ) $(BLACKBOXDLLOBJ): | mkcompiledirs + +$(TESTPUBLICRUNNERSMOKEEXE): $(TESTPUBLICRUNNERSMOKEOBJ) $(TESTCOREOBJ) $(TESTPUBLICRUNNERSMOKERES) $(ALLLIB) + $(CXX) @EXEOUTPUT@$@ $(TESTPUBLICRUNNERSMOKEOBJ) $(TESTCOREOBJ) $(TESTPUBLICRUNNERSMOKERES) \ + $(DLLPATH) $(CXXFLAGS) \ + $(LINKSEARCH) $(LINKINT) $(LINKKERNEL) $(LINKSUPPORT) $(GLDFLAGS) + $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) + $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 + +$(TESTPUBLICINTSMOKEEXE): $(TESTPUBLICINTSMOKEOBJ) $(TESTINTSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICINTSMOKERES) $(ALLLIB) + $(CXX) @EXEOUTPUT@$@ $(TESTPUBLICINTSMOKEOBJ) $(TESTINTSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICINTSMOKERES) \ + $(DLLPATH) $(CXXFLAGS) \ + $(LINKSEARCH) $(LINKINT) $(LINKKERNEL) $(LINKSUPPORT) $(GLDFLAGS) + $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) + $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 $(BLACKBOXEXEC): $(BLACKBOXEXECOBJ) $(CXX) @EXEOUTPUT@$@ $(BLACKBOXEXECOBJ) $(CXXFLAGS) @@ -2043,7 +2202,7 @@ endif endif -doinstallheaders: $(INSTALLHDR:%=$(top_srcdir)/%) $(EXTRA_HEADERS) $(VARIMPHDR) $(FLATZINCCONFIG) +doinstallheaders: $(INSTALLHDR:%=$(top_srcdir)/%) $(EXTRA_HEADERS) $(VARIMPHDR) $(FLATZINCCONFIG) $(TESTPUBLICINSTALLHDR:%=$(top_srcdir)/%) mkdir -p $(DESTDIR)$(includedir) && \ (cd $(top_srcdir) && tar cf - $(INSTALLHDR)) | \ (cd $(DESTDIR)$(includedir) && tar xf -) && \ @@ -2053,6 +2212,12 @@ doinstallheaders: $(INSTALLHDR:%=$(top_srcdir)/%) $(EXTRA_HEADERS) $(VARIMPHDR) for_extraheaders="$(EXTRA_HEADERS)" && \ for f in $$for_extraheaders; do \ cp $$f $(DESTDIR)$(includedir)/$$f; done && \ + for_publictestheaders="$(TESTPUBLICINSTALLHDR)" && \ + if test -n "$$for_publictestheaders"; then \ + mkdir -p $(DESTDIR)$(includedir)/test; \ + fi && \ + for f in $$for_publictestheaders; do \ + cp $(top_srcdir)/$$f $(DESTDIR)$(includedir)/test; done && \ for_flatconf="$(FLATZINCCONFIG)" && \ if test -n "$$for_flatconf"; then \ mkdir -p $(DESTDIR)$(datadir)/minizinc/solvers && \ @@ -2160,7 +2325,8 @@ clean: $(RMF) doxygen.log doxygen.hh doxygen.conf.use stat.hh \ changelog.hh doxygen.hh license.hh header.html $(RMF) $(ALLOBJ) $(ALLSBJ) $(ALLOBJ:%$(OBJSUFFIX)=%.pdb) - $(RMF) $(TESTOBJ) $(TESTSBJ) $(TESTOBJ:%$(OBJSUFFIX)=%.pdb) + $(RMF) $(TESTOBJ) $(TESTAUXOBJ) $(TESTSBJ) \ + $(TESTOBJ:%$(OBJSUFFIX)=%.pdb) $(TESTAUXOBJ:%$(OBJSUFFIX)=%.pdb) $(RMF) $(BLACKBOXEXECOBJ) $(BLACKBOXDLLOBJ) \ $(BLACKBOXEXECOBJ:%$(OBJSUFFIX)=%.pdb) \ $(BLACKBOXDLLOBJ:%$(OBJSUFFIX)=%.pdb) @@ -2177,6 +2343,10 @@ clean: $(EXAMPLEEXE:%=%.rc) $(EXAMPLEEXE:%=%.res) $(RMF) $(TESTEXE:%.exe=%.pdb) $(TESTEXE:%=%.manifest) \ $(TESTEXE:%=%.rc) $(TESTEXE:%=%.res) + $(RMF) $(TESTPUBLICRUNNERSMOKEEXE:%.exe=%.pdb) $(TESTPUBLICRUNNERSMOKEEXE:%=%.manifest) \ + $(TESTPUBLICRUNNERSMOKEEXE:%=%.rc) $(TESTPUBLICRUNNERSMOKEEXE:%=%.res) + $(RMF) $(TESTPUBLICINTSMOKEEXE:%.exe=%.pdb) $(TESTPUBLICINTSMOKEEXE:%=%.manifest) \ + $(TESTPUBLICINTSMOKEEXE:%=%.rc) $(TESTPUBLICINTSMOKEEXE:%=%.res) $(RMF) $(BLACKBOXEXEC:%.exe=%.pdb) $(BLACKBOXEXEC:%=%.manifest) \ $(BLACKBOXDLL:%$(DLLSUFFIX)=%$(LIBSUFFIX)) \ $(BLACKBOXDLL:%$(DLLSUFFIX)=%$(PDBSUFFIX)) \ @@ -2195,11 +2365,13 @@ veryclean: clean $(LIBTARGETS:%$(DLLSUFFIX)=%$(SOSUFFIX)) $(RMF) $(EXAMPLEEXE) $(RMF) $(TESTEXE) + $(RMF) $(TESTPUBLICRUNNERSMOKEEXE) + $(RMF) $(TESTPUBLICINTSMOKEEXE) $(RMF) $(BLACKBOXFIXTURES) $(RMF) $(FLATZINCEXE) $(RMF) doc ChangeLog - $(RMF) $(ALLOBJ:%$(OBJSUFFIX)=%.gcno) $(TESTOBJ:%$(OBJSUFFIX)=%.gcno) - $(RMF) $(ALLOBJ:%$(OBJSUFFIX)=%.gcda) $(TESTOBJ:%$(OBJSUFFIX)=%.gcda) + $(RMF) $(ALLOBJ:%$(OBJSUFFIX)=%.gcno) $(TESTOBJ:%$(OBJSUFFIX)=%.gcno) $(TESTAUXOBJ:%$(OBJSUFFIX)=%.gcno) + $(RMF) $(ALLOBJ:%$(OBJSUFFIX)=%.gcda) $(TESTOBJ:%$(OBJSUFFIX)=%.gcda) $(TESTAUXOBJ:%$(OBJSUFFIX)=%.gcda) distclean: veryclean $(RMF) $(VARIMP) diff --git a/docs/public-test-harness.md b/docs/public-test-harness.md index a85a70e078..7c5991d240 100644 --- a/docs/public-test-harness.md +++ b/docs/public-test-harness.md @@ -39,6 +39,7 @@ Supported: - The core process-global test registry and runner from `test/test.hh` - Integer-test helpers based on `Test::Int::Test` - Installed CMake consumption through `find_package(Gecode CONFIG REQUIRED COMPONENTS test)` +- Installed native legacy consumption through the maintained `-I`, `-L`, and `-l...` contract below - A downstream executable that registers tests and forwards `main(...)` into `Test::run_registered_tests(argc, argv)` @@ -46,13 +47,19 @@ Not yet part of the supported installed surface: - Public installation of the wider helper families under `test/` such as `set`, `float`, `assign`, `branch`, or `flatzinc` -- Non-CMake downstream consumption paths +- Smoke executables such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test` + as install artifacts - A separate replacement framework or a redesigned runner model -## Prerequisite: install Gecode with the test component enabled +## Prerequisite: install Gecode with the public harness enabled -The `test` component is only exported when the installed package was built with -`BUILD_TESTING=ON` and the required harness dependencies were available. +The public harness is only installable when the required search and integer modules are +available. + +### CMake install path + +The installed CMake package exports the `test` component when the build was configured +with `BUILD_TESTING=ON` and the required harness dependencies were available. Typical flow: @@ -65,6 +72,92 @@ cmake --install build --prefix /path/to/install For full build/install details, platform notes, and package-location hints, see [`docs/cmake-build.md`](./cmake-build.md). +### Legacy Autoconf/Make install path + +The legacy install path builds and installs the same narrow public harness boundary when +search and integer support remain enabled. + +Typical flow: + +```bash +mkdir -p build/legacy && cd build/legacy +../../configure --disable-qt --disable-gist --disable-doc-search --disable-examples +make -j4 +make install prefix="$PWD/prefix" +``` + +## Native legacy install contract + +This section is the maintained native contract for downstream consumers using the +Autoconf/Make install path. + +Installed public headers: + +- `/include/test/test.hh` +- `/include/test/test.hpp` +- `/include/test/int.hh` +- `/include/test/int.hpp` + +Installed reusable harness libraries: + +- `gecodetest` — static core runner/registry seam +- `gecodetestint` — static integer-helper seam layered over `gecodetest` + +Use the installed include directory and the normal installed library directory from the +legacy build (`/include` and typically `/lib`; if you configured a +custom `libdir`/`sharedlibdir`, use that installed path instead). + +Supported native compile/link shape: + +```bash +c++ -std=c++17 -I/include consumer-smoke.cpp \ + -L/lib \ + -lgecodetestint -lgecodetest -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport +``` + +That full link closure is the honest maintained contract for native legacy consumers. +Some shared-library linkers may accept a shorter command line, but downstream proofs and +support assume the explicit closure above rather than implicit transitive behavior. + +What the native legacy contract still does **not** install or support: + +- `test/set.hh`, `test/set.hpp`, `test/float.hh`, `test/float.hpp`, `test/assign.hh`, + `test/branch.hh`, or `test/flatzinc.hh` +- proof-only binaries such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test` +- a second discovery layer such as pkg-config + +### Maintained installed-proof entrypoint + +The canonical installed-proof entrypoint for the native legacy path is: + +```bash +python test/package/verify-installed-legacy-test-component.py \ + --source . \ + --build-root build/legacy-test-component-proof \ + --prefix build/legacy-test-component-proof/prefix +``` + +This is the maintained anti-drift path used by the docs and the Ubuntu Autoconf CI +job. It first re-checks the installed prefix surface, then builds the downstream +consumer in a temp workspace outside the source tree, and finally launches +separate `-list` and filtered-run executions against the installed prefix. + +The verifier owns runtime library-path setup for launched processes. It injects +the resolved installed library directory into `LD_LIBRARY_PATH` and +`DYLD_LIBRARY_PATH`, so the proof stays bound to the selected install prefix +instead of ambient system state. + +There is also a supported negative mode that proves unsupported helper headers +remain outside the installed legacy surface: + +```bash +python test/package/verify-installed-legacy-test-component.py \ + --source . \ + --build-root build/legacy-test-component-proof \ + --prefix build/legacy-test-component-proof/prefix \ + --mode unsupported-header +``` + ## Minimal downstream CMake consumer ```cmake @@ -146,7 +239,7 @@ targets are present: If a consumer requests an unsupported component, the package emits an explicit configure-time diagnostic rather than failing later at link time. -## Supported proof path +## Installed CMake proof path The canonical downstream verifier is: diff --git a/test/package/verify-installed-legacy-test-component.py b/test/package/verify-installed-legacy-test-component.py new file mode 100644 index 0000000000..b3a60fb23b --- /dev/null +++ b/test/package/verify-installed-legacy-test-component.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +VERIFIER_PREFIX = "[verify-installed-legacy-test-component]" +EXPECTED_TEST_NAME = "Package::ConsumerSmoke" +LIBRARIES = [ + "gecodetestint", + "gecodetest", + "gecodesearch", + "gecodeint", + "gecodekernel", + "gecodesupport", +] + + +def format_command(command: list[str]) -> str: + return shlex.join(command) + + + +def write_stream(stream, text: str) -> None: + if text: + stream.write(text) + if not text.endswith("\n"): + stream.write("\n") + + + +def fail_phase( + phase: str, + message: str, + *, + command: list[str] | None = None, + cwd: Path | None = None, + result: subprocess.CompletedProcess[str] | None = None, + env_summary: dict[str, str] | None = None, +) -> "NoReturn": + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") + if command is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: command: {format_command(command)}\n") + if cwd is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: cwd: {cwd}\n") + if env_summary is not None: + sys.stderr.write( + f"{VERIFIER_PREFIX} {phase}: runtime-env: {json.dumps(env_summary, sort_keys=True)}\n" + ) + if result is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: exit: {result.returncode}\n") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stdout:\n{result.stdout}") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stderr:\n{result.stderr}") + raise SystemExit(1) + + + +def assert_phase(condition: bool, phase: str, message: str) -> None: + if not condition: + fail_phase(phase, message) + + + +def run_phase( + phase: str, + command: list[str], + *, + cwd: Path, + env: dict[str, str] | None = None, + expect_success: bool = True, + env_summary: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + capture_output=True, + ) + if expect_success and result.returncode != 0: + fail_phase( + phase, + "command failed", + command=command, + cwd=cwd, + result=result, + env_summary=env_summary, + ) + if not expect_success and result.returncode == 0: + fail_phase( + phase, + "command unexpectedly succeeded", + command=command, + cwd=cwd, + result=result, + env_summary=env_summary, + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + return result + + + +def resolve_library_dir(prefix: Path) -> Path: + phase = "inputs" + required = ["libgecodetest.a", "libgecodetestint.a"] + candidates: dict[str, list[Path]] = {} + for filename in required: + matches = sorted(path.resolve() for path in prefix.rglob(filename) if path.is_file()) + assert_phase(matches, phase, f"missing installed library: {prefix / filename}") + candidates[filename] = matches + + lib_dirs = {path.parent for matches in candidates.values() for path in matches} + assert_phase(len(lib_dirs) == 1, phase, f"expected one installed library directory, found: {sorted(str(path) for path in lib_dirs)}") + return next(iter(lib_dirs)) + + + +def build_runtime_env(prefix_lib: Path) -> tuple[dict[str, str], dict[str, str]]: + env = os.environ.copy() + prefix_lib_str = str(prefix_lib) + env["DYLD_LIBRARY_PATH"] = prefix_lib_str + env["LD_LIBRARY_PATH"] = prefix_lib_str + env_summary = { + "DYLD_LIBRARY_PATH": env["DYLD_LIBRARY_PATH"], + "LD_LIBRARY_PATH": env["LD_LIBRARY_PATH"], + } + return env, env_summary + + + +def create_workspace(build_root: Path, source: Path, *, mode: str) -> tuple[tempfile.TemporaryDirectory[str], Path, Path]: + artifact_dir = build_root / "installed-legacy-verifier" + artifact_dir.mkdir(parents=True, exist_ok=True) + temp_dir = tempfile.TemporaryDirectory(prefix=f"{build_root.name}-{mode}-") + workspace = Path(temp_dir.name).resolve() + assert_phase(not workspace.is_relative_to(source), "workspace", f"workspace must stay outside the source tree: {workspace}") + (artifact_dir / "last-workspace.txt").write_text(f"{workspace}\n") + (artifact_dir / "last-mode.txt").write_text(f"{mode}\n") + sys.stdout.write(f"{VERIFIER_PREFIX} workspace: path={workspace}\n") + return temp_dir, workspace, artifact_dir + + + +def resolve_consumer_binary(binary: Path) -> Path: + if binary.exists(): + return binary + exe_binary = binary.with_suffix(binary.suffix + ".exe") + if exe_binary.exists(): + return exe_binary + fail_phase("build", f"missing built consumer binary: {binary}") + + + +def assert_compiler_command_surface( + command: list[str], + *, + source: Path, + workspace: Path, + prefix: Path, + prefix_include: Path, + expected_library_dirs: list[Path], +) -> None: + phase = "include-leakage" + workspace = workspace.resolve() + prefix = prefix.resolve() + prefix_include = prefix_include.resolve() + source = source.resolve() + expected_library_dirs = [path.resolve() for path in expected_library_dirs] + + include_dirs: list[Path] = [] + library_dirs: list[Path] = [] + workspace_sources: list[Path] = [] + output_paths: list[Path] = [] + i = 0 + while i < len(command): + argument = command[i] + include_path: str | None = None + library_path: str | None = None + output_path: str | None = None + if argument == "-I": + i += 1 + include_path = command[i] + elif argument.startswith("-I") and argument != "-I": + include_path = argument[2:] + elif argument == "-L": + i += 1 + library_path = command[i] + elif argument.startswith("-L") and argument != "-L": + library_path = argument[2:] + elif argument == "-o": + i += 1 + output_path = command[i] + elif argument.endswith((".cpp", ".cc", ".cxx", ".c")): + candidate = Path(argument) + candidate = (workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve() + workspace_sources.append(candidate) + i += 1 + + if include_path is not None: + candidate = Path(include_path) + include_dirs.append((workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()) + if library_path is not None: + candidate = Path(library_path) + library_dirs.append((workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()) + if output_path is not None: + candidate = Path(output_path) + output_paths.append((workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()) + + assert_phase(include_dirs == [prefix_include], phase, f"unexpected include directories in compiler command: {[str(path) for path in include_dirs]}") + assert_phase(library_dirs == expected_library_dirs, phase, f"unexpected library directories in compiler command: {[str(path) for path in library_dirs]}") + assert_phase(workspace_sources, phase, "compiler command did not reference any workspace source files") + assert_phase(output_paths, phase, "compiler command did not set an output path") + assert_phase( + all(path.is_relative_to(workspace) for path in [*workspace_sources, *output_paths]), + phase, + f"compiler command referenced non-workspace source/output paths: {[str(path) for path in [*workspace_sources, *output_paths]]}", + ) + + candidate_paths = [*include_dirs, *library_dirs, *workspace_sources, *output_paths] + disallowed_source_paths = [ + path + for path in candidate_paths + if path.is_relative_to(source) and not path.is_relative_to(prefix) and not path.is_relative_to(workspace) + ] + assert_phase( + not disallowed_source_paths, + phase, + f"compiler command referenced disallowed source-tree paths: {[str(path) for path in disallowed_source_paths]}", + ) + sys.stdout.write( + f"{VERIFIER_PREFIX} {phase}: command={format_command(command)} include={prefix_include} lib-dirs={[str(path) for path in expected_library_dirs]}\n" + ) + + + +def run_prefix_surface(source: Path, prefix: Path) -> None: + command = [ + sys.executable, + str((source / "test/verify-legacy-install-surface.py").resolve()), + "--source", + str(source), + "--prefix", + str(prefix), + ] + result = run_phase("prefix-surface", command, cwd=source) + write_stream(sys.stdout, result.stdout) + write_stream(sys.stderr, result.stderr) + + + +def write_positive_consumer(source: Path, workspace: Path) -> Path: + source_fixture = (source / "test/package/public-test-component/consumer-smoke.cpp").resolve() + destination = workspace / "consumer-smoke.cpp" + shutil.copy2(source_fixture, destination) + return destination + + + +def compiler_command_prefix() -> list[str]: + cxx = os.environ.get("CXX", "c++") + return shlex.split(cxx) + + + +def build_positive_consumer( + source: Path, + workspace: Path, + prefix: Path, + prefix_lib: Path, + artifact_dir: Path, +) -> Path: + consumer_source = write_positive_consumer(source, workspace) + consumer_binary = workspace / "consumer-smoke" + command = [ + *compiler_command_prefix(), + "-std=c++17", + f"-I{prefix / 'include'}", + str(consumer_source), + f"-L{prefix_lib}", + *[f"-l{name}" for name in LIBRARIES], + "-o", + str(consumer_binary), + ] + assert_compiler_command_surface( + command, + source=source, + workspace=workspace, + prefix=prefix, + prefix_include=prefix / "include", + expected_library_dirs=[prefix_lib], + ) + (artifact_dir / "last-compile-command.txt").write_text(f"{format_command(command)}\n") + result = run_phase("build", command, cwd=workspace) + write_stream(sys.stdout, result.stdout) + write_stream(sys.stderr, result.stderr) + binary = resolve_consumer_binary(consumer_binary) + sys.stdout.write(f"{VERIFIER_PREFIX} build: binary={binary}\n") + return binary + + + +def run_list_phase(consumer_binary: Path, workspace: Path, runtime_env: dict[str, str], env_summary: dict[str, str]) -> None: + result = run_phase( + "list", + [str(consumer_binary), "-list"], + cwd=workspace, + env=runtime_env, + env_summary=env_summary, + ) + assert_phase(EXPECTED_TEST_NAME in result.stdout, "list", f"-list output missing {EXPECTED_TEST_NAME!r}") + sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={EXPECTED_TEST_NAME}\n") + + + +def run_filtered_phase( + consumer_binary: Path, + workspace: Path, + runtime_env: dict[str, str], + env_summary: dict[str, str], +) -> None: + result = run_phase( + "filtered-run", + [str(consumer_binary), "-test", EXPECTED_TEST_NAME, "-iter", "1", "-stop", "true"], + cwd=workspace, + env=runtime_env, + env_summary=env_summary, + ) + assert_phase(EXPECTED_TEST_NAME in result.stdout, "filtered-run", "filtered run did not print the selected downstream test") + assert_phase("+" in result.stdout, "filtered-run", "filtered run did not report success") + sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={EXPECTED_TEST_NAME}\n") + + + +def write_unsupported_consumer(workspace: Path) -> Path: + source = workspace / "unsupported-header.cpp" + source.write_text( + "#include \n\n" + "int main() {\n" + " return 0;\n" + "}\n" + ) + return source + + + +def assert_missing_header_diagnostic(result: subprocess.CompletedProcess[str]) -> None: + phase = "unsupported-header" + combined_output = f"{result.stdout}\n{result.stderr}" + assert_phase("test/set.hh" in combined_output, phase, "missing expected unsupported-header diagnostic target") + assert_phase( + "file not found" in combined_output or "No such file or directory" in combined_output, + phase, + "missing missing-header diagnostic wording", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: diagnostic=test/set.hh missing\n") + + + +def run_unsupported_header_mode( + source: Path, + workspace: Path, + prefix: Path, + artifact_dir: Path, +) -> None: + unsupported_source = write_unsupported_consumer(workspace) + unsupported_object = workspace / "unsupported-header.o" + command = [ + *compiler_command_prefix(), + "-std=c++17", + f"-I{prefix / 'include'}", + str(unsupported_source), + "-c", + "-o", + str(unsupported_object), + ] + assert_compiler_command_surface( + command, + source=source, + workspace=workspace, + prefix=prefix, + prefix_include=prefix / "include", + expected_library_dirs=[], + ) + (artifact_dir / "last-runtime-env.json").write_text("{}\n") + (artifact_dir / "last-compile-command.txt").write_text(f"{format_command(command)}\n") + result = run_phase("unsupported-header", command, cwd=workspace, expect_success=False) + assert_missing_header_diagnostic(result) + + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--source", required=True) + parser.add_argument("--build-root", required=True) + parser.add_argument("--prefix", required=True) + parser.add_argument("--mode", choices=["positive", "unsupported-header"], default="positive") + return parser.parse_args() + + + +def main() -> int: + args = parse_args() + source = Path(args.source).resolve() + build_root = Path(args.build_root).resolve() + prefix = Path(args.prefix).resolve() + + assert_phase(source.is_dir(), "inputs", f"missing source tree: {source}") + assert_phase(prefix.is_dir(), "inputs", f"missing installed prefix: {prefix}") + build_root.mkdir(parents=True, exist_ok=True) + + prefix_lib = resolve_library_dir(prefix) + run_prefix_surface(source, prefix) + + temp_dir, workspace, artifact_dir = create_workspace(build_root, source, mode=args.mode) + with temp_dir: + if args.mode == "unsupported-header": + run_unsupported_header_mode(source, workspace, prefix, artifact_dir) + return 0 + + consumer_binary = build_positive_consumer(source, workspace, prefix, prefix_lib, artifact_dir) + runtime_env, env_summary = build_runtime_env(prefix_lib) + (artifact_dir / "last-runtime-env.json").write_text(json.dumps(env_summary, indent=2, sort_keys=True) + "\n") + sys.stdout.write( + f"{VERIFIER_PREFIX} runtime-env: {json.dumps(env_summary, sort_keys=True)}\n" + ) + run_list_phase(consumer_binary, workspace, runtime_env, env_summary) + run_filtered_phase(consumer_binary, workspace, runtime_env, env_summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/verify-legacy-check-selection.py b/test/verify-legacy-check-selection.py new file mode 100644 index 0000000000..cb907720bf --- /dev/null +++ b/test/verify-legacy-check-selection.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass + +VERIFIER_PREFIX = "[verify-legacy-check-selection]" +TEST_TOKEN_RE = re.compile(r"(?:^|\s)-test\s+([^\s\\]+)") + + +@dataclass(frozen=True) +class ModeExpectation: + required: tuple[str, ...] + forbidden_prefixes: tuple[str, ...] + + +MODE_EXPECTATIONS = { + "no-set-float-flatzinc": ModeExpectation( + required=( + "Branch::Int::Dense::3", + "Int::Arithmetic::Abs", + "Int::MiniModel::LinExpr::Bool::352", + "NoGoods::Queens", + "Search::DFS::Sol::Binary::Nary::Binary::1::1::1", + ), + forbidden_prefixes=("Set::", "Float::", "FlatZinc::"), + ), +} + + +def fail(message: str) -> "NoReturn": + sys.stderr.write(f"{VERIFIER_PREFIX} selection: FAIL - {message}\n") + raise SystemExit(1) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=sorted(MODE_EXPECTATIONS), required=True) + return parser.parse_args() + + +def extract_selected_tests(make_output: str) -> list[str]: + return TEST_TOKEN_RE.findall(make_output.replace("\\\n", " ")) + + +def main() -> int: + args = parse_args() + make_output = sys.stdin.read() + selected_tests = extract_selected_tests(make_output) + if not selected_tests: + fail("did not find any '-test ' selections in make output") + + expectation = MODE_EXPECTATIONS[args.mode] + missing_required = [name for name in expectation.required if name not in selected_tests] + if missing_required: + fail(f"missing required selections: {', '.join(missing_required)}") + + forbidden = [ + name + for name in selected_tests + if any(name.startswith(prefix) for prefix in expectation.forbidden_prefixes) + ] + if forbidden: + fail(f"found forbidden selections: {', '.join(forbidden)}") + + sys.stdout.write( + f"{VERIFIER_PREFIX} selection: ok - mode={args.mode} selected={len(selected_tests)}\n" + ) + for name in selected_tests: + sys.stdout.write(f"{VERIFIER_PREFIX} selection: {name}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/verify-legacy-install-surface.py b/test/verify-legacy-install-surface.py new file mode 100644 index 0000000000..18de7a7ef1 --- /dev/null +++ b/test/verify-legacy-install-surface.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +VERIFIER_PREFIX = "[verify-legacy-install-surface]" +EXPECTED_HEADERS = ["int.hh", "int.hpp", "test.hh", "test.hpp"] +UNSUPPORTED_HEADERS = [ + "assign.hh", + "branch.hh", + "float.hh", + "float.hpp", + "flatzinc.hh", + "set.hh", + "set.hpp", +] +UNSUPPORTED_BINARIES = [ + "gecode-test", + "public-int-smoke", + "public-runner-smoke", +] +EXPECTED_STATIC_LIBS = { + "gecodetest": "libgecodetest.a", + "gecodetestint": "libgecodetestint.a", +} +REQUIRED_DOC_STRINGS = [ + "## Native legacy install contract", + "`gecodetest` — static core runner/registry seam", + "`gecodetestint` — static integer-helper seam layered over `gecodetest`", + "-I/include consumer-smoke.cpp \\", + "-L/lib \\", + "-lgecodetestint -lgecodetest -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport", + "That full link closure is the honest maintained contract for native legacy consumers.", + "proof-only binaries such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test`", +] + + +def fail_phase(phase: str, message: str) -> "NoReturn": + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") + raise SystemExit(1) + + + +def assert_phase(condition: bool, phase: str, message: str) -> None: + if not condition: + fail_phase(phase, message) + + + +def is_installed_library_path(prefix: Path, path: Path) -> bool: + relative_path = path.relative_to(prefix) + return any(part.startswith("lib") for part in relative_path.parts[:-1]) and "cmake" not in relative_path.parts + + + +def verify_prefix_surface(prefix: Path) -> None: + phase = "prefix-surface" + include_test_dir = prefix / "include" / "test" + assert_phase(include_test_dir.is_dir(), phase, f"missing installed test include dir: {include_test_dir}") + + installed_headers = sorted(path.name for path in include_test_dir.iterdir() if path.is_file()) + missing_headers = [name for name in EXPECTED_HEADERS if name not in installed_headers] + unexpected_headers = [name for name in installed_headers if name not in EXPECTED_HEADERS] + assert_phase( + not missing_headers and not unexpected_headers, + phase, + f"missing expected headers: {missing_headers}; unexpected installed headers: {unexpected_headers}", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: headers={installed_headers}\n") + + + +def verify_harness_libs(prefix: Path) -> None: + phase = "harness-libs" + expected_locations: list[str] = [] + + for library_name, expected_filename in EXPECTED_STATIC_LIBS.items(): + matches = sorted( + path.relative_to(prefix).as_posix() + for path in prefix.rglob(expected_filename) + if path.is_file() and is_installed_library_path(prefix, path) + ) + assert_phase(matches, phase, f"missing installed {library_name} static library: {expected_filename}") + assert_phase( + len(matches) == 1, + phase, + f"expected exactly one installed {library_name} static library, found: {matches}", + ) + expected_locations.extend(matches) + + unexpected_harness_artifacts = sorted( + path.relative_to(prefix).as_posix() + for path in prefix.rglob("*") + if path.is_file() + and is_installed_library_path(prefix, path) + and path.name.startswith(("libgecodetest", "libgecodetestint")) + and path.name not in EXPECTED_STATIC_LIBS.values() + ) + assert_phase( + not unexpected_harness_artifacts, + phase, + f"unexpected installed harness artifacts: {unexpected_harness_artifacts}", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: artifacts={sorted(expected_locations)}\n") + + + +def verify_unsupported_surface(prefix: Path) -> None: + phase = "unsupported-surface" + include_test_dir = prefix / "include" / "test" + unexpected_headers = [name for name in UNSUPPORTED_HEADERS if (include_test_dir / name).exists()] + assert_phase(not unexpected_headers, phase, f"unexpected public helper headers installed: {unexpected_headers}") + + unexpected_binaries = sorted( + path.relative_to(prefix).as_posix() + for path in prefix.rglob("*") + if path.is_file() and (path.name in UNSUPPORTED_BINARIES or path.stem in UNSUPPORTED_BINARIES) + ) + assert_phase(not unexpected_binaries, phase, f"unexpected proof-only binaries installed: {unexpected_binaries}") + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: headers-ok binaries-ok\n") + + + +def verify_docs_contract(source: Path) -> None: + phase = "docs-contract" + docs_path = source / "docs" / "public-test-harness.md" + assert_phase(docs_path.is_file(), phase, f"missing docs file: {docs_path}") + + docs_text = docs_path.read_text() + missing = [needle for needle in REQUIRED_DOC_STRINGS if needle not in docs_text] + assert_phase(not missing, phase, f"missing required legacy contract text: {missing}") + assert_phase( + docs_text.index("## Native legacy install contract") < docs_text.index("## Minimal downstream CMake consumer"), + phase, + "legacy install contract section moved after the downstream consumer guidance", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: anchor=Native legacy install contract\n") + + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--source", required=True) + parser.add_argument("--prefix", required=True) + return parser.parse_args() + + + +def main() -> int: + args = parse_args() + source = Path(args.source).resolve() + prefix = Path(args.prefix).resolve() + + assert_phase(source.is_dir(), "inputs", f"missing source tree: {source}") + assert_phase(prefix.is_dir(), "inputs", f"missing installed prefix: {prefix}") + + verify_prefix_surface(prefix) + verify_harness_libs(prefix) + verify_unsupported_surface(prefix) + verify_docs_contract(source) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/verify-legacy-selfhost.py b/test/verify-legacy-selfhost.py new file mode 100644 index 0000000000..b008f0e821 --- /dev/null +++ b/test/verify-legacy-selfhost.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +VERIFIER_PREFIX = "[verify-legacy-selfhost]" + + +def format_command(command: list[str]) -> str: + return shlex.join(command) + + +def write_stream(stream, text: str) -> None: + if text: + stream.write(text) + if not text.endswith("\n"): + stream.write("\n") + + +def fail_phase( + phase: str, + message: str, + *, + command: list[str] | None = None, + cwd: Path | None = None, + result: subprocess.CompletedProcess[str] | None = None, +) -> "NoReturn": + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") + if command is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: command: {format_command(command)}\n") + if cwd is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: cwd: {cwd}\n") + if result is not None: + sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: exit: {result.returncode}\n") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stdout:\n{result.stdout}") + write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stderr:\n{result.stderr}") + raise SystemExit(1) + + +def build_runtime_env(build_dir: Path) -> dict[str, str]: + env = os.environ.copy() + build_dir_str = str(build_dir) + for key in ("DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"): + existing = env.get(key) + env[key] = build_dir_str if not existing else os.pathsep.join([build_dir_str, existing]) + existing_path = env.get("PATH") + env["PATH"] = build_dir_str if not existing_path else os.pathsep.join([build_dir_str, existing_path]) + return env + + +def run_phase( + phase: str, + command: list[str], + *, + cwd: Path, + env: dict[str, str], +) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True) + if result.returncode != 0: + fail_phase(phase, "command failed", command=command, cwd=cwd, result=result) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + return result + + +def assert_phase(condition: bool, phase: str, message: str) -> None: + if not condition: + fail_phase(phase, message) + + +def resolve_binary(build_dir: Path, relative_path: str) -> Path: + binary = build_dir / relative_path + if binary.exists(): + return binary + exe_binary = binary.with_suffix(binary.suffix + ".exe") + if exe_binary.exists(): + return exe_binary + fail_phase("build-surface", f"missing built binary: {binary}") + + +def verify_runner_smoke(build_dir: Path, runtime_env: dict[str, str]) -> None: + phase = "runner-smoke" + binary = resolve_binary(build_dir, "test/public-runner-smoke") + run_phase(phase, [str(binary)], cwd=build_dir, env=runtime_env) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: binary={binary}\n") + + +def verify_int_smoke(build_dir: Path, runtime_env: dict[str, str]) -> None: + phase = "int-smoke" + binary = resolve_binary(build_dir, "test/public-int-smoke") + run_phase(phase, [str(binary)], cwd=build_dir, env=runtime_env) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: binary={binary}\n") + + +def verify_list_phase(build_dir: Path, expected_filter: str, runtime_env: dict[str, str]) -> Path: + phase = "list" + binary = resolve_binary(build_dir, "test/test") + result = run_phase(phase, [str(binary), "-list"], cwd=build_dir, env=runtime_env) + listed = [line.strip() for line in result.stdout.splitlines() if line.strip()] + assert_phase(bool(listed), phase, "-list produced no registered test names") + assert_phase( + any(expected_filter in name for name in listed), + phase, + f"missing expected filter in -list output: {expected_filter}", + ) + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: discovered-prefix={expected_filter}\n") + return binary + + +def verify_filtered_phase( + build_dir: Path, + test_binary: Path, + expected_filter: str, + runtime_env: dict[str, str], +) -> None: + phase = "filtered-run" + result = run_phase( + phase, + [str(test_binary), "-test", expected_filter, "-iter", "1", "-stop", "true"], + cwd=build_dir, + env=runtime_env, + ) + assert_phase(expected_filter in result.stdout, phase, "filtered run did not print the selected test") + assert_phase("+" in result.stdout, phase, "filtered run did not report success") + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: executed={expected_filter}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--build-dir", required=True) + parser.add_argument("--expected-filter", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + build_dir = Path(args.build_dir).resolve() + assert_phase(build_dir.exists(), "build-surface", f"missing build directory: {build_dir}") + runtime_env = build_runtime_env(build_dir) + sys.stdout.write(f"{VERIFIER_PREFIX} runtime-env: library-path={build_dir}\n") + + verify_runner_smoke(build_dir, runtime_env) + verify_int_smoke(build_dir, runtime_env) + test_binary = verify_list_phase(build_dir, args.expected_filter, runtime_env) + verify_filtered_phase(build_dir, test_binary, args.expected_filter, runtime_env) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c7c403757c311edea2ac549168e6491f6634efdc Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 13:42:08 +0200 Subject: [PATCH 4/6] Run and document public test-library checks --- .github/workflows/build.yml | 4 ++-- CMakeLists.txt | 11 +++++++++++ changelog.in | 9 +++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32b0980817..39e9749944 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -178,11 +178,11 @@ jobs: set -euxo pipefail cmake --build . --config $BUILD_TYPE --target check - - name: Build installed test component targets + - name: Run public test harness smokes if: matrix.os == 'ubuntu-latest' working-directory: ${{github.workspace}}/build shell: bash - run: cmake --build . --config $BUILD_TYPE --target gecodetest gecodetestint + run: ctest --build-config "$BUILD_TYPE" --output-on-failure -R '^public-(runner|int)-smoke$' - name: Install CMake package working-directory: ${{github.workspace}}/build diff --git a/CMakeLists.txt b/CMakeLists.txt index 414a75bc16..2d82f64a62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1404,6 +1404,17 @@ if(BUILD_TESTING) add_executable(public-int-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_INT_SMOKE_SOURCE}) target_link_libraries(public-int-smoke PRIVATE gecodetestint) add_test(NAME public-int-smoke COMMAND public-int-smoke) + + add_test(NAME build-public-test-smokes + COMMAND ${CMAKE_COMMAND} --build "${CMAKE_BINARY_DIR}" + --target public-runner-smoke public-int-smoke --config $ + ) + set_tests_properties(build-public-test-smokes PROPERTIES + FIXTURES_SETUP public-test-smokes-built + ) + set_tests_properties(public-runner-smoke public-int-smoke PROPERTIES + FIXTURES_REQUIRED public-test-smokes-built + ) else() message(WARNING "Skipping public test harness targets because required modules are disabled") endif() diff --git a/changelog.in b/changelog.in index 5356b24303..a27b454b3e 100755 --- a/changelog.in +++ b/changelog.in @@ -73,6 +73,15 @@ Date: unreleased [DESCRIPTION] This is the development changelog for the next Gecode release. +[ENTRY] +Module: test +What: new +Rank: major +[DESCRIPTION] +Install reusable core and integer test-harness libraries and headers for CMake +and Autoconf builds, allowing downstream propagator projects to register and +run Gecode-style tests. + [RELEASE] Version: 6.4.0 Date: 2026-07-15 From 0bdfbf60f5c912b041ca01a57a0459ed2b382f9c Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 14:39:26 +0200 Subject: [PATCH 5/6] Polish public test-library integration --- .github/workflows/build.yml | 14 +- CMakeLists.txt | 16 +- Makefile.in | 5 +- cmake/GecodeConfig.cmake.in | 4 +- docs/cmake-build.md | 19 +- docs/public-test-harness.md | 364 +++++++----------- .../public-test-component/consumer-smoke.cpp | 16 +- .../verify-installed-legacy-test-component.py | 4 +- .../verify-installed-test-component.py | 60 ++- test/test.hh | 7 +- test/verify-legacy-check-selection.py | 77 ---- test/verify-legacy-install-surface.py | 35 +- test/verify-legacy-selfhost.py | 154 -------- 13 files changed, 233 insertions(+), 542 deletions(-) delete mode 100644 test/verify-legacy-check-selection.py delete mode 100644 test/verify-legacy-selfhost.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39e9749944..2234ddeac0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: shell: bash run: | set -euxo pipefail - build_root="$RUNNER_TEMP/legacy-test-component-proof" + build_root="$RUNNER_TEMP/legacy-test-component-check" prefix="$build_root/prefix" rm -rf "$build_root" mkdir -p "$prefix" @@ -95,7 +95,7 @@ jobs: shell: bash run: | set -euxo pipefail - build_root="$RUNNER_TEMP/legacy-test-component-proof" + build_root="$RUNNER_TEMP/legacy-test-component-check" prefix="$build_root/prefix" python test/package/verify-installed-legacy-test-component.py \ @@ -212,13 +212,17 @@ jobs: set -euxo pipefail python test/package/verify-installed-test-component.py \ --source "$GITHUB_WORKSPACE" \ - --build-root "$RUNNER_TEMP/s03-package-proof" \ - --prefix "$GITHUB_WORKSPACE/install" + --build-root "$RUNNER_TEMP/test-component-check" \ + --prefix "$GITHUB_WORKSPACE/install" \ + --include-dir "/tmp/gecode-install-abs-include" \ + --lib-dir "$GITHUB_WORKSPACE/install/lib" python test/package/verify-installed-test-component.py \ --source "$GITHUB_WORKSPACE" \ - --build-root "$RUNNER_TEMP/s03-package-proof-missing-component" \ + --build-root "$RUNNER_TEMP/test-component-check-missing-component" \ --prefix "$GITHUB_WORKSPACE/install" \ + --include-dir "/tmp/gecode-install-abs-include" \ + --lib-dir "$GITHUB_WORKSPACE/install/lib" \ --expect-missing-component-failure - name: Package version checks diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d82f64a62..2cfa8516ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -814,7 +814,7 @@ ${CONFIG_OUT}") include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/GecodeSources.cmake) if(GECODE_ENABLE_FAULT_INJECTION) list(APPEND GECODE_SUPPORT_SOURCES gecode/support/failpoint.cpp) - set(GECODE_FAULT_TEST_SOURCES test/test.cpp test/fault.cpp) + set(GECODE_FAULT_TEST_SOURCES ${GECODE_TEST_MAIN_SOURCE} test/fault.cpp) endif() # --------------------------------------------------------------------------- @@ -1372,7 +1372,10 @@ if(BUILD_TESTING) if(GECODE_VISIBILITY_COMPILE_OPTION) target_compile_options(gecodetest PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) endif() + set_target_properties(gecodetest PROPERTIES + OUTPUT_NAME "${GECODE_LIB_PREFIX}gecodetest${GECODE_LIB_SUFFIX}") target_link_libraries(gecodetest PUBLIC gecodesearch gecodeint) + add_library(Gecode::gecodetest ALIAS gecodetest) list(APPEND GECODE_INSTALL_TARGETS gecodetest) list(APPEND GECODE_EXPORT_TARGETS gecodetest) @@ -1386,7 +1389,10 @@ if(BUILD_TESTING) if(GECODE_VISIBILITY_COMPILE_OPTION) target_compile_options(gecodetestint PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) endif() + set_target_properties(gecodetestint PROPERTIES + OUTPUT_NAME "${GECODE_LIB_PREFIX}gecodetestint${GECODE_LIB_SUFFIX}") target_link_libraries(gecodetestint PUBLIC gecodetest) + add_library(Gecode::gecodetestint ALIAS gecodetestint) list(APPEND GECODE_INSTALL_TARGETS gecodetestint) list(APPEND GECODE_EXPORT_TARGETS gecodetestint) if(GECODE_INSTALL) @@ -1411,6 +1417,7 @@ if(BUILD_TESTING) ) set_tests_properties(build-public-test-smokes PROPERTIES FIXTURES_SETUP public-test-smokes-built + RESOURCE_LOCK gecode-test-build ) set_tests_properties(public-runner-smoke public-int-smoke PROPERTIES FIXTURES_REQUIRED public-test-smokes-built @@ -1552,7 +1559,8 @@ if(BUILD_TESTING) COMMAND ${CMAKE_COMMAND} --build "${CMAKE_BINARY_DIR}" --target ${GECODE_TEST_BUILD_TARGETS} --config $) set_tests_properties(build-gecode-test PROPERTIES - FIXTURES_SETUP "${GECODE_TEST_BUILD_FIXTURES}") + FIXTURES_SETUP "${GECODE_TEST_BUILD_FIXTURES}" + RESOURCE_LOCK gecode-test-build) add_test(NAME test COMMAND gecode-test ${GECODE_CHECK_ARGS}) set_tests_properties(test PROPERTIES FIXTURES_REQUIRED gecode-test-built) @@ -1737,6 +1745,10 @@ if(GECODE_INSTALL) if(GECODE_ENABLE_FLOAT_VARS AND GECODE_ENABLE_MPFR AND MPFR_FOUND) set(GECODE_PACKAGE_NEEDS_MPFR ON) endif() + set(GECODE_PACKAGE_HAS_TEST_COMPONENT OFF) + if(TARGET gecodetest AND TARGET gecodetestint) + set(GECODE_PACKAGE_HAS_TEST_COMPONENT ON) + endif() set(GECODE_PACKAGE_QT_MAJOR "") set(GECODE_PACKAGE_QT_COMPONENTS "") set(GECODE_PACKAGE_QT_VERSION "") diff --git a/Makefile.in b/Makefile.in index 5645a0acaf..fdc39ad001 100755 --- a/Makefile.in +++ b/Makefile.in @@ -1438,7 +1438,8 @@ compilelib: mkcompiledirs compileexamples: $(EXAMPLEEXE) test: mkcompiledirs $(BLACKBOXFIXTURES) - @$(MAKE) $(VARIMP) $(TESTEXE) + @$(MAKE) $(VARIMP) $(TESTEXE) \ + $(TESTPUBLICRUNNERSMOKEEXE) $(TESTPUBLICINTSMOKEEXE) CHECKTESTS = Branch::Int::Dense::3 \ Int::Arithmetic::Abs \ @@ -1487,6 +1488,8 @@ endif # A basic integrity test check: test + $(RUNENVIRONMENT) $(TESTPUBLICRUNNERSMOKEEXE) + $(RUNENVIRONMENT) $(TESTPUBLICINTSMOKEEXE) @for t in $(REQUIREDFLOATCHECKTESTS); do \ case " $(CHECKTESTS) " in *" $$t "*) ;; \ *) echo "Required Float check test is missing: $$t" >&2; exit 1 ;; \ diff --git a/cmake/GecodeConfig.cmake.in b/cmake/GecodeConfig.cmake.in index c694f2e2db..3840671ecc 100644 --- a/cmake/GecodeConfig.cmake.in +++ b/cmake/GecodeConfig.cmake.in @@ -15,9 +15,7 @@ set(_gecode_supported_components gist test) set(_gecode_built_components "@GECODE_LIBRARY_COMPONENTS@") -if("@BUILD_TESTING@" STREQUAL "ON" AND - search IN_LIST _gecode_built_components AND - int IN_LIST _gecode_built_components) +if("@GECODE_PACKAGE_HAS_TEST_COMPONENT@" STREQUAL "ON") list(APPEND _gecode_built_components test) endif() diff --git a/docs/cmake-build.md b/docs/cmake-build.md index 977624effe..b5d5786bd9 100644 --- a/docs/cmake-build.md +++ b/docs/cmake-build.md @@ -179,21 +179,20 @@ add_executable(app main.cpp) target_link_libraries(app PRIVATE Gecode::gecodedriver) ``` -### Installed test component for downstream custom tests +### Test component for custom propagators -If the installed Gecode package was built with `BUILD_TESTING=ON`, it also exports a -`test` component for downstream consumers that want to register and run custom tests -through the public harness. - -Quick reference: +A Gecode installation configured with `BUILD_TESTING=ON` exports the `test` +component. It lets downstream projects check custom integer propagators with +Gecode's test runner. - Imported targets: `Gecode::gecodetest`, `Gecode::gecodetestint` - Installed public headers: `test/test.hh`, `test/test.hpp`, `test/int.hh`, `test/int.hpp` - Public runner entrypoint: `Test::run_registered_tests(argc, argv)` -- Canonical proof path: `python test/package/verify-installed-test-component.py ...` +- Package check: `python test/package/verify-installed-test-component.py ...` -For the full downstream contract, minimal consumer example, verifier workflow, -and support boundaries, see [`docs/public-test-harness.md`](./public-test-harness.md). +See [Testing custom propagators](./public-test-harness.md) for a complete test, +consistency and reification controls, failure reproduction, and direct linking +without CMake package metadata. Legacy component spellings are also accepted in `COMPONENTS`: @@ -250,7 +249,7 @@ Deprecation horizon: - `find_package(Gecode COMPONENTS ...)` fails: - Verify requested component is enabled in the installed build. - For optional modules (`flatzinc`, `gist`, `float` with MPFR), ensure dependencies were available. - - The `test` component is only exported when the installed package was built with `BUILD_TESTING=ON`. + - The `test` component requires `BUILD_TESTING` to be true in the installed package's configuration. - Qt/Gist issues: - `GECODE_ENABLE_QT=ON` and `GECODE_ENABLE_GIST=ON` are requirement modes; use `AUTO` when dependency discovery should be best-effort. diff --git a/docs/public-test-harness.md b/docs/public-test-harness.md index 7c5991d240..6798ed27c2 100644 --- a/docs/public-test-harness.md +++ b/docs/public-test-harness.md @@ -1,183 +1,77 @@ -# Public Test Harness Guide +# Testing custom propagators -This document describes Gecode's supported public testing surface. -It is for downstream users who want to write Gecode-style tests for custom propagators -without developing inside the Gecode source tree. +Gecode's test harness can check a custom integer propagator against a small, +independent specification. The test supplies two things: a predicate that says +which complete assignments are valid, and a function that posts the propagator. +The harness compares them while exercising propagation, cloning, pruning, and +search. -For general build, install, and package-consumption setup, see -[`docs/cmake-build.md`](./cmake-build.md). +The installed API covers the core runner and the integer test helpers: -## What this gives you +- `Gecode::gecodetest` provides test registration and the runner. +- `Gecode::gecodetestint` provides `Test::Int::Test` and links to the core runner. +- `test/test.hh` and `test/test.hpp` declare the core API. +- `test/int.hh` and `test/int.hpp` declare the integer helpers. -If Gecode was built and installed with `BUILD_TESTING=ON`, the installed CMake package -exports a `test` component with: +Other helper families under `test/`, including the set, float, branch, assign, +and FlatZinc helpers, are not part of the installed API. -- `Gecode::gecodetest` — core test registration and runner support -- `Gecode::gecodetestint` — integer-test helpers layered over the core runner +## Install the test component -The supported installed header surface is intentionally narrow: - -- `test/test.hh` -- `test/test.hpp` -- `test/int.hh` -- `test/int.hpp` - -The public runner entrypoint is: - -```c++ -int Test::run_registered_tests(int argc, char* argv[]); -``` - -This is the same runner seam used by Gecode's own `gecode-test` executable. - -## Supported first-release scope - -The first public release is intentionally focused. - -Supported: - -- The core process-global test registry and runner from `test/test.hh` -- Integer-test helpers based on `Test::Int::Test` -- Installed CMake consumption through `find_package(Gecode CONFIG REQUIRED COMPONENTS test)` -- Installed native legacy consumption through the maintained `-I`, `-L`, and `-l...` contract below -- A downstream executable that registers tests and forwards `main(...)` into - `Test::run_registered_tests(argc, argv)` - -Not yet part of the supported installed surface: - -- Public installation of the wider helper families under `test/` such as `set`, `float`, - `assign`, `branch`, or `flatzinc` -- Smoke executables such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test` - as install artifacts -- A separate replacement framework or a redesigned runner model - -## Prerequisite: install Gecode with the public harness enabled - -The public harness is only installable when the required search and integer modules are -available. - -### CMake install path - -The installed CMake package exports the `test` component when the build was configured -with `BUILD_TESTING=ON` and the required harness dependencies were available. - -Typical flow: +The test component requires the search and integer modules. With CMake, enable +testing when configuring Gecode: ```bash cmake -S . -B build -DBUILD_TESTING=ON -cmake --build build --target gecodetest gecodetestint gecode-test -cmake --install build --prefix /path/to/install +cmake --build build +cmake --install build --prefix /path/to/gecode ``` -For full build/install details, platform notes, and package-location hints, see -[`docs/cmake-build.md`](./cmake-build.md). - -### Legacy Autoconf/Make install path - -The legacy install path builds and installs the same narrow public harness boundary when -search and integer support remain enabled. - -Typical flow: +The Autoconf build installs the same headers and static libraries when search +and integer variables are enabled: ```bash -mkdir -p build/legacy && cd build/legacy +mkdir -p build/legacy +cd build/legacy ../../configure --disable-qt --disable-gist --disable-doc-search --disable-examples make -j4 -make install prefix="$PWD/prefix" -``` - -## Native legacy install contract - -This section is the maintained native contract for downstream consumers using the -Autoconf/Make install path. - -Installed public headers: - -- `/include/test/test.hh` -- `/include/test/test.hpp` -- `/include/test/int.hh` -- `/include/test/int.hpp` - -Installed reusable harness libraries: - -- `gecodetest` — static core runner/registry seam -- `gecodetestint` — static integer-helper seam layered over `gecodetest` - -Use the installed include directory and the normal installed library directory from the -legacy build (`/include` and typically `/lib`; if you configured a -custom `libdir`/`sharedlibdir`, use that installed path instead). - -Supported native compile/link shape: - -```bash -c++ -std=c++17 -I/include consumer-smoke.cpp \ - -L/lib \ - -lgecodetestint -lgecodetest -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport -``` - -That full link closure is the honest maintained contract for native legacy consumers. -Some shared-library linkers may accept a shorter command line, but downstream proofs and -support assume the explicit closure above rather than implicit transitive behavior. - -What the native legacy contract still does **not** install or support: - -- `test/set.hh`, `test/set.hpp`, `test/float.hh`, `test/float.hpp`, `test/assign.hh`, - `test/branch.hh`, or `test/flatzinc.hh` -- proof-only binaries such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test` -- a second discovery layer such as pkg-config - -### Maintained installed-proof entrypoint - -The canonical installed-proof entrypoint for the native legacy path is: - -```bash -python test/package/verify-installed-legacy-test-component.py \ - --source . \ - --build-root build/legacy-test-component-proof \ - --prefix build/legacy-test-component-proof/prefix +make install prefix=/path/to/gecode ``` -This is the maintained anti-drift path used by the docs and the Ubuntu Autoconf CI -job. It first re-checks the installed prefix surface, then builds the downstream -consumer in a temp workspace outside the source tree, and finally launches -separate `-list` and filtered-run executions against the installed prefix. - -The verifier owns runtime library-path setup for launched processes. It injects -the resolved installed library directory into `LD_LIBRARY_PATH` and -`DYLD_LIBRARY_PATH`, so the proof stays bound to the selected install prefix -instead of ambient system state. - -There is also a supported negative mode that proves unsupported helper headers -remain outside the installed legacy surface: +See [the CMake build guide](./cmake-build.md) for package lookup and other build +options. -```bash -python test/package/verify-installed-legacy-test-component.py \ - --source . \ - --build-root build/legacy-test-component-proof \ - --prefix build/legacy-test-component-proof/prefix \ - --mode unsupported-header -``` +## Link a test executable with CMake -## Minimal downstream CMake consumer +An installed-package consumer needs the `test` component and the integer helper +target: ```cmake cmake_minimum_required(VERSION 3.21) -project(gecode_public_test_consumer LANGUAGES CXX) +project(custom_propagator_tests LANGUAGES CXX) find_package(Gecode CONFIG REQUIRED COMPONENTS test) -add_executable(consumer-smoke consumer-smoke.cpp) -target_compile_features(consumer-smoke PRIVATE cxx_std_17) -target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestint) +add_executable(custom-propagator-test custom-propagator-test.cpp) +target_compile_features(custom-propagator-test PRIVATE cxx_std_17) +target_link_libraries(custom-propagator-test PRIVATE Gecode::gecodetestint) ``` -If CMake does not find the package automatically, point it at the install prefix: +The target names are identical when Gecode is brought in with +`add_subdirectory` or `FetchContent`. Set `BUILD_TESTING` to a true CMake value +before adding Gecode so that the test targets exist. + +If CMake cannot locate an installed package, set one of these variables: + +- `CMAKE_PREFIX_PATH=/path/to/gecode` +- `Gecode_ROOT=/path/to/gecode` +- `Gecode_DIR=/path/to/gecode/lib/cmake/Gecode` -- `-DCMAKE_PREFIX_PATH=/path/to/install` -- or `-DGecode_ROOT=/path/to/install` -- or `-DGecode_DIR=/path/to/install/lib/cmake/Gecode` +## Write the test -## Minimal downstream test +The following test checks an equality propagator over two variables with domain +`0..1`. Two assignments satisfy equality and two violate it, so an empty or +incorrect posting function cannot pass. ```c++ #include @@ -186,19 +80,24 @@ If CMake does not find the package automatically, point it at the install prefix namespace { -class ConsumerSmoke final : public ::Test::Int::Test { +void post_equal(Gecode::Space& home, Gecode::IntVar x, Gecode::IntVar y) { + // Replace this body with the posting function for the propagator under test. + Gecode::rel(home, x, Gecode::IRT_EQ, y); +} + +class EqualityTest final : public ::Test::Int::Test { public: - ConsumerSmoke() - : ::Test::Int::Test("Package::ConsumerSmoke", 1, 0, 1) {} + EqualityTest() + : ::Test::Int::Test("Package::Equality", 2, 0, 1) {} bool solution(const ::Test::Int::Assignment& assignment) const override { - return assignment[0] >= 0; + return assignment[0] == assignment[1]; } void post(Gecode::Space& home, Gecode::IntVarArray& x) override { - Gecode::rel(home, x[0], Gecode::IRT_GQ, 0); + post_equal(home, x[0], x[1]); } -} consumer_smoke; +} equality_test; } // namespace @@ -207,102 +106,117 @@ int main(int argc, char* argv[]) { } ``` -This mirrors the maintained sample consumer at -`test/package/public-test-component/consumer-smoke.cpp`. +`solution()` is the specification. Keep it independent of the propagator code. +For a complicated constraint, a direct calculation over the assigned values is +usually a better oracle than calling another version of the same propagator. -## Running the downstream test executable +The constructor arguments give the test its name, arity, and common variable +domain. Override `assignment()` when the variables need different domains or a +custom assignment generator. -List registered tests: +`post()` receives fresh variables in a test space. It should call the same +posting function that users of the propagator call. The harness checks the +posted constraint against the oracle across complete assignments and under +partial-domain modifications. -```bash -./consumer-smoke -list -``` +The test object registers during construction. Give it static lifetime, as in +the example. Destruction does not unregister it. Registered objects must remain +alive until all calls to `run_registered_tests` have returned, and runner calls +must not overlap. -Run just one test: +## Consistency and reification -```bash -./consumer-smoke -test Package::ConsumerSmoke -iter 1 -stop true -``` +The final constructor arguments select reification and the integer propagation +level: -The runner uses the same option model as Gecode's own `gecode-test` binary. -The supported public seam is the runner function, not a separate alternate CLI. +```c++ +EqualityTest() + : ::Test::Int::Test("Package::Equality", 2, 0, 1, + false, Gecode::IPL_DOM) {} +``` -## Package component behavior +`IPL_DOM` also selects the domain-consistency check. A subclass can set the +protected `contest` member to `CTL_NONE`, `CTL_BOUNDS_D`, or `CTL_BOUNDS_Z` when +the propagator promises a different consistency level. -The installed `GecodeConfig.cmake` recognizes `test` as a supported component. -The package treats the component as available only when both of these imported -targets are present: +Pass `true` as the reification argument for a reified propagator and override +the reified posting function: -- `Gecode::gecodetest` -- `Gecode::gecodetestint` +```c++ +void post(Gecode::Space& home, Gecode::IntVarArray& x, + Gecode::Reify r) override { + Gecode::rel(home, x[0], Gecode::IRT_EQ, x[1], r); +} +``` -If a consumer requests an unsupported component, the package emits an explicit -configure-time diagnostic rather than failing later at link time. +The protected `rms` bit mask restricts the tested modes to `RM_EQV`, `RM_IMP`, +or `RM_PMI` when the propagator supports only part of the reification API. +`testsearch` and `testfix` can disable the corresponding checks for constraints +where those checks do not apply. -## Installed CMake proof path +## Run and reproduce tests -The canonical downstream verifier is: +List the registered tests: ```bash -python test/package/verify-installed-test-component.py \ - --source . \ - --build-root build/package-proof \ - --prefix /path/to/install +./custom-propagator-test -list ``` -What it checks: - -- the installed `test/` headers are present and limited to the supported public set -- the package exports `Gecode::gecodetest` and `Gecode::gecodetestint` -- the downstream consumer configures through the installed package metadata -- consumer compile flags use the installed prefix instead of source-tree include leakage -- the consumer binary builds successfully -- `-list` discovers the registered downstream test -- a filtered run executes the selected test successfully - -There is also a negative-path mode that confirms unsupported-component diagnostics: +Run the equality test once and stop on its first error: ```bash -python test/package/verify-installed-test-component.py \ - --source . \ - --build-root build/package-proof-missing \ - --prefix /path/to/install \ - --expect-missing-component-failure +./custom-propagator-test -test Package::Equality -iter 1 -stop true ``` -## How this relates to Gecode's own tests - -Inside the repo, `gecode-test` now links through the same public harness seam: - -- `gecodetest` -- `gecodetestint` +A failure reports the random seed and test name. Use both values to reproduce +the run: -That means the supported downstream path is not a sidecar proof-only API. The -same runner/library boundary is used both for in-tree testing and installed -package consumption. +```bash +./custom-propagator-test \ + -test Package::Equality \ + -seed 12345 \ + -iter 1 \ + -threads 1 \ + -log \ + -stop true +``` -## Troubleshooting +`-log` prints the buffered test log on failure and cannot be combined with a +multi-threaded run. `-help` prints the complete option list. Help and malformed +options terminate the process, so the runner belongs in a test executable +rather than an embedding library. -### `find_package(Gecode CONFIG REQUIRED COMPONENTS test)` fails +## Link without CMake package metadata -Check: +The Autoconf installation provides static `gecodetest` and `gecodetestint` +libraries. A direct compiler invocation must name their dependency closure: -- the installation was built with `BUILD_TESTING=ON` -- the package lookup points at the intended install prefix -- the install tree actually contains `lib/cmake/Gecode/GecodeConfig.cmake` +```bash +c++ -std=c++17 -I/include custom-propagator-test.cpp \ + -L/lib \ + -lgecodetestint -lgecodetest \ + -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport +``` -### `Gecode::gecodetestint` is missing +Use the configured `libdir` or `sharedlibdir` instead of `/lib` when the +installation uses a different library directory. -The `test` component is only considered available when both `Gecode::gecodetest` -and `Gecode::gecodetestint` are exported. Treat a missing companion target as an -installation/configuration issue, not as something to patch around in the consumer. +The installation does not include `gecode-test`, `public-runner-smoke`, or +`public-int-smoke`. Those executables check Gecode's own build and are not part +of the downstream interface. -### The consumer builds but seems to use source-tree headers +## Check an installation -Run the verifier. It checks `compile_commands.json` for include-leakage and is the -maintained proof path used by CI as well. +The package checks build and run the sample consumer against an installed +prefix: -### I need non-integer helper families +```bash +python test/package/verify-installed-test-component.py \ + --source . \ + --build-root build/package-check \ + --prefix /path/to/gecode +``` -That is outside the supported first-release installed surface. The installed public -contract currently stops at the core runner plus integer-test helpers. +For a nonstandard layout, pass `--include-dir` and `--lib-dir`. The Autoconf +package check uses `verify-installed-legacy-test-component.py` with the same +`--source`, `--build-root`, and `--prefix` arguments. diff --git a/test/package/public-test-component/consumer-smoke.cpp b/test/package/public-test-component/consumer-smoke.cpp index 943671f51b..0e53a3d330 100644 --- a/test/package/public-test-component/consumer-smoke.cpp +++ b/test/package/public-test-component/consumer-smoke.cpp @@ -4,19 +4,23 @@ namespace { -class ConsumerSmoke final : public ::Test::Int::Test { +void post_equal(Gecode::Space& home, Gecode::IntVar x, Gecode::IntVar y) { + Gecode::rel(home, x, Gecode::IRT_EQ, y); +} + +class EqualityTest final : public ::Test::Int::Test { public: - ConsumerSmoke() - : ::Test::Int::Test("Package::ConsumerSmoke", 1, 0, 1) {} + EqualityTest() + : ::Test::Int::Test("Package::Equality", 2, 0, 1) {} bool solution(const ::Test::Int::Assignment& assignment) const override { - return assignment[0] >= 0; + return assignment[0] == assignment[1]; } void post(Gecode::Space& home, Gecode::IntVarArray& x) override { - Gecode::rel(home, x[0], Gecode::IRT_GQ, 0); + post_equal(home, x[0], x[1]); } -} consumer_smoke; +} equality_test; } // namespace diff --git a/test/package/verify-installed-legacy-test-component.py b/test/package/verify-installed-legacy-test-component.py index b3a60fb23b..70f95df7a1 100644 --- a/test/package/verify-installed-legacy-test-component.py +++ b/test/package/verify-installed-legacy-test-component.py @@ -12,7 +12,7 @@ from pathlib import Path VERIFIER_PREFIX = "[verify-installed-legacy-test-component]" -EXPECTED_TEST_NAME = "Package::ConsumerSmoke" +EXPECTED_TEST_NAME = "Package::Equality" LIBRARIES = [ "gecodetestint", "gecodetest", @@ -244,8 +244,6 @@ def run_prefix_surface(source: Path, prefix: Path) -> None: command = [ sys.executable, str((source / "test/verify-legacy-install-surface.py").resolve()), - "--source", - str(source), "--prefix", str(prefix), ] diff --git a/test/package/verify-installed-test-component.py b/test/package/verify-installed-test-component.py index 43822283cd..8aee630f5e 100644 --- a/test/package/verify-installed-test-component.py +++ b/test/package/verify-installed-test-component.py @@ -11,16 +11,16 @@ EXPECTED_HEADERS = [ - "include/test/int.hh", - "include/test/int.hpp", - "include/test/test.hh", - "include/test/test.hpp", + "test/int.hh", + "test/int.hpp", + "test/test.hh", + "test/test.hpp", ] EXPECTED_METADATA = [ - "lib/cmake/Gecode/GecodeConfig.cmake", - "lib/cmake/Gecode/GecodeTargets.cmake", + "cmake/Gecode/GecodeConfig.cmake", + "cmake/Gecode/GecodeTargets.cmake", ] -EXPECTED_TEST_NAME = "Int::Package::ConsumerSmoke" +EXPECTED_TEST_NAME = "Int::Package::Equality" VERIFIER_PREFIX = "[verify-installed-test-component]" @@ -81,20 +81,23 @@ def assert_phase(condition: bool, phase: str, message: str) -> None: fail_phase(phase, message) -def assert_prefix_surface(prefix: Path) -> None: +def assert_prefix_surface(include_dir: Path, lib_dir: Path) -> None: phase = "prefix-surface" - for rel in [*EXPECTED_HEADERS, *EXPECTED_METADATA]: - path = prefix / rel + for rel in EXPECTED_HEADERS: + path = include_dir / rel + assert_phase(path.exists(), phase, f"missing installed path: {path}") + for rel in EXPECTED_METADATA: + path = lib_dir / rel assert_phase(path.exists(), phase, f"missing installed path: {path}") installed_test_headers = sorted( - path.relative_to(prefix).as_posix() - for path in (prefix / "include" / "test").glob("*") + path.relative_to(include_dir).as_posix() + for path in (include_dir / "test").glob("*") if path.is_file() ) assert_phase(installed_test_headers == EXPECTED_HEADERS, phase, f"unexpected installed test headers: {installed_test_headers}") - targets = (prefix / "lib/cmake/Gecode/GecodeTargets.cmake").read_text() + targets = (lib_dir / "cmake/Gecode/GecodeTargets.cmake").read_text() assert_phase("Gecode::gecodetest" in targets, phase, "missing Gecode::gecodetest export") assert_phase("Gecode::gecodetestint" in targets, phase, "missing Gecode::gecodetestint export") @@ -128,7 +131,12 @@ def iter_include_dirs(entry: dict[str, object]) -> list[Path]: return include_dirs -def assert_no_source_tree_include_leakage(source: Path, consumer_build: Path, prefix: Path) -> None: +def assert_no_source_tree_include_leakage( + source: Path, + consumer_build: Path, + prefix: Path, + include_dir: Path, +) -> None: phase = "include-leakage" compile_commands_path = consumer_build / "compile_commands.json" assert_phase(compile_commands_path.exists(), phase, f"missing compile commands: {compile_commands_path}") @@ -139,13 +147,13 @@ def assert_no_source_tree_include_leakage(source: Path, consumer_build: Path, pr source_root = source.resolve() consumer_build = consumer_build.resolve() prefix = prefix.resolve() - prefix_include = (prefix / "include").resolve() + installed_include = include_dir.resolve() leaked_include_dirs: set[str] = set() saw_prefix_include = False for entry in compile_commands: for include_dir in iter_include_dirs(entry): - if include_dir == prefix_include or include_dir.is_relative_to(prefix_include): + if include_dir == installed_include or include_dir.is_relative_to(installed_include): saw_prefix_include = True if ( include_dir.is_relative_to(source_root) @@ -160,11 +168,16 @@ def assert_no_source_tree_include_leakage(source: Path, consumer_build: Path, pr sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") -def configure_consumer(source: Path, build_root: Path, prefix: Path, *, require_unknown_component: bool) -> tuple[Path, Path, subprocess.CompletedProcess[str]]: +def configure_consumer( + source: Path, + build_root: Path, + prefix: Path, + cmake_dir: Path, + *, + require_unknown_component: bool, +) -> tuple[Path, Path, subprocess.CompletedProcess[str]]: consumer_source = source / "test/package/public-test-component" consumer_build = build_root / "consumer" - cmake_dir = prefix / "lib/cmake/Gecode" - shutil.rmtree(build_root, ignore_errors=True) consumer_build.mkdir(parents=True, exist_ok=True) @@ -231,6 +244,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--source", required=True) parser.add_argument("--build-root", required=True) parser.add_argument("--prefix", required=True) + parser.add_argument("--include-dir") + parser.add_argument("--lib-dir") parser.add_argument("--expect-missing-component-failure", action="store_true") return parser.parse_args() @@ -240,13 +255,16 @@ def main() -> int: source = Path(args.source).resolve() build_root = Path(args.build_root).resolve() prefix = Path(args.prefix).resolve() + include_dir = Path(args.include_dir).resolve() if args.include_dir else prefix / "include" + lib_dir = Path(args.lib_dir).resolve() if args.lib_dir else prefix / "lib" - assert_prefix_surface(prefix) + assert_prefix_surface(include_dir, lib_dir) _, consumer_build, configure_result = configure_consumer( source, build_root, prefix, + lib_dir / "cmake/Gecode", require_unknown_component=args.expect_missing_component_failure, ) @@ -254,7 +272,7 @@ def main() -> int: assert_missing_component_failure(configure_result) return 0 - assert_no_source_tree_include_leakage(source, consumer_build, prefix) + assert_no_source_tree_include_leakage(source, consumer_build, prefix, include_dir) consumer_binary = build_consumer(consumer_build) run_list_phase(consumer_binary) run_filtered_phase(consumer_binary) diff --git a/test/test.hh b/test/test.hh index a805770429..53e605f89c 100755 --- a/test/test.hh +++ b/test/test.hh @@ -166,7 +166,12 @@ namespace Test { }; /** - * \brief Run all currently registered tests through the shared runner + * \brief Run the tests in the process-wide registry + * + * Tests register during construction. They must remain alive until every + * runner call has returned; destruction does not remove a test from the + * process-wide registry. Runner calls must not overlap. Option parsing exits + * the process after printing help or reporting a malformed option. * \relates Test::Base */ int run_registered_tests(int argc, char* argv[]); diff --git a/test/verify-legacy-check-selection.py b/test/verify-legacy-check-selection.py deleted file mode 100644 index cb907720bf..0000000000 --- a/test/verify-legacy-check-selection.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import re -import sys -from dataclasses import dataclass - -VERIFIER_PREFIX = "[verify-legacy-check-selection]" -TEST_TOKEN_RE = re.compile(r"(?:^|\s)-test\s+([^\s\\]+)") - - -@dataclass(frozen=True) -class ModeExpectation: - required: tuple[str, ...] - forbidden_prefixes: tuple[str, ...] - - -MODE_EXPECTATIONS = { - "no-set-float-flatzinc": ModeExpectation( - required=( - "Branch::Int::Dense::3", - "Int::Arithmetic::Abs", - "Int::MiniModel::LinExpr::Bool::352", - "NoGoods::Queens", - "Search::DFS::Sol::Binary::Nary::Binary::1::1::1", - ), - forbidden_prefixes=("Set::", "Float::", "FlatZinc::"), - ), -} - - -def fail(message: str) -> "NoReturn": - sys.stderr.write(f"{VERIFIER_PREFIX} selection: FAIL - {message}\n") - raise SystemExit(1) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--mode", choices=sorted(MODE_EXPECTATIONS), required=True) - return parser.parse_args() - - -def extract_selected_tests(make_output: str) -> list[str]: - return TEST_TOKEN_RE.findall(make_output.replace("\\\n", " ")) - - -def main() -> int: - args = parse_args() - make_output = sys.stdin.read() - selected_tests = extract_selected_tests(make_output) - if not selected_tests: - fail("did not find any '-test ' selections in make output") - - expectation = MODE_EXPECTATIONS[args.mode] - missing_required = [name for name in expectation.required if name not in selected_tests] - if missing_required: - fail(f"missing required selections: {', '.join(missing_required)}") - - forbidden = [ - name - for name in selected_tests - if any(name.startswith(prefix) for prefix in expectation.forbidden_prefixes) - ] - if forbidden: - fail(f"found forbidden selections: {', '.join(forbidden)}") - - sys.stdout.write( - f"{VERIFIER_PREFIX} selection: ok - mode={args.mode} selected={len(selected_tests)}\n" - ) - for name in selected_tests: - sys.stdout.write(f"{VERIFIER_PREFIX} selection: {name}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/test/verify-legacy-install-surface.py b/test/verify-legacy-install-surface.py index 18de7a7ef1..fd16f32705 100644 --- a/test/verify-legacy-install-surface.py +++ b/test/verify-legacy-install-surface.py @@ -25,18 +25,6 @@ "gecodetest": "libgecodetest.a", "gecodetestint": "libgecodetestint.a", } -REQUIRED_DOC_STRINGS = [ - "## Native legacy install contract", - "`gecodetest` — static core runner/registry seam", - "`gecodetestint` — static integer-helper seam layered over `gecodetest`", - "-I/include consumer-smoke.cpp \\", - "-L/lib \\", - "-lgecodetestint -lgecodetest -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport", - "That full link closure is the honest maintained contract for native legacy consumers.", - "proof-only binaries such as `public-runner-smoke`, `public-int-smoke`, or `gecode-test`", -] - - def fail_phase(phase: str, message: str) -> "NoReturn": sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") raise SystemExit(1) @@ -118,31 +106,13 @@ def verify_unsupported_surface(prefix: Path) -> None: for path in prefix.rglob("*") if path.is_file() and (path.name in UNSUPPORTED_BINARIES or path.stem in UNSUPPORTED_BINARIES) ) - assert_phase(not unexpected_binaries, phase, f"unexpected proof-only binaries installed: {unexpected_binaries}") + assert_phase(not unexpected_binaries, phase, f"unexpected internal test binaries installed: {unexpected_binaries}") sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: headers-ok binaries-ok\n") -def verify_docs_contract(source: Path) -> None: - phase = "docs-contract" - docs_path = source / "docs" / "public-test-harness.md" - assert_phase(docs_path.is_file(), phase, f"missing docs file: {docs_path}") - - docs_text = docs_path.read_text() - missing = [needle for needle in REQUIRED_DOC_STRINGS if needle not in docs_text] - assert_phase(not missing, phase, f"missing required legacy contract text: {missing}") - assert_phase( - docs_text.index("## Native legacy install contract") < docs_text.index("## Minimal downstream CMake consumer"), - phase, - "legacy install contract section moved after the downstream consumer guidance", - ) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: anchor=Native legacy install contract\n") - - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() - parser.add_argument("--source", required=True) parser.add_argument("--prefix", required=True) return parser.parse_args() @@ -150,16 +120,13 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - source = Path(args.source).resolve() prefix = Path(args.prefix).resolve() - assert_phase(source.is_dir(), "inputs", f"missing source tree: {source}") assert_phase(prefix.is_dir(), "inputs", f"missing installed prefix: {prefix}") verify_prefix_surface(prefix) verify_harness_libs(prefix) verify_unsupported_surface(prefix) - verify_docs_contract(source) return 0 diff --git a/test/verify-legacy-selfhost.py b/test/verify-legacy-selfhost.py deleted file mode 100644 index b008f0e821..0000000000 --- a/test/verify-legacy-selfhost.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import os -import shlex -import subprocess -import sys -from pathlib import Path - -VERIFIER_PREFIX = "[verify-legacy-selfhost]" - - -def format_command(command: list[str]) -> str: - return shlex.join(command) - - -def write_stream(stream, text: str) -> None: - if text: - stream.write(text) - if not text.endswith("\n"): - stream.write("\n") - - -def fail_phase( - phase: str, - message: str, - *, - command: list[str] | None = None, - cwd: Path | None = None, - result: subprocess.CompletedProcess[str] | None = None, -) -> "NoReturn": - sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: FAIL - {message}\n") - if command is not None: - sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: command: {format_command(command)}\n") - if cwd is not None: - sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: cwd: {cwd}\n") - if result is not None: - sys.stderr.write(f"{VERIFIER_PREFIX} {phase}: exit: {result.returncode}\n") - write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stdout:\n{result.stdout}") - write_stream(sys.stderr, f"{VERIFIER_PREFIX} {phase}: stderr:\n{result.stderr}") - raise SystemExit(1) - - -def build_runtime_env(build_dir: Path) -> dict[str, str]: - env = os.environ.copy() - build_dir_str = str(build_dir) - for key in ("DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"): - existing = env.get(key) - env[key] = build_dir_str if not existing else os.pathsep.join([build_dir_str, existing]) - existing_path = env.get("PATH") - env["PATH"] = build_dir_str if not existing_path else os.pathsep.join([build_dir_str, existing_path]) - return env - - -def run_phase( - phase: str, - command: list[str], - *, - cwd: Path, - env: dict[str, str], -) -> subprocess.CompletedProcess[str]: - result = subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True) - if result.returncode != 0: - fail_phase(phase, "command failed", command=command, cwd=cwd, result=result) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") - return result - - -def assert_phase(condition: bool, phase: str, message: str) -> None: - if not condition: - fail_phase(phase, message) - - -def resolve_binary(build_dir: Path, relative_path: str) -> Path: - binary = build_dir / relative_path - if binary.exists(): - return binary - exe_binary = binary.with_suffix(binary.suffix + ".exe") - if exe_binary.exists(): - return exe_binary - fail_phase("build-surface", f"missing built binary: {binary}") - - -def verify_runner_smoke(build_dir: Path, runtime_env: dict[str, str]) -> None: - phase = "runner-smoke" - binary = resolve_binary(build_dir, "test/public-runner-smoke") - run_phase(phase, [str(binary)], cwd=build_dir, env=runtime_env) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: binary={binary}\n") - - -def verify_int_smoke(build_dir: Path, runtime_env: dict[str, str]) -> None: - phase = "int-smoke" - binary = resolve_binary(build_dir, "test/public-int-smoke") - run_phase(phase, [str(binary)], cwd=build_dir, env=runtime_env) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: binary={binary}\n") - - -def verify_list_phase(build_dir: Path, expected_filter: str, runtime_env: dict[str, str]) -> Path: - phase = "list" - binary = resolve_binary(build_dir, "test/test") - result = run_phase(phase, [str(binary), "-list"], cwd=build_dir, env=runtime_env) - listed = [line.strip() for line in result.stdout.splitlines() if line.strip()] - assert_phase(bool(listed), phase, "-list produced no registered test names") - assert_phase( - any(expected_filter in name for name in listed), - phase, - f"missing expected filter in -list output: {expected_filter}", - ) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: discovered-prefix={expected_filter}\n") - return binary - - -def verify_filtered_phase( - build_dir: Path, - test_binary: Path, - expected_filter: str, - runtime_env: dict[str, str], -) -> None: - phase = "filtered-run" - result = run_phase( - phase, - [str(test_binary), "-test", expected_filter, "-iter", "1", "-stop", "true"], - cwd=build_dir, - env=runtime_env, - ) - assert_phase(expected_filter in result.stdout, phase, "filtered run did not print the selected test") - assert_phase("+" in result.stdout, phase, "filtered run did not report success") - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: executed={expected_filter}\n") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--build-dir", required=True) - parser.add_argument("--expected-filter", required=True) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - build_dir = Path(args.build_dir).resolve() - assert_phase(build_dir.exists(), "build-surface", f"missing build directory: {build_dir}") - runtime_env = build_runtime_env(build_dir) - sys.stdout.write(f"{VERIFIER_PREFIX} runtime-env: library-path={build_dir}\n") - - verify_runner_smoke(build_dir, runtime_env) - verify_int_smoke(build_dir, runtime_env) - test_binary = verify_list_phase(build_dir, args.expected_filter, runtime_env) - verify_filtered_phase(build_dir, test_binary, args.expected_filter, runtime_env) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 6c7b9ef2a2f74384d5111ff6b88ff6b1d74d0738 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Sun, 6 Sep 2026 16:41:03 +0200 Subject: [PATCH 6/6] Expose set and float test helpers --- CMakeLists.txt | 92 +++++++++++++++- Makefile.in | 102 ++++++++++++++++-- changelog.in | 6 +- cmake/GecodeConfig.cmake.in | 22 +++- cmake/GecodeSources.cmake | 10 ++ docs/cmake-build.md | 13 ++- docs/public-test-harness.md | 72 +++++++++---- .../public-test-component/CMakeLists.txt | 8 ++ .../public-test-component/consumer-smoke.cpp | 47 ++++++++ .../verify-installed-legacy-test-component.py | 35 ++++-- .../verify-installed-test-component.py | 48 ++++++--- test/public-float-smoke.cpp | 76 +++++++++++++ test/public-set-smoke.cpp | 76 +++++++++++++ test/verify-legacy-install-surface.py | 54 +++++++--- 14 files changed, 578 insertions(+), 83 deletions(-) create mode 100644 test/public-float-smoke.cpp create mode 100644 test/public-set-smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cfa8516ad..9fface316a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1395,6 +1395,44 @@ if(BUILD_TESTING) add_library(Gecode::gecodetestint ALIAS gecodetestint) list(APPEND GECODE_INSTALL_TARGETS gecodetestint) list(APPEND GECODE_EXPORT_TARGETS gecodetestint) + + if(TARGET gecodeset) + add_library(gecodetestset STATIC ${GECODE_TEST_SET_SOURCES}) + target_compile_features(gecodetestset PUBLIC cxx_std_17) + target_include_directories(gecodetestset + PUBLIC + $ + $ + $) + if(GECODE_VISIBILITY_COMPILE_OPTION) + target_compile_options(gecodetestset PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) + endif() + set_target_properties(gecodetestset PROPERTIES + OUTPUT_NAME "${GECODE_LIB_PREFIX}gecodetestset${GECODE_LIB_SUFFIX}") + target_link_libraries(gecodetestset PUBLIC gecodetestint gecodeset) + add_library(Gecode::gecodetestset ALIAS gecodetestset) + list(APPEND GECODE_INSTALL_TARGETS gecodetestset) + list(APPEND GECODE_EXPORT_TARGETS gecodetestset) + endif() + + if(TARGET gecodefloat) + add_library(gecodetestfloat STATIC ${GECODE_TEST_FLOAT_SOURCES}) + target_compile_features(gecodetestfloat PUBLIC cxx_std_17) + target_include_directories(gecodetestfloat + PUBLIC + $ + $ + $) + if(GECODE_VISIBILITY_COMPILE_OPTION) + target_compile_options(gecodetestfloat PRIVATE ${GECODE_VISIBILITY_COMPILE_OPTION}) + endif() + set_target_properties(gecodetestfloat PROPERTIES + OUTPUT_NAME "${GECODE_LIB_PREFIX}gecodetestfloat${GECODE_LIB_SUFFIX}") + target_link_libraries(gecodetestfloat PUBLIC gecodetest gecodefloat) + add_library(Gecode::gecodetestfloat ALIAS gecodetestfloat) + list(APPEND GECODE_INSTALL_TARGETS gecodetestfloat) + list(APPEND GECODE_EXPORT_TARGETS gecodetestfloat) + endif() if(GECODE_INSTALL) foreach(gecode_install_component IN LISTS GECODE_LIBRARY_COMPONENTS) if(TARGET gecode${gecode_install_component}_${GECODE_DEFAULT_LINK_VARIANT}) @@ -1411,15 +1449,29 @@ if(BUILD_TESTING) target_link_libraries(public-int-smoke PRIVATE gecodetestint) add_test(NAME public-int-smoke COMMAND public-int-smoke) + set(GECODE_PUBLIC_TEST_SMOKE_TARGETS public-runner-smoke public-int-smoke) + if(TARGET gecodetestset) + add_executable(public-set-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_SET_SMOKE_SOURCE}) + target_link_libraries(public-set-smoke PRIVATE gecodetestset) + add_test(NAME public-set-smoke COMMAND public-set-smoke) + list(APPEND GECODE_PUBLIC_TEST_SMOKE_TARGETS public-set-smoke) + endif() + if(TARGET gecodetestfloat) + add_executable(public-float-smoke EXCLUDE_FROM_ALL ${GECODE_TEST_PUBLIC_FLOAT_SMOKE_SOURCE}) + target_link_libraries(public-float-smoke PRIVATE gecodetestfloat) + add_test(NAME public-float-smoke COMMAND public-float-smoke) + list(APPEND GECODE_PUBLIC_TEST_SMOKE_TARGETS public-float-smoke) + endif() + add_test(NAME build-public-test-smokes COMMAND ${CMAKE_COMMAND} --build "${CMAKE_BINARY_DIR}" - --target public-runner-smoke public-int-smoke --config $ + --target ${GECODE_PUBLIC_TEST_SMOKE_TARGETS} --config $ ) set_tests_properties(build-public-test-smokes PROPERTIES FIXTURES_SETUP public-test-smokes-built RESOURCE_LOCK gecode-test-build ) - set_tests_properties(public-runner-smoke public-int-smoke PROPERTIES + set_tests_properties(${GECODE_PUBLIC_TEST_SMOKE_TARGETS} PROPERTIES FIXTURES_REQUIRED public-test-smokes-built ) else() @@ -1454,6 +1506,12 @@ if(BUILD_TESTING) endif() set(GECODE_TEST_SOURCES_SELECTED ${GECODE_TEST_SOURCES}) + if(TARGET gecodetestset) + list(REMOVE_ITEM GECODE_TEST_SOURCES_SELECTED test/set.cpp) + endif() + if(TARGET gecodetestfloat) + list(REMOVE_ITEM GECODE_TEST_SOURCES_SELECTED test/float.cpp) + endif() if(NOT GECODE_ENABLE_SET_VARS) list(FILTER GECODE_TEST_SOURCES_SELECTED EXCLUDE REGEX "^test/set(/|\\.cpp)") endif() @@ -1466,7 +1524,15 @@ if(BUILD_TESTING) if(GECODE_CAN_BUILD_TESTS) add_executable(gecode-test EXCLUDE_FROM_ALL ${GECODE_TEST_MAIN_SOURCE} ${GECODE_TEST_SOURCES_SELECTED}) - set(GECODE_TEST_LINK_LIBS gecodetestint gecodeminimodel) + set(GECODE_TEST_LINK_LIBS gecodeminimodel) + if(TARGET gecodetestset) + list(APPEND GECODE_TEST_LINK_LIBS gecodetestset) + else() + list(APPEND GECODE_TEST_LINK_LIBS gecodetestint) + endif() + if(TARGET gecodetestfloat) + list(APPEND GECODE_TEST_LINK_LIBS gecodetestfloat) + endif() if(GECODE_ENABLE_FLATZINC) list(APPEND GECODE_TEST_LINK_LIBS gecodeflatzinc) endif() @@ -1723,6 +1789,18 @@ if(GECODE_INSTALL) ${CMAKE_CURRENT_SOURCE_DIR}/test/int.hh ${CMAKE_CURRENT_SOURCE_DIR}/test/int.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/test) + if(TARGET gecodetestset) + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/test/set.hh + ${CMAKE_CURRENT_SOURCE_DIR}/test/set.hpp + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/test) + endif() + if(TARGET gecodetestfloat) + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/test/float.hh + ${CMAKE_CURRENT_SOURCE_DIR}/test/float.hpp + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/test) + endif() endif() if(GECODE_ENABLE_FLATZINC) @@ -1746,8 +1824,16 @@ if(GECODE_INSTALL) set(GECODE_PACKAGE_NEEDS_MPFR ON) endif() set(GECODE_PACKAGE_HAS_TEST_COMPONENT OFF) + set(GECODE_PACKAGE_TEST_TYPES "") if(TARGET gecodetest AND TARGET gecodetestint) set(GECODE_PACKAGE_HAS_TEST_COMPONENT ON) + set(GECODE_PACKAGE_TEST_TYPES int) + if(TARGET gecodetestset) + list(APPEND GECODE_PACKAGE_TEST_TYPES set) + endif() + if(TARGET gecodetestfloat) + list(APPEND GECODE_PACKAGE_TEST_TYPES float) + endif() endif() set(GECODE_PACKAGE_QT_MAJOR "") set(GECODE_PACKAGE_QT_COMPONENTS "") diff --git a/Makefile.in b/Makefile.in index fdc39ad001..b887199987 100755 --- a/Makefile.in +++ b/Makefile.in @@ -1283,11 +1283,21 @@ TESTMAINSRC = test/test-main.cpp TESTMAINOBJ = $(TESTMAINSRC:%.cpp=%$(OBJSUFFIX)) TESTINTSEAMSRC = test/int.cpp TESTINTSEAMOBJ = $(TESTINTSEAMSRC:%.cpp=%$(OBJSUFFIX)) +TESTSETSEAMSRC = test/set.cpp +TESTSETSEAMOBJ = $(TESTSETSEAMSRC:%.cpp=%$(OBJSUFFIX)) +TESTFLOATSEAMSRC = test/float.cpp +TESTFLOATSEAMOBJ = $(TESTFLOATSEAMSRC:%.cpp=%$(OBJSUFFIX)) TESTPUBLICRUNNERSMOKESRC = test/public-runner-smoke.cpp TESTPUBLICRUNNERSMOKEOBJ = $(TESTPUBLICRUNNERSMOKESRC:%.cpp=%$(OBJSUFFIX)) TESTPUBLICINTSMOKESRC = test/public-int-smoke.cpp TESTPUBLICINTSMOKEOBJ = $(TESTPUBLICINTSMOKESRC:%.cpp=%$(OBJSUFFIX)) -TESTAUXOBJ = $(TESTMAINOBJ) $(TESTPUBLICRUNNERSMOKEOBJ) $(TESTPUBLICINTSMOKEOBJ) +TESTPUBLICSETSMOKESRC = test/public-set-smoke.cpp +TESTPUBLICSETSMOKEOBJ = $(TESTPUBLICSETSMOKESRC:%.cpp=%$(OBJSUFFIX)) +TESTPUBLICFLOATSMOKESRC = test/public-float-smoke.cpp +TESTPUBLICFLOATSMOKEOBJ = $(TESTPUBLICFLOATSMOKESRC:%.cpp=%$(OBJSUFFIX)) +TESTAUXOBJ = $(TESTMAINOBJ) $(TESTPUBLICRUNNERSMOKEOBJ) \ + $(TESTPUBLICINTSMOKEOBJ) $(TESTPUBLICSETSMOKEOBJ) \ + $(TESTPUBLICFLOATSMOKEOBJ) TESTSRC0 = $(TESTCORESRC) test/afc.cpp test/ldsb.cpp test/region.cpp \ test/groups.cpp @@ -1308,6 +1318,8 @@ TESTPUBLICINSTALLLIBTARGETS = TESTPUBLICINSTALLLIBLINKTARGETS = TESTPUBLICINSTALLLIBLIBTARGETS = TESTPUBLICINSTALLPDBTARGETS = +TESTSETSTATICLIB = +TESTFLOATSTATICLIB = ifeq "@enable_search@" "yes" ifeq "@enable_int_vars@" "yes" @@ -1350,6 +1362,16 @@ export TESTINTRC = endif TESTPUBLICINSTALLLIBTARGETS += $(TESTCORESTATICLIB) $(TESTINTSTATICLIB) +ifeq "@enable_set_vars@" "yes" +TESTPUBLICINSTALLHDR += test/set.hh test/set.hpp +TESTSETSTATICLIB = $(LIBPREFIX)testset$(STATICLIBSUFFIX) +TESTPUBLICINSTALLLIBTARGETS += $(TESTSETSTATICLIB) +endif +ifeq "@enable_float_vars@" "yes" +TESTPUBLICINSTALLHDR += test/float.hh test/float.hpp +TESTFLOATSTATICLIB = $(LIBPREFIX)testfloat$(STATICLIBSUFFIX) +TESTPUBLICINSTALLLIBTARGETS += $(TESTFLOATSTATICLIB) +endif else export TESTCOREDLL = export TESTCORELIB = @@ -1393,6 +1415,19 @@ TESTSBJ = $(TESTOBJ:%$(OBJSUFFIX)=%$(SBJSUFFIX)) TESTEXE = test/test$(EXESUFFIX) TESTPUBLICRUNNERSMOKEEXE = test/public-runner-smoke$(EXESUFFIX) TESTPUBLICINTSMOKEEXE = test/public-int-smoke$(EXESUFFIX) +ifeq "@enable_set_vars@" "yes" +TESTPUBLICSETSMOKEEXE = test/public-set-smoke$(EXESUFFIX) +else +TESTPUBLICSETSMOKEEXE = +endif +ifeq "@enable_float_vars@" "yes" +TESTPUBLICFLOATSMOKEEXE = test/public-float-smoke$(EXESUFFIX) +else +TESTPUBLICFLOATSMOKEEXE = +endif +TESTPUBLICSMOKEEXES = $(TESTPUBLICRUNNERSMOKEEXE) \ + $(TESTPUBLICINTSMOKEEXE) $(TESTPUBLICSETSMOKEEXE) \ + $(TESTPUBLICFLOATSMOKEEXE) BLACKBOXEXECOBJ = $(BLACKBOXEXECSRC:%.cpp=%$(OBJSUFFIX)) BLACKBOXDLLOBJ = $(BLACKBOXDLLSRC:%.cpp=%$(OBJSUFFIX)) BLACKBOXEXEC = test/flatzinc/blackbox-exec$(EXESUFFIX) @@ -1438,8 +1473,7 @@ compilelib: mkcompiledirs compileexamples: $(EXAMPLEEXE) test: mkcompiledirs $(BLACKBOXFIXTURES) - @$(MAKE) $(VARIMP) $(TESTEXE) \ - $(TESTPUBLICRUNNERSMOKEEXE) $(TESTPUBLICINTSMOKEEXE) + @$(MAKE) $(VARIMP) $(TESTEXE) $(TESTPUBLICSMOKEEXES) CHECKTESTS = Branch::Int::Dense::3 \ Int::Arithmetic::Abs \ @@ -1488,8 +1522,9 @@ endif # A basic integrity test check: test - $(RUNENVIRONMENT) $(TESTPUBLICRUNNERSMOKEEXE) - $(RUNENVIRONMENT) $(TESTPUBLICINTSMOKEEXE) + @for t in $(TESTPUBLICSMOKEEXES); do \ + $(RUNENVIRONMENT) $$t || exit 1; \ + done @for t in $(REQUIREDFLOATCHECKTESTS); do \ case " $(CHECKTESTS) " in *" $$t "*) ;; \ *) echo "Required Float check test is missing: $$t" >&2; exit 1 ;; \ @@ -1964,6 +1999,16 @@ $(TESTCORESTATICLIB): $(TESTCOREOBJ) $(TESTINTSTATICLIB): $(TESTINTSEAMOBJ) $(AR) $(ARFLAGS) $@ $(TESTINTSEAMOBJ) $(RANLIB) $@ +ifeq "@enable_set_vars@" "yes" +$(TESTSETSTATICLIB): $(TESTSETSEAMOBJ) + $(AR) $(ARFLAGS) $@ $(TESTSETSEAMOBJ) + $(RANLIB) $@ +endif +ifeq "@enable_float_vars@" "yes" +$(TESTFLOATSTATICLIB): $(TESTFLOATSEAMOBJ) + $(AR) $(ARFLAGS) $@ $(TESTFLOATSEAMOBJ) + $(RANLIB) $@ +endif # # Mac OS X Framework @@ -2022,17 +2067,38 @@ ifeq "@enable_resource@" "yes" TESTRES = $(TESTEXE).res TESTPUBLICRUNNERSMOKERES = $(TESTPUBLICRUNNERSMOKEEXE).res TESTPUBLICINTSMOKERES = $(TESTPUBLICINTSMOKEEXE).res +ifeq "@enable_set_vars@" "yes" +TESTPUBLICSETSMOKERES = $(TESTPUBLICSETSMOKEEXE).res +else +TESTPUBLICSETSMOKERES = +endif +ifeq "@enable_float_vars@" "yes" +TESTPUBLICFLOATSMOKERES = $(TESTPUBLICFLOATSMOKEEXE).res +else +TESTPUBLICFLOATSMOKERES = +endif $(TESTEXE).rc: $(RCGEN) $(TESTEXE) $(TESTSRC) $(TESTHDR) $(TESTMAINSRC) > $@ $(TESTPUBLICRUNNERSMOKEEXE).rc: $(RCGEN) $(TESTPUBLICRUNNERSMOKEEXE) $(TESTPUBLICRUNNERSMOKESRC) test/test.hh test/test.hpp > $@ $(TESTPUBLICINTSMOKEEXE).rc: $(RCGEN) $(TESTPUBLICINTSMOKEEXE) $(TESTPUBLICINTSMOKESRC) test/test.hh test/test.hpp test/int.hh test/int.hpp > $@ -$(TESTEXE).rc $(TESTPUBLICRUNNERSMOKEEXE).rc $(TESTPUBLICINTSMOKEEXE).rc: | mkcompiledirs +ifeq "@enable_set_vars@" "yes" +$(TESTPUBLICSETSMOKEEXE).rc: + $(RCGEN) $(TESTPUBLICSETSMOKEEXE) $(TESTPUBLICSETSMOKESRC) test/test.hh test/test.hpp test/int.hh test/int.hpp test/set.hh test/set.hpp > $@ +endif +ifeq "@enable_float_vars@" "yes" +$(TESTPUBLICFLOATSMOKEEXE).rc: + $(RCGEN) $(TESTPUBLICFLOATSMOKEEXE) $(TESTPUBLICFLOATSMOKESRC) test/test.hh test/test.hpp test/float.hh test/float.hpp > $@ +endif +$(TESTEXE).rc $(TESTPUBLICRUNNERSMOKEEXE).rc $(TESTPUBLICINTSMOKEEXE).rc \ + $(TESTPUBLICSETSMOKEEXE:%=%.rc) $(TESTPUBLICFLOATSMOKEEXE:%=%.rc): | mkcompiledirs else TESTRES = TESTPUBLICRUNNERSMOKERES = TESTPUBLICINTSMOKERES = +TESTPUBLICSETSMOKERES = +TESTPUBLICFLOATSMOKERES = endif $(TESTEXE): $(TESTMAINOBJ) $(TESTOBJ) $(TESTRES) $(ALLLIB) $(CXX) @EXEOUTPUT@$@ $(TESTMAINOBJ) $(TESTOBJ) $(TESTRES) $(DLLPATH) $(CXXFLAGS) \ @@ -2056,6 +2122,24 @@ $(TESTPUBLICINTSMOKEEXE): $(TESTPUBLICINTSMOKEOBJ) $(TESTINTSEAMOBJ) $(TESTCOREO $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 +ifeq "@enable_set_vars@" "yes" +$(TESTPUBLICSETSMOKEEXE): $(TESTPUBLICSETSMOKEOBJ) $(TESTSETSEAMOBJ) $(TESTINTSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICSETSMOKERES) $(ALLLIB) + $(CXX) @EXEOUTPUT@$@ $(TESTPUBLICSETSMOKEOBJ) $(TESTSETSEAMOBJ) $(TESTINTSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICSETSMOKERES) \ + $(DLLPATH) $(CXXFLAGS) \ + $(LINKSET) $(LINKSEARCH) $(LINKINT) $(LINKKERNEL) $(LINKSUPPORT) $(GLDFLAGS) + $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) + $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 +endif + +ifeq "@enable_float_vars@" "yes" +$(TESTPUBLICFLOATSMOKEEXE): $(TESTPUBLICFLOATSMOKEOBJ) $(TESTFLOATSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICFLOATSMOKERES) $(ALLLIB) + $(CXX) @EXEOUTPUT@$@ $(TESTPUBLICFLOATSMOKEOBJ) $(TESTFLOATSEAMOBJ) $(TESTCOREOBJ) $(TESTPUBLICFLOATSMOKERES) \ + $(DLLPATH) $(CXXFLAGS) \ + $(LINKFLOAT) $(LINKSEARCH) $(LINKINT) $(LINKKERNEL) $(LINKSUPPORT) $(GLDFLAGS) + $(FIXMANIFEST) $@.manifest $(DLLSUFFIX) + $(MANIFEST) -manifest $@.manifest -outputresource:$@\;1 +endif + $(BLACKBOXEXEC): $(BLACKBOXEXECOBJ) $(CXX) @EXEOUTPUT@$@ $(BLACKBOXEXECOBJ) $(CXXFLAGS) @@ -2350,6 +2434,10 @@ clean: $(TESTPUBLICRUNNERSMOKEEXE:%=%.rc) $(TESTPUBLICRUNNERSMOKEEXE:%=%.res) $(RMF) $(TESTPUBLICINTSMOKEEXE:%.exe=%.pdb) $(TESTPUBLICINTSMOKEEXE:%=%.manifest) \ $(TESTPUBLICINTSMOKEEXE:%=%.rc) $(TESTPUBLICINTSMOKEEXE:%=%.res) + $(RMF) $(TESTPUBLICSETSMOKEEXE:%.exe=%.pdb) $(TESTPUBLICSETSMOKEEXE:%=%.manifest) \ + $(TESTPUBLICSETSMOKEEXE:%=%.rc) $(TESTPUBLICSETSMOKEEXE:%=%.res) + $(RMF) $(TESTPUBLICFLOATSMOKEEXE:%.exe=%.pdb) $(TESTPUBLICFLOATSMOKEEXE:%=%.manifest) \ + $(TESTPUBLICFLOATSMOKEEXE:%=%.rc) $(TESTPUBLICFLOATSMOKEEXE:%=%.res) $(RMF) $(BLACKBOXEXEC:%.exe=%.pdb) $(BLACKBOXEXEC:%=%.manifest) \ $(BLACKBOXDLL:%$(DLLSUFFIX)=%$(LIBSUFFIX)) \ $(BLACKBOXDLL:%$(DLLSUFFIX)=%$(PDBSUFFIX)) \ @@ -2370,6 +2458,8 @@ veryclean: clean $(RMF) $(TESTEXE) $(RMF) $(TESTPUBLICRUNNERSMOKEEXE) $(RMF) $(TESTPUBLICINTSMOKEEXE) + $(RMF) $(TESTPUBLICSETSMOKEEXE) + $(RMF) $(TESTPUBLICFLOATSMOKEEXE) $(RMF) $(BLACKBOXFIXTURES) $(RMF) $(FLATZINCEXE) $(RMF) doc ChangeLog diff --git a/changelog.in b/changelog.in index a27b454b3e..79375c5e56 100755 --- a/changelog.in +++ b/changelog.in @@ -78,9 +78,9 @@ Module: test What: new Rank: major [DESCRIPTION] -Install reusable core and integer test-harness libraries and headers for CMake -and Autoconf builds, allowing downstream propagator projects to register and -run Gecode-style tests. +Install reusable core, integer, set, and float test-harness libraries and +headers for CMake and Autoconf builds, allowing downstream propagator projects +to register and run Gecode-style tests for every configured variable family. [RELEASE] Version: 6.4.0 diff --git a/cmake/GecodeConfig.cmake.in b/cmake/GecodeConfig.cmake.in index 3840671ecc..98aff4b67b 100644 --- a/cmake/GecodeConfig.cmake.in +++ b/cmake/GecodeConfig.cmake.in @@ -15,6 +15,7 @@ set(_gecode_supported_components gist test) set(_gecode_built_components "@GECODE_LIBRARY_COMPONENTS@") +set(_gecode_test_types "@GECODE_PACKAGE_TEST_TYPES@") if("@GECODE_PACKAGE_HAS_TEST_COMPONENT@" STREQUAL "ON") list(APPEND _gecode_built_components test) endif() @@ -29,6 +30,9 @@ foreach(_gecode_component IN LISTS _gecode_supported_components) set(Gecode_gecode${_gecode_component}_FOUND ${_gecode_component_found}) if(_gecode_component STREQUAL test) set(Gecode_gecodetestint_FOUND ${_gecode_component_found}) + foreach(_gecode_test_type IN LISTS _gecode_test_types) + set(Gecode_gecodetest${_gecode_test_type}_FOUND ${_gecode_component_found}) + endforeach() endif() endforeach() @@ -39,17 +43,24 @@ set(_gecode_unsupported_components) if(Gecode_FIND_COMPONENTS) foreach(_gecode_requested_component IN LISTS Gecode_FIND_COMPONENTS) set(_gecode_canonical_component "${_gecode_requested_component}") + set(_gecode_requested_test_type "") if(_gecode_canonical_component MATCHES "^gecode(.+)$") set(_gecode_canonical_component "${CMAKE_MATCH_1}") endif() - if(_gecode_canonical_component STREQUAL testint) + if(_gecode_canonical_component MATCHES "^test(int|set|float)$") + set(_gecode_requested_test_type "${CMAKE_MATCH_1}") set(_gecode_canonical_component test) endif() if(_gecode_canonical_component IN_LIST _gecode_supported_components) - if(_gecode_canonical_component IN_LIST _gecode_built_components) + if(_gecode_canonical_component IN_LIST _gecode_built_components AND + ("${_gecode_requested_test_type}" STREQUAL "" OR + _gecode_requested_test_type IN_LIST _gecode_test_types)) list(APPEND _gecode_dependency_roots ${_gecode_canonical_component}) if(_gecode_canonical_component STREQUAL test) - list(APPEND Gecode_LIBRARIES Gecode::gecodetest Gecode::gecodetestint) + list(APPEND Gecode_LIBRARIES Gecode::gecodetest) + foreach(_gecode_test_type IN LISTS _gecode_test_types) + list(APPEND Gecode_LIBRARIES Gecode::gecodetest${_gecode_test_type}) + endforeach() else() list(APPEND Gecode_LIBRARIES "Gecode::gecode${_gecode_canonical_component}") endif() @@ -111,6 +122,11 @@ while(_gecode_dependency_queue) set(_gecode_component_dependencies minimodel driver gist) elseif(_gecode_dependency_component STREQUAL test) set(_gecode_component_dependencies search int) + foreach(_gecode_test_type IN LISTS _gecode_test_types) + if(NOT _gecode_test_type STREQUAL int) + list(APPEND _gecode_component_dependencies ${_gecode_test_type}) + endif() + endforeach() endif() foreach(_gecode_component_dependency IN LISTS _gecode_component_dependencies) if(_gecode_component_dependency IN_LIST _gecode_built_components) diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index 1ddd513014..5d07c2777c 100644 --- a/cmake/GecodeSources.cmake +++ b/cmake/GecodeSources.cmake @@ -252,9 +252,19 @@ set(GECODE_TEST_INT_SOURCES test/int.cpp ) +set(GECODE_TEST_SET_SOURCES + test/set.cpp +) + +set(GECODE_TEST_FLOAT_SOURCES + test/float.cpp +) + set(GECODE_TEST_MAIN_SOURCE test/test-main.cpp) set(GECODE_TEST_PUBLIC_RUNNER_SMOKE_SOURCE test/public-runner-smoke.cpp) set(GECODE_TEST_PUBLIC_INT_SMOKE_SOURCE test/public-int-smoke.cpp) +set(GECODE_TEST_PUBLIC_SET_SMOKE_SOURCE test/public-set-smoke.cpp) +set(GECODE_TEST_PUBLIC_FLOAT_SMOKE_SOURCE test/public-float-smoke.cpp) set(GECODE_TEST_SOURCES test/afc.cpp diff --git a/docs/cmake-build.md b/docs/cmake-build.md index b5d5786bd9..8af6e2dbc0 100644 --- a/docs/cmake-build.md +++ b/docs/cmake-build.md @@ -182,11 +182,14 @@ target_link_libraries(app PRIVATE Gecode::gecodedriver) ### Test component for custom propagators A Gecode installation configured with `BUILD_TESTING=ON` exports the `test` -component. It lets downstream projects check custom integer propagators with -Gecode's test runner. - -- Imported targets: `Gecode::gecodetest`, `Gecode::gecodetestint` -- Installed public headers: `test/test.hh`, `test/test.hpp`, `test/int.hh`, `test/int.hpp` +component. It lets downstream projects check custom integer, Boolean, set, and +float propagators with Gecode's test runner. + +- Always available targets: `Gecode::gecodetest`, `Gecode::gecodetestint` +- Set target when configured: `Gecode::gecodetestset` +- Float target when configured: `Gecode::gecodetestfloat` +- Installed headers follow the same configuration: `test/test.*`, `test/int.*`, + `test/set.*`, and `test/float.*` - Public runner entrypoint: `Test::run_registered_tests(argc, argv)` - Package check: `python test/package/verify-installed-test-component.py ...` diff --git a/docs/public-test-harness.md b/docs/public-test-harness.md index 6798ed27c2..e2f4f3ef57 100644 --- a/docs/public-test-harness.md +++ b/docs/public-test-harness.md @@ -1,24 +1,28 @@ # Testing custom propagators -Gecode's test harness can check a custom integer propagator against a small, -independent specification. The test supplies two things: a predicate that says -which complete assignments are valid, and a function that posts the propagator. -The harness compares them while exercising propagation, cloning, pruning, and -search. +Gecode's test harness can check a custom propagator against a small, independent +specification. The test supplies two things: a predicate that says which complete +assignments are valid, and a function that posts the propagator. The harness +compares them while exercising propagation, cloning, pruning, and search. -The installed API covers the core runner and the integer test helpers: +The installed API covers the core runner and every configured variable family: - `Gecode::gecodetest` provides test registration and the runner. - `Gecode::gecodetestint` provides `Test::Int::Test` and links to the core runner. +- `Gecode::gecodetestset` provides `Test::Set::SetTest` when set variables are enabled. +- `Gecode::gecodetestfloat` provides `Test::Float::Test` when float variables are enabled. - `test/test.hh` and `test/test.hpp` declare the core API. - `test/int.hh` and `test/int.hpp` declare the integer helpers. +- `test/set.hh` and `test/set.hpp` declare the set helpers when configured. +- `test/float.hh` and `test/float.hpp` declare the float helpers when configured. -Other helper families under `test/`, including the set, float, branch, assign, -and FlatZinc helpers, are not part of the installed API. +The Boolean tests use the integer helper, matching Gecode's `BoolVar` interface. +The branch, assign, and FlatZinc helpers are internal to Gecode's test suite. ## Install the test component -The test component requires the search and integer modules. With CMake, enable +The test component requires the search and integer modules. Set and float helpers +follow `GECODE_ENABLE_SET_VARS` and `GECODE_ENABLE_FLOAT_VARS`. With CMake, enable testing when configuring Gecode: ```bash @@ -27,8 +31,8 @@ cmake --build build cmake --install build --prefix /path/to/gecode ``` -The Autoconf build installs the same headers and static libraries when search -and integer variables are enabled: +The Autoconf build installs the same configured helper families when search and +integer variables are enabled: ```bash mkdir -p build/legacy @@ -43,8 +47,8 @@ options. ## Link a test executable with CMake -An installed-package consumer needs the `test` component and the integer helper -target: +An installed-package consumer needs the `test` component and the helper target +for its variable family: ```cmake cmake_minimum_required(VERSION 3.21) @@ -57,6 +61,10 @@ target_compile_features(custom-propagator-test PRIVATE cxx_std_17) target_link_libraries(custom-propagator-test PRIVATE Gecode::gecodetestint) ``` +Use `Gecode::gecodetestset` for `Test::Set::SetTest` and +`Gecode::gecodetestfloat` for `Test::Float::Test`. Each target carries the core +runner and the corresponding Gecode module as transitive dependencies. + The target names are identical when Gecode is brought in with `add_subdirectory` or `FetchContent`. Set `BUILD_TESTING` to a true CMake value before adding Gecode so that the test targets exist. @@ -154,6 +162,25 @@ or `RM_PMI` when the propagator supports only part of the reification API. `testsearch` and `testfix` can disable the corresponding checks for constraints where those checks do not apply. +## Set and float tests + +A set test derives from `Test::Set::SetTest`. Its constructor specifies the +number of set variables, their common least upper bound, whether reification is +supported, and the number of accompanying integer variables. `solution()` sees +each set as a bit pattern over that bound; `CountableSetRanges` converts the bit +pattern to ranges when the oracle needs set operations. `post()` receives both +the set and integer arrays. + +A float test derives from `Test::Float::Test`. Its constructor specifies the +arity, interval, sampling step, assignment strategy, and reification support. +Its oracle returns `MT_TRUE`, `MT_FALSE`, or `MT_MAYBE`, which accounts for +interval assignments. The static `cmp()` helper implements the same +three-valued interpretation for Gecode float relations. + +Both families use `run_registered_tests()` and the command-line controls shown +below. Their protected `rms`, `testsearch`, `testfix`, and `testsubsumed` members +select checks that match the propagator's contract. + ## Run and reproduce tests List the registered tests: @@ -188,22 +215,27 @@ rather than an embedding library. ## Link without CMake package metadata -The Autoconf installation provides static `gecodetest` and `gecodetestint` -libraries. A direct compiler invocation must name their dependency closure: +The Autoconf installation provides one static library for each configured +helper family. A direct compiler invocation using integer, set, and float tests +must name their dependency closure: ```bash c++ -std=c++17 -I/include custom-propagator-test.cpp \ -L/lib \ - -lgecodetestint -lgecodetest \ - -lgecodesearch -lgecodeint -lgecodekernel -lgecodesupport + -lgecodetestfloat -lgecodetestset -lgecodetestint -lgecodetest \ + -lgecodefloat -lgecodeset -lgecodesearch -lgecodeint \ + -lgecodekernel -lgecodesupport ``` +Omit the set or float test library and its Gecode module when that variable +family is disabled or unused. + Use the configured `libdir` or `sharedlibdir` instead of `/lib` when the installation uses a different library directory. -The installation does not include `gecode-test`, `public-runner-smoke`, or -`public-int-smoke`. Those executables check Gecode's own build and are not part -of the downstream interface. +The installation does not include `gecode-test` or the `public-*-smoke` +executables. Those programs check Gecode's build and are not part of the +downstream interface. ## Check an installation diff --git a/test/package/public-test-component/CMakeLists.txt b/test/package/public-test-component/CMakeLists.txt index d7802be382..2370ad111b 100644 --- a/test/package/public-test-component/CMakeLists.txt +++ b/test/package/public-test-component/CMakeLists.txt @@ -17,3 +17,11 @@ find_package(Gecode CONFIG REQUIRED COMPONENTS ${_gecode_components}) add_executable(consumer-smoke consumer-smoke.cpp) target_compile_features(consumer-smoke PRIVATE cxx_std_17) target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestint) +if(TARGET Gecode::gecodetestset) + target_compile_definitions(consumer-smoke PRIVATE GECODE_PACKAGE_HAS_SET) + target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestset) +endif() +if(TARGET Gecode::gecodetestfloat) + target_compile_definitions(consumer-smoke PRIVATE GECODE_PACKAGE_HAS_FLOAT) + target_link_libraries(consumer-smoke PRIVATE Gecode::gecodetestfloat) +endif() diff --git a/test/package/public-test-component/consumer-smoke.cpp b/test/package/public-test-component/consumer-smoke.cpp index 0e53a3d330..8bcdf80813 100644 --- a/test/package/public-test-component/consumer-smoke.cpp +++ b/test/package/public-test-component/consumer-smoke.cpp @@ -1,6 +1,18 @@ #include +#ifdef GECODE_PACKAGE_HAS_SET +#include +#endif +#ifdef GECODE_PACKAGE_HAS_FLOAT +#include +#endif #include +#ifdef GECODE_PACKAGE_HAS_SET +#include +#endif +#ifdef GECODE_PACKAGE_HAS_FLOAT +#include +#endif namespace { @@ -22,6 +34,41 @@ class EqualityTest final : public ::Test::Int::Test { } } equality_test; +#ifdef GECODE_PACKAGE_HAS_SET +class SingletonSetTest final : public ::Test::Set::SetTest { +public: + SingletonSetTest() + : ::Test::Set::SetTest("Package::Singleton", 1, Gecode::IntSet(0, 1)) {} + + bool solution(const ::Test::Set::SetAssignment& assignment) const override { + return assignment[0] == 1; + } + + void post(Gecode::Space& home, Gecode::SetVarArray& x, + Gecode::IntVarArray&) override { + Gecode::dom(home, x[0], Gecode::SRT_EQ, Gecode::IntSet(0, 0)); + } +} singleton_set_test; +#endif + +#ifdef GECODE_PACKAGE_HAS_FLOAT +class FloatEqualityTest final : public ::Test::Float::Test { +public: + FloatEqualityTest() + : ::Test::Float::Test("Package::Equality", 2, 0.0, 1.0, 1.0, + ::Test::Float::CPLT_ASSIGNMENT, false) {} + + ::Test::Float::MaybeType + solution(const ::Test::Float::Assignment& assignment) const override { + return cmp(assignment[0], Gecode::FRT_EQ, assignment[1]); + } + + void post(Gecode::Space& home, Gecode::FloatVarArray& x) override { + Gecode::rel(home, x[0], Gecode::FRT_EQ, x[1]); + } +} float_equality_test; +#endif + } // namespace int main(int argc, char* argv[]) { diff --git a/test/package/verify-installed-legacy-test-component.py b/test/package/verify-installed-legacy-test-component.py index 70f95df7a1..103aff6994 100644 --- a/test/package/verify-installed-legacy-test-component.py +++ b/test/package/verify-installed-legacy-test-component.py @@ -12,10 +12,18 @@ from pathlib import Path VERIFIER_PREFIX = "[verify-installed-legacy-test-component]" -EXPECTED_TEST_NAME = "Package::Equality" +EXPECTED_TEST_NAMES = [ + "Float::Package::Equality", + "Int::Package::Equality", + "Set::Package::Singleton", +] LIBRARIES = [ + "gecodetestfloat", + "gecodetestset", "gecodetestint", "gecodetest", + "gecodefloat", + "gecodeset", "gecodesearch", "gecodeint", "gecodekernel", @@ -109,7 +117,12 @@ def run_phase( def resolve_library_dir(prefix: Path) -> Path: phase = "inputs" - required = ["libgecodetest.a", "libgecodetestint.a"] + required = [ + "libgecodetest.a", + "libgecodetestint.a", + "libgecodetestset.a", + "libgecodetestfloat.a", + ] candidates: dict[str, list[Path]] = {} for filename in required: matches = sorted(path.resolve() for path in prefix.rglob(filename) if path.is_file()) @@ -312,8 +325,9 @@ def run_list_phase(consumer_binary: Path, workspace: Path, runtime_env: dict[str env=runtime_env, env_summary=env_summary, ) - assert_phase(EXPECTED_TEST_NAME in result.stdout, "list", f"-list output missing {EXPECTED_TEST_NAME!r}") - sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={EXPECTED_TEST_NAME}\n") + for test_name in EXPECTED_TEST_NAMES: + assert_phase(test_name in result.stdout, "list", f"-list output missing {test_name!r}") + sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={EXPECTED_TEST_NAMES}\n") @@ -325,21 +339,22 @@ def run_filtered_phase( ) -> None: result = run_phase( "filtered-run", - [str(consumer_binary), "-test", EXPECTED_TEST_NAME, "-iter", "1", "-stop", "true"], + [str(consumer_binary), "-test", "Package", "-iter", "1", "-stop", "true"], cwd=workspace, env=runtime_env, env_summary=env_summary, ) - assert_phase(EXPECTED_TEST_NAME in result.stdout, "filtered-run", "filtered run did not print the selected downstream test") + for test_name in EXPECTED_TEST_NAMES: + assert_phase(test_name in result.stdout, "filtered-run", f"filtered run did not print {test_name!r}") assert_phase("+" in result.stdout, "filtered-run", "filtered run did not report success") - sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={EXPECTED_TEST_NAME}\n") + sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={EXPECTED_TEST_NAMES}\n") def write_unsupported_consumer(workspace: Path) -> Path: source = workspace / "unsupported-header.cpp" source.write_text( - "#include \n\n" + "#include \n\n" "int main() {\n" " return 0;\n" "}\n" @@ -351,13 +366,13 @@ def write_unsupported_consumer(workspace: Path) -> Path: def assert_missing_header_diagnostic(result: subprocess.CompletedProcess[str]) -> None: phase = "unsupported-header" combined_output = f"{result.stdout}\n{result.stderr}" - assert_phase("test/set.hh" in combined_output, phase, "missing expected unsupported-header diagnostic target") + assert_phase("test/branch.hh" in combined_output, phase, "missing expected unsupported-header diagnostic target") assert_phase( "file not found" in combined_output or "No such file or directory" in combined_output, phase, "missing missing-header diagnostic wording", ) - sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: diagnostic=test/set.hh missing\n") + sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: diagnostic=test/branch.hh missing\n") diff --git a/test/package/verify-installed-test-component.py b/test/package/verify-installed-test-component.py index 8aee630f5e..c82a204244 100644 --- a/test/package/verify-installed-test-component.py +++ b/test/package/verify-installed-test-component.py @@ -10,7 +10,7 @@ from pathlib import Path -EXPECTED_HEADERS = [ +CORE_HEADERS = [ "test/int.hh", "test/int.hpp", "test/test.hh", @@ -20,7 +20,7 @@ "cmake/Gecode/GecodeConfig.cmake", "cmake/Gecode/GecodeTargets.cmake", ] -EXPECTED_TEST_NAME = "Int::Package::Equality" +CORE_TEST_NAMES = ["Int::Package::Equality"] VERIFIER_PREFIX = "[verify-installed-test-component]" @@ -81,9 +81,23 @@ def assert_phase(condition: bool, phase: str, message: str) -> None: fail_phase(phase, message) -def assert_prefix_surface(include_dir: Path, lib_dir: Path) -> None: +def assert_prefix_surface(include_dir: Path, lib_dir: Path) -> list[str]: phase = "prefix-surface" - for rel in EXPECTED_HEADERS: + targets = (lib_dir / "cmake/Gecode/GecodeTargets.cmake").read_text() + has_set = "Gecode::gecodetestset" in targets + has_float = "Gecode::gecodetestfloat" in targets + expected_headers = list(CORE_HEADERS) + expected_test_names = list(CORE_TEST_NAMES) + if has_set: + expected_headers.extend(["test/set.hh", "test/set.hpp"]) + expected_test_names.append("Set::Package::Singleton") + if has_float: + expected_headers.extend(["test/float.hh", "test/float.hpp"]) + expected_test_names.append("Float::Package::Equality") + expected_headers.sort() + expected_test_names.sort() + + for rel in expected_headers: path = include_dir / rel assert_phase(path.exists(), phase, f"missing installed path: {path}") for rel in EXPECTED_METADATA: @@ -95,13 +109,13 @@ def assert_prefix_surface(include_dir: Path, lib_dir: Path) -> None: for path in (include_dir / "test").glob("*") if path.is_file() ) - assert_phase(installed_test_headers == EXPECTED_HEADERS, phase, f"unexpected installed test headers: {installed_test_headers}") + assert_phase(installed_test_headers == expected_headers, phase, f"unexpected installed test headers: {installed_test_headers}") - targets = (lib_dir / "cmake/Gecode/GecodeTargets.cmake").read_text() assert_phase("Gecode::gecodetest" in targets, phase, "missing Gecode::gecodetest export") assert_phase("Gecode::gecodetestint" in targets, phase, "missing Gecode::gecodetestint export") sys.stdout.write(f"{VERIFIER_PREFIX} {phase}: ok\n") + return expected_test_names def iter_include_dirs(entry: dict[str, object]) -> list[Path]: @@ -223,20 +237,22 @@ def build_consumer(consumer_build: Path) -> Path: return consumer_binary -def run_list_phase(consumer_binary: Path) -> None: +def run_list_phase(consumer_binary: Path, expected_test_names: list[str]) -> None: result = run_phase("list", [str(consumer_binary), "-list"]) - assert_phase(EXPECTED_TEST_NAME in result.stdout, "list", f"-list output missing {EXPECTED_TEST_NAME!r}") - sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={EXPECTED_TEST_NAME}\n") + for test_name in expected_test_names: + assert_phase(test_name in result.stdout, "list", f"-list output missing {test_name!r}") + sys.stdout.write(f"{VERIFIER_PREFIX} list: discovered={expected_test_names}\n") -def run_filtered_phase(consumer_binary: Path) -> None: +def run_filtered_phase(consumer_binary: Path, expected_test_names: list[str]) -> None: result = run_phase( "filtered-run", - [str(consumer_binary), "-test", EXPECTED_TEST_NAME, "-iter", "1", "-stop", "true"], + [str(consumer_binary), "-test", "Package", "-iter", "1", "-stop", "true"], ) - assert_phase(EXPECTED_TEST_NAME in result.stdout, "filtered-run", "filtered run did not print the selected downstream test") + for test_name in expected_test_names: + assert_phase(test_name in result.stdout, "filtered-run", f"filtered run did not print {test_name!r}") assert_phase("+" in result.stdout, "filtered-run", "filtered run did not report success") - sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={EXPECTED_TEST_NAME}\n") + sys.stdout.write(f"{VERIFIER_PREFIX} filtered-run: executed={expected_test_names}\n") def parse_args() -> argparse.Namespace: @@ -258,7 +274,7 @@ def main() -> int: include_dir = Path(args.include_dir).resolve() if args.include_dir else prefix / "include" lib_dir = Path(args.lib_dir).resolve() if args.lib_dir else prefix / "lib" - assert_prefix_surface(include_dir, lib_dir) + expected_test_names = assert_prefix_surface(include_dir, lib_dir) _, consumer_build, configure_result = configure_consumer( source, @@ -274,8 +290,8 @@ def main() -> int: assert_no_source_tree_include_leakage(source, consumer_build, prefix, include_dir) consumer_binary = build_consumer(consumer_build) - run_list_phase(consumer_binary) - run_filtered_phase(consumer_binary) + run_list_phase(consumer_binary, expected_test_names) + run_filtered_phase(consumer_binary, expected_test_names) return 0 diff --git a/test/public-float-smoke.cpp b/test/public-float-smoke.cpp new file mode 100644 index 0000000000..f22777b307 --- /dev/null +++ b/test/public-float-smoke.cpp @@ -0,0 +1,76 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Christian Schulte + * + * Contributing authors: + * Mikael Lagerkvist + * + * Copyright: + * Christian Schulte, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.org + * + * 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 or substantial portions of the 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. + * + */ + + +#include "test/float.hh" + +#include +#include + +namespace { + + int float_runs = 0; + + class FloatSmokeTest : public Test::Float::Test { + public: + FloatSmokeTest(void) + : ::Test::Float::Test("Public::Smoke",1,0.0,1.0,1.0, + ::Test::Float::CPLT_ASSIGNMENT,false) {} + + ::Test::Float::MaybeType + solution(const ::Test::Float::Assignment&) const override { + return ::Test::Float::MT_TRUE; + } + + void post(Gecode::Space&, Gecode::FloatVarArray&) override { + float_runs++; + } + } float_smoke_test; + +} + +int +main(int argc, char* argv[]) { + const int result = Test::run_registered_tests(argc,argv); + if ((result != EXIT_SUCCESS) || (float_runs == 0)) { + std::cerr << "public-float-smoke: helper-backed test did not run" + << std::endl; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +// STATISTICS: test-float diff --git a/test/public-set-smoke.cpp b/test/public-set-smoke.cpp new file mode 100644 index 0000000000..5eb87b7077 --- /dev/null +++ b/test/public-set-smoke.cpp @@ -0,0 +1,76 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Christian Schulte + * + * Contributing authors: + * Mikael Lagerkvist + * + * Copyright: + * Christian Schulte, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.org + * + * 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 or substantial portions of the 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. + * + */ + + +#include "test/set.hh" + +#include +#include +#include + +namespace { + + int set_runs = 0; + + class SetSmokeTest : public Test::Set::SetTest { + public: + SetSmokeTest(void) + : Test::Set::SetTest("Public::Smoke",1,Gecode::IntSet(0,1)) {} + + bool solution(const Test::Set::SetAssignment&) const override { + return true; + } + + void post(Gecode::Space&, Gecode::SetVarArray&, + Gecode::IntVarArray&) override { + set_runs++; + } + } set_smoke_test; + +} + +int +main(int argc, char* argv[]) { + const int result = Test::run_registered_tests(argc,argv); + if ((result != EXIT_SUCCESS) || (set_runs == 0)) { + std::cerr << "public-set-smoke: helper-backed test did not run" + << std::endl; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +// STATISTICS: test-set diff --git a/test/verify-legacy-install-surface.py b/test/verify-legacy-install-surface.py index fd16f32705..dcdb411d4c 100644 --- a/test/verify-legacy-install-surface.py +++ b/test/verify-legacy-install-surface.py @@ -6,15 +6,11 @@ from pathlib import Path VERIFIER_PREFIX = "[verify-legacy-install-surface]" -EXPECTED_HEADERS = ["int.hh", "int.hpp", "test.hh", "test.hpp"] +CORE_HEADERS = ["int.hh", "int.hpp", "test.hh", "test.hpp"] UNSUPPORTED_HEADERS = [ "assign.hh", "branch.hh", - "float.hh", - "float.hpp", "flatzinc.hh", - "set.hh", - "set.hpp", ] UNSUPPORTED_BINARIES = [ "gecode-test", @@ -43,14 +39,28 @@ def is_installed_library_path(prefix: Path, path: Path) -> bool: -def verify_prefix_surface(prefix: Path) -> None: +def configured_types(prefix: Path) -> set[str]: + library_names = {path.name for path in prefix.rglob("*") if path.is_file()} + result = {"int"} + if any(name.startswith("libgecodeset.") for name in library_names): + result.add("set") + if any(name.startswith("libgecodefloat.") for name in library_names): + result.add("float") + return result + + +def verify_prefix_surface(prefix: Path, types: set[str]) -> None: phase = "prefix-surface" include_test_dir = prefix / "include" / "test" assert_phase(include_test_dir.is_dir(), phase, f"missing installed test include dir: {include_test_dir}") installed_headers = sorted(path.name for path in include_test_dir.iterdir() if path.is_file()) - missing_headers = [name for name in EXPECTED_HEADERS if name not in installed_headers] - unexpected_headers = [name for name in installed_headers if name not in EXPECTED_HEADERS] + expected_headers = list(CORE_HEADERS) + for variable_type in ("set", "float"): + if variable_type in types: + expected_headers.extend([f"{variable_type}.hh", f"{variable_type}.hpp"]) + missing_headers = [name for name in expected_headers if name not in installed_headers] + unexpected_headers = [name for name in installed_headers if name not in expected_headers] assert_phase( not missing_headers and not unexpected_headers, phase, @@ -60,11 +70,16 @@ def verify_prefix_surface(prefix: Path) -> None: -def verify_harness_libs(prefix: Path) -> None: +def verify_harness_libs(prefix: Path, types: set[str]) -> None: phase = "harness-libs" expected_locations: list[str] = [] - for library_name, expected_filename in EXPECTED_STATIC_LIBS.items(): + expected_static_libs = dict(EXPECTED_STATIC_LIBS) + for variable_type in ("set", "float"): + if variable_type in types: + expected_static_libs[f"gecodetest{variable_type}"] = f"libgecodetest{variable_type}.a" + + for library_name, expected_filename in expected_static_libs.items(): matches = sorted( path.relative_to(prefix).as_posix() for path in prefix.rglob(expected_filename) @@ -83,8 +98,8 @@ def verify_harness_libs(prefix: Path) -> None: for path in prefix.rglob("*") if path.is_file() and is_installed_library_path(prefix, path) - and path.name.startswith(("libgecodetest", "libgecodetestint")) - and path.name not in EXPECTED_STATIC_LIBS.values() + and path.name.startswith("libgecodetest") + and path.name not in expected_static_libs.values() ) assert_phase( not unexpected_harness_artifacts, @@ -95,10 +110,14 @@ def verify_harness_libs(prefix: Path) -> None: -def verify_unsupported_surface(prefix: Path) -> None: +def verify_unsupported_surface(prefix: Path, types: set[str]) -> None: phase = "unsupported-surface" include_test_dir = prefix / "include" / "test" - unexpected_headers = [name for name in UNSUPPORTED_HEADERS if (include_test_dir / name).exists()] + unsupported_headers = list(UNSUPPORTED_HEADERS) + for variable_type in ("set", "float"): + if variable_type not in types: + unsupported_headers.extend([f"{variable_type}.hh", f"{variable_type}.hpp"]) + unexpected_headers = [name for name in unsupported_headers if (include_test_dir / name).exists()] assert_phase(not unexpected_headers, phase, f"unexpected public helper headers installed: {unexpected_headers}") unexpected_binaries = sorted( @@ -124,9 +143,10 @@ def main() -> int: assert_phase(prefix.is_dir(), "inputs", f"missing installed prefix: {prefix}") - verify_prefix_surface(prefix) - verify_harness_libs(prefix) - verify_unsupported_surface(prefix) + types = configured_types(prefix) + verify_prefix_surface(prefix, types) + verify_harness_libs(prefix, types) + verify_unsupported_surface(prefix, types) return 0