test(assembly): regression tests for KCSolve solver refactor (#296)
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
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)
This commit is contained in:
216
src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py
Normal file
216
src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
# /****************************************************************************
|
||||||
|
# *
|
||||||
|
# Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
|
||||||
|
# *
|
||||||
|
# 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 *
|
||||||
|
# <https://www.gnu.org/licenses/>. *
|
||||||
|
# *
|
||||||
|
# ***************************************************************************/
|
||||||
|
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
)
|
||||||
@@ -57,6 +57,7 @@ SET(AssemblyTests_SRCS
|
|||||||
AssemblyTests/__init__.py
|
AssemblyTests/__init__.py
|
||||||
AssemblyTests/TestCore.py
|
AssemblyTests/TestCore.py
|
||||||
AssemblyTests/TestCommandInsertLink.py
|
AssemblyTests/TestCommandInsertLink.py
|
||||||
|
AssemblyTests/TestSolverIntegration.py
|
||||||
AssemblyTests/mocks/__init__.py
|
AssemblyTests/mocks/__init__.py
|
||||||
AssemblyTests/mocks/MockGui.py
|
AssemblyTests/mocks/MockGui.py
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
# **************************************************************************/
|
# **************************************************************************/
|
||||||
|
|
||||||
import TestApp
|
import TestApp
|
||||||
|
|
||||||
from AssemblyTests.TestCore import TestCore
|
|
||||||
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
|
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)
|
# Use the modules so that code checkers don't complain (flake8)
|
||||||
True if TestCore else False
|
True if TestCore else False
|
||||||
True if TestCommandInsertLink else False
|
True if TestCommandInsertLink else False
|
||||||
|
True if TestSolverIntegration else False
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ if(BUILD_GUI)
|
|||||||
endif()
|
endif()
|
||||||
if(BUILD_ASSEMBLY)
|
if(BUILD_ASSEMBLY)
|
||||||
list (APPEND TestExecutables Assembly_tests_run)
|
list (APPEND TestExecutables Assembly_tests_run)
|
||||||
|
list (APPEND TestExecutables KCSolve_tests_run)
|
||||||
endif(BUILD_ASSEMBLY)
|
endif(BUILD_ASSEMBLY)
|
||||||
if(BUILD_MATERIAL)
|
if(BUILD_MATERIAL)
|
||||||
list (APPEND TestExecutables Material_tests_run)
|
list (APPEND TestExecutables Material_tests_run)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
|
||||||
add_subdirectory(App)
|
add_subdirectory(App)
|
||||||
|
add_subdirectory(Solver)
|
||||||
|
|
||||||
if (NOT FREECAD_USE_EXTERNAL_ONDSELSOLVER)
|
if (NOT FREECAD_USE_EXTERNAL_ONDSELSOLVER)
|
||||||
target_include_directories(Assembly_tests_run PUBLIC
|
target_include_directories(Assembly_tests_run PUBLIC
|
||||||
|
|||||||
13
tests/src/Mod/Assembly/Solver/CMakeLists.txt
Normal file
13
tests/src/Mod/Assembly/Solver/CMakeLists.txt
Normal file
@@ -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
|
||||||
|
)
|
||||||
251
tests/src/Mod/Assembly/Solver/OndselAdapter.cpp
Normal file
251
tests/src/Mod/Assembly/Solver/OndselAdapter.cpp
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <FCConfig.h>
|
||||||
|
|
||||||
|
#include <App/Application.h>
|
||||||
|
#include <Mod/Assembly/Solver/IKCSolver.h>
|
||||||
|
#include <Mod/Assembly/Solver/OndselAdapter.h>
|
||||||
|
#include <Mod/Assembly/Solver/Types.h>
|
||||||
|
#include <src/App/InitApplication.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace KCSolve;
|
||||||
|
|
||||||
|
// ── Fixture ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OndselAdapterTest : public ::testing::Test
|
||||||
|
{
|
||||||
|
protected:
|
||||||
|
static void SetUpTestSuite()
|
||||||
|
{
|
||||||
|
tests::initApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
adapter_ = std::make_unique<OndselAdapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<OndselAdapter> 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);
|
||||||
|
}
|
||||||
131
tests/src/Mod/Assembly/Solver/SolverRegistry.cpp
Normal file
131
tests/src/Mod/Assembly/Solver/SolverRegistry.cpp
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <Mod/Assembly/Solver/IKCSolver.h>
|
||||||
|
#include <Mod/Assembly/Solver/SolverRegistry.h>
|
||||||
|
#include <Mod/Assembly/Solver/Types.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
using namespace KCSolve;
|
||||||
|
|
||||||
|
// ── Minimal mock solver for registry tests ─────────────────────────
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
class MockSolver : public IKCSolver
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
std::string name() const override
|
||||||
|
{
|
||||||
|
return "MockSolver";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<BaseJointKind> 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<MockSolver>(); });
|
||||||
|
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<MockSolver>(); });
|
||||||
|
EXPECT_TRUE(first);
|
||||||
|
|
||||||
|
bool second = reg.register_solver("test_dup",
|
||||||
|
[]() { return std::make_unique<MockSolver>(); });
|
||||||
|
EXPECT_FALSE(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SolverRegistryTest, AvailableListsSolvers) // NOLINT
|
||||||
|
{
|
||||||
|
auto& reg = SolverRegistry::instance();
|
||||||
|
|
||||||
|
reg.register_solver("test_avail_1",
|
||||||
|
[]() { return std::make_unique<MockSolver>(); });
|
||||||
|
reg.register_solver("test_avail_2",
|
||||||
|
[]() { return std::make_unique<MockSolver>(); });
|
||||||
|
|
||||||
|
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<MockSolver>(); });
|
||||||
|
|
||||||
|
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<MockSolver>(); });
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user