From 188c228d78170d123d2211ebf96fddfae2d7a6b5 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:38 +0800 Subject: [PATCH 1/4] Test public conversion and discovery boundaries --- test/test_discovery.cpp | 37 +++++++++++++++++++++++++ test/test_types.cpp | 61 +++++++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/test/test_discovery.cpp b/test/test_discovery.cpp index 8374b04..f8ace75 100644 --- a/test/test_discovery.cpp +++ b/test/test_discovery.cpp @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include "detail/xml_config.hpp" #include "netft/discovery.hpp" @@ -70,6 +72,41 @@ netft::DiscoveryOptions options_for(const FakeHttpServer &server) { return options; } +TEST(SensorDiscovery, RejectsInvalidPublicOptionsBeforeNetworkIo) { + const std::vector> cases{ + [](auto &options) { options.sensor_host.clear(); }, + [](auto &options) { options.sensor_host = " \t\r\n"; }, + [](auto &options) { options.http_port = 0; }, + [](auto &options) { options.http_port = 65'536; }, + [](auto &options) { options.connect_timeout = std::chrono::duration{0.0}; }, + [](auto &options) { options.connect_timeout = std::chrono::duration{-1.0}; }, + [](auto &options) { + options.connect_timeout = + std::chrono::duration{std::numeric_limits::infinity()}; + }, + [](auto &options) { + options.connect_timeout = + std::chrono::duration{std::numeric_limits::quiet_NaN()}; + }, + [](auto &options) { options.total_timeout = std::chrono::duration{0.0}; }, + [](auto &options) { options.total_timeout = std::chrono::duration{-1.0}; }, + [](auto &options) { + options.total_timeout = + std::chrono::duration{std::numeric_limits::infinity()}; + }, + [](auto &options) { + options.total_timeout = + std::chrono::duration{std::numeric_limits::quiet_NaN()}; + }, + }; + + for (const auto &configure : cases) { + netft::DiscoveryOptions options; + configure(options); + EXPECT_THROW(netft::discover_sensor(options), netft::DiscoveryError); + } +} + TEST(XmlConfiguration, ParsesTheRealSensorFixtureExactly) { const auto result = netft::detail::parse_sensor_configuration(kValidXml); diff --git a/test/test_types.cpp b/test/test_types.cpp index e5e3b19..abe81a8 100644 --- a/test/test_types.cpp +++ b/test/test_types.cpp @@ -1,9 +1,13 @@ #include +#include #include #include #include +#include #include +#include +#include #include "netft/types.hpp" @@ -263,25 +267,52 @@ TEST(Config, RejectsAnAmbiguousManualCalibration) { } TEST(Units, ConvertsForceAndTorqueUnitStrings) { - EXPECT_EQ(netft::to_string(netft::ForceUnit::PoundForce), "lbf"); - EXPECT_EQ(netft::to_string(netft::ForceUnit::Newton), "N"); - EXPECT_EQ(netft::to_string(netft::ForceUnit::KiloPoundForce), "klbf"); - EXPECT_EQ(netft::to_string(netft::ForceUnit::KiloNewton), "kN"); - EXPECT_EQ(netft::to_string(netft::ForceUnit::KilogramForce), "kgf"); - EXPECT_EQ(netft::force_unit_from_string("N"), netft::ForceUnit::Newton); + constexpr std::array force_cases{ + std::pair{netft::ForceUnit::Unknown, std::string_view{"unknown"}}, + std::pair{netft::ForceUnit::PoundForce, std::string_view{"lbf"}}, + std::pair{netft::ForceUnit::Newton, std::string_view{"N"}}, + std::pair{netft::ForceUnit::KiloPoundForce, std::string_view{"klbf"}}, + std::pair{netft::ForceUnit::KiloNewton, std::string_view{"kN"}}, + std::pair{netft::ForceUnit::KilogramForce, std::string_view{"kgf"}}, + }; + for (const auto &[unit, spelling] : force_cases) { + EXPECT_EQ(netft::to_string(unit), spelling); + EXPECT_EQ(netft::force_unit_from_string(spelling), unit); + } EXPECT_EQ(netft::force_unit_from_string("invalid"), std::nullopt); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::PoundForceInch), "lbf-in"); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::PoundForceFoot), "lbf-ft"); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::NewtonMeter), "N-m"); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::NewtonMillimeter), "N-mm"); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::KilogramForceCentimeter), "kgf-cm"); - EXPECT_EQ(netft::to_string(netft::TorqueUnit::KiloNewtonMeter), "kN-m"); - EXPECT_EQ(netft::torque_unit_from_string("N-m"), netft::TorqueUnit::NewtonMeter); + constexpr std::array torque_cases{ + std::pair{netft::TorqueUnit::Unknown, std::string_view{"unknown"}}, + std::pair{netft::TorqueUnit::PoundForceInch, std::string_view{"lbf-in"}}, + std::pair{netft::TorqueUnit::PoundForceFoot, std::string_view{"lbf-ft"}}, + std::pair{netft::TorqueUnit::NewtonMeter, std::string_view{"N-m"}}, + std::pair{netft::TorqueUnit::NewtonMillimeter, std::string_view{"N-mm"}}, + std::pair{netft::TorqueUnit::KilogramForceCentimeter, std::string_view{"kgf-cm"}}, + std::pair{netft::TorqueUnit::KiloNewtonMeter, std::string_view{"kN-m"}}, + }; + for (const auto &[unit, spelling] : torque_cases) { + EXPECT_EQ(netft::to_string(unit), spelling); + EXPECT_EQ(netft::torque_unit_from_string(spelling), unit); + } EXPECT_EQ(netft::torque_unit_from_string("invalid"), std::nullopt); } TEST(States, ConvertsStateAndFaultCodeStrings) { - EXPECT_EQ(netft::to_string(netft::ClientState::Streaming), "streaming"); - EXPECT_EQ(netft::to_string(netft::FaultCode::SeriousStatus), "serious_status"); + std::set state_names; + for (const auto state : + {netft::ClientState::Stopped, netft::ClientState::Connecting, netft::ClientState::Streaming, + netft::ClientState::Backoff, netft::ClientState::Faulted}) { + state_names.insert(netft::to_string(state)); + } + EXPECT_EQ(state_names.size(), 5U); + + std::set fault_names; + for (const auto code : + {netft::FaultCode::None, netft::FaultCode::SensorConfiguration, netft::FaultCode::Timeout, + netft::FaultCode::Socket, netft::FaultCode::SeriousStatus, netft::FaultCode::FtStall, + netft::FaultCode::FtBackward, netft::FaultCode::MalformedStorm, + netft::FaultCode::Callback}) { + fault_names.insert(netft::to_string(code)); + } + EXPECT_EQ(fault_names.size(), 9U); } From 9a50fd12e580ab56142bd2c9c49bb5faa8223cdd Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:22:40 +0800 Subject: [PATCH 2/4] Test CLI boundary behavior --- test/test_cli.cpp | 112 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/test/test_cli.cpp b/test/test_cli.cpp index 92d7652..05c4eb9 100644 --- a/test/test_cli.cpp +++ b/test/test_cli.cpp @@ -139,6 +139,27 @@ TEST(CliParser, RejectsMissingUnknownAndInvalidValues) { EXPECT_THROW(parse({"monitor", "--duration", "0"}), netft::cli::UsageError); } +TEST(CliParser, RejectsEveryUnsupportedBoundaryWithoutInspectingCopy) { + const std::vector> cases{ + {"monitor", "--host"}, + {"monitor", "--duration", "not-a-number"}, + {"monitor", "--rdt-port", "not-a-port"}, + {"monitor", "--output", ""}, + {"monitor", "--force-unit", "unsupported"}, + {"monitor", "--torque-unit", "unsupported"}, + {"monitor", "--unknown"}, + {"info", "--duration", "1"}, + {"bias", "--duration", "1"}, + {"monitor", "--host", " "}, + {"info", "--counts-per-force-unit", "1", "--counts-per-torque-unit", "1", "--force-unit", "N", + "--torque-unit", "N-mm"}, + }; + + for (const auto &arguments : cases) { + EXPECT_THROW(netft::cli::parse_options(arguments), netft::cli::UsageError); + } +} + TEST(CliParser, RequiresCompleteManualCalibration) { EXPECT_THROW(parse({"monitor", "--counts-per-force-unit", "1000000"}), netft::cli::UsageError); @@ -233,6 +254,41 @@ TEST(CliInfo, PreservesValidUtf8WhileEscapingJsonControls) { std::string::npos); } +TEST(CliInfo, SerializesEveryJsonControlBranch) { + netft::test::FakeSensor sensor; + std::string product{"controls"}; + product.push_back('"'); + product.push_back('\\'); + product.push_back('\b'); + product.push_back('\f'); + product.push_back('\n'); + product.push_back('\r'); + product.push_back('\t'); + product.push_back(static_cast(0x01)); + sensor.set_xml_configuration("" + product + + "10000001000000" + "NNm"); + auto options = options_for(netft::cli::Command::Info, sensor); + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors), 0); + EXPECT_FALSE(output.str().empty()); + EXPECT_TRUE(errors.str().empty()); +} + +TEST(CliInfo, HumanOutputIsNonempty) { + netft::test::FakeSensor sensor; + auto options = options_for(netft::cli::Command::Info, sensor); + options.json = false; + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors), 0); + EXPECT_FALSE(output.str().empty()); + EXPECT_TRUE(errors.str().empty()); +} + TEST(CliMonitor, EmitsStableJsonAndUsesDeliveredCountAsSampleCount) { netft::test::FakeSensor sensor; auto options = options_for(netft::cli::Command::Monitor, sensor); @@ -362,6 +418,20 @@ TEST(CliMonitor, ReturnsTwoWhenNoSampleArrives) { EXPECT_FALSE(errors.str().empty()); } +TEST(CliMonitor, IncludesAStoredClientFailureWhenNoSampleArrives) { + netft::test::FakeSensor sensor; + auto options = options_for(netft::cli::Command::Monitor, sensor); + options.config.rdt_port = 1; + options.config.recovery_policy = netft::RecoveryPolicy::FailStop; + options.duration = 50ms; + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors), 2); + EXPECT_TRUE(output.str().empty()); + EXPECT_FALSE(errors.str().empty()); +} + TEST(CliBias, BiasesAfterFirstSampleAndReportsLaterSample) { netft::test::FakeSensor sensor{10.0}; auto options = options_for(netft::cli::Command::Bias, sensor); @@ -382,6 +452,32 @@ TEST(CliBias, BiasesAfterFirstSampleAndReportsLaterSample) { EXPECT_GE(json_unsigned(output.str(), "delivered_count"), 3U); } +TEST(CliBias, StopsWhenAlreadyInterruptedBeforeAFirstSample) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto options = options_for(netft::cli::Command::Bias, sensor); + volatile std::sig_atomic_t interrupted = 1; + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors, &interrupted), 130); + EXPECT_TRUE(output.str().empty()); +} + +TEST(CliBias, ReturnsTwoWhenTheClientFaultsBeforeAFirstSample) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto options = options_for(netft::cli::Command::Bias, sensor); + options.config.receive_timeout = 20ms; + options.config.recovery_policy = netft::RecoveryPolicy::FailStop; + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors), 2); + EXPECT_TRUE(output.str().empty()); + EXPECT_FALSE(errors.str().empty()); +} + TEST(CliOutput, WritesResultToRequestedFile) { netft::test::FakeSensor sensor; auto options = options_for(netft::cli::Command::Info, sensor); @@ -400,6 +496,22 @@ TEST(CliOutput, WritesResultToRequestedFile) { std::filesystem::remove(path); } +TEST(CliOutput, PreservesAnExistingDirectoryWhenReplacementFails) { + netft::test::FakeSensor sensor; + auto options = options_for(netft::cli::Command::Info, sensor); + const auto path = temporary_path("output-directory"); + std::filesystem::remove_all(path); + ASSERT_TRUE(std::filesystem::create_directory(path)); + options.output_path = path.string(); + std::ostringstream output; + std::ostringstream errors; + + EXPECT_EQ(netft::cli::run(options, output, errors), 2); + EXPECT_TRUE(output.str().empty()); + EXPECT_TRUE(std::filesystem::is_directory(path)); + std::filesystem::remove(path); +} + TEST(CliProcess, SigintStopsClientAndReturns130) { netft::test::FakeSensor sensor; const int null_fd = ::open("/dev/null", O_WRONLY); From 436bb637717023f6efb62dfedb6dcf64b14976bf Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:24:04 +0800 Subject: [PATCH 3/4] Enforce project coverage gates --- codecov.yml | 13 +++++++++++++ pixi.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..11fddea --- /dev/null +++ b/codecov.yml @@ -0,0 +1,13 @@ +coverage: + precision: 2 + round: down + range: "85...100" + status: + project: + default: + target: 85% + threshold: 0% + patch: + default: + target: 90% + threshold: 0% diff --git a/pixi.toml b/pixi.toml index 78d781c..1bd1d11 100644 --- a/pixi.toml +++ b/pixi.toml @@ -29,7 +29,7 @@ hardware-bias-test = "NETFT_ALLOW_BIAS=1 bash test/hardware_test.sh" format = "git ls-files -z '*.cpp' '*.hpp' | xargs -0 clang-format -i" format-check = "git ls-files -z '*.cpp' '*.hpp' | xargs -0 clang-format --dry-run --Werror" tidy = { cmd = "cmake -S . -B build/tidy -G Ninja -DBUILD_TESTING=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON && clang-tidy -p build/tidy --config-file=.clang-tidy $(git ls-files 'src/*.cpp' 'app/*.cpp')", depends-on = [] } -coverage = "cmake -S . -B build/coverage -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=ON -DCMAKE_CXX_FLAGS='--coverage -O0 -g' -DCMAKE_EXE_LINKER_FLAGS=--coverage -DCMAKE_SHARED_LINKER_FLAGS=--coverage && cmake --build build/coverage && ctest --test-dir build/coverage --output-on-failure && gcovr --root . --filter 'src/' --filter 'app/' --exclude 'test/' --exclude-unreachable-branches --exclude-throw-branches --xml-pretty --output coverage.xml --fail-under-line 85 build/coverage" +coverage = "cmake -S . -B build/coverage -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=ON -DCMAKE_CXX_FLAGS='--coverage -O0 -g' -DCMAKE_EXE_LINKER_FLAGS=--coverage -DCMAKE_SHARED_LINKER_FLAGS=--coverage && cmake --build build/coverage && ctest --test-dir build/coverage --output-on-failure && gcovr --root . --filter 'src/' --filter 'app/' --exclude 'test/' --exclude-unreachable-branches --exclude-throw-branches --xml-pretty --output coverage.xml --fail-under-line 85 --fail-under-branch 85 build/coverage" [feature.cmake-316.dependencies] cmake = "3.16.*" From 9aaf0e8af8f8012b9bd5a05003d09fc6f6f01066 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:39:54 +0800 Subject: [PATCH 4/4] Harden coverage tests and configuration --- .codecov.yml | 4 ++-- codecov.yml | 13 ------------- test/test_cli.cpp | 12 +++++++----- test/test_types.cpp | 37 ++++++++++++++++++++++--------------- 4 files changed, 31 insertions(+), 35 deletions(-) delete mode 100644 codecov.yml diff --git a/.codecov.yml b/.codecov.yml index c1f4394..bcdd50f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,11 +1,11 @@ coverage: - range: 85..100 + range: "85...100" round: down precision: 2 status: project: default: - target: auto + target: 85% threshold: 0% patch: default: diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 11fddea..0000000 --- a/codecov.yml +++ /dev/null @@ -1,13 +0,0 @@ -coverage: - precision: 2 - round: down - range: "85...100" - status: - project: - default: - target: 85% - threshold: 0% - patch: - default: - target: 90% - threshold: 0% diff --git a/test/test_cli.cpp b/test/test_cli.cpp index 05c4eb9..ec0b199 100644 --- a/test/test_cli.cpp +++ b/test/test_cli.cpp @@ -272,8 +272,9 @@ TEST(CliInfo, SerializesEveryJsonControlBranch) { std::ostringstream output; std::ostringstream errors; - EXPECT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_FALSE(output.str().empty()); + ASSERT_EQ(netft::cli::run(options, output, errors), 0); + EXPECT_NE(output.str().find(R"json("product":"controls\"\\\b\f\n\r\t\u0001")json"), + std::string::npos); EXPECT_TRUE(errors.str().empty()); } @@ -418,12 +419,13 @@ TEST(CliMonitor, ReturnsTwoWhenNoSampleArrives) { EXPECT_FALSE(errors.str().empty()); } -TEST(CliMonitor, IncludesAStoredClientFailureWhenNoSampleArrives) { +TEST(CliMonitor, ReturnsTwoAfterAStoredClientTimeout) { netft::test::FakeSensor sensor; + sensor.pause(); auto options = options_for(netft::cli::Command::Monitor, sensor); - options.config.rdt_port = 1; + options.config.receive_timeout = 20ms; options.config.recovery_policy = netft::RecoveryPolicy::FailStop; - options.duration = 50ms; + options.duration = 80ms; std::ostringstream output; std::ostringstream errors; diff --git a/test/test_types.cpp b/test/test_types.cpp index abe81a8..c195fcd 100644 --- a/test/test_types.cpp +++ b/test/test_types.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -298,21 +297,29 @@ TEST(Units, ConvertsForceAndTorqueUnitStrings) { } TEST(States, ConvertsStateAndFaultCodeStrings) { - std::set state_names; - for (const auto state : - {netft::ClientState::Stopped, netft::ClientState::Connecting, netft::ClientState::Streaming, - netft::ClientState::Backoff, netft::ClientState::Faulted}) { - state_names.insert(netft::to_string(state)); + constexpr std::array state_cases{ + std::pair{netft::ClientState::Stopped, std::string_view{"stopped"}}, + std::pair{netft::ClientState::Connecting, std::string_view{"connecting"}}, + std::pair{netft::ClientState::Streaming, std::string_view{"streaming"}}, + std::pair{netft::ClientState::Backoff, std::string_view{"backoff"}}, + std::pair{netft::ClientState::Faulted, std::string_view{"faulted"}}, + }; + for (const auto &[state, spelling] : state_cases) { + EXPECT_EQ(netft::to_string(state), spelling); } - EXPECT_EQ(state_names.size(), 5U); - std::set fault_names; - for (const auto code : - {netft::FaultCode::None, netft::FaultCode::SensorConfiguration, netft::FaultCode::Timeout, - netft::FaultCode::Socket, netft::FaultCode::SeriousStatus, netft::FaultCode::FtStall, - netft::FaultCode::FtBackward, netft::FaultCode::MalformedStorm, - netft::FaultCode::Callback}) { - fault_names.insert(netft::to_string(code)); + constexpr std::array fault_cases{ + std::pair{netft::FaultCode::None, std::string_view{"none"}}, + std::pair{netft::FaultCode::SensorConfiguration, std::string_view{"sensor_configuration"}}, + std::pair{netft::FaultCode::Timeout, std::string_view{"timeout"}}, + std::pair{netft::FaultCode::Socket, std::string_view{"socket"}}, + std::pair{netft::FaultCode::SeriousStatus, std::string_view{"serious_status"}}, + std::pair{netft::FaultCode::FtStall, std::string_view{"ft_stall"}}, + std::pair{netft::FaultCode::FtBackward, std::string_view{"ft_backward"}}, + std::pair{netft::FaultCode::MalformedStorm, std::string_view{"malformed_storm"}}, + std::pair{netft::FaultCode::Callback, std::string_view{"callback"}}, + }; + for (const auto &[code, spelling] : fault_cases) { + EXPECT_EQ(netft::to_string(code), spelling); } - EXPECT_EQ(fault_names.size(), 9U); }