test(assembly): regression tests for KCSolve solver refactor (#296)
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:
forbes
2026-02-19 16:56:11 -06:00
parent 5c33aacecb
commit 934cdf5767
8 changed files with 617 additions and 3 deletions

View 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",
)

View File

@@ -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
) )

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View 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
)

View 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);
}

View 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());
}