From 934cdf57673e26bbee34ceebd26e648869f4798b Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 16:56:11 -0600 Subject: [PATCH] test(assembly): regression tests for KCSolve solver refactor (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1e: Add C++ gtest and Python unittest coverage for the pluggable solver system introduced in Phases 1a-1d. C++ tests (KCSolve_tests_run): - SolverRegistryTest (8 tests): register/get, duplicates, defaults, available list, joints_for capability queries - OndselAdapterTest (10 tests): identity checks, supported/unsupported joints, Fixed/Revolute solve round-trips, no-grounded-parts handling, exception safety, drag protocol (pre_drag/drag_step/post_drag), redundant constraint diagnostics Python tests (TestSolverIntegration): - Full-stack solve via AssemblyObject → IKCSolver → OndselAdapter - Fixed joint placement matching, revolute joint success - No-ground error code (-6), redundancy detection (-2) - ASMT export produces non-empty file - Deterministic solve stability (solve twice → same result) --- .../AssemblyTests/TestSolverIntegration.py | 216 +++++++++++++++ src/Mod/Assembly/CMakeLists.txt | 1 + src/Mod/Assembly/TestAssemblyWorkbench.py | 6 +- tests/CMakeLists.txt | 1 + tests/src/Mod/Assembly/CMakeLists.txt | 1 + tests/src/Mod/Assembly/Solver/CMakeLists.txt | 13 + .../src/Mod/Assembly/Solver/OndselAdapter.cpp | 251 ++++++++++++++++++ .../Mod/Assembly/Solver/SolverRegistry.cpp | 131 +++++++++ 8 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py create mode 100644 tests/src/Mod/Assembly/Solver/CMakeLists.txt create mode 100644 tests/src/Mod/Assembly/Solver/OndselAdapter.cpp create mode 100644 tests/src/Mod/Assembly/Solver/SolverRegistry.cpp diff --git a/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py b/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py new file mode 100644 index 0000000000..7062876e74 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2025 Kindred Systems * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +""" +Solver integration tests for Phase 1e (KCSolve refactor). + +These tests verify that the AssemblyObject → IKCSolver → OndselAdapter pipeline +produces correct results via the full FreeCAD stack. They complement the C++ +unit tests in tests/src/Mod/Assembly/Solver/. +""" + +import os +import tempfile +import unittest + +import FreeCAD as App +import JointObject + + +class TestSolverIntegration(unittest.TestCase): + """Full-stack solver regression tests exercising AssemblyObject.solve().""" + + def setUp(self): + doc_name = self.__class__.__name__ + if App.ActiveDocument: + if App.ActiveDocument.Name != doc_name: + App.newDocument(doc_name) + else: + App.newDocument(doc_name) + App.setActiveDocument(doc_name) + self.doc = App.ActiveDocument + + self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly") + self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints") + + def tearDown(self): + App.closeDocument(self.doc.Name) + + # ── Helpers ───────────────────────────────────────────────────── + + def _make_box(self, x=0, y=0, z=0, size=10): + """Create a Part::Box inside the assembly with a given offset.""" + box = self.assembly.newObject("Part::Box", "Box") + box.Length = size + box.Width = size + box.Height = size + box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation()) + return box + + def _ground(self, obj): + """Create a grounded joint for the given object.""" + gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint") + JointObject.GroundedJoint(gnd, obj) + return gnd + + def _make_joint(self, joint_type, ref1, ref2): + """Create a joint of the given type connecting two (obj, subelements) pairs. + + joint_type: integer JointType enum value (0=Fixed, 1=Revolute, etc.) + ref1, ref2: tuples of (obj, [sub_element, sub_element]) + """ + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, joint_type) + + refs = [ + [ref1[0], ref1[1]], + [ref2[0], ref2[1]], + ] + joint.Proxy.setJointConnectors(joint, refs) + return joint + + # ── Tests ─────────────────────────────────────────────────────── + + def test_solve_fixed_joint(self): + """Two boxes + grounded + fixed joint → placements match.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + # Fixed joint (type 0) connecting Face6+Vertex7 on each box. + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + # After setJointConnectors, solve() was already called internally. + # Verify that box1 moved to match box2. + self.assertTrue( + box1.Placement.isSame(box2.Placement, 1e-6), + "Fixed joint: box1 should match box2 placement", + ) + + def test_solve_revolute_joint(self): + """Two boxes + grounded + revolute joint → solve succeeds (return 0).""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(100, 0, 0) + self._ground(box1) + + # Revolute joint (type 1) connecting Face6+Vertex7. + self._make_joint( + 1, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + result = self.assembly.solve() + self.assertEqual(result, 0, "Revolute joint solve should succeed") + + def test_solve_returns_code_for_no_ground(self): + """Assembly with no grounded parts → solve returns -6.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(50, 0, 0) + + # Fixed joint but no ground. + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, 0) + refs = [ + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ] + joint.Proxy.setJointConnectors(joint, refs) + + result = self.assembly.solve() + self.assertEqual(result, -6, "No grounded parts should return -6") + + def test_solve_returns_redundancy(self): + """Over-constrained assembly → solve returns -2 (redundant).""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(50, 0, 0) + self._ground(box1) + + # Two fixed joints between the same faces → redundant. + self._make_joint( + 0, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + self._make_joint( + 0, + [box1, ["Face5", "Vertex5"]], + [box2, ["Face5", "Vertex5"]], + ) + + result = self.assembly.solve() + self.assertEqual(result, -2, "Redundant constraints should return -2") + + def test_export_asmt(self): + """exportAsASMT() produces a non-empty file.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(50, 0, 0) + self._ground(box1) + + self._make_joint( + 0, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + + with tempfile.NamedTemporaryFile(suffix=".asmt", delete=False) as f: + path = f.name + + try: + self.assembly.exportAsASMT(path) + self.assertTrue(os.path.exists(path), "ASMT file should exist") + self.assertGreater( + os.path.getsize(path), 0, "ASMT file should be non-empty" + ) + finally: + if os.path.exists(path): + os.unlink(path) + + def test_solve_multiple_times_stable(self): + """Solving the same assembly twice produces identical placements.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + plc_first = App.Placement(box1.Placement) + + self.assembly.solve() + plc_second = box1.Placement + + self.assertTrue( + plc_first.isSame(plc_second, 1e-6), + "Deterministic solver should produce identical results", + ) diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index 1c51a9a13f..0505b216fa 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -57,6 +57,7 @@ SET(AssemblyTests_SRCS AssemblyTests/__init__.py AssemblyTests/TestCore.py AssemblyTests/TestCommandInsertLink.py + AssemblyTests/TestSolverIntegration.py AssemblyTests/mocks/__init__.py AssemblyTests/mocks/MockGui.py ) diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index 911a298a6a..387f4e4e14 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -22,11 +22,11 @@ # **************************************************************************/ import TestApp - -from AssemblyTests.TestCore import TestCore from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink - +from AssemblyTests.TestCore import TestCore +from AssemblyTests.TestSolverIntegration import TestSolverIntegration # Use the modules so that code checkers don't complain (flake8) True if TestCore else False True if TestCommandInsertLink else False +True if TestSolverIntegration else False diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index da69d6f09c..6c9e3a42ea 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -95,6 +95,7 @@ if(BUILD_GUI) endif() if(BUILD_ASSEMBLY) list (APPEND TestExecutables Assembly_tests_run) + list (APPEND TestExecutables KCSolve_tests_run) endif(BUILD_ASSEMBLY) if(BUILD_MATERIAL) list (APPEND TestExecutables Material_tests_run) diff --git a/tests/src/Mod/Assembly/CMakeLists.txt b/tests/src/Mod/Assembly/CMakeLists.txt index 1d3bb763b3..787d743e2d 100644 --- a/tests/src/Mod/Assembly/CMakeLists.txt +++ b/tests/src/Mod/Assembly/CMakeLists.txt @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LGPL-2.1-or-later add_subdirectory(App) +add_subdirectory(Solver) if (NOT FREECAD_USE_EXTERNAL_ONDSELSOLVER) target_include_directories(Assembly_tests_run PUBLIC diff --git a/tests/src/Mod/Assembly/Solver/CMakeLists.txt b/tests/src/Mod/Assembly/Solver/CMakeLists.txt new file mode 100644 index 0000000000..7160170070 --- /dev/null +++ b/tests/src/Mod/Assembly/Solver/CMakeLists.txt @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +add_executable(KCSolve_tests_run + SolverRegistry.cpp + OndselAdapter.cpp +) + +target_link_libraries(KCSolve_tests_run + gtest_main + ${Google_Tests_LIBS} + KCSolve + FreeCADApp +) diff --git a/tests/src/Mod/Assembly/Solver/OndselAdapter.cpp b/tests/src/Mod/Assembly/Solver/OndselAdapter.cpp new file mode 100644 index 0000000000..be475bd808 --- /dev/null +++ b/tests/src/Mod/Assembly/Solver/OndselAdapter.cpp @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +using namespace KCSolve; + +// ── Fixture ──────────────────────────────────────────────────────── + +class OndselAdapterTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + { + adapter_ = std::make_unique(); + } + + /// Build a minimal two-part context with a single constraint. + static SolveContext twoPartContext(BaseJointKind jointKind, + bool groundFirst = true) + { + SolveContext ctx; + + Part p1; + p1.id = "Part1"; + p1.placement = Transform::identity(); + p1.grounded = groundFirst; + ctx.parts.push_back(p1); + + Part p2; + p2.id = "Part2"; + p2.placement = Transform::identity(); + p2.placement.position = {100.0, 0.0, 0.0}; + p2.grounded = false; + ctx.parts.push_back(p2); + + Constraint c; + c.id = "Joint1"; + c.part_i = "Part1"; + c.marker_i = Transform::identity(); + c.part_j = "Part2"; + c.marker_j = Transform::identity(); + c.type = jointKind; + ctx.constraints.push_back(c); + + return ctx; + } + + std::unique_ptr adapter_; +}; + + +// ── Identity / capability tests ──────────────────────────────────── + +TEST_F(OndselAdapterTest, Name) // NOLINT +{ + auto n = adapter_->name(); + EXPECT_FALSE(n.empty()); + EXPECT_NE(n.find("Ondsel"), std::string::npos); +} + +TEST_F(OndselAdapterTest, SupportedJoints) // NOLINT +{ + auto joints = adapter_->supported_joints(); + EXPECT_FALSE(joints.empty()); + + // Must include core kinematic joints. + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Fixed), joints.end()); + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Revolute), joints.end()); + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Cylindrical), joints.end()); + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Ball), joints.end()); + + // Must exclude unsupported types. + EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Universal), joints.end()); + EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Cam), joints.end()); + EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Slot), joints.end()); +} + +TEST_F(OndselAdapterTest, IsDeterministic) // NOLINT +{ + EXPECT_TRUE(adapter_->is_deterministic()); +} + +TEST_F(OndselAdapterTest, SupportsBundleFixed) // NOLINT +{ + EXPECT_FALSE(adapter_->supports_bundle_fixed()); +} + + +// ── Solve round-trips ────────────────────────────────────────────── + +TEST_F(OndselAdapterTest, SolveFixedJoint) // NOLINT +{ + auto ctx = twoPartContext(BaseJointKind::Fixed); + auto result = adapter_->solve(ctx); + + EXPECT_EQ(result.status, SolveStatus::Success); + EXPECT_FALSE(result.placements.empty()); + + // Both parts should end up at the same position (fixed joint). + const auto* pr1 = &result.placements[0]; + const auto* pr2 = &result.placements[1]; + if (pr1->id == "Part2") { + std::swap(pr1, pr2); + } + + // Part1 is grounded — should remain at origin. + EXPECT_NEAR(pr1->placement.position[0], 0.0, 1e-3); + EXPECT_NEAR(pr1->placement.position[1], 0.0, 1e-3); + EXPECT_NEAR(pr1->placement.position[2], 0.0, 1e-3); + + // Part2 should be pulled to Part1's position by the fixed joint + // (markers are both identity, so the parts are welded at the same point). + EXPECT_NEAR(pr2->placement.position[0], 0.0, 1e-3); + EXPECT_NEAR(pr2->placement.position[1], 0.0, 1e-3); + EXPECT_NEAR(pr2->placement.position[2], 0.0, 1e-3); +} + +TEST_F(OndselAdapterTest, SolveRevoluteJoint) // NOLINT +{ + auto ctx = twoPartContext(BaseJointKind::Revolute); + auto result = adapter_->solve(ctx); + + EXPECT_EQ(result.status, SolveStatus::Success); + EXPECT_FALSE(result.placements.empty()); +} + +TEST_F(OndselAdapterTest, SolveNoGroundedParts) // NOLINT +{ + // OndselAdapter itself doesn't require grounded parts — that check + // lives in AssemblyObject. The solver should still attempt to solve. + auto ctx = twoPartContext(BaseJointKind::Fixed, /*groundFirst=*/false); + auto result = adapter_->solve(ctx); + + // May succeed or fail depending on OndselSolver's behavior, but must not crash. + EXPECT_TRUE(result.status == SolveStatus::Success + || result.status == SolveStatus::Failed); +} + +TEST_F(OndselAdapterTest, SolveCatchesException) // NOLINT +{ + // Malformed context: constraint references non-existent parts. + SolveContext ctx; + + Part p; + p.id = "LonePart"; + p.placement = Transform::identity(); + p.grounded = true; + ctx.parts.push_back(p); + + Constraint c; + c.id = "BadJoint"; + c.part_i = "DoesNotExist"; + c.marker_i = Transform::identity(); + c.part_j = "AlsoDoesNotExist"; + c.marker_j = Transform::identity(); + c.type = BaseJointKind::Fixed; + ctx.constraints.push_back(c); + + // Should not crash — returns Failed or succeeds with warnings. + auto result = adapter_->solve(ctx); + SUCCEED(); // If we get here without crashing, the test passes. +} + + +// ── Drag protocol ────────────────────────────────────────────────── + +TEST_F(OndselAdapterTest, DragProtocol) // NOLINT +{ + auto ctx = twoPartContext(BaseJointKind::Revolute); + + auto preResult = adapter_->pre_drag(ctx, {"Part2"}); + EXPECT_EQ(preResult.status, SolveStatus::Success); + + // Move Part2 slightly. + SolveResult::PartResult dragPlc; + dragPlc.id = "Part2"; + dragPlc.placement = Transform::identity(); + dragPlc.placement.position = {10.0, 5.0, 0.0}; + + auto stepResult = adapter_->drag_step({dragPlc}); + // drag_step may fail if the solver can't converge — that's OK. + EXPECT_TRUE(stepResult.status == SolveStatus::Success + || stepResult.status == SolveStatus::Failed); + + // post_drag must not crash. + adapter_->post_drag(); + SUCCEED(); +} + + +// ── Diagnostics ──────────────────────────────────────────────────── + +TEST_F(OndselAdapterTest, DiagnoseRedundant) // NOLINT +{ + // Over-constrained: two fixed joints between the same two parts. + SolveContext ctx; + + Part p1; + p1.id = "PartA"; + p1.placement = Transform::identity(); + p1.grounded = true; + ctx.parts.push_back(p1); + + Part p2; + p2.id = "PartB"; + p2.placement = Transform::identity(); + p2.placement.position = {50.0, 0.0, 0.0}; + p2.grounded = false; + ctx.parts.push_back(p2); + + Constraint c1; + c1.id = "FixedJoint1"; + c1.part_i = "PartA"; + c1.marker_i = Transform::identity(); + c1.part_j = "PartB"; + c1.marker_j = Transform::identity(); + c1.type = BaseJointKind::Fixed; + ctx.constraints.push_back(c1); + + Constraint c2; + c2.id = "FixedJoint2"; + c2.part_i = "PartA"; + c2.marker_i = Transform::identity(); + c2.part_j = "PartB"; + c2.marker_j = Transform::identity(); + c2.type = BaseJointKind::Fixed; + ctx.constraints.push_back(c2); + + auto diags = adapter_->diagnose(ctx); + // With two identical fixed joints, one must be redundant. + bool hasRedundant = std::any_of(diags.begin(), diags.end(), [](const auto& d) { + return d.kind == ConstraintDiagnostic::Kind::Redundant; + }); + EXPECT_TRUE(hasRedundant); +} diff --git a/tests/src/Mod/Assembly/Solver/SolverRegistry.cpp b/tests/src/Mod/Assembly/Solver/SolverRegistry.cpp new file mode 100644 index 0000000000..5c29027620 --- /dev/null +++ b/tests/src/Mod/Assembly/Solver/SolverRegistry.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include + +#include +#include +#include + +#include + +using namespace KCSolve; + +// ── Minimal mock solver for registry tests ───────────────────────── + +namespace +{ + +class MockSolver : public IKCSolver +{ +public: + std::string name() const override + { + return "MockSolver"; + } + + std::vector supported_joints() const override + { + return {BaseJointKind::Fixed, BaseJointKind::Revolute}; + } + + SolveResult solve(const SolveContext& /*ctx*/) override + { + return SolveResult {SolveStatus::Success, {}, 0, {}, 0}; + } +}; + +} // namespace + + +// ── Tests ────────────────────────────────────────────────────────── +// +// SolverRegistry is a singleton — tests use unique names to avoid +// interference across test cases. + +TEST(SolverRegistryTest, GetUnknownReturnsNull) // NOLINT +{ + auto solver = SolverRegistry::instance().get("nonexistent_solver_xyz"); + EXPECT_EQ(solver, nullptr); +} + +TEST(SolverRegistryTest, RegisterAndGet) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + + bool ok = reg.register_solver("test_reg_get", + []() { return std::make_unique(); }); + EXPECT_TRUE(ok); + + auto solver = reg.get("test_reg_get"); + ASSERT_NE(solver, nullptr); + EXPECT_EQ(solver->name(), "MockSolver"); +} + +TEST(SolverRegistryTest, DuplicateRegistrationFails) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + + bool first = reg.register_solver("test_dup", + []() { return std::make_unique(); }); + EXPECT_TRUE(first); + + bool second = reg.register_solver("test_dup", + []() { return std::make_unique(); }); + EXPECT_FALSE(second); +} + +TEST(SolverRegistryTest, AvailableListsSolvers) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + + reg.register_solver("test_avail_1", + []() { return std::make_unique(); }); + reg.register_solver("test_avail_2", + []() { return std::make_unique(); }); + + auto names = reg.available(); + EXPECT_NE(std::find(names.begin(), names.end(), "test_avail_1"), names.end()); + EXPECT_NE(std::find(names.begin(), names.end(), "test_avail_2"), names.end()); +} + +TEST(SolverRegistryTest, SetDefaultAndGet) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + + reg.register_solver("test_default", + []() { return std::make_unique(); }); + + bool ok = reg.set_default("test_default"); + EXPECT_TRUE(ok); + + // get() with no arg should return the default. + auto solver = reg.get(); + ASSERT_NE(solver, nullptr); + EXPECT_EQ(solver->name(), "MockSolver"); +} + +TEST(SolverRegistryTest, SetDefaultUnknownFails) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + bool ok = reg.set_default("totally_unknown_solver"); + EXPECT_FALSE(ok); +} + +TEST(SolverRegistryTest, JointsForReturnsCapabilities) // NOLINT +{ + auto& reg = SolverRegistry::instance(); + + reg.register_solver("test_joints", + []() { return std::make_unique(); }); + + auto joints = reg.joints_for("test_joints"); + EXPECT_EQ(joints.size(), 2u); + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Fixed), joints.end()); + EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Revolute), joints.end()); +} + +TEST(SolverRegistryTest, JointsForUnknownReturnsEmpty) // NOLINT +{ + auto joints = SolverRegistry::instance().joints_for("totally_unknown_solver_2"); + EXPECT_TRUE(joints.empty()); +}