Files
create/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py
Billy Huddleston 9cab1f8f52 CAM: Make CAM tests and serialization robust to locale-dependent decimal separators
Updated CAM unit tests and LinuxCNC serializer to handle decimal separators consistently, ensuring tests pass regardless of system locale. Assertions now compare FreeCAD.Units.Quantity objects directly or normalize decimal separators in strings. LinuxCNC serializer output is forced to use periods for decimals.

src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py:
- Normalize decimal separators in tool description assertions for locale robustness.

src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py:
- Use FreeCAD.Units.Quantity for direct quantity comparisons in property editor tests.

src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py:
- Compare deserialized and serialized quantities using FreeCAD.Units.Quantity for consistency.

src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py:
- Compare parameter values and units directly instead of relying on string formatting.

src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py:
- Force period as decimal separator in LinuxCNC serializer output to avoid locale issues.
2025-11-24 18:53:32 +01:00

393 lines
19 KiB
Python

# SPDX-License-Identifier: LGPL-2.1-or-later
# Unit tests for the Path.Tool.Shape module and its utilities.
from pathlib import Path
from typing import Mapping, Tuple
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.assets import DummyAssetSerializer
from Path.Tool.shape import (
ToolBitShape,
ToolBitShapeBallend,
ToolBitShapeVBit,
ToolBitShapeBullnose,
ToolBitShapeSlittingSaw,
)
# Helper dummy class for testing abstract methods
class DummyShape(ToolBitShape):
name = "dummy"
def __init__(self, id, **kwargs):
super().__init__(id=id, **kwargs)
# Always define defaults in the subclass
self._defaults = {
"Param1": FreeCAD.Units.Quantity("10 mm"),
"Param2": FreeCAD.Units.Quantity("5 deg"),
}
# Merge defaults into _params, allowing kwargs to override
self._params = self._defaults | self._params
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Param1": (
FreeCAD.Qt.translate("Param1", "Parameter 1"),
"App::PropertyLength",
),
"Param2": (
FreeCAD.Qt.translate("Param2", "Parameter 2"),
"App::PropertyAngle",
),
}
@property
def label(self):
return "Dummy Shape"
def unit(param):
return param.getUserPreferred()[2]
class TestPathToolShapeClasses(PathTestWithAssets):
"""Tests for the concrete ToolBitShape subclasses."""
def _test_shape_common(self, alias):
uri = ToolBitShape.resolve_name(alias)
shape = self.assets.get(uri)
return shape.get_parameters()
def test_base_init_with_defaults(self):
"""Test base class initialization uses default parameters."""
# Provide a dummy filepath and id for instantiation
shape = DummyShape(id="dummy_shape_1", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.get_parameter("Param1").Value, 10.0)
self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm"))
self.assertEqual(shape.get_parameter("Param2").Value, 5.0)
self.assertEqual(shape.get_parameter("Param2").Unit, FreeCAD.Units.Unit("deg"))
def test_base_init_with_kwargs(self):
"""Test base class initialization overrides defaults with kwargs."""
# Provide a dummy filepath for instantiation
shape = DummyShape(
id="dummy_shape_2",
filepath=Path("/fake/dummy.fcstd"),
Param1=FreeCAD.Units.Quantity("20 mm"),
Param3="Ignored",
)
self.assertEqual(shape.get_parameter("Param1").Value, 20.0)
self.assertEqual(shape.get_parameter("Param1").Value, 20.0)
self.assertEqual(
str(shape.get_parameter("Param1").Unit),
"Unit: mm (1,0,0,0,0,0,0,0) [Length]",
)
self.assertEqual(shape.get_parameter("Param2").Value, 5.0)
self.assertEqual(
str(shape.get_parameter("Param2").Unit),
"Unit: deg (0,0,0,0,0,0,0,1) [Angle]",
) # Should remain default
def test_base_get_set_parameter(self):
"""Test getting and setting individual parameters."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_3", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.get_parameter("Param1").Value, 10.0)
self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm"))
shape.set_parameter("Param1", FreeCAD.Units.Quantity("15 mm"))
self.assertEqual(shape.get_parameter("Param1").Value, 15.0)
self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm"))
with self.assertRaisesRegex(KeyError, "Shape 'dummy' has no parameter 'InvalidParam'"):
shape.get_parameter("InvalidParam")
def test_base_get_parameters(self):
"""Test getting the full parameter dictionary."""
# Provide a dummy filepath for instantiation
shape = DummyShape(
id="dummy_shape_4",
filepath=Path("/fake/dummy.fcstd"),
Param1=FreeCAD.Units.Quantity("12 mm"),
)
# Create mock quantity instances using the configured mock class
expected_param1 = FreeCAD.Units.Quantity("12.0 mm")
expected_param2 = FreeCAD.Units.Quantity("5.0 deg")
expected = {"Param1": expected_param1, "Param2": expected_param2}
params = shape.get_parameters()
self.assertEqual(params["Param1"].Value, expected["Param1"].Value)
self.assertEqual(str(params["Param1"].Unit), str(expected["Param1"].Unit))
self.assertEqual(params["Param2"].Value, expected["Param2"].Value)
self.assertEqual(str(params["Param2"].Unit), str(expected["Param2"].Unit))
def test_base_name_property(self):
"""Test the name property returns the primary alias."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_5", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.name, "dummy")
def test_base_get_parameter_label(self):
"""Test retrieving parameter labels."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_6", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.get_parameter_label("Param1"), "Parameter 1")
self.assertEqual(shape.get_parameter_label("Param2"), "Parameter 2")
# Test fallback for unknown parameter
self.assertEqual(shape.get_parameter_label("UnknownParam"), "UnknownParam")
def test_base_get_expected_shape_parameters(self):
"""Test retrieving the list of expected parameter names."""
expected = ["Param1", "Param2"]
self.assertCountEqual(DummyShape.get_expected_shape_parameters(), expected)
def test_base_str_repr(self):
"""Test string representation."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_7", filepath=Path("/fake/dummy.fcstd"))
# Dynamically construct the expected string using the actual parameter string representations
params_str = ", ".join(f"{name}={str(val)}" for name, val in shape.get_parameters().items())
expected_str = f"dummy({params_str})"
self.assertEqual(str(shape), expected_str)
self.assertEqual(repr(shape), expected_str)
def test_base_resolve_name(self):
"""Test resolving shape aliases to canonical names."""
self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "v-bit")
self.assertEqual(ToolBitShape.resolve_name("vbit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("bullnose.fcstd").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "SlittingSaw")
# Test unknown name - should return the input name
self.assertEqual(ToolBitShape.resolve_name("nonexistent").asset_id, "nonexistent")
self.assertEqual(ToolBitShape.resolve_name("UnknownShape").asset_id, "UnknownShape")
def test_concrete_classes_instantiation(self):
"""Test that all concrete classes can be instantiated."""
# No patching of FreeCAD document operations here.
# The test relies on the actual FreeCAD environment.
shape_uris = self.assets.list_assets(asset_type="toolbitshape")
for uri in shape_uris:
# Skip the DummyShape asset if it exists
if uri.asset_id == "dummy":
continue
with self.subTest(uri=uri):
instance = self.assets.get(uri)
self.assertIsInstance(instance, ToolBitShape)
# Check if default params were set by checking if the
# parameters dictionary is not empty.
self.assertTrue(instance.get_parameters())
def test_get_shape_class(self):
"""Test the get_shape_class function."""
uri = ToolBitShape.resolve_name("ballend")
self.assets.get(uri) # Ensure it's loadable
self.assertEqual(ToolBitShape.get_subclass_by_name("ballend"), ToolBitShapeBallend)
self.assertEqual(ToolBitShape.get_subclass_by_name("v-bit"), ToolBitShapeVBit)
self.assertEqual(ToolBitShape.get_subclass_by_name("VBit"), ToolBitShapeVBit)
self.assertEqual(ToolBitShape.get_subclass_by_name("torus"), ToolBitShapeBullnose)
self.assertEqual(ToolBitShape.get_subclass_by_name("slitting-saw"), ToolBitShapeSlittingSaw)
self.assertIsNone(ToolBitShape.get_subclass_by_name("nonexistent"))
# The following tests for default parameters and labels
# should also not use mocks for FreeCAD document operations or Units.
# They should rely on the actual FreeCAD environment and the
# load_file method of the base class.
def test_toolbitshapeballend_defaults(self):
"""Test ToolBitShapeBallend default parameters and labels."""
# Provide a dummy filepath for instantiation.
# The actual file content is not loaded in this test,
# only the default parameters are checked.
shape = self._test_shape_common("ballend")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["Length"].Value, 50.0)
self.assertEqual(unit(shape["Length"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("ballend")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
self.assertEqual(instance.get_parameter_label("Length"), "Overall tool length")
def test_toolbitshapedrill_defaults(self):
"""Test ToolBitShapeDrill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("drill")
self.assertEqual(shape["Diameter"].Value, 3.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["TipAngle"].Value, 119.0)
self.assertEqual(unit(shape["TipAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("drill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle")
def test_toolbitshapechamfer_defaults(self):
"""Test ToolBitShapeChamfer default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("chamfer")
self.assertEqual(shape["Diameter"].Value, 12.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("chamfer")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
def test_toolbitshapedovetail_defaults(self):
"""Test ToolBitShapeDovetail default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("dovetail")
self.assertEqual(shape["Diameter"].Value, 20.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("dovetail")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting angle")
def test_toolbitshapeendmill_defaults(self):
"""Test ToolBitShapeEndmill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("endmill")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeHeight"].Value, 30.0)
self.assertEqual(unit(shape["CuttingEdgeHeight"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("endmill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeHeight"), "Cutting edge height")
def test_toolbitshapeprobe_defaults(self):
"""Test ToolBitShapeProbe default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("probe")
self.assertEqual(shape["Diameter"].Value, 6.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["ShaftDiameter"].Value, 4.0)
self.assertEqual(unit(shape["ShaftDiameter"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("probe")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Ball diameter")
self.assertEqual(instance.get_parameter_label("ShaftDiameter"), "Shaft diameter")
def test_toolbitshapereamer_defaults(self):
"""Test ToolBitShapeReamer default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("reamer")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["Length"].Value, 50.0)
self.assertEqual(unit(shape["Length"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("reamer")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
def test_toolbitshapeslittingsaw_defaults(self):
"""Test ToolBitShapeSlittingSaw default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("slittingsaw")
self.assertEqual(shape["Diameter"].Value, 100.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["BladeThickness"].Value, 3.0)
self.assertEqual(unit(shape["BladeThickness"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("slittingsaw")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("BladeThickness"), "Blade thickness")
def test_toolbitshapetap_defaults(self):
"""Test ToolBitShapeTap default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("tap")
self.assertAlmostEqual(shape["Diameter"].Value, 8, 4)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["TipAngle"].Value, 90.0)
self.assertEqual(unit(shape["TipAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("tap")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle")
def test_toolbitshapethreadmill_defaults(self):
"""Test ToolBitShapeThreadMill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("thread-mill")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["cuttingAngle"].Value, 60.0)
self.assertEqual(unit(shape["cuttingAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("thread-mill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("cuttingAngle"), "Cutting angle")
def test_toolbitshapebullnose_defaults(self):
"""Test ToolBitShapeBullnose default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("bullnose")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CornerRadius"].Value, 1.5)
self.assertEqual(unit(shape["CornerRadius"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("bullnose")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CornerRadius"), "Corner radius")
def test_toolbitshapevbit_defaults(self):
"""Test ToolBitShapeVBit default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("v-bit")
self.assertEqual(shape["Diameter"].Value, 10.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 90.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
self.assertEqual(shape["TipDiameter"].Value, 1.0)
self.assertEqual(unit(shape["TipDiameter"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("v-bit")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting edge angle")
def test_serialize_deserialize(self):
"""
Tests serialization and deserialization of a ToolBitShape object
using the Asset interface methods.
"""
# Load a shape instance from a fixture file
fixture_path = (
Path(__file__).parent / "Tools" / "Shape" / "test-path-tool-bit-shape-00.fcstd"
)
original_shape = ToolBitShape.from_file(fixture_path)
# Serialize the shape using the to_bytes method
serialized_data = original_shape.to_bytes(DummyAssetSerializer)
# Assert that the serialized data is bytes and not empty
self.assertIsInstance(serialized_data, bytes)
self.assertTrue(len(serialized_data) > 0)
# Deserialize the data using the from_bytes classmethod
# Provide an empty dependencies mapping for this test
deserialized_shape = ToolBitShape.from_bytes(
serialized_data, original_shape.get_id(), {}, DummyAssetSerializer
)
# Assert that the deserialized object is a ToolBitShape instance
self.assertIsInstance(deserialized_shape, ToolBitShape)
# Assert that the deserialized shape has the same parameters as the original
self.assertEqual(original_shape.get_parameters(), deserialized_shape.get_parameters())
self.assertEqual(original_shape.name, deserialized_shape.name)