Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/tests
diff options
context:
space:
mode:
authorLeitwolf2026-10-23 08:00:25 +0300
committerGitHub2026-10-23 08:00:25 +0300
commit83c6fc3ac13abe83fa39f7b5f8559ccaf4412233 (patch)
treefc298723d8fb7e7212a48e345744650eb18e9678 /tests
parent976522bf2f4421e058e13e2b8f8fb842546c2127 (diff)
parent59d200efe0d6a0645e2ae508cccb43affe874500 (diff)
downloadcatch3-83c6fc3ac13abe83fa39f7b5f8559ccaf4412233.tar.gz
Merge pull request #7 from Progmasoft/feature/native-property-engine
Add a native property-testing engine with choice-sequence shrinking
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt3
-rw-r--r--tests/Progmasoft/BUILD.bazel3
-rw-r--r--tests/Progmasoft/ForAll.tests.cpp314
-rw-r--r--tests/Progmasoft/Stateful.tests.cpp129
-rw-r--r--tests/Progmasoft/Strategy.tests.cpp312
-rw-r--r--tests/meson.build3
6 files changed, 764 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 79660687..3d4db3b3 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -160,8 +160,11 @@ target_link_libraries(SelfTest PRIVATE Catch2WithMain)
add_executable(ProgmasoftSelfTest
Progmasoft/Generators.tests.cpp
Progmasoft/Property.tests.cpp
+ Progmasoft/ForAll.tests.cpp
Progmasoft/Results.tests.cpp
Progmasoft/Snapshot.tests.cpp
+ Progmasoft/Stateful.tests.cpp
+ Progmasoft/Strategy.tests.cpp
)
target_link_libraries(ProgmasoftSelfTest PRIVATE Progmasoft::Catch3 Catch2WithMain)
target_compile_features(ProgmasoftSelfTest PRIVATE cxx_std_20)
diff --git a/tests/Progmasoft/BUILD.bazel b/tests/Progmasoft/BUILD.bazel
index d2eeca92..853d99d4 100644
--- a/tests/Progmasoft/BUILD.bazel
+++ b/tests/Progmasoft/BUILD.bazel
@@ -7,10 +7,13 @@ package(default_visibility = ["//visibility:public"])
cc_test(
name = "property_tests",
srcs = [
+ "ForAll.tests.cpp",
"Generators.tests.cpp",
"Property.tests.cpp",
"Results.tests.cpp",
"Snapshot.tests.cpp",
+ "Stateful.tests.cpp",
+ "Strategy.tests.cpp",
],
copts = select({
"@platforms//os:windows": ["/std:c++20"],
diff --git a/tests/Progmasoft/ForAll.tests.cpp b/tests/Progmasoft/ForAll.tests.cpp
new file mode 100644
index 00000000..b88477a6
--- /dev/null
+++ b/tests/Progmasoft/ForAll.tests.cpp
@@ -0,0 +1,314 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3.hpp>
+
+#include <algorithm>
+#include <cstdint>
+#include <filesystem>
+#include <numeric>
+#include <set>
+#include <stdexcept>
+#include <string>
+#include <vector>
+
+namespace {
+
+ using Progmasoft::Catch3::ForAll;
+ using Progmasoft::Catch3::PropertyOptions;
+ using Progmasoft::Catch3::PropertyStatus;
+ using Progmasoft::Catch3::SizeRange;
+ namespace Gen = Progmasoft::Catch3::Gen;
+
+ PropertyOptions Quick( std::size_t trials = 200 ) {
+ PropertyOptions options;
+ options.Trials = trials;
+ options.Seed = 0xCA7C3;
+ options.MaxShrinkSteps = 5000;
+ return options;
+ }
+
+} // namespace
+
+TEST_CASE( "ForAll passes and counts its trials",
+ "[progmasoft][forall]" ) {
+ const auto result = ForAll( Quick( 150 ),
+ Gen::Integers<int>( -100, 100 ),
+ Gen::Integers<int>( -100, 100 ),
+ []( int left, int right ) {
+ return left + right == right + left;
+ } );
+ CHECK( result.Status == PropertyStatus::kPassed );
+ CHECK( result.TrialsRun == 150 );
+ CHECK( result.Describe() == "property passed: 150 trials" );
+}
+
+TEST_CASE( "ForAll shrinks integers to the boundary of the failure",
+ "[progmasoft][forall]" ) {
+ const auto result = ForAll(
+ Quick(), Gen::Integers<int>(), []( int value ) { return value <= 1000; } );
+ REQUIRE( result.Status == PropertyStatus::kFailed );
+ REQUIRE( result.Failure.has_value() );
+ CHECK( result.Failure->Counterexample == "1001 (0x3e9)" );
+ CHECK( result.Failure->Reason == "property returned false" );
+ CHECK( result.Failure->Replay.rfind( "c3.", 0 ) == 0 );
+}
+
+TEST_CASE( "ForAll shrinks collections to their essential element",
+ "[progmasoft][forall]" ) {
+ const auto result = ForAll(
+ Quick(),
+ Gen::Vectors( Gen::Integers<int>( 0, 100 ) ),
+ []( const std::vector<int>& values ) {
+ return std::none_of( values.begin(), values.end(),
+ []( int value ) { return value >= 10; } );
+ } );
+ REQUIRE( result.Status == PropertyStatus::kFailed );
+ CHECK( result.Failure->Counterexample == "{ 10 }" );
+}
+
+TEST_CASE( "ForAll shrinks through dependent and filtered strategies",
+ "[progmasoft][forall]" ) {
+ const auto dependent =
+ Gen::Integers<int>( 0, 20 ).FlatMap( []( int size ) {
+ return Gen::Vectors(
+ Gen::Integers<int>( 0, 100 ),
+ SizeRange{ static_cast<std::size_t>( size ),
+ static_cast<std::size_t>( size ) } );
+ } );
+ const auto sized = ForAll( Quick(), dependent,
+ []( const std::vector<int>& values ) {
+ return values.size() < 3;
+ } );
+ REQUIRE( sized.Status == PropertyStatus::kFailed );
+ CHECK( sized.Failure->Counterexample == "{ 0, 0, 0 }" );
+
+ const auto even = Gen::Integers<int>( 0, 100000 ).Filter(
+ []( int value ) { return value % 2 == 0; } );
+ const auto filtered =
+ ForAll( Quick(), even, []( int value ) { return value <= 100; } );
+ INFO( filtered.Describe() );
+ REQUIRE( filtered.Status == PropertyStatus::kFailed );
+ CHECK( filtered.Failure->Counterexample == "102" );
+
+ const auto mapped = ForAll(
+ Quick(),
+ Gen::Integers<int>( 0, 1000 ).Map( []( int value ) {
+ return std::to_string( value );
+ } ),
+ []( const std::string& text ) { return text.size() < 3; } );
+ REQUIRE( mapped.Status == PropertyStatus::kFailed );
+ CHECK( mapped.Failure->Counterexample == "\"100\"" );
+}
+
+TEST_CASE( "ForAll shrinks several arguments together",
+ "[progmasoft][forall]" ) {
+ const auto result = ForAll( Quick(),
+ Gen::Integers<int>( 0, 1000 ),
+ Gen::Integers<int>( 0, 1000 ),
+ []( int left, int right ) {
+ return left + right < 100;
+ } );
+ REQUIRE( result.Status == PropertyStatus::kFailed );
+ CHECK( result.Failure->Counterexample == "{ 0, 100 }" );
+}
+
+TEST_CASE( "A replay token reproduces exactly the reported input",
+ "[progmasoft][forall]" ) {
+ const auto body = []( const std::vector<int>& values ) {
+ return std::accumulate( values.begin(), values.end(), 0 ) < 50;
+ };
+ const auto strategy = Gen::Vectors( Gen::Integers<int>( 0, 30 ) );
+ const auto first = ForAll( Quick(), strategy, body );
+ REQUIRE( first.Status == PropertyStatus::kFailed );
+
+ auto options = Quick();
+ options.Replay = first.Failure->Replay;
+ const auto replayed = ForAll( options, strategy, body );
+ REQUIRE( replayed.Status == PropertyStatus::kFailed );
+ CHECK( replayed.TrialsRun == 1 );
+ CHECK( replayed.Failure->Counterexample == first.Failure->Counterexample );
+ CHECK( replayed.Failure->Replay == first.Failure->Replay );
+ CHECK( first.Describe().find( "replay: set PropertyOptions::Replay to \"" +
+ first.Failure->Replay + "\"" ) !=
+ std::string::npos );
+
+ // The same options give the same report.
+ CHECK( ForAll( Quick(), strategy, body ).Describe() == first.Describe() );
+
+ options.Replay = "not a token";
+ CHECK( ForAll( options, strategy, body ).Status ==
+ PropertyStatus::kInvalidConfiguration );
+}
+
+TEST_CASE( "Property bodies assert, assume and note without exceptions",
+ "[progmasoft][forall]" ) {
+ const auto failing = ForAll(
+ Quick(), Gen::Integers<int>( 0, 1000 ), []( int value ) {
+ Progmasoft::Catch3::Note( "half", value / 2 );
+ CATCH3_PROPERTY_ASSERT( value < 500 );
+ } );
+ REQUIRE( failing.Status == PropertyStatus::kFailed );
+ CHECK( failing.Failure->Counterexample == "500 (0x1f4)" );
+ CHECK( failing.Failure->Reason.find(
+ "CATCH3_PROPERTY_ASSERT( value < 500 )" ) == 0 );
+ CHECK( failing.Failure->Notes == std::vector<std::string>{ "half = 250" } );
+
+ const auto assumed = ForAll(
+ Quick(), Gen::Integers<int>( 0, 9 ), []( int value ) {
+ CATCH3_PROPERTY_ASSUME( value % 2 == 0 );
+ CATCH3_PROPERTY_ASSERT( value % 2 == 0 );
+ } );
+ CHECK( assumed.Status == PropertyStatus::kPassed );
+ CHECK( assumed.Discarded > 0 );
+
+ auto strict = Quick( 50 );
+ strict.MaxDiscardRatio = 1;
+ const auto gaveUp = ForAll( strict, Gen::Integers<int>( 0, 9 ),
+ []( int ) { CATCH3_PROPERTY_ASSUME( false ); } );
+ CHECK( gaveUp.Status == PropertyStatus::kGaveUp );
+ CHECK( gaveUp.Describe().find( "property gave up" ) == 0 );
+}
+
+TEST_CASE( "Exceptions from bodies fail and from strategies error",
+ "[progmasoft][forall]" ) {
+ const auto throwing = ForAll(
+ Quick(), Gen::Integers<int>( 0, 100 ), []( int value ) -> bool {
+ if ( value > 10 ) { throw std::runtime_error( "too big" ); }
+ return true;
+ } );
+ REQUIRE( throwing.Status == PropertyStatus::kFailed );
+ CHECK( throwing.Failure->Counterexample == "11" );
+ CHECK( throwing.Failure->Reason ==
+ "property threw std::exception: too big" );
+
+ const auto broken = ForAll(
+ Quick(),
+ Gen::Integers<int>( 0, 10 ).Map( []( int ) -> int {
+ throw std::runtime_error( "cannot build" );
+ } ),
+ []( int ) { return true; } );
+ CHECK( broken.Status == PropertyStatus::kErrored );
+ CHECK( broken.Error == "strategy threw std::exception: cannot build" );
+}
+
+TEST_CASE( "Classify and Cover report and enforce statistics",
+ "[progmasoft][forall]" ) {
+ auto options = Quick( 400 );
+ options.ShowStatistics = true;
+ const auto covered = ForAll(
+ options, Gen::Integers<int>( -100, 100 ), []( int value ) {
+ Progmasoft::Catch3::Classify( value == 0, "zero" );
+ Progmasoft::Catch3::Cover( 20.0, value < 0, "negative" );
+ return true;
+ } );
+ CHECK( covered.Status == PropertyStatus::kPassed );
+ CHECK( covered.Describe().find( "negative:" ) != std::string::npos );
+
+ const auto uncovered = ForAll(
+ Quick( 400 ), Gen::Integers<int>( 0, 1000 ), []( int value ) {
+ Progmasoft::Catch3::Cover( 50.0, value == 7, "seven" );
+ return true;
+ } );
+ CHECK( uncovered.Status == PropertyStatus::kInsufficientCoverage );
+ CHECK( uncovered.Describe().find( "required 50%" ) != std::string::npos );
+}
+
+TEST_CASE( "Targeted search runs after the regular trials",
+ "[progmasoft][forall]" ) {
+ auto options = Quick( 100 );
+ options.TargetSteps = 300;
+ const auto result = ForAll(
+ options, Gen::Integers<std::int64_t>( 0, 1LL << 40 ), []( std::int64_t value ) {
+ Progmasoft::Catch3::Target( static_cast<double>( value ) );
+ return value < ( 1LL << 39 ) + ( 1LL << 38 );
+ } );
+ // Climbing towards the largest values must reach the failing region.
+ REQUIRE( result.Status == PropertyStatus::kFailed );
+ CHECK( result.Failure->Counterexample == "824633720832 (0xc000000000)" );
+}
+
+TEST_CASE( "The example database replays a stored failure first",
+ "[progmasoft][forall]" ) {
+ const auto directory = std::filesystem::temp_directory_path() /
+ "catch3-property-database-test";
+ std::filesystem::remove_all( directory );
+ auto options = Quick();
+ options.DatabasePath = directory.string();
+ options.Name = "database test";
+ int threshold = 200;
+ const auto body = [&]( int value ) { return value < threshold; };
+
+ const auto first = ForAll( options, Gen::Integers<int>( 0, 1000 ), body );
+ REQUIRE( first.Status == PropertyStatus::kFailed );
+ CHECK( first.Failure->Counterexample == "200" );
+ CHECK( !std::filesystem::is_empty( directory ) );
+
+ // A new seed would need its own trials; the stored example fails at
+ // once.
+ options.Seed = 0xD1FFE7;
+ const auto second = ForAll( options, Gen::Integers<int>( 0, 1000 ), body );
+ REQUIRE( second.Status == PropertyStatus::kFailed );
+ CHECK( second.TrialsRun == 1 );
+ CHECK( second.Failure->Counterexample == "200" );
+
+ // Once fixed, the example passes and is dropped.
+ threshold = 2000;
+ const auto fixed = ForAll( options, Gen::Integers<int>( 0, 1000 ), body );
+ CHECK( fixed.Status == PropertyStatus::kPassed );
+ CHECK( std::filesystem::is_empty( directory ) );
+ std::filesystem::remove_all( directory );
+}
+
+TEST_CASE( "Relations check differential, metamorphic and round-trip oracles",
+ "[progmasoft][forall][relations]" ) {
+ using namespace Progmasoft::Catch3;
+ const auto sorted = []( std::vector<int> values ) {
+ std::sort( values.begin(), values.end() );
+ return values;
+ };
+ // A "fast" sort that loses duplicates.
+ const auto lossy = []( const std::vector<int>& values ) {
+ const std::set<int> unique( values.begin(), values.end() );
+ return std::vector<int>( unique.begin(), unique.end() );
+ };
+ const auto differential = ForAll(
+ Quick(), Gen::Vectors( Gen::Integers<int>( 0, 50 ) ),
+ Equivalent( sorted, lossy ) );
+ REQUIRE( differential.Status == PropertyStatus::kFailed );
+ CHECK( differential.Failure->Counterexample == "{ 0, 0 }" );
+ CHECK( differential.Failure->Notes ==
+ std::vector<std::string>{ "reference: { 0, 0 }",
+ "candidate: { 0 }" } );
+
+ const auto sum = []( const std::vector<int>& values ) {
+ return std::accumulate( values.begin(), values.end(), 0LL );
+ };
+ const auto reversed = []( std::vector<int> values ) {
+ std::reverse( values.begin(), values.end() );
+ return values;
+ };
+ CHECK( ForAll( Quick(), Gen::Vectors( Gen::Integers<int>( -9, 9 ) ),
+ Metamorphic( sum, reversed, std::equal_to<>() ) )
+ .Succeeded() );
+
+ const auto encode = []( int value ) { return std::to_string( value ); };
+ const auto decode = []( const std::string& text ) {
+ return std::stoi( text );
+ };
+ CHECK( ForAll( Quick(), Gen::Integers<int>(), RoundTrip( encode, decode ) )
+ .Succeeded() );
+ CHECK( ForAll( Quick(), Gen::Vectors( Gen::Integers<int>( 0, 9 ) ),
+ Idempotent( sorted ) )
+ .Succeeded() );
+}
+
+TEST_CASE( "CATCH3_CHECK_FOR_ALL reports through Catch2",
+ "[progmasoft][forall]" ) {
+ CATCH3_CHECK_FOR_ALL( Gen::Integers<int>( 0, 100 ),
+ []( int value ) { return value >= 0; } );
+ CATCH3_REQUIRE_FOR_ALL( Quick( 20 ),
+ Gen::Strings( Gen::PrintableCharacters() ),
+ []( const std::string& text ) {
+ return text.size() == text.length();
+ } );
+}
diff --git a/tests/Progmasoft/Stateful.tests.cpp b/tests/Progmasoft/Stateful.tests.cpp
new file mode 100644
index 00000000..48e2c918
--- /dev/null
+++ b/tests/Progmasoft/Stateful.tests.cpp
@@ -0,0 +1,129 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3.hpp>
+
+#include <cstddef>
+#include <deque>
+#include <string>
+#include <vector>
+
+namespace {
+
+ using Progmasoft::Catch3::Action;
+ using Progmasoft::Catch3::Command;
+ using Progmasoft::Catch3::PropertyOptions;
+ using Progmasoft::Catch3::PropertyStatus;
+ using Progmasoft::Catch3::SizeRange;
+ namespace Gen = Progmasoft::Catch3::Gen;
+
+ // A queue with a bug: past three elements it silently drops the
+ // oldest one.
+ class SmallQueue {
+ public:
+ void Push( int value ) {
+ if ( values_.size() == 3 ) { values_.pop_front(); }
+ values_.push_back( value );
+ }
+ int Pop() {
+ const int value = values_.front();
+ values_.pop_front();
+ return value;
+ }
+ [[nodiscard]] std::size_t Size() const { return values_.size(); }
+
+ private:
+ std::deque<int> values_;
+ };
+
+ using Model = std::deque<int>;
+
+ std::vector<Command<Model, SmallQueue>> QueueCommands() {
+ Command<Model, SmallQueue> push{
+ "push",
+ []( const Model& ) {
+ return Gen::Integers<int>( 0, 100 ).Map( []( int value ) {
+ return Action<Model, SmallQueue>{
+ "push(" + std::to_string( value ) + ")",
+ [value]( Model& model, SmallQueue& queue ) {
+ model.push_back( value );
+ queue.Push( value );
+ CATCH3_PROPERTY_ASSERT( queue.Size() ==
+ model.size() );
+ } };
+ } );
+ } };
+ Command<Model, SmallQueue> pop{
+ "pop",
+ []( const Model& ) {
+ return Gen::Just( Action<Model, SmallQueue>{
+ "pop", []( Model& model, SmallQueue& queue ) {
+ const int expected = model.front();
+ model.pop_front();
+ CATCH3_PROPERTY_ASSERT( queue.Pop() == expected );
+ } } );
+ },
+ []( const Model& model ) { return !model.empty(); } };
+ return { push, pop };
+ }
+
+} // namespace
+
+TEST_CASE( "Stateful tests find and shrink a failing command sequence",
+ "[progmasoft][stateful]" ) {
+ PropertyOptions options;
+ options.Trials = 300;
+ options.Seed = 0x57A7E;
+ options.MaxShrinkSteps = 5000;
+ const auto result = Progmasoft::Catch3::CheckStateful<Model, SmallQueue>(
+ options,
+ [] { return Model{}; },
+ []( const Model& ) { return SmallQueue{}; },
+ QueueCommands() );
+ REQUIRE( result.Status == PropertyStatus::kFailed );
+ // The shortest failure: the fourth push drops an element.
+ CHECK( result.Failure->Counterexample ==
+ "{ push(0), push(0), push(0), push(0) }" );
+ CHECK( result.Failure->Reason.find(
+ "CATCH3_PROPERTY_ASSERT( queue.Size() == model.size() )" ) ==
+ 0 );
+}
+
+TEST_CASE( "Stateful tests pass for a correct system and respect preconditions",
+ "[progmasoft][stateful]" ) {
+ struct Correct {
+ std::deque<int> Values;
+ };
+ Command<Model, Correct> push{
+ "push", []( const Model& ) {
+ return Gen::Integers<int>( 0, 9 ).Map( []( int value ) {
+ return Action<Model, Correct>{
+ "push", [value]( Model& model, Correct& system ) {
+ model.push_back( value );
+ system.Values.push_back( value );
+ } };
+ } );
+ } };
+ Command<Model, Correct> pop{
+ "pop",
+ []( const Model& ) {
+ return Gen::Just( Action<Model, Correct>{
+ "pop", []( Model& model, Correct& system ) {
+ // The precondition guarantees an element.
+ CATCH3_PROPERTY_ASSERT( !model.empty() );
+ CATCH3_PROPERTY_ASSERT( system.Values.front() ==
+ model.front() );
+ model.pop_front();
+ system.Values.pop_front();
+ } } );
+ },
+ []( const Model& model ) { return !model.empty(); } };
+ PropertyOptions options;
+ options.Trials = 200;
+ const auto result = Progmasoft::Catch3::CheckStateful<Model, Correct>(
+ options,
+ [] { return Model{}; },
+ []( const Model& ) { return Correct{}; },
+ { push, pop },
+ SizeRange{ 0, 50 } );
+ CHECK( result.Status == PropertyStatus::kPassed );
+}
diff --git a/tests/Progmasoft/Strategy.tests.cpp b/tests/Progmasoft/Strategy.tests.cpp
new file mode 100644
index 00000000..5a1d5a2a
--- /dev/null
+++ b/tests/Progmasoft/Strategy.tests.cpp
@@ -0,0 +1,312 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3.hpp>
+
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <limits>
+#include <map>
+#include <optional>
+#include <set>
+#include <stdexcept>
+#include <string>
+#include <vector>
+
+namespace {
+
+ using Progmasoft::Catch3::ChoiceSource;
+ using Progmasoft::Catch3::SizeRange;
+ using Progmasoft::Catch3::Strategy;
+ namespace Gen = Progmasoft::Catch3::Gen;
+
+ template <typename ValueType>
+ std::vector<ValueType> Sample( const Strategy<ValueType>& strategy,
+ std::size_t count,
+ std::uint64_t seed ) {
+ std::vector<ValueType> values;
+ for ( std::size_t index = 0; index < count; ++index ) {
+ ChoiceSource source( seed + index, index % 101 );
+ auto value = strategy.Draw( source );
+ if ( value.has_value() ) { values.push_back( *value ); }
+ }
+ return values;
+ }
+
+ // Draws once, then replays the recorded choices: the same value must
+ // come back, which is what shrinking and replay tokens rely on.
+ template <typename ValueType>
+ bool ReplaysIdentically( const Strategy<ValueType>& strategy,
+ std::uint64_t seed ) {
+ ChoiceSource original( seed, 60 );
+ const auto value = strategy.Draw( original );
+ const auto choices = original.Choices();
+ ChoiceSource replay( choices, 60 );
+ const auto again = strategy.Draw( replay );
+ return value == again && replay.Choices() == choices &&
+ !replay.Overran();
+ }
+
+} // namespace
+
+TEST_CASE( "Choice sources replay, clamp and run out to zero",
+ "[progmasoft][strategy]" ) {
+ const std::vector<std::uint64_t> recorded{ 3, 99, 1 };
+ ChoiceSource replay( recorded, 10 );
+ CHECK( replay.Draw( 10 ) == 3 );
+ CHECK( replay.Draw( 7 ) == 7 ); // clamped to the new maximum
+ CHECK( replay.DrawBoolean( 0.0 ) );
+ CHECK_FALSE( replay.Overran() );
+ CHECK( replay.Draw( 5 ) == 0 ); // past the end: the simplest choice
+ CHECK( replay.Overran() );
+ CHECK( replay.Choices() == std::vector<std::uint64_t>{ 3, 7, 1, 0 } );
+
+ ChoiceSource generated( 42, 50 );
+ for ( int index = 0; index < 1000; ++index ) {
+ CHECK( generated.Draw( 9 ) <= 9 );
+ CHECK( generated.DrawBiased( 1000 ) <= 1000 );
+ }
+ CHECK( generated.Choices().size() == 2000 );
+}
+
+TEST_CASE( "Integer strategies respect every kind of range",
+ "[progmasoft][strategy]" ) {
+ const auto crossing = Gen::Integers<int>( -50, 70 );
+ for ( int value : Sample( crossing, 2000, 1 ) ) {
+ CHECK( value >= -50 );
+ CHECK( value <= 70 );
+ }
+ const auto negative = Gen::Integers<std::int64_t>( -900, -100 );
+ for ( auto value : Sample( negative, 2000, 2 ) ) {
+ CHECK( value >= -900 );
+ CHECK( value <= -100 );
+ }
+ const auto positive = Gen::Integers<std::uint8_t>( 10, 20 );
+ for ( auto value : Sample( positive, 2000, 3 ) ) {
+ CHECK( value >= 10 );
+ CHECK( value <= 20 );
+ }
+ const auto full = Gen::Integers<std::int64_t>();
+ const auto values = Sample( full, 4000, 4 );
+ CHECK( std::any_of( values.begin(), values.end(), []( auto value ) {
+ return value == std::numeric_limits<std::int64_t>::max();
+ } ) );
+ CHECK( std::any_of( values.begin(), values.end(), []( auto value ) {
+ return value < 0;
+ } ) );
+ const auto upToZero = Gen::Integers<std::int64_t>(
+ std::numeric_limits<std::int64_t>::lowest(), 0 );
+ for ( auto value : Sample( upToZero, 2000, 5 ) ) { CHECK( value <= 0 ); }
+
+ // The simplest choices give the value closest to zero.
+ const std::vector<std::uint64_t> zeros;
+ ChoiceSource simplest( zeros, 0 );
+ CHECK( crossing.Draw( simplest ) == 0 );
+ ChoiceSource simplestNegative( zeros, 0 );
+ CHECK( negative.Draw( simplestNegative ) == -100 );
+ ChoiceSource simplestPositive( zeros, 0 );
+ CHECK( positive.Draw( simplestPositive ) == 10 );
+
+ CHECK_THROWS_AS( Gen::Integers<int>( 2, 1 ), std::invalid_argument );
+ for ( std::uint64_t seed = 0; seed < 50; ++seed ) {
+ CHECK( ReplaysIdentically( full, seed ) );
+ }
+}
+
+TEST_CASE( "Float strategies stay finite and in range unless allowed",
+ "[progmasoft][strategy]" ) {
+ using Progmasoft::Catch3::FloatOptions;
+ const auto bounded = Gen::Floats<double>(
+ FloatOptions{ .Minimum = -2.5, .Maximum = 1000.0 } );
+ for ( double value : Sample( bounded, 3000, 6 ) ) {
+ CHECK( std::isfinite( value ) );
+ CHECK( value >= -2.5 );
+ CHECK( value <= 1000.0 );
+ }
+ const auto everything = Gen::Floats<double>(
+ FloatOptions{ .AllowNaN = true, .AllowInfinity = true } );
+ const auto values = Sample( everything, 3000, 7 );
+ CHECK( std::any_of( values.begin(), values.end(), []( double value ) {
+ return std::isnan( value );
+ } ) );
+ CHECK( std::any_of( values.begin(), values.end(), []( double value ) {
+ return std::isinf( value );
+ } ) );
+ const auto positive = Gen::Floats<float>(
+ FloatOptions{ .Minimum = 1.0, .Maximum = 2.0 } );
+ const std::vector<std::uint64_t> zeros;
+ ChoiceSource simplest( zeros, 0 );
+ CHECK( positive.Draw( simplest ) == 1.0F );
+ CHECK_THROWS_AS( Gen::Floats<double>( FloatOptions{ .Minimum = 2.0,
+ .Maximum = 1.0 } ),
+ std::invalid_argument );
+}
+
+TEST_CASE( "Collection strategies respect their size ranges",
+ "[progmasoft][strategy]" ) {
+ const auto vectors = Gen::Vectors( Gen::Integers<int>( 0, 9 ),
+ SizeRange{ 2, 5 } );
+ for ( const auto& value : Sample( vectors, 1000, 8 ) ) {
+ CHECK( value.size() >= 2 );
+ CHECK( value.size() <= 5 );
+ }
+ const auto unique = Gen::UniqueVectors( Gen::Integers<int>( 0, 30 ),
+ SizeRange{ 0, 20 } );
+ for ( const auto& value : Sample( unique, 1000, 9 ) ) {
+ CHECK( std::set<int>( value.begin(), value.end() ).size() ==
+ value.size() );
+ }
+ const auto sets = Gen::Sets( Gen::Integers<int>( 0, 3 ),
+ SizeRange{ 4, 4 } );
+ for ( const auto& value : Sample( sets, 200, 10 ) ) {
+ CHECK( value == std::set<int>{ 0, 1, 2, 3 } );
+ }
+ const auto maps = Gen::Maps( Gen::Integers<int>( 0, 100 ),
+ Gen::Booleans(),
+ SizeRange{ 1, 10 } );
+ for ( const auto& value : Sample( maps, 500, 11 ) ) {
+ CHECK( !value.empty() );
+ CHECK( value.size() <= 10 );
+ }
+ const auto permutations = Gen::Permutations( std::vector<int>{ 1, 2, 3, 4, 5 } );
+ for ( auto value : Sample( permutations, 300, 12 ) ) {
+ std::sort( value.begin(), value.end() );
+ CHECK( value == std::vector<int>{ 1, 2, 3, 4, 5 } );
+ }
+ const std::vector<std::uint64_t> zeros;
+ ChoiceSource simplest( zeros, 0 );
+ CHECK( permutations.Draw( simplest ) ==
+ std::vector<int>{ 1, 2, 3, 4, 5 } );
+ CHECK_THROWS_AS( Gen::Vectors( Gen::Booleans(), SizeRange{ 3, 2 } ),
+ std::invalid_argument );
+ for ( std::uint64_t seed = 0; seed < 50; ++seed ) {
+ CHECK( ReplaysIdentically( vectors, seed ) );
+ CHECK( ReplaysIdentically( maps, seed ) );
+ }
+}
+
+TEST_CASE( "Text strategies produce valid UTF-8 without surrogates",
+ "[progmasoft][strategy]" ) {
+ const auto text = Gen::Utf8Strings( SizeRange{ 0, 30 }, 0, 0x10FFFF );
+ for ( const auto& value : Sample( text, 1000, 13 ) ) {
+ for ( std::size_t index = 0; index + 1 < value.size(); ++index ) {
+ const auto lead = static_cast<unsigned char>( value[index] );
+ const auto next = static_cast<unsigned char>( value[index + 1] );
+ // ED A0..BF would encode a surrogate.
+ CHECK_FALSE( ( lead == 0xED && next >= 0xA0 ) );
+ }
+ }
+ const auto printable = Gen::Strings( Gen::PrintableCharacters(),
+ SizeRange{ 1, 8 } );
+ for ( const auto& value : Sample( printable, 500, 14 ) ) {
+ CHECK( !value.empty() );
+ CHECK( value.size() <= 8 );
+ CHECK( std::all_of( value.begin(), value.end(), []( char c ) {
+ return c >= 0x20 && c < 0x7F;
+ } ) );
+ }
+}
+
+TEST_CASE( "Combinators compose and keep replays stable",
+ "[progmasoft][strategy]" ) {
+ const auto even = Gen::Integers<int>( 0, 1000 ).Filter(
+ []( int value ) { return value % 2 == 0; } );
+ for ( int value : Sample( even, 500, 15 ) ) { CHECK( value % 2 == 0 ); }
+
+ const auto sized = Gen::Integers<int>( 0, 6 ).FlatMap( []( int size ) {
+ return Gen::Vectors( Gen::Just( size ),
+ SizeRange{ static_cast<std::size_t>( size ),
+ static_cast<std::size_t>( size ) } );
+ } );
+ for ( const auto& value : Sample( sized, 500, 16 ) ) {
+ CHECK( std::all_of( value.begin(), value.end(), [&]( int element ) {
+ return element == static_cast<int>( value.size() );
+ } ) );
+ }
+
+ const auto mixed = Gen::OneOf( Gen::Just( std::string( "a" ) ),
+ Gen::Just( std::string( "b" ) ) );
+ const auto mixedValues = Sample( mixed, 200, 17 );
+ CHECK( std::count( mixedValues.begin(), mixedValues.end(), "a" ) > 0 );
+ CHECK( std::count( mixedValues.begin(), mixedValues.end(), "b" ) > 0 );
+
+ const auto weighted = Gen::Frequency<int>( { { 1, Gen::Just( 0 ) },
+ { 9, Gen::Just( 1 ) } } );
+ const auto weightedValues = Sample( weighted, 2000, 18 );
+ CHECK( std::count( weightedValues.begin(), weightedValues.end(), 1 ) >
+ std::count( weightedValues.begin(), weightedValues.end(), 0 ) );
+
+ const auto trees = Gen::Recursive(
+ Gen::Just( 1 ),
+ []( const Strategy<int>& children ) {
+ return Gen::Tuples( children, children )
+ .Map( []( std::tuple<int, int> pair ) {
+ return std::get<0>( pair ) + std::get<1>( pair );
+ } );
+ },
+ 4 );
+ for ( int leaves : Sample( trees, 500, 19 ) ) {
+ CHECK( leaves >= 1 );
+ CHECK( leaves <= 16 ); // depth 4 at most: 2^4 leaves
+ }
+
+ const auto never = Gen::Integers<int>( 0, 10 ).Filter(
+ []( int ) { return false; }, 5 );
+ ChoiceSource source( 1, 50 );
+ CHECK_FALSE( never.Draw( source ).has_value() );
+}
+
+TEST_CASE( "Replay tokens round-trip and reject malformed text",
+ "[progmasoft][strategy]" ) {
+ using Progmasoft::Catch3::Detail::DecodeChoices;
+ using Progmasoft::Catch3::Detail::EncodeChoices;
+ const std::vector<std::uint64_t> choices{
+ 0, 1, 35, 36, std::numeric_limits<std::uint64_t>::max() };
+ const auto token = EncodeChoices( 73, choices );
+ CHECK( token == "c3.21.0-1-z-10-3w5e11264sgsf" );
+ const auto decoded = DecodeChoices( token );
+ REQUIRE( decoded.has_value() );
+ CHECK( decoded->Size == 73 );
+ CHECK( decoded->Choices == choices );
+
+ const auto empty = DecodeChoices( EncodeChoices( 0, {} ) );
+ REQUIRE( empty.has_value() );
+ CHECK( empty->Choices.empty() );
+
+ for ( const char* malformed :
+ { "", "c3.", "c3.1", "x3.1.2", "c3.1.2-", "c3.1.-2", "c3.2t.1",
+ "c3.1.2--3", "c3.1.zzzzzzzzzzzzzzz" } ) {
+ CHECK_FALSE( DecodeChoices( malformed ).has_value() );
+ }
+}
+
+TEST_CASE( "The choice shrinker finds simple failing sequences",
+ "[progmasoft][strategy]" ) {
+ using Progmasoft::Catch3::Detail::IsSimpler;
+ using Progmasoft::Catch3::Detail::ShrinkChoices;
+ CHECK( IsSimpler( std::vector<std::uint64_t>{ 9 },
+ std::vector<std::uint64_t>{ 0, 0 } ) );
+ CHECK( IsSimpler( std::vector<std::uint64_t>{ 1, 9 },
+ std::vector<std::uint64_t>{ 2, 0 } ) );
+ CHECK_FALSE( IsSimpler( std::vector<std::uint64_t>{ 2, 0 },
+ std::vector<std::uint64_t>{ 2, 0 } ) );
+
+ // Fails when the first two choices sum to at least 10.
+ const auto sumAtLeastTen = []( const std::vector<std::uint64_t>& choices )
+ -> std::optional<std::vector<std::uint64_t>> {
+ std::vector<std::uint64_t> consumed = choices;
+ consumed.resize( 2, 0 );
+ if ( consumed[0] + consumed[1] >= 10 ) { return consumed; }
+ return std::nullopt;
+ };
+ std::size_t calls = 0;
+ const auto minimized = ShrinkChoices(
+ { 7, 9, 4, 4, 1 }, 10000, sumAtLeastTen, calls );
+ // Shortlex: two choices, the first as small as the sum allows.
+ CHECK( minimized == std::vector<std::uint64_t>{ 0, 10 } );
+ CHECK( calls > 0 );
+
+ std::size_t limitedCalls = 0;
+ (void)ShrinkChoices( { 7, 9, 4, 4, 1 }, 3, sumAtLeastTen, limitedCalls );
+ CHECK( limitedCalls <= 3 );
+}
diff --git a/tests/meson.build b/tests/meson.build
index c4abaf29..4e258ee5 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -84,8 +84,11 @@ progmasoft_property_tests = executable(
[
'Progmasoft/Generators.tests.cpp',
'Progmasoft/Property.tests.cpp',
+ 'Progmasoft/ForAll.tests.cpp',
'Progmasoft/Results.tests.cpp',
'Pr