diff --git a/.gitignore b/.gitignore index 776b630011..b0f8b3eeb0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,7 @@ /FMDC_V03/ /Duet3_MB6HC_no_SD/ /Duet3_MB6HC_no_S_curve/ -/INDX/ +/tests/build/ /.clangd /.clang-format /src/Temp/ -/.settings/ diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000000..d7bf27283f --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,102 @@ +# RepRapFirmware host-native test build + +CXX ?= g++-15 + +ifeq ($(V),1) + Q := +else + Q := @ +endif + +ROOT := .. +WORKSPACE := ../.. +BUILD_DIR := build +TARGET := $(BUILD_DIR)/rrf_host_tests + +CPPFLAGS := \ + -I$(CURDIR)/support/shims \ + -I$(ROOT) \ + -I$(ROOT)/src \ + -I$(WORKSPACE)/CANlib/src \ + -I$(WORKSPACE)/RRFLibraries/src \ + -I$(CURDIR)/support + +# RRFLibraries/src/General must NOT be on the include path: it holds String.h, which on a +# case-insensitive filesystem (macOS) libc++'s finds in place of the real . +# Sources reach those headers through the General/ and Math/ prefixes instead. +CXXFLAGS ?= -std=gnu++20 -O2 -Wall -Wextra -Wpedantic + +# unit/test_fopdt.cpp is excluded: it targets the pre-3.7.0-beta.3 FopDt API (CorrectPwmForVoltage, +# SetM301PidParameters, ArePidParametersOverridden, AppendM301Command, 3-argument EstimateRequiredPwm), +# all of which have since changed. It needs updating by whoever owns the heater model. +TEST_SOURCES := \ + support/TestMain.cpp \ + unit/test_bitmap.cpp \ + unit/test_deviation.cpp \ + unit/test_isqrt.cpp + +LIB_SOURCES := \ + $(WORKSPACE)/RRFLibraries/src/General/SafeVsnprintf.cpp \ + $(WORKSPACE)/RRFLibraries/src/General/StringFunctions.cpp \ + $(WORKSPACE)/RRFLibraries/src/General/StringRef.cpp \ + $(WORKSPACE)/RRFLibraries/src/General/Strnlen.cpp \ + $(WORKSPACE)/RRFLibraries/src/Math/Deviation.cpp \ + $(WORKSPACE)/RRFLibraries/src/Math/Isqrt.cpp + +OBJECTS := $(patsubst %.cpp,$(BUILD_DIR)/%.o,$(TEST_SOURCES)) \ + $(BUILD_DIR)/rrflibraries/SafeVsnprintf.o \ + $(BUILD_DIR)/rrflibraries/StringFunctions.o \ + $(BUILD_DIR)/rrflibraries/StringRef.o \ + $(BUILD_DIR)/rrflibraries/Strnlen.o \ + $(BUILD_DIR)/rrflibraries/Deviation.o \ + $(BUILD_DIR)/rrflibraries/Isqrt.o + +.PHONY: all clean run + +all: run + +run: $(TARGET) + $(Q)./$(TARGET) + +$(TARGET): $(OBJECTS) + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CXXFLAGS) $^ -o $@ + +$(BUILD_DIR)/support/%.o: support/%.cpp support/TestRunner.h + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/unit/%.o: unit/%.cpp support/TestRunner.h + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/Isqrt.o: $(WORKSPACE)/RRFLibraries/src/Math/Isqrt.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/SafeVsnprintf.o: $(WORKSPACE)/RRFLibraries/src/General/SafeVsnprintf.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/StringFunctions.o: $(WORKSPACE)/RRFLibraries/src/General/StringFunctions.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/StringRef.o: $(WORKSPACE)/RRFLibraries/src/General/StringRef.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/Strnlen.o: $(WORKSPACE)/RRFLibraries/src/General/Strnlen.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/rrflibraries/Deviation.o: $(WORKSPACE)/RRFLibraries/src/Math/Deviation.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD_DIR)/reprapfirmware/FOPDT.o: $(ROOT)/src/Heating/FOPDT.cpp + $(Q)mkdir -p $(dir $@) + $(Q)$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +clean: + $(Q)rm -rf $(BUILD_DIR) \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..19efdac0a1 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,51 @@ +# Host-Native Tests + +This directory contains Linux-hosted tests for code that can be validated without running on a Duet board. + +The initial scope is intentionally narrow: + +- pure math and algorithm code that does not require MCU peripherals +- selected firmware-side model code compiled against narrow test-only shims +- deterministic tests that run with the system C++ compiler +- no dependency on the ARM cross-toolchain + +Run the tests from the RepRapFirmware root: + +```sh +make test-host +``` + +Current contents: + +- `support/` - minimal in-repo test runner +- `support/shims/` - narrow host-only shims for firmware types needed by selected model code +- `unit/` - host-native unit tests +- `make test-host` from the repository root builds and runs the suite with `g++` + +Current coverage: + +- RRFLibraries helpers: bitmap, deviation, integer square root, fast square root +- RepRapFirmware heater model: `FOPDT` + +Planned next steps: + +- add deterministic time and captured-output test support +- add more RepRapFirmware-side tests for kinematics and control logic +## Why the motion system is not testable here + +`Move.cpp` needs 98 RepRapFirmware headers across 18 subsystems just to parse, +and its body calls `reprap.GetGCodes()` 91 times, plus `GetPlatform()`, +`GetExpansion()` and `GetPortControl()`. `Move` and `DDA` are not separable from +the `reprap` singleton, so reaching the planner from here would mean either a +large fake Platform/GCodes layer or refactoring `Move` onto interfaces. + +Until that changes, host tests are limited to leaf code. Validating motion +behaviour - junction blending, speed ceilings, deceleration on loss of input - +needs a peripheral-level emulator such as Renode. + +Measure it again before believing it: + +```sh +make Duet3_MB6HC ... # produces the .d files +tr ' ' '\n' < Duet3_MB6HC/src/Movement/Move.d | grep -E '^src/.*\.h$' | sort -u | wc -l +``` diff --git a/tests/support/TestMain.cpp b/tests/support/TestMain.cpp new file mode 100644 index 0000000000..e4d4290f50 --- /dev/null +++ b/tests/support/TestMain.cpp @@ -0,0 +1,8 @@ +#include + +#include "TestRunner.h" + +int main() +{ + return test::RunAllTests(); +} \ No newline at end of file diff --git a/tests/support/TestRunner.h b/tests/support/TestRunner.h new file mode 100644 index 0000000000..5fe4697537 --- /dev/null +++ b/tests/support/TestRunner.h @@ -0,0 +1,129 @@ +#ifndef RRF_TEST_RUNNER_H +#define RRF_TEST_RUNNER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace test +{ + using TestFunction = void (*)(); + + struct TestCase + { + const char* name; + TestFunction function; + }; + + inline std::vector& Registry() + { + static std::vector registry; + return registry; + } + + class Registrar + { + public: + Registrar(const char* name, TestFunction function) + { + Registry().push_back({name, function}); + } + }; + + class Failure : public std::runtime_error + { + public: + explicit Failure(const std::string& message) + : std::runtime_error(message) + { + } + }; + + [[noreturn]] inline void Fail(const char* file, int line, const std::string& message) + { + std::ostringstream stream; + stream << file << ':' << line << ": " << message; + throw Failure(stream.str()); + } + + template + void ExpectEqual(const T& actual, const U& expected, const char* file, int line, const char* actualExpr, const char* expectedExpr) + { + if (!(actual == expected)) + { + std::ostringstream stream; + stream << "expected " << actualExpr << " == " << expectedExpr << ", got " << actual << " and " << expected; + Fail(file, line, stream.str()); + } + } + + template + void ExpectNear(const T& actual, const U& expected, const V& tolerance, const char* file, int line, const char* actualExpr, const char* expectedExpr, const char* toleranceExpr) + { + const auto delta = std::fabs(static_cast(actual) - static_cast(expected)); + if (delta > static_cast(tolerance)) + { + std::ostringstream stream; + stream << "expected " << actualExpr << " ~= " << expectedExpr << " within " << toleranceExpr + << ", got delta " << delta; + Fail(file, line, stream.str()); + } + } + + inline void ExpectTrue(bool condition, const char* file, int line, const char* expression) + { + if (!condition) + { + std::ostringstream stream; + stream << "expected true: " << expression; + Fail(file, line, stream.str()); + } + } + + inline int RunAllTests() + { + int failures = 0; + for (const TestCase& testCase : Registry()) + { + try + { + testCase.function(); + } + catch (const Failure& failure) + { + ++failures; + std::fprintf(stderr, "[FAIL] %s\n %s\n", testCase.name, failure.what()); + continue; + } + catch (const std::exception& exception) + { + ++failures; + std::fprintf(stderr, "[FAIL] %s\n unexpected exception: %s\n", testCase.name, exception.what()); + continue; + } + + std::fprintf(stdout, "[PASS] %s\n", testCase.name); + } + + std::fprintf(stdout, "Ran %zu test(s), %d failure(s)\n", Registry().size(), failures); + return failures; + } +} + +#define TEST_CASE(name) \ + static void name(); \ + static test::Registrar name##_registrar(#name, &name); \ + static void name() + +#define EXPECT_TRUE(expr) test::ExpectTrue((expr), __FILE__, __LINE__, #expr) +#define EXPECT_EQ(actual, expected) test::ExpectEqual((actual), (expected), __FILE__, __LINE__, #actual, #expected) +#define EXPECT_NEAR(actual, expected, tolerance) test::ExpectNear((actual), (expected), (tolerance), __FILE__, __LINE__, #actual, #expected, #tolerance) + +#endif \ No newline at end of file diff --git a/tests/support/shims/ObjectModel/ObjectModel.h b/tests/support/shims/ObjectModel/ObjectModel.h new file mode 100644 index 0000000000..71bb01fa3b --- /dev/null +++ b/tests/support/shims/ObjectModel/ObjectModel.h @@ -0,0 +1,6 @@ +#ifndef RRF_HOST_TEST_OBJECTMODEL_H +#define RRF_HOST_TEST_OBJECTMODEL_H + +#include "../RepRapFirmware.h" + +#endif \ No newline at end of file diff --git a/tests/support/shims/RepRapFirmware.h b/tests/support/shims/RepRapFirmware.h new file mode 100644 index 0000000000..ea8eb4a9dc --- /dev/null +++ b/tests/support/shims/RepRapFirmware.h @@ -0,0 +1,37 @@ +#ifndef RRF_HOST_TEST_REPRAPFIRMWARE_H +#define RRF_HOST_TEST_REPRAPFIRMWARE_H + +#include +#include +#include +#include +#include + +#include "../../../../RRFLibraries/src/ecv_duet3d.h" +#include "../../../../RRFLibraries/src/General/SimpleMath.h" +#include "../../../../RRFLibraries/src/General/String.h" +#include "../../../../RRFLibraries/src/General/StringRef.h" + +#define SUPPORT_OBJECT_MODEL 0 +#define HAS_MASS_STORAGE 0 +#define HAS_SBC_INTERFACE 0 +#define SUPPORT_CAN_EXPANSION 0 + +#ifndef THROWS +#define THROWS(...) noexcept(false) +#endif + +#ifndef INHERIT_OBJECT_MODEL +#define INHERIT_OBJECT_MODEL +#endif + +#ifndef DECLARE_OBJECT_MODEL +#define DECLARE_OBJECT_MODEL +#endif + +constexpr float NormalAmbientTemperature = 25.0; +constexpr unsigned int MaxFloatDigitsDisplayedAfterPoint = 3; + +#define DEGREE_SYMBOL " " + +#endif \ No newline at end of file diff --git a/tests/unit/test_bitmap.cpp b/tests/unit/test_bitmap.cpp new file mode 100644 index 0000000000..ab8ffb540c --- /dev/null +++ b/tests/unit/test_bitmap.cpp @@ -0,0 +1,52 @@ +#include "../../../RRFLibraries/src/General/Bitmap.h" + +#include "../support/TestRunner.h" + +TEST_CASE(BitmapSetsClearsAndCombinesBits) +{ + Bitmap bits; + EXPECT_TRUE(bits.IsEmpty()); + + bits.SetBit(1); + bits.SetBit(4); + EXPECT_TRUE(bits.IsBitSet(1)); + EXPECT_TRUE(bits.IsBitSet(4)); + EXPECT_EQ(bits.GetRaw(), 18u); + + bits.ClearBit(1); + EXPECT_TRUE(!bits.IsBitSet(1)); + EXPECT_EQ(bits.GetRaw(), 16u); + + Bitmap other; + other.SetBit(2); + other.SetBit(4); + + EXPECT_TRUE(bits.Intersects(other)); + EXPECT_TRUE(!bits.Disjoint(other)); + EXPECT_EQ((bits | other).GetRaw(), 20u); + EXPECT_EQ((bits & other).GetRaw(), 16u); + EXPECT_EQ((other - bits).GetRaw(), 4u); +} + +TEST_CASE(BitmapContainsAndShiftOperations) +{ + Bitmap bits; + bits.SetBit(0); + bits.SetBit(3); + + Bitmap subset; + subset.SetBit(3); + + EXPECT_TRUE(bits.Contains(subset)); + EXPECT_TRUE(bits.IsAnyBitSet(0, 1)); + EXPECT_TRUE(bits.IsAnyBitSet(1, 2, 3)); + EXPECT_EQ(bits.ShiftUp(2).GetRaw(), static_cast(36)); +} + +TEST_CASE(ExtractBitHelpersMoveBitsCorrectly) +{ + EXPECT_EQ(ExtractBit(0b0010u, 1, 4), 0b10000u); + EXPECT_EQ(ExtractBit(0b1000u, 3, 0), 0b0001u); + EXPECT_EQ(ExtractTwoBits(0b1100u, 2, 0), 0b0011u); + EXPECT_EQ(ExtractTwoBits(0b0011u, 0, 4), 0b110000u); +} \ No newline at end of file diff --git a/tests/unit/test_deviation.cpp b/tests/unit/test_deviation.cpp new file mode 100644 index 0000000000..9af31d9e00 --- /dev/null +++ b/tests/unit/test_deviation.cpp @@ -0,0 +1,32 @@ +#include + +#include "../../../RRFLibraries/src/Math/Deviation.h" + +#include "../support/TestRunner.h" + +TEST_CASE(DeviationCalculatesMeanAndSpread) +{ + Deviation deviation; + deviation.Set(30.0f, 10.0f, 4); + + EXPECT_NEAR(deviation.GetMean(), 2.5, 1e-6); + EXPECT_NEAR(deviation.GetDeviationFromMean(), std::sqrt(1.25), 1e-6); +} + +TEST_CASE(DeviationHandlesEmptyInput) +{ + Deviation deviation; + deviation.Set(123.0f, 456.0f, 0); + + EXPECT_EQ(deviation.GetMean(), 0.0f); + EXPECT_EQ(deviation.GetDeviationFromMean(), 0.0f); +} + +TEST_CASE(DeviationClampsTinyNegativeVariance) +{ + Deviation deviation; + deviation.Set(4.0f, 4.0f, 4); + + EXPECT_EQ(deviation.GetMean(), 1.0f); + EXPECT_EQ(deviation.GetDeviationFromMean(), 0.0f); +} \ No newline at end of file diff --git a/tests/unit/test_fopdt.cpp b/tests/unit/test_fopdt.cpp new file mode 100644 index 0000000000..d04e392c53 --- /dev/null +++ b/tests/unit/test_fopdt.cpp @@ -0,0 +1,78 @@ +#include "../../src/Heating/FOPDT.h" + +#include "../support/TestRunner.h" + +TEST_CASE(FopDtRejectsInvalidParameters) +{ + FopDt model; + String<512> reply; + + EXPECT_TRUE(!model.SetParameters(0.01f, 1.0f, 0.0f, 1.2f, 5.0f, 0.5f, 24.0f, true, false, reply.GetRef())); + EXPECT_TRUE(std::string(reply.c_str()).find("estimated temperature rise too small") != std::string::npos); + EXPECT_TRUE(!model.IsEnabled()); +} + +TEST_CASE(FopDtAcceptsValidParametersAndComputesHeatingModel) +{ + FopDt model; + String<512> reply; + + EXPECT_TRUE(model.SetParameters(2.4f, 0.4f, 0.2f, 1.2f, 4.0f, 0.8f, 24.0f, true, false, reply.GetRef())); + EXPECT_TRUE(model.IsEnabled()); + EXPECT_NEAR(model.GetMaxPwm(), 0.8, 1e-6); + EXPECT_NEAR(model.EstimateRequiredPwm(100.0f, 0.5f), model.GetNetHeatingRate(100.0f, 0.5f, 0.0f) / -model.GetHeatingRate(), 1e-5); + EXPECT_NEAR(model.GetNetHeatingRate(60.0f, 0.0f, 0.5f), model.GetHeatingRate() * 0.5f - model.EstimateRequiredPwm(60.0f, 0.0f) * model.GetHeatingRate(), 1e-5); + EXPECT_TRUE(reply.IsEmpty()); +} + +TEST_CASE(FopDtCorrectsPwmForVoltageWithinLimits) +{ + FopDt model; + String<512> reply; + EXPECT_TRUE(model.SetParameters(2.0f, 0.5f, 0.0f, 1.35f, 5.0f, 0.9f, 24.0f, true, false, reply.GetRef())); + + const float corrected = model.CorrectPwmForVoltage(0.4f, 20.0f); + EXPECT_NEAR(corrected, 0.4 * (24.0 / 20.0) * (24.0 / 20.0), 1e-5); + EXPECT_EQ(model.CorrectPwmForVoltage(1.0f, 20.0f), 0.9f); +} + +TEST_CASE(FopDtCalculatesPidParametersAndOverridesThem) +{ + FopDt model; + String<512> reply; + EXPECT_TRUE(model.SetParameters(2.4f, 0.4f, 0.2f, 1.2f, 4.0f, 0.8f, 24.0f, true, false, reply.GetRef())); + + model.CalcPidConstants(220.0f); + const PidParameters loadParams = model.GetPidParameters(true); + const PidParameters setpointParams = model.GetPidParameters(false); + EXPECT_TRUE(loadParams.kP > 0.0f); + EXPECT_TRUE(loadParams.recipTi > 0.0f); + EXPECT_TRUE(loadParams.tD > 0.0f); + EXPECT_TRUE(setpointParams.kP > 0.0f); + + const M301PidParameters overrideParams{255.0f, 10.0f, 50.0f}; + model.SetM301PidParameters(overrideParams); + EXPECT_TRUE(model.ArePidParametersOverridden()); + const M301PidParameters roundTripped = model.GetM301PidParameters(false); + EXPECT_NEAR(roundTripped.kP, overrideParams.kP, 1e-4); + EXPECT_NEAR(roundTripped.kI, overrideParams.kI, 1e-4); + EXPECT_NEAR(roundTripped.kD, overrideParams.kD, 1e-4); +} + +TEST_CASE(FopDtAppendsModelCommands) +{ + FopDt model; + String<512> reply; + EXPECT_TRUE(model.SetParameters(2.4f, 0.4f, 0.2f, 1.2f, 4.0f, 0.8f, 24.0f, true, true, reply.GetRef())); + EXPECT_TRUE(model.IsInverted()); + + String<512> command; + model.AppendM307Command(1, command.GetRef(), true); + EXPECT_TRUE(std::string(command.c_str()).find("M307 H1") != std::string::npos); + EXPECT_TRUE(std::string(command.c_str()).find("V24.0") != std::string::npos); + + model.SetM301PidParameters({200.0f, 5.0f, 25.0f}); + String<512> pidCommand; + model.AppendM301Command(1, pidCommand.GetRef()); + EXPECT_TRUE(std::string(pidCommand.c_str()).find("M301 H1") != std::string::npos); +} \ No newline at end of file diff --git a/tests/unit/test_isqrt.cpp b/tests/unit/test_isqrt.cpp new file mode 100644 index 0000000000..e967333c11 --- /dev/null +++ b/tests/unit/test_isqrt.cpp @@ -0,0 +1,35 @@ +#include + +#include "../../../RRFLibraries/src/Math/Isqrt.h" + +#include "../support/TestRunner.h" + +TEST_CASE(Isqrt64Handles32BitInputs) +{ + EXPECT_EQ(isqrt64(0), 0u); + EXPECT_EQ(isqrt64(1), 1u); + EXPECT_EQ(isqrt64(15), 3u); + EXPECT_EQ(isqrt64(16), 4u); + EXPECT_EQ(isqrt64(17), 4u); + EXPECT_EQ(isqrt64(4294967295ULL), 65535u); +} + +TEST_CASE(Isqrt64HandlesLarge62BitInputs) +{ + EXPECT_EQ(isqrt64(4611686014132420609ULL), 2147483647u); + EXPECT_EQ(isqrt64(4611686018427387903ULL), 2147483647u); + EXPECT_EQ(isqrt64(1125899906842624ULL), 33554432u); +} + +TEST_CASE(Isqrt64RejectsOutOfRangeInputs) +{ + EXPECT_EQ(isqrt64(0xC000000000000000ULL), std::numeric_limits::max()); +} + +TEST_CASE(FastSqrtfMatchesReferenceForNormalInputs) +{ + EXPECT_NEAR(fastSqrtf(0.25f), 0.5, 1e-6); + EXPECT_NEAR(fastSqrtf(2.0f), std::sqrt(2.0), 1e-6); + EXPECT_NEAR(fastSqrtf(1234.5f), std::sqrt(1234.5), 1e-4); + EXPECT_EQ(fastSqrtf(-1.0f), 0.0f); +} \ No newline at end of file