From 65863b326f806243966de359600bf1faa6092eb1 Mon Sep 17 00:00:00 2001 From: Huamonarch Date: Fri, 15 May 2026 09:14:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=20RNG=20=E6=A8=A1=E5=9D=97=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20CTest=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E8=A6=86=E7=9B=96=E5=B8=B8=E9=87=8F/=E7=BA=BF?= =?UTF-8?q?=E6=80=A7/=E6=BC=82=E7=A7=BB/=E6=AD=A3=E5=BC=A6/=E5=A4=8D?= =?UTF-8?q?=E5=90=88/=E5=B8=83=E5=B0=94/=E6=B3=A8=E5=86=8C=E8=A1=A8/?= =?UTF-8?q?=E9=98=80=E5=AF=B9=E7=AD=89=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TestProject/RNG/CMakeLists.txt | 40 ++++++----- TestProject/RNG/test/test_bool_models.cc | 76 +++++++++++++++++++++ TestProject/RNG/test/test_composite.cc | 28 ++++++++ TestProject/RNG/test/test_constant.cc | 25 +++++++ TestProject/RNG/test/test_drift.cc | 28 ++++++++ TestProject/RNG/test/test_harness.h | 71 +++++++++++++++++++ TestProject/RNG/test/test_linear.cc | 42 ++++++++++++ TestProject/RNG/test/test_main.cc | 6 ++ TestProject/RNG/test/test_models.json | 9 +++ TestProject/RNG/test/test_registry.cc | 87 ++++++++++++++++++++++++ TestProject/RNG/test/test_sine.cc | 45 ++++++++++++ TestProject/RNG/test/test_valve_pair.cc | 69 +++++++++++++++++++ 12 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 TestProject/RNG/test/test_bool_models.cc create mode 100644 TestProject/RNG/test/test_composite.cc create mode 100644 TestProject/RNG/test/test_constant.cc create mode 100644 TestProject/RNG/test/test_drift.cc create mode 100644 TestProject/RNG/test/test_harness.h create mode 100644 TestProject/RNG/test/test_linear.cc create mode 100644 TestProject/RNG/test/test_main.cc create mode 100644 TestProject/RNG/test/test_models.json create mode 100644 TestProject/RNG/test/test_registry.cc create mode 100644 TestProject/RNG/test/test_sine.cc create mode 100644 TestProject/RNG/test/test_valve_pair.cc diff --git a/TestProject/RNG/CMakeLists.txt b/TestProject/RNG/CMakeLists.txt index 53469fa..37eb50e 100644 --- a/TestProject/RNG/CMakeLists.txt +++ b/TestProject/RNG/CMakeLists.txt @@ -56,23 +56,29 @@ target_include_directories( set_target_properties(RNG PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${bin_dir}) # ###################### add test ######################## -# 1. loss_compress_test -# -# ############################################################################## -# include(../cmake_include/unit_test.cmake) +include(CTest) -# add_executable(loss_compress_test ${DISTRIBUTION} test/loss_compress_test.cc) -# target_link_libraries(loss_compress_test ${LINK_OPTION} -# Boost::unit_test_framework) -# target_include_directories( -# loss_compress_test PUBLIC ./ ../ ../../inc ../../inc/dbinc -# ${iPlature_include}) +aux_source_directory(./test TEST_SOURCES) -# set_target_properties(loss_compress_test PROPERTIES RUNTIME_OUTPUT_DIRECTORY -# ${UNIT_TEST_BIN_OUTPUT_DIR}) +add_executable(rng_test + ${TEST_SOURCES} + ./model/ModelRegistry.cc +) -# enable_testing() -# add_test( -# NAME loss_compress_test -# WORKING_DIRECTORY ${UNIT_TEST_BIN_OUTPUT_DIR} -# COMMAND loss_compress_test) +target_compile_definitions(rng_test PRIVATE + TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/test" +) + +target_include_directories(rng_test PUBLIC + ./ + ../ + ${my_lib_include} +) + +set_target_properties(rng_test PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/test) + +enable_testing() +add_test(NAME rng_test + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/test + COMMAND rng_test +) diff --git a/TestProject/RNG/test/test_bool_models.cc b/TestProject/RNG/test/test_bool_models.cc new file mode 100644 index 0000000..133cc33 --- /dev/null +++ b/TestProject/RNG/test/test_bool_models.cc @@ -0,0 +1,76 @@ +#include "test_harness.h" +#include +#include +#include +#include +using json = nlohmann::json; + +// --- BoolToggleModel --- + +TEST(toggle_default_period) { + // default period_ms=2000 → 100 ticks per period, first 50 true + BoolToggleModel m(json::object(), 0.0f); + CHECK_EQ(m.evaluateBool(0), true); + CHECK_EQ(m.evaluateBool(49), true); + CHECK_EQ(m.evaluateBool(50), false); + CHECK_EQ(m.evaluateBool(99), false); + CHECK_EQ(m.evaluateBool(100), true); // new period +} + +TEST(toggle_custom_period) { + // period_ms=40 → 2 ticks per period, first 1 true + BoolToggleModel m(json{{"period_ms", 40}}, 0.0f); + CHECK_EQ(m.evaluateBool(0), true); + CHECK_EQ(m.evaluateBool(1), false); + CHECK_EQ(m.evaluateBool(2), true); +} + +TEST(toggle_large_t) { + BoolToggleModel m(json {{"period_ms", 2000}}, 0.0f); + // After 5000 periods, behavior should hold + size_t base = 5000 * 100; + CHECK_EQ(m.evaluateBool(base), true); + CHECK_EQ(m.evaluateBool(base + 50), false); +} + +// --- BoolRandomModel --- + +TEST(random_always_true) { + BoolRandomModel m(json{{"prob_true", 1.0f}}, 0.0f); + for (int i = 0; i < 100; i++) { + CHECK_EQ(m.evaluateBool(i), true); + } +} + +TEST(random_always_false) { + BoolRandomModel m(json{{"prob_true", 0.0f}}, 0.0f); + for (int i = 0; i < 100; i++) { + CHECK_EQ(m.evaluateBool(i), false); + } +} + +TEST(random_default_prob_is_half) { + BoolRandomModel m(json::object(), 0.0f); + int trues = 0; + for (int i = 0; i < 10000; i++) { + if (m.evaluateBool(i)) trues++; + } + // With 10000 samples, true rate should be in [0.35, 0.65] + CHECK(trues > 3500); + CHECK(trues < 6500); +} + +// --- NotModel --- + +TEST(not_inverts) { + auto inner = std::make_unique(json::object(), 0.0f); + NotModel m(std::move(inner)); + CHECK_EQ(m.evaluateBool(0), false); // toggle is true at t=0 + CHECK_EQ(m.evaluateBool(50), true); // toggle is false at t=50 +} + +TEST(not_evaluate_returns_zero) { + auto inner = std::make_unique(json::object(), 5.0f); + NotModel m(std::move(inner)); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); +} diff --git a/TestProject/RNG/test/test_composite.cc b/TestProject/RNG/test/test_composite.cc new file mode 100644 index 0000000..f817e4e --- /dev/null +++ b/TestProject/RNG/test/test_composite.cc @@ -0,0 +1,28 @@ +#include "test_harness.h" +#include +#include +#include +using json = nlohmann::json; + +TEST(composite_sums_base_and_noise) { + auto base = std::make_unique(json::object(), 50.0f); + auto noise = std::make_unique(json{{"k", 2.0f}}, 0.0f); + CompositeModel m(std::move(base), std::move(noise)); + CHECK_FLOAT_EQ(m.evaluate(0), 50.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(5), 60.0f, 0.001f); +} + +TEST(composite_both_constant) { + auto base = std::make_unique(json::object(), 10.0f); + auto noise = std::make_unique(json::object(), 3.0f); + CompositeModel m(std::move(base), std::move(noise)); + CHECK_FLOAT_EQ(m.evaluate(0), 13.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(999), 13.0f, 0.001f); +} + +TEST(composite_evaluateBool_returns_false) { + auto base = std::make_unique(json::object(), 1.0f); + auto noise = std::make_unique(json::object(), 2.0f); + CompositeModel m(std::move(base), std::move(noise)); + CHECK_EQ(m.evaluateBool(0), false); +} diff --git a/TestProject/RNG/test/test_constant.cc b/TestProject/RNG/test/test_constant.cc new file mode 100644 index 0000000..307f94f --- /dev/null +++ b/TestProject/RNG/test/test_constant.cc @@ -0,0 +1,25 @@ +#include "test_harness.h" +#include +using json = nlohmann::json; + +TEST(constant_returns_default) { + ConstantModel m(json::object(), 100.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 100.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(999), 100.0f, 0.001f); +} + +TEST(constant_zero) { + ConstantModel m(json::object(), 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); +} + +TEST(constant_negative) { + ConstantModel m(json::object(), -42.5f); + CHECK_FLOAT_EQ(m.evaluate(0), -42.5f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(1), -42.5f, 0.001f); +} + +TEST(constant_evaluateBool_returns_false) { + ConstantModel m(json::object(), 1.0f); + CHECK_EQ(m.evaluateBool(0), false); +} diff --git a/TestProject/RNG/test/test_drift.cc b/TestProject/RNG/test/test_drift.cc new file mode 100644 index 0000000..c4283a5 --- /dev/null +++ b/TestProject/RNG/test/test_drift.cc @@ -0,0 +1,28 @@ +#include "test_harness.h" +#include +using json = nlohmann::json; + +TEST(drift_basic) { + DriftModel m(json{{"drift_rate", 0.1f}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(10), 1.0f, 0.001f); +} + +TEST(drift_default_rate_is_zero) { + DriftModel m(json::object(), 42.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 42.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(1000), 42.0f, 0.001f); +} + +TEST(drift_negative_rate) { + DriftModel m(json{{"drift_rate", -0.5f}}, 100.0f); + CHECK_FLOAT_EQ(m.evaluate(10), 95.0f, 0.001f); +} + +TEST(drift_with_period) { + // period_ms=100 → 5 ticks per period + DriftModel m(json{{"drift_rate", 1.0f}, {"period_ms", 100}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(4), 4.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(5), 0.0f, 0.001f); // wraps +} diff --git a/TestProject/RNG/test/test_harness.h b/TestProject/RNG/test/test_harness.h new file mode 100644 index 0000000..c896e96 --- /dev/null +++ b/TestProject/RNG/test/test_harness.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +struct TestRunner { + struct Case { + const char* name; + std::function fn; + }; + inline static std::vector cases; + + static int run() { + int passed = 0, failed = 0; + for (auto& c : cases) { + std::cout << " " << c.name << " ... "; + try { + c.fn(); + std::cout << "PASSED\n"; + passed++; + } catch (const std::exception& e) { + std::cout << "FAILED\n " << e.what() << "\n"; + failed++; + } catch (...) { + std::cout << "FAILED (unknown)\n"; + failed++; + } + } + std::cout << "\n" << passed << " passed, " << failed << " failed\n"; + return failed ? 1 : 0; + } +}; + +struct AutoReg { + AutoReg(const char* name, std::function fn) { + TestRunner::cases.push_back({name, std::move(fn)}); + } +}; + +#define TEST(name) \ + static void testfn_##name(); \ + static AutoReg autoreg_##name(#name, testfn_##name); \ + static void testfn_##name() + +#define CHECK(expr) \ + do { \ + if (!(expr)) \ + throw std::runtime_error("CHECK(" #expr ") failed"); \ + } while (0) + +#define CHECK_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + std::ostringstream os; \ + os << "CHECK_EQ: " << (a) << " != " << (b); \ + throw std::runtime_error(os.str()); \ + } \ + } while (0) + +#define CHECK_FLOAT_EQ(a, b, eps) \ + do { \ + if (std::fabs((a) - (b)) > (eps)) { \ + std::ostringstream os; \ + os << "CHECK_FLOAT_EQ: |" << (a) << " - " << (b) << "| > " << (eps); \ + throw std::runtime_error(os.str()); \ + } \ + } while (0) diff --git a/TestProject/RNG/test/test_linear.cc b/TestProject/RNG/test/test_linear.cc new file mode 100644 index 0000000..f261722 --- /dev/null +++ b/TestProject/RNG/test/test_linear.cc @@ -0,0 +1,42 @@ +#include "test_harness.h" +#include +using json = nlohmann::json; + +TEST(linear_basic) { + LinearModel m(json{{"k", 2.0f}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(1), 2.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(5), 10.0f, 0.001f); +} + +TEST(linear_default_k_is_zero) { + LinearModel m(json::object(), 50.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 50.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(100), 50.0f, 0.001f); +} + +TEST(linear_negative_k) { + LinearModel m(json{{"k", -1.0f}}, 10.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 10.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(3), 7.0f, 0.001f); +} + +TEST(linear_with_period) { + // period_ms=100 → 5 ticks per period + LinearModel m(json{{"k", 1.0f}, {"period_ms", 100}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(4), 4.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(5), 0.0f, 0.001f); // wraps + CHECK_FLOAT_EQ(m.evaluate(7), 2.0f, 0.001f); // 7%5=2 +} + +TEST(linear_no_period) { + LinearModel m(json{{"k", 0.5f}}, 100.0f); + CHECK_FLOAT_EQ(m.evaluate(10), 105.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(1000), 600.0f, 0.001f); +} + +TEST(linear_large_values) { + LinearModel m(json{{"k", 0.001f}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(100000), 100.0f, 0.01f); +} diff --git a/TestProject/RNG/test/test_main.cc b/TestProject/RNG/test/test_main.cc new file mode 100644 index 0000000..8e5f0b1 --- /dev/null +++ b/TestProject/RNG/test/test_main.cc @@ -0,0 +1,6 @@ +#include "test_harness.h" + +int main() { + std::cout << "RNG Model Tests\n===============\n\n"; + return TestRunner::run(); +} diff --git a/TestProject/RNG/test/test_models.json b/TestProject/RNG/test/test_models.json new file mode 100644 index 0000000..0948c1f --- /dev/null +++ b/TestProject/RNG/test/test_models.json @@ -0,0 +1,9 @@ +{ + "models": { + "const_100": { "mode": "constant", "params": {} }, + "linear_k1": { "mode": "linear", "params": { "k": 1.0 } }, + "toggle_1s": { "mode": "bool_toggle", "params": { "period_ms": 1000 } }, + "valve_std": { "mode": "valve_pair", "params": { "on_delay_ms": 100, "off_delay_ms": 80 } }, + "normal_tiny": { "mode": "normal", "params": { "sigma": 0.01 } } + } +} diff --git a/TestProject/RNG/test/test_registry.cc b/TestProject/RNG/test/test_registry.cc new file mode 100644 index 0000000..c287748 --- /dev/null +++ b/TestProject/RNG/test/test_registry.cc @@ -0,0 +1,87 @@ +#include "test_harness.h" +#include +using json = nlohmann::json; + +static const char* testJsonPath() { + // TEST_DATA_DIR is defined via CMake target_compile_definitions + return TEST_DATA_DIR "/test_models.json"; +} + +TEST(registry_singleton_is_same) { + CHECK_EQ(&ModelRegistry::instance(), &ModelRegistry::instance()); +} + +TEST(registry_load_models_and_get_builtin) { + auto& reg = ModelRegistry::instance(); + reg.loadModels(testJsonPath()); + + IModel* m = reg.getOrCreate("const_100", 0.0f, "builtin_1"); + CHECK(m != nullptr); + CHECK_FLOAT_EQ(m->evaluate(0), 100.0f, 0.001f); + CHECK_FLOAT_EQ(m->evaluate(999), 100.0f, 0.001f); +} + +TEST(registry_linear_model_via_template) { + auto& reg = ModelRegistry::instance(); + IModel* m = reg.getOrCreate("linear_k1", 5.0f, "linear_1"); + CHECK(m != nullptr); + CHECK_FLOAT_EQ(m->evaluate(0), 5.0f, 0.001f); + CHECK_FLOAT_EQ(m->evaluate(3), 8.0f, 0.001f); +} + +TEST(registry_same_key_returns_same_instance) { + auto& reg = ModelRegistry::instance(); + IModel* a = reg.getOrCreate("const_100", 10.0f, "same_key"); + IModel* b = reg.getOrCreate("linear_k1", 20.0f, "same_key"); + // same instanceKey → returns cached instance, not a new one + CHECK_EQ(a, b); +} + +TEST(registry_different_keys_different_instances) { + auto& reg = ModelRegistry::instance(); + IModel* a = reg.getOrCreate("const_100", 0.0f, "key_a"); + IModel* b = reg.getOrCreate("const_100", 0.0f, "key_b"); + CHECK(a != b); +} + +TEST(registry_empty_spec_falls_back_to_normal_tiny) { + auto& reg = ModelRegistry::instance(); + IModel* m = reg.getOrCreate("", 0.0f, "empty_spec_test"); + CHECK(m != nullptr); + // normal_tiny → NormalModel with sigma=0.01, returns near-default + float v = m->evaluate(0); + (void)v; +} + +TEST(registry_find_by_model_name) { + auto& reg = ModelRegistry::instance(); + // "const_100" was created via getOrCreate above; should be tracked + auto found = reg.findByModelName("const_100"); + CHECK(!found.empty()); +} + +TEST(registry_find_nonexistent) { + auto& reg = ModelRegistry::instance(); + auto found = reg.findByModelName("no_such_model_xyz"); + CHECK(found.empty()); +} + +TEST(registry_composite_syntax) { + auto& reg = ModelRegistry::instance(); + // const_100 + linear_k1: evaluate(0) = 100 + 0 = 100, evaluate(3) = 100 + 8 = 108 + IModel* m = reg.getOrCreate("const_100+linear_k1", 0.0f, "composite_1"); + CHECK(m != nullptr); + CHECK_FLOAT_EQ(m->evaluate(0), 100.0f, 0.001f); + CHECK_FLOAT_EQ(m->evaluate(3), 108.0f, 0.001f); +} + +TEST(registry_not_syntax) { + auto& reg = ModelRegistry::instance(); + // !toggle_1s: negates the bool toggle + IModel* m = reg.getOrCreate("!toggle_1s", 0.0f, "not_1"); + CHECK(m != nullptr); + // toggle_1s period_ms=1000 → 50 ticks, first 25 true. + // !toggle_1s at t=0 should be false + CHECK_EQ(m->evaluateBool(0), false); + CHECK_EQ(m->evaluateBool(26), true); // toggle is false at 26, so ! is true +} diff --git a/TestProject/RNG/test/test_sine.cc b/TestProject/RNG/test/test_sine.cc new file mode 100644 index 0000000..47bb78b --- /dev/null +++ b/TestProject/RNG/test/test_sine.cc @@ -0,0 +1,45 @@ +#include "test_harness.h" +#include +#include +using json = nlohmann::json; + +TEST(sine_at_zero) { + SineModel m(json::object(), 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); +} + +TEST(sine_basic) { + // A=1, omega=π/2, phi=0 → sin(π/2 * t) + float omega = M_PI / 2.0f; + SineModel m(json{{"A", 1.0f}, {"omega", omega}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 0.0f, 0.001f); // sin(0)=0 + CHECK_FLOAT_EQ(m.evaluate(1), 1.0f, 0.001f); // sin(π/2)=1 + CHECK_FLOAT_EQ(m.evaluate(2), 0.0f, 0.01f); // sin(π)=0 + CHECK_FLOAT_EQ(m.evaluate(3), -1.0f, 0.001f); // sin(3π/2)=-1 +} + +TEST(sine_with_offset) { + float omega = M_PI / 2.0f; + SineModel m(json{{"A", 1.0f}, {"omega", omega}}, 100.0f); + CHECK_FLOAT_EQ(m.evaluate(1), 101.0f, 0.001f); // sin(π/2)=1 + 100 + CHECK_FLOAT_EQ(m.evaluate(3), 99.0f, 0.001f); // sin(3π/2)=-1 + 100 +} + +TEST(sine_zero_amplitude) { + SineModel m(json{{"A", 0.0f}, {"omega", 1.0f}}, 5.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 5.0f, 0.001f); + CHECK_FLOAT_EQ(m.evaluate(100), 5.0f, 0.001f); +} + +TEST(sine_with_phase) { + // phi=π/2 → sin(ωt + π/2) = cos(ωt). At t=0: cos(0)=1 + float phi = M_PI / 2.0f; + SineModel m(json{{"A", 1.0f}, {"omega", 0.0f}, {"phi", phi}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(0), 1.0f, 0.001f); +} + +TEST(sine_negative_amplitude) { + float omega = M_PI / 2.0f; + SineModel m(json{{"A", -2.0f}, {"omega", omega}}, 0.0f); + CHECK_FLOAT_EQ(m.evaluate(1), -2.0f, 0.001f); +} diff --git a/TestProject/RNG/test/test_valve_pair.cc b/TestProject/RNG/test/test_valve_pair.cc new file mode 100644 index 0000000..39c5363 --- /dev/null +++ b/TestProject/RNG/test/test_valve_pair.cc @@ -0,0 +1,69 @@ +#include "test_harness.h" +#include +#include +using json = nlohmann::json; + +// Helper: create a simple action model and attach it +struct ValvePairFixture { + std::unique_ptr action; + ValvePairModel valve; + + ValvePairFixture(int on_delay_ms = 0, int off_delay_ms = 0) + : action(std::make_unique(json{{"period_ms", 200}}, 0.0f)) + , valve(json{{"on_delay_ms", on_delay_ms}, {"off_delay_ms", off_delay_ms}}, 0.0f) { + valve.action = action.get(); + } +}; + +TEST(valve_no_action_returns_false) { + ValvePairModel m(json::object(), 0.0f); + CHECK_EQ(m.evaluateBool(0), false); +} + +TEST(valve_idle_to_high_direct) { + // Zero delays: action=true → immediately high + ValvePairFixture f(0, 0); + // At t=0, toggle is true (first half of period 100 ticks) + CHECK_EQ(f.valve.evaluateBool(0), true); +} + +TEST(valve_falls_when_action_goes_false) { + // Zero off delay: when action becomes false, output immediately false + ValvePairFixture f(0, 0); + // t=0..49: action=true, output=true + CHECK_EQ(f.valve.evaluateBool(0), true); + // t=50..99: action=false, output=false (no delay) + for (int i = 0; i < 49; i++) f.valve.evaluateBool(i); // advance + CHECK_EQ(f.valve.evaluateBool(49), true); + // t=50: action goes false, check + CHECK_EQ(f.valve.evaluateBool(50), false); +} + +TEST(valve_on_delay) { + // on_delay_ms=40 → 2 ticks delay before rising + ValvePairFixture f(40, 0); + // t=0: action becomes true, but valve should wait 2 ticks + bool t0 = f.valve.evaluateBool(0); // transitions to WAITING_RISE + bool t1 = f.valve.evaluateBool(1); // still waiting + bool t2 = f.valve.evaluateBool(2); // should be HIGH now + CHECK_EQ(f.valve.evaluateBool(3), true); + (void)t0; (void)t1; (void)t2; + // The exact tick when it becomes true depends on how the counter + // interacts with the evaluate calls. Just verify it eventually goes high. + // After delay_counter counts down from on_delay_ticks, it should be HIGH. + int ticks = (int)f.valve.on_delay_ticks + 1; + bool went_high = false; + for (int i = 0; i < ticks + 5; i++) { + if (f.valve.evaluateBool(i)) { went_high = true; break; } + } + CHECK(went_high); +} + +TEST(valve_reset) { + ValvePairFixture f(100, 100); // long delays + f.valve.evaluateBool(0); // enter WAITING_RISE + f.valve.reset(); + // After reset, should be IDLE_LOW + CHECK_EQ(f.valve.state, ValvePairModel::IDLE_LOW); + CHECK_EQ(f.valve.evaluateBool(0), false); +}