diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1fd88c..b8b62a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: python-version: '3.13' - name: Install hegel - run: pip install hegel-core==0.4.0 + run: pip install hegel-core==0.4.1 - name: Run conformance tests timeout-minutes: 10 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..0c0cf2e --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,10 @@ +RELEASE_TYPE: patch + +Fix the compositional fallback path of `vectors` and `maps` to +correctly honor uniqueness when element/key generators lack a schema. +Previously the fallback for `vectors({.unique = true})` produced a +potentially non-unique vector, and `maps` could loop for an +unbounded number of attempts when the key generator repeatedly returned +duplicates. Both now cap attempts and reject the test case via +`tc.assume()` when the generator cannot satisfy the requested size with +unique values. diff --git a/include/hegel/generators/collections.h b/include/hegel/generators/collections.h index cba55dc..478c57c 100644 --- a/include/hegel/generators/collections.h +++ b/include/hegel/generators/collections.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -7,6 +8,7 @@ #include "hegel/core.h" #include "hegel/generators/numeric.h" +#include "hegel/internal.h" namespace hegel::generators { @@ -94,6 +96,27 @@ namespace hegel::generators { size_t len = length_gen.do_draw(tc); std::vector result; result.reserve(len); + + if constexpr (requires(T a, T b) { a == b; }) { + if (params_.unique) { + size_t max_attempts = len * 10 + 10; + for (size_t attempts = 0; + result.size() < len && attempts < max_attempts; + ++attempts) { + T elem = elements_.do_draw(tc); + if (std::find(result.begin(), result.end(), elem) == + result.end()) { + result.push_back(std::move(elem)); + } + } + // If the element generator can't produce enough unique + // values (e.g. it's heavily constrained), reject this + // test case rather than returning a short vector. + tc.assume(result.size() == len); + return result; + } + } + for (size_t i = 0; i < len; ++i) { result.push_back(elements_.do_draw(tc)); } @@ -220,11 +243,21 @@ namespace hegel::generators { {.min_value = params_.min_size, .max_value = max_size}); size_t len = length_gen.do_draw(tc); std::map result; - while (result.size() < len) { + + size_t max_attempts = len * 10 + 10; + for (size_t attempts = 0; + result.size() < len && attempts < max_attempts; ++attempts) { K key = keys_.do_draw(tc); + if (result.find(key) != result.end()) { + continue; + } V value = values_.do_draw(tc); - result[std::move(key)] = std::move(value); + result.emplace(std::move(key), std::move(value)); } + // If the key generator can't produce enough unique keys + // (e.g. it's heavily constrained), reject this test case + // rather than returning a smaller map. + tc.assume(result.size() == len); return result; } diff --git a/tests/conformance/cpp/metrics.h b/tests/conformance/cpp/metrics.h index b5b466a..dcc8734 100644 --- a/tests/conformance/cpp/metrics.h +++ b/tests/conformance/cpp/metrics.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -36,4 +37,19 @@ namespace conformance { } } + // Wrap a generator in a trivial filter so it loses its schema, + // forcing the compositional fallback path. + template + hegel::generators::Generator + make_non_basic(hegel::generators::Generator gen) { + return gen.filter([](const T&) { return true; }); + } + + inline std::string get_mode(const nlohmann::json& args) { + if (args.contains("mode") && args["mode"].is_string()) { + return args["mode"].get(); + } + return "basic"; + } + } // namespace conformance diff --git a/tests/conformance/cpp/test_hashmaps.cpp b/tests/conformance/cpp/test_hashmaps.cpp index 0485f98..24aa107 100644 --- a/tests/conformance/cpp/test_hashmaps.cpp +++ b/tests/conformance/cpp/test_hashmaps.cpp @@ -25,6 +25,7 @@ int main(int argc, char* argv[]) { int max_key = args["max_key"].get(); int min_value = args["min_value"].get(); int max_value = args["max_value"].get(); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::test( @@ -32,12 +33,16 @@ int main(int argc, char* argv[]) { nlohmann::json metrics; if (key_type == "integer") { - auto gen = - gs::maps(gs::integers( - {.min_value = min_key, .max_value = max_key}), - gs::integers({.min_value = min_value, - .max_value = max_value}), - {.min_size = min_size, .max_size = max_size}); + auto key_gen = gs::integers( + {.min_value = min_key, .max_value = max_key}); + auto val_gen = gs::integers( + {.min_value = min_value, .max_value = max_value}); + auto gen = gs::maps( + mode == "non_basic" ? conformance::make_non_basic(key_gen) + : key_gen, + mode == "non_basic" ? conformance::make_non_basic(val_gen) + : val_gen, + {.min_size = min_size, .max_size = max_size}); auto dict = tc.draw(gen); @@ -61,11 +66,15 @@ int main(int argc, char* argv[]) { } } else { // string keys - auto gen = - gs::maps(gs::text(), - gs::integers({.min_value = min_value, - .max_value = max_value}), - {.min_size = min_size, .max_size = max_size}); + auto text_gen = gs::text(); + auto val_gen = gs::integers( + {.min_value = min_value, .max_value = max_value}); + auto gen = gs::maps( + mode == "non_basic" ? conformance::make_non_basic(text_gen) + : text_gen, + mode == "non_basic" ? conformance::make_non_basic(val_gen) + : val_gen, + {.min_size = min_size, .max_size = max_size}); auto dict = tc.draw(gen); diff --git a/tests/conformance/cpp/test_lists.cpp b/tests/conformance/cpp/test_lists.cpp index c5902fd..74adcda 100644 --- a/tests/conformance/cpp/test_lists.cpp +++ b/tests/conformance/cpp/test_lists.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -30,25 +29,21 @@ int main(int argc, char* argv[]) { args["max_value"].is_null() ? std::nullopt : std::optional(args["max_value"].get()); + bool unique = args["unique"].get(); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::test( [=](hegel::TestCase& tc) { - auto gen = - gs::vectors(gs::integers({.min_value = min_value, - .max_value = max_value}), - {.min_size = min_size, .max_size = max_size}); + auto elem_gen = gs::integers( + {.min_value = min_value, .max_value = max_value}); + auto gen = gs::vectors( + mode == "non_basic" ? conformance::make_non_basic(elem_gen) + : elem_gen, + {.min_size = min_size, .max_size = max_size, .unique = unique}); auto vec = tc.draw(gen); - - nlohmann::json metrics = {{"size", vec.size()}}; - if (!vec.empty()) { - metrics["min_element"] = - *std::min_element(vec.begin(), vec.end()); - metrics["max_element"] = - *std::max_element(vec.begin(), vec.end()); - } - conformance::write_metrics(metrics); + conformance::write_metrics({{"elements", vec}}); }, {.test_cases = test_cases});