CAM: Replace complete tool management (PR 21425)

This commit is contained in:
Samuel Abels
2025-05-19 20:25:00 +02:00
parent 1cfb85a71f
commit d749098dcb
169 changed files with 22274 additions and 2905 deletions

View File

@@ -21,10 +21,20 @@
# ***************************************************************************
import FreeCAD
import os
import Part
import Path
import math
import pathlib
import unittest
from Path.Tool.assets import AssetManager, MemoryStore, DummyAssetSerializer
from Path.Tool.library.serializers import FCTLSerializer
from Path.Tool.toolbit.serializers import FCTBSerializer
from Path.Tool.camassets import ensure_assets_initialized
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBit
from Path.Tool.shape import ToolBitShape
from Path.Tool.shape.models.icon import ToolBitShapeSvgIcon, ToolBitShapePngIcon
from FreeCAD import Vector
@@ -196,3 +206,43 @@ class PathTestBase(unittest.TestCase):
failed_objects = [o.Name for o in objs if "Invalid" in o.State]
if len(failed_objects) > 0:
self.fail(msg or f"Recompute failed for {failed_objects}")
class PathTestWithAssets(PathTestBase):
"""
A base class that creates an AssetManager, so tests can easily fetch
test data. Examples:
toolbit = self.assets.get("toolbit://ballend")
toolbit = self.assets.get("toolbitshape://chamfer")
"""
__tool_dir = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools"
def setUp(self):
# Set up the manager with an in-memory store.
self.assets: AssetManager = AssetManager()
self.asset_store: MemoryStore = MemoryStore("local")
self.assets.register_store(self.asset_store)
# Register some asset classes.
self.assets.register_asset(Library, FCTLSerializer)
self.assets.register_asset(ToolBit, FCTBSerializer)
self.assets.register_asset(ToolBitShape, DummyAssetSerializer)
self.assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer)
self.assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer)
# Include the built-in assets from src/Mod/CAM/Tools.
# These functions only copy if there are no assets, so this
# must be done BEFORE adding the additional test assets below.
ensure_assets_initialized(self.assets, self.asset_store.name)
# Additional test assets.
for path in pathlib.Path(self.__tool_dir / "Bit").glob("*.fctb"):
self.assets.add_file("toolbit", path)
for path in pathlib.Path(self.__tool_dir / "Shape").glob("*.fcstd"):
self.assets.add_file("toolbitshape", path)
def tearDown(self):
del self.assets
del self.asset_store

View File

@@ -25,7 +25,7 @@ import Part
import Path
import Path.Base.FeedRate as PathFeedRate
import Path.Base.MachineState as PathMachineState
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Tool.Controller as PathToolController
import PathScripts.PathUtils as PathUtils
@@ -34,12 +34,13 @@ from CAMTests.PathTestUtils import PathTestBase
def createTool(name="t1", diameter=1.75):
attrs = {
"shape": None,
"name": name,
"name": name or "t1",
"shape": "endmill.fcstd",
"parameter": {"Diameter": diameter},
"attribute": [],
"attribute": {},
}
return PathToolBit.Factory.CreateFromAttrs(attrs, name)
toolbit = ToolBit.from_dict(attrs)
return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
class TestPathHelpers(PathTestBase):

View File

@@ -22,7 +22,6 @@
import Path
import Path.Op.Deburr as PathDeburr
import Path.Tool.Bit as PathToolBit
import CAMTests.PathTestUtils as PathTestUtils
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())

View File

@@ -51,11 +51,19 @@ class TestPathPreferences(PathTestUtils.PathTestBase):
def test10(self):
"""Default paths for tools are resolved correctly"""
self.assertTrue(Path.Preferences.pathDefaultToolsPath().endswith("/CAM/Tools/"))
self.assertTrue(Path.Preferences.pathDefaultToolsPath("Bit").endswith("/CAM/Tools/Bit"))
self.assertTrue(
Path.Preferences.pathDefaultToolsPath("Library").endswith("/CAM/Tools/Library")
self.assertEqual(
Path.Preferences.getDefaultAssetPath().parts[-2:],
("CAM", "Tools"),
str(Path.Preferences.getDefaultAssetPath()),
)
self.assertTrue(
Path.Preferences.pathDefaultToolsPath("Template").endswith("/CAM/Tools/Template")
self.assertEqual(
Path.Preferences.getBuiltinToolPath().parts[-2:],
("CAM", "Tools"),
str(Path.Preferences.getBuiltinToolPath()),
)
self.assertEqual(
Path.Preferences.getBuiltinShapePath().parts[-3:],
("CAM", "Tools", "Shape"),
str(Path.Preferences.getBuiltinShapePath()),
)
self.assertEqual(Path.Preferences.getToolBitPath().name, "Bit")

View File

@@ -0,0 +1,41 @@
import unittest
from typing import Any, List, Mapping
from Path.Tool.assets import Asset, AssetUri
class TestAsset(Asset):
asset_type: str = "test_asset"
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(cls, data: bytes, id: str, dependencies: Mapping[AssetUri, Any]) -> Any:
return "dummy_object"
def to_bytes(self) -> bytes:
return b"dummy_serialized_data"
def get_id(self) -> str:
# Dummy implementation for testing purposes
return "dummy_id"
class TestPathToolAsset(unittest.TestCase):
def test_asset_cannot_be_instantiated(self):
with self.assertRaises(TypeError):
Asset() # type: ignore
def test_asset_can_be_instantiated_and_has_members(self):
asset = TestAsset()
self.assertIsInstance(asset, Asset)
self.assertEqual(asset.asset_type, "test_asset")
self.assertEqual(asset.to_bytes(), b"dummy_serialized_data")
self.assertEqual(TestAsset.dependencies(b"some_data"), [])
self.assertEqual(TestAsset.from_bytes(b"some_data", "some_id", {}), "dummy_object")
self.assertEqual(asset.get_id(), "dummy_id")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
import unittest
import asyncio
import hashlib
from typing import Any, Type, Optional, List, Mapping
from Path.Tool.assets.cache import AssetCache, CacheKey
from Path.Tool.assets import (
AssetManager,
Asset,
AssetUri,
AssetSerializer,
DummyAssetSerializer,
MemoryStore,
)
class MockAsset(Asset):
asset_type: str = "mock_asset"
_build_counter = 0
def __init__(
self,
asset_id: str,
raw_data: bytes,
dependencies: Optional[Mapping[AssetUri, Any]] = None,
):
super().__init__() # Initialize Asset ABC
self._asset_id = asset_id # Store id internally
self.raw_data_content = raw_data
self.resolved_dependencies = dependencies or {}
MockAsset._build_counter += 1
self.build_id = MockAsset._build_counter
def get_id(self) -> str: # Implement abstract method
return self._asset_id
# get_uri() is inherited from Asset and uses self.asset_type and self.get_id()
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
# This mock implementation handles the simple "dep:" format
data_str = data.decode()
if data_str.startswith("dep:"):
try:
# Get content after the first "dep:"
dep_content = data_str.split(":", 1)[1]
except IndexError:
# This case should ideally not be reached if startswith("dep:") is true
# and there's content after "dep:", but good for robustness.
return []
dep_uri_strings = dep_content.split(",")
uris = []
for uri_string in dep_uri_strings:
uri_string = uri_string.strip() # Remove leading/trailing whitespace
if not uri_string:
continue
try:
uris.append(AssetUri(uri_string))
except ValueError:
# This print will now show the full problematic uri_string
print(f"Warning: Could not parse mock dependency URI: '{uri_string}'")
return uris
return []
@classmethod
def from_bytes(
cls: Type["MockAsset"],
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Any]],
serializer: AssetSerializer,
) -> "MockAsset":
return cls(asset_id=id, raw_data=data, dependencies=dependencies)
def to_bytes(self, serializer: AssetSerializer) -> bytes: # Implement abstract method
return self.raw_data_content
class MockAssetB(Asset): # New mock asset class for type 'mock_asset_b'
asset_type: str = "mock_asset_b"
_build_counter = 0 # Separate counter if needed, or share MockAsset's
def __init__(
self,
asset_id: str,
raw_data: bytes,
dependencies: Optional[Mapping[AssetUri, Any]] = None,
):
super().__init__()
self._asset_id = asset_id
self.raw_data_content = raw_data
self.resolved_dependencies = dependencies or {}
MockAssetB._build_counter += 1
self.build_id = MockAssetB._build_counter
def get_id(self) -> str:
return self._asset_id
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
# Keep simple, or adapt if MockAssetB has different dep logic
return []
@classmethod
def from_bytes(
cls: Type["MockAssetB"],
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Any]],
serializer: AssetSerializer,
) -> "MockAssetB":
return cls(asset_id=id, raw_data=data, dependencies=dependencies)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return self.raw_data_content
def _get_raw_data_hash(raw_data: bytes) -> int:
return int(hashlib.sha256(raw_data).hexdigest(), 16)
class TestPathToolAssetCache(unittest.TestCase):
def setUp(self):
self.cache = AssetCache(max_size_bytes=1000)
def test_put_and_get_simple(self):
key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), ("dep1",))
asset_obj = MockAsset(asset_id="id1", raw_data=b"data1")
self.cache.put(key, asset_obj, len(b"data1"), {"dep_uri_str"})
retrieved = self.cache.get(key)
self.assertIsNotNone(retrieved)
# Assuming retrieved is MockAsset, it will have get_id()
self.assertEqual(retrieved.get_id(), "id1")
def test_get_miss(self):
key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), tuple())
self.assertIsNone(self.cache.get(key))
def test_lru_eviction(self):
asset_data_size = 300
asset1_data = b"a" * asset_data_size
asset2_data = b"b" * asset_data_size
asset3_data = b"c" * asset_data_size
asset4_data = b"d" * asset_data_size
key1 = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(asset1_data), tuple())
key2 = CacheKey("s", "mock_asset://id2", _get_raw_data_hash(asset2_data), tuple())
key3 = CacheKey("s", "mock_asset://id3", _get_raw_data_hash(asset3_data), tuple())
key4 = CacheKey("s", "mock_asset://id4", _get_raw_data_hash(asset4_data), tuple())
self.cache.put(key1, MockAsset("id1", asset1_data), asset_data_size, set())
self.cache.put(key2, MockAsset("id2", asset2_data), asset_data_size, set())
self.cache.put(key3, MockAsset("id3", asset3_data), asset_data_size, set())
self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size)
self.assertIsNotNone(self.cache.get(key1)) # Access key1 to make it MRU
# Adding key4 should evict key2 (oldest after key1 accessed)
self.cache.put(key4, MockAsset("id4", asset4_data), asset_data_size, set())
self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size)
self.assertIsNotNone(self.cache.get(key1))
self.assertIsNone(self.cache.get(key2)) # Evicted
self.assertIsNotNone(self.cache.get(key3))
self.assertIsNotNone(self.cache.get(key4))
def test_invalidate_direct(self):
key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple())
self.cache.put(key, MockAsset("id1", b"data"), 4, set())
retrieved = self.cache.get(key) # Ensure it's there
self.assertIsNotNone(retrieved)
self.cache.invalidate_for_uri("mock_asset://id1")
self.assertIsNone(self.cache.get(key))
self.assertEqual(self.cache.current_size_bytes, 0)
def test_invalidate_recursive(self):
data_a = b"data_a_dep:mock_asset_b://idB" # A depends on B
data_b = b"data_b"
data_c = b"data_c_dep:mock_asset_a://idA" # C depends on A
uri_a_str = "mock_asset_a://idA"
uri_b_str = "mock_asset_b://idB"
uri_c_str = "mock_asset_c://idC"
key_b = CacheKey("s", uri_b_str, _get_raw_data_hash(data_b), tuple())
key_a = CacheKey("s", uri_a_str, _get_raw_data_hash(data_a), (uri_b_str,))
key_c = CacheKey("s", uri_c_str, _get_raw_data_hash(data_c), (uri_a_str,))
self.cache.put(key_b, MockAsset("idB", data_b), len(data_b), set())
self.cache.put(key_a, MockAsset("idA", data_a), len(data_a), {uri_b_str})
self.cache.put(key_c, MockAsset("idC", data_c), len(data_c), {uri_a_str})
self.assertIsNotNone(self.cache.get(key_a))
self.assertIsNotNone(self.cache.get(key_b))
self.assertIsNotNone(self.cache.get(key_c))
self.cache.invalidate_for_uri(uri_b_str) # Invalidate B
self.assertIsNone(self.cache.get(key_a), "Asset A should be invalidated")
self.assertIsNone(self.cache.get(key_b), "Asset B should be invalidated")
self.assertIsNone(self.cache.get(key_c), "Asset C should be invalidated")
self.assertEqual(self.cache.current_size_bytes, 0)
def test_clear_cache(self):
key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple())
self.cache.put(key, MockAsset("id1", b"data"), 4, set())
self.assertNotEqual(self.cache.current_size_bytes, 0)
self.cache.clear()
self.assertIsNone(self.cache.get(key))
self.assertEqual(self.cache.current_size_bytes, 0)
self.assertEqual(len(self.cache._cache_dependencies_map), 0)
self.assertEqual(len(self.cache._cache_dependents_map), 0)
class TestPathToolAssetCacheIntegration(unittest.TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.manager = AssetManager(cache_max_size_bytes=10 * 1024) # 10KB cache
self.store_name = "test_store"
self.store = MemoryStore(name=self.store_name)
self.manager.register_store(self.store, cacheable=True)
self.manager.register_asset(MockAsset, DummyAssetSerializer)
self.manager.register_asset(MockAssetB, DummyAssetSerializer) # Register the new mock type
MockAsset._build_counter = 0
MockAssetB._build_counter = 0
def tearDown(self):
self.loop.close()
def _run_async(self, coro):
return self.loop.run_until_complete(coro)
def test_get_caches_asset(self):
uri_str = "mock_asset://asset1"
raw_data = b"asset1_data"
self._run_async(self.store.create("mock_asset", "asset1", raw_data))
# First get - should build and cache
asset1 = self.manager.get(uri_str, store=self.store_name)
self.assertIsInstance(asset1, MockAsset)
self.assertEqual(asset1.get_id(), "asset1")
self.assertEqual(MockAsset._build_counter, 1) # Built once
# Second get - should hit cache
asset2 = self.manager.get(uri_str, store=self.store_name)
self.assertIsInstance(asset2, MockAsset)
self.assertEqual(asset2.get_id(), "asset1")
self.assertEqual(MockAsset._build_counter, 1) # Still 1, not rebuilt
self.assertIs(asset1, asset2) # Should be the same instance from cache
def test_get_respects_depth_in_cache_key(self):
uri_str = "mock_asset://asset_depth"
# A depends on B (mock_asset_b://dep_b)
raw_data_a = b"dep:mock_asset_b://dep_b"
raw_data_b = b"dep_b_data"
self._run_async(self.store.create("mock_asset", "asset_depth", raw_data_a))
self._run_async(self.store.create("mock_asset_b", "dep_b", raw_data_b))
# Get with depth=0 (shallow)
asset_shallow = self.manager.get(uri_str, store=self.store_name, depth=0)
self.assertIsInstance(asset_shallow, MockAsset)
self.assertEqual(len(asset_shallow.resolved_dependencies), 0)
self.assertEqual(MockAsset._build_counter, 1) # asset_depth built
# Get with depth=None (full)
asset_full = self.manager.get(uri_str, store=self.store_name, depth=None)
self.assertIsInstance(asset_full, MockAsset)
self.assertEqual(len(asset_full.resolved_dependencies), 1)
# asset_depth (MockAsset) built twice (once shallow, once full)
self.assertEqual(MockAsset._build_counter, 2)
# dep_b (MockAssetB) built once as a dependency of the full asset_depth
self.assertEqual(MockAssetB._build_counter, 1)
# Get shallow again - should hit shallow cache
asset_shallow_2 = self.manager.get(uri_str, store=self.store_name, depth=0)
self.assertIs(asset_shallow, asset_shallow_2)
self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds
self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds
# Get full again - should hit full cache
asset_full_2 = self.manager.get(uri_str, store=self.store_name, depth=None)
self.assertIs(asset_full, asset_full_2)
self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds
self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds
def test_update_invalidates_cache(self):
uri_str = "mock_asset://asset_upd"
raw_data_v1 = b"version1"
raw_data_v2 = b"version2"
asset_uri = AssetUri(uri_str) # Use real AssetUri
self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data_v1))
asset_v1 = self.manager.get(asset_uri, store=self.store_name)
self.assertEqual(asset_v1.raw_data_content, raw_data_v1)
self.assertEqual(MockAsset._build_counter, 1)
# Update the asset in the store (MemoryStore creates new version)
# For this test, let's simulate an update by re-adding with add_raw
# which should trigger invalidation.
# Note: MemoryStore's update creates a new version, so get() would get latest.
# To test invalidation of the *exact* cached object, we'd need to ensure
# the cache key changes (e.g. raw_data_hash).
# Let's use add_raw which calls invalidate.
self.manager.add_raw(
asset_uri.asset_type, asset_uri.asset_id, raw_data_v2, store=self.store_name
)
# Get again - should rebuild because v1 was invalidated
# And MemoryStore's get() for a URI without version gets latest.
# The add_raw invalidates based on URI (type+id), so all versions of it.
asset_v2 = self.manager.get(asset_uri, store=self.store_name)
self.assertEqual(asset_v2.raw_data_content, raw_data_v2)
self.assertEqual(MockAsset._build_counter, 2) # Rebuilt
def test_delete_invalidates_cache(self):
uri_str = "mock_asset://asset_del"
raw_data = b"delete_me"
asset_uri = AssetUri(uri_str)
self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data))
asset1 = self.manager.get(asset_uri, store=self.store_name)
self.assertIsNotNone(asset1)
self.assertEqual(MockAsset._build_counter, 1)
self.manager.delete(asset_uri, store=self.store_name)
with self.assertRaises(FileNotFoundError):
self.manager.get(asset_uri, store=self.store_name)
# Check build counter didn't increase due to trying to get deleted asset
self.assertEqual(MockAsset._build_counter, 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,468 @@
import unittest
import asyncio
from unittest.mock import Mock
import pathlib
import tempfile
from typing import Any, Mapping, List
from Path.Tool.assets import (
AssetManager,
FileStore,
Asset,
AssetUri,
MemoryStore,
AssetSerializer,
DummyAssetSerializer,
)
# Mock Asset class for testing
class MockAsset(Asset):
asset_type: str = "mock_asset"
def __init__(self, data: Any = None, id: str = "mock_id"):
self._data = data
self._id = id
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
# Mock implementation doesn't use data or format for dependencies
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Mapping[AssetUri, Asset] | None,
serializer: AssetSerializer,
) -> "MockAsset":
# Create instance with provided id
return cls(data, id)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return self._data
def get_id(self) -> str:
return self._id
class TestPathToolAssetManager(unittest.TestCase):
def test_register_store(self):
manager = AssetManager()
mock_store_local = Mock()
mock_store_local.name = "local"
mock_store_remote = Mock()
mock_store_remote.name = "remote"
manager.register_store(mock_store_local)
self.assertEqual(manager.stores["local"], mock_store_local)
manager.register_store(mock_store_remote)
self.assertEqual(manager.stores["remote"], mock_store_remote)
# Test overwriting
mock_store_local_new = Mock()
mock_store_local_new.name = "local"
manager.register_store(mock_store_local_new)
self.assertEqual(manager.stores["local"], mock_store_local_new)
def test_register_asset(self):
manager = AssetManager()
# Register the actual MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset)
# Test registering a different actual Asset class
class AnotherMockAsset(Asset):
asset_type: str = "another_mock_asset"
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Mapping[AssetUri, Asset] | None,
serializer: AssetSerializer,
) -> "AnotherMockAsset":
return cls()
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return b""
def get_id(self) -> str:
return "another_mock_id"
manager.register_asset(AnotherMockAsset, DummyAssetSerializer)
self.assertEqual(manager._asset_classes[AnotherMockAsset.asset_type], AnotherMockAsset)
# Test overwriting
manager.register_asset(
MockAsset, DummyAssetSerializer
) # Registering again should overwrite
self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset)
# Test registering non-Asset class
with self.assertRaises(TypeError):
class NotAnAsset(Asset): # Inherit from Asset
pass
manager.register_asset(NotAnAsset, DummyAssetSerializer)
def test_get(self):
# Setup AssetManager with a real LocalStore and the MockAsset class
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create a test asset file via AssetManager
test_data = b"test asset data"
test_uri = manager.add_raw(
asset_type=MockAsset.asset_type,
asset_id="dummy_id_get",
data=test_data,
store="local",
)
# Call AssetManager.get
retrieved_object = manager.get(test_uri)
# Assert the retrieved object is an instance of MockAsset
self.assertIsInstance(retrieved_object, MockAsset)
# Assert the data was passed to from_bytes
self.assertEqual(retrieved_object._data, test_data)
# Test error handling for non-existent URI
non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1")
with self.assertRaises(FileNotFoundError):
manager.get(non_existent_uri)
# Test error handling for no asset class registered
non_registered_uri = AssetUri.build("non_existent_type", "dummy_id", "1")
# Need to create a dummy file for the store to find
dummy_data = b"dummy"
manager.add_raw(
asset_type="non_existent_type", asset_id="dummy_id", data=dummy_data, store="local"
)
with self.assertRaises(ValueError) as cm:
manager.get(non_registered_uri)
self.assertIn("No asset class registered for asset type:", str(cm.exception))
def test_delete(self):
# Setup AssetManager with a real LocalStore
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Create a test asset file
test_data = b"test asset data to delete"
test_uri = manager.add_raw(
asset_type="temp_asset", asset_id="dummy_id_delete", data=test_data, store="local"
)
test_path = base_dir / "temp_asset" / str(test_uri.asset_id) / str(test_uri.version)
self.assertTrue(test_path.exists())
# Call AssetManager.delete
manager.delete(test_uri)
# Verify file deletion
self.assertFalse(test_path.exists())
# Test error handling for non-existent URI (should not raise error
# as LocalStore.delete handles this)
non_existent_uri = AssetUri.build(
"temp_asset", "non_existent", "1" # Keep original for logging
)
manager.delete(non_existent_uri) # Should not raise
def test_create(self):
# Setup AssetManager with LocalStore and MockAsset class
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create a MockAsset instance with a specific id
test_obj = MockAsset(b"object data", id="mocked_asset_id")
# Call manager.add to create
created_uri = manager.add(test_obj, store="local")
# Assert returned URI is as expected
expected_uri = AssetUri.build(MockAsset.asset_type, "mocked_asset_id", "1")
self.assertEqual(created_uri, expected_uri)
# Verify the asset was created
retrieved_data = asyncio.run(local_store.get(created_uri))
self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add(test_obj, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
with tempfile.TemporaryDirectory() as tmpdir:
local_store = MemoryStore("local")
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# First, create an asset
initial_data = b"initial data"
asset_id = "some_asset_id"
test_uri = manager.add_raw(MockAsset.asset_type, asset_id, initial_data, "local")
self.assertEqual(test_uri.version, "1")
# Create a MockAsset instance with the same id for update
updated_data = b"updated object data"
test_obj = MockAsset(updated_data, id=asset_id)
# Call manager.add to update
updated_uri = manager.add(test_obj, store="local")
# Assert returned URI matches the original except for version
self.assertEqual(updated_uri.asset_type, test_uri.asset_type)
self.assertEqual(updated_uri.asset_id, test_uri.asset_id)
self.assertEqual(updated_uri.version, "2")
# Verify the asset was updated
obj = manager.get(updated_uri, store="local")
self.assertEqual(updated_data, test_obj.to_bytes(DummyAssetSerializer))
self.assertEqual(updated_data, obj.to_bytes(DummyAssetSerializer))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add(test_obj, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_create_raw(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_raw")
manager = AssetManager()
manager.register_store(memory_store)
asset_type = "raw_test_type"
asset_id = "raw_test_id"
data = b"raw test data"
# Expected URI with version 1 (assuming MemoryStore uses integer versions)
expected_uri = AssetUri.build(asset_type, asset_id, "1")
# Call manager.add_raw
created_uri = manager.add_raw(
asset_type=asset_type, asset_id=asset_id, data=data, store="memory_raw"
)
# Assert returned URI is correct (check asset_type and asset_id)
self.assertEqual(created_uri.asset_type, asset_type)
self.assertEqual(created_uri.asset_id, asset_id)
self.assertEqual(created_uri, expected_uri)
# Verify data was stored using the actual created_uri
# Await the async get method using asyncio.run
retrieved_data = asyncio.run(memory_store.get(created_uri))
self.assertEqual(retrieved_data, data)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add_raw(
asset_type=asset_type, asset_id=asset_id, data=data, store="non_existent_store"
)
self.assertIn("No store registered for name:", str(cm.exception))
def test_get_raw(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_raw_get")
manager = AssetManager()
manager.register_store(memory_store)
test_uri_str = "test_type://test_id/1"
test_uri = AssetUri(test_uri_str)
expected_data = b"retrieved raw data"
# Manually put data into the memory store
manager.add_raw("test_type", "test_id", expected_data, "memory_raw_get")
# Call manager.get_raw using the URI returned by add_raw
retrieved_data = manager.get_raw(test_uri, store="memory_raw_get")
# Assert returned data matches store's result
self.assertEqual(retrieved_data, expected_data)
# Test error handling (store not found)
non_existent_uri = AssetUri("type://id/1")
with self.assertRaises(ValueError) as cm:
manager.get_raw(non_existent_uri, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_is_empty(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_empty")
manager = AssetManager()
manager.register_store(memory_store)
# Test when store is empty
self.assertTrue(manager.is_empty(store="memory_empty"))
# Add an asset and test again
manager.add_raw("test_type", "test_id", b"data", "memory_empty")
self.assertFalse(manager.is_empty(store="memory_empty"))
# Test with asset type
self.assertTrue(manager.is_empty(store="memory_empty", asset_type="another_type"))
self.assertFalse(manager.is_empty(store="memory_empty", asset_type="test_type"))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.is_empty(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_count_assets(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_count")
manager = AssetManager()
manager.register_store(memory_store)
# Test when store is empty
self.assertEqual(manager.count_assets(store="memory_count"), 0)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 0)
# Add assets and test counts
manager.add_raw("type1", "asset1", b"data1", "memory_count")
self.assertEqual(manager.count_assets(store="memory_count"), 1)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 1)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 0)
manager.add_raw("type2", "asset2", b"data2", "memory_count")
manager.add_raw("type1", "asset3", b"data3", "memory_count")
self.assertEqual(manager.count_assets(store="memory_count"), 3)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 2)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 1)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.count_assets(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_get_bulk(self):
# Setup AssetManager with a real MemoryStore and MockAsset class
memory_store = MemoryStore("memory_bulk")
manager = AssetManager()
manager.register_store(memory_store)
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create some assets in the memory store
data1 = b"data for id1"
data2 = b"data for id2"
uri1 = manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_bulk")
uri2 = manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_bulk")
uri3 = AssetUri.build(MockAsset.asset_type, "non_existent", "1")
uris = [uri1, uri2, uri3]
# Call manager.get_bulk
retrieved_assets = manager.get_bulk(uris, store="memory_bulk")
# Assert the correct number of assets were returned
self.assertEqual(len(retrieved_assets), 3)
# Assert the retrieved assets are MockAsset instances with correct data
self.assertIsInstance(retrieved_assets[0], MockAsset)
self.assertEqual(
retrieved_assets[0].to_bytes(DummyAssetSerializer),
data1,
)
self.assertIsInstance(retrieved_assets[1], MockAsset)
self.assertEqual(
retrieved_assets[1].to_bytes(DummyAssetSerializer),
data2,
)
# Assert the non-existent asset is None
self.assertIsNone(retrieved_assets[2])
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.get_bulk(uris, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_fetch(self):
# Setup AssetManager with a real MemoryStore and MockAsset class
memory_store = MemoryStore("memory_fetch")
manager = AssetManager()
manager.register_store(memory_store)
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create some assets in the memory store
data1 = b"data for id1"
data2 = b"data for id2"
manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch")
manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch")
# Create an asset of a different type
manager.add_raw("another_type", "id3", b"data for id3", "memory_fetch")
AssetUri.build(MockAsset.asset_type, "non_existent", "1")
# Call manager.fetch without filters
# This should raise ValueError because uri3 has an unregistered type
with self.assertRaises(ValueError) as cm:
manager.fetch(store="memory_fetch")
self.assertIn("No asset class registered for asset type:", str(cm.exception))
# Now test fetching with a registered asset type filter
# Setup a new manager and store to avoid state from previous test
memory_store_filtered = MemoryStore("memory_fetch_filtered")
manager_filtered = AssetManager()
manager_filtered.register_store(memory_store_filtered)
manager_filtered.register_asset(MockAsset, DummyAssetSerializer)
# Create assets again
manager_filtered.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch_filtered")
manager_filtered.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch_filtered")
manager_filtered.add_raw("another_type", "id3", b"data for id3", "memory_fetch_filtered")
retrieved_assets_filtered = manager_filtered.fetch(
asset_type=MockAsset.asset_type, store="memory_fetch_filtered"
)
# Assert the correct number of assets were returned
self.assertEqual(len(retrieved_assets_filtered), 2)
# Assert the retrieved assets are MockAsset instances with correct data
self.assertIsInstance(retrieved_assets_filtered[0], MockAsset)
self.assertEqual(
retrieved_assets_filtered[0].to_bytes(DummyAssetSerializer).decode("utf-8"),
data1.decode("utf-8"),
)
self.assertIsInstance(retrieved_assets_filtered[1], MockAsset)
self.assertEqual(
retrieved_assets_filtered[1].to_bytes(DummyAssetSerializer).decode("utf-8"),
data2.decode("utf-8"),
)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.fetch(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,389 @@
import unittest
import pathlib
import asyncio
import tempfile
from uuid import uuid4
from Path.Tool.assets import (
AssetUri,
AssetStore,
MemoryStore,
FileStore,
)
class BaseTestPathToolAssetStore(unittest.TestCase):
"""
Base test suite for Path Tool Asset Stores assuming full versioning support.
Store-agnostic tests without direct file system access.
"""
store: AssetStore
def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.tmp_path = pathlib.Path(self.tmp_dir.name)
def tearDown(self):
self.tmp_dir.cleanup()
def test_name(self):
self.assertIsNotNone(self.store)
self.assertIsInstance(self.store.name, str)
self.assertTrue(len(self.store.name) > 0)
def test_create_and_get(self):
async def async_test():
data = b"test data"
asset_type = f"type_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
self.assertIsInstance(uri, AssetUri)
self.assertEqual(uri.asset_type, asset_type)
self.assertEqual(uri.asset_id, asset_id)
self.assertIsNotNone(uri.version)
retrieved_data = await self.store.get(uri)
self.assertEqual(retrieved_data, data)
# Test non-existent URI
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
with self.assertRaises(FileNotFoundError):
await self.store.get(non_existent_uri)
asyncio.run(async_test())
def test_delete(self):
async def async_test():
data = b"data to delete"
asset_type = "delete_type"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
await self.store.delete(uri)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri)
# Deleting non-existent URI should not raise
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
await self.store.delete(non_existent_uri)
asyncio.run(async_test())
def test_is_empty(self):
async def async_test():
self.assertTrue(await self.store.is_empty())
self.assertTrue(await self.store.is_empty("type1"))
uri1 = await self.store.create("type1", f"asset_{uuid4()}", b"data")
self.assertFalse(await self.store.is_empty())
self.assertFalse(await self.store.is_empty("type1"))
self.assertTrue(await self.store.is_empty("type2"))
uri2 = await self.store.create("type2", f"asset_{uuid4()}", b"data")
await self.store.delete(uri1)
self.assertFalse(await self.store.is_empty())
self.assertTrue(await self.store.is_empty("type1"))
self.assertFalse(await self.store.is_empty("type2"))
await self.store.delete(uri2)
self.assertTrue(await self.store.is_empty())
asyncio.run(async_test())
def test_count_assets(self):
async def async_test():
self.assertEqual(await self.store.count_assets(), 0)
self.assertEqual(await self.store.count_assets("type1"), 0)
uri1 = await self.store.create("type1", f"asset1_{uuid4()}", b"data1")
self.assertEqual(await self.store.count_assets(), 1)
self.assertEqual(await self.store.count_assets("type1"), 1)
self.assertEqual(await self.store.count_assets("type2"), 0)
uri2 = await self.store.create("type2", f"asset2_{uuid4()}", b"data2")
uri3 = await self.store.create("type1", f"asset3_{uuid4()}", b"data3")
self.assertEqual(await self.store.count_assets(), 3)
self.assertEqual(await self.store.count_assets("type1"), 2)
self.assertEqual(await self.store.count_assets("type2"), 1)
await self.store.delete(uri1)
self.assertEqual(await self.store.count_assets(), 2)
self.assertEqual(await self.store.count_assets("type1"), 1)
self.assertEqual(await self.store.count_assets("type2"), 1)
await self.store.delete(uri2)
await self.store.delete(uri3)
self.assertEqual(await self.store.count_assets(), 0)
self.assertEqual(await self.store.count_assets("type1"), 0)
self.assertEqual(await self.store.count_assets("type2"), 0)
asyncio.run(async_test())
def test_list_assets(self):
async def async_test():
asset_typedata = [
("type1", f"asset1_{uuid4()}", b"data1"),
("type1", f"asset2_{uuid4()}", b"data2"),
("type2", f"asset3_{uuid4()}", b"data3"),
]
uris = []
for asset_type, asset_id, data in asset_typedata:
uri = await self.store.create(asset_type, asset_id, data)
uris.append(uri)
all_assets = await self.store.list_assets()
self.assertEqual(len(all_assets), 3)
for uri in uris:
self.assertTrue(any(u.asset_id == uri.asset_id for u in all_assets))
type1_assets = await self.store.list_assets(asset_type="type1")
self.assertEqual(len(type1_assets), 2)
self.assertTrue(any(u.asset_id == uris[0].asset_id for u in type1_assets))
self.assertTrue(any(u.asset_id == uris[1].asset_id for u in type1_assets))
paginated = await self.store.list_assets(limit=2)
self.assertEqual(len(paginated), 2)
asyncio.run(async_test())
def test_update_versioning(self):
async def async_test():
initial_data = b"initial data"
updated_data = b"updated data"
asset_type = f"update_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, initial_data)
uri2 = await self.store.update(uri1, updated_data)
self.assertEqual(uri1.asset_type, uri2.asset_type)
self.assertEqual(uri1.asset_id, uri2.asset_id)
self.assertEqual(uri1.version, "1")
self.assertEqual(uri2.version, "2")
self.assertNotEqual(uri1.version, uri2.version)
self.assertEqual(await self.store.get(uri1), initial_data)
self.assertEqual(await self.store.get(uri2), updated_data)
with self.assertRaises(FileNotFoundError):
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
await self.store.update(non_existent_uri, b"data")
asyncio.run(async_test())
def test_list_versions(self):
async def async_test():
asset_type = f"version_{uuid4()}"
asset_id = f"asset_{uuid4()}"
data1 = b"version1"
data2 = b"version2"
uri1 = await self.store.create(asset_type, asset_id, data1)
uri2 = await self.store.update(uri1, data2)
versions = await self.store.list_versions(uri1)
self.assertEqual(len(versions), 2)
version_ids = {v.version for v in versions if v.version}
self.assertEqual(version_ids, {uri1.version, uri2.version})
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
self.assertEqual(await self.store.list_versions(non_existent_uri), [])
asyncio.run(async_test())
def test_create_with_empty_data(self):
async def async_test():
data = b""
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
self.assertIsInstance(uri, AssetUri)
retrieved_data = await self.store.get(uri)
self.assertEqual(retrieved_data, data)
asyncio.run(async_test())
def test_list_assets_non_existent_type(self):
async def async_test():
assets = await self.store.list_assets(asset_type=f"non_existent_type_{uuid4()}")
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_list_assets_pagination_offset_too_high(self):
async def async_test():
await self.store.create("shape", f"asset1_{uuid4()}", b"data")
assets = await self.store.list_assets(offset=100) # Assuming less than 100 assets
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_list_assets_pagination_limit_zero(self):
async def async_test():
await self.store.create("shape", f"asset1_{uuid4()}", b"data")
assets = await self.store.list_assets(limit=0)
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_create_delete_recreate(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
data1 = b"first data"
data2 = b"second data"
uri1 = await self.store.create(asset_type, asset_id, data1)
self.assertEqual(await self.store.get(uri1), data1)
# For versioned stores, this would be version "1"
# For stores that don't deeply track versions, it's important what happens on recreate
await self.store.delete(uri1)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri1)
uri2 = await self.store.create(asset_type, asset_id, data2)
self.assertEqual(await self.store.get(uri2), data2)
# Behavior of uri1.version vs uri2.version depends on store implementation
# For a fully versioned store that starts fresh:
self.assertEqual(
uri2.version, "1", "Recreating should yield version 1 for a fresh start"
)
# Ensure only the new asset exists if the store fully removes old versions
versions = await self.store.list_versions(
AssetUri.build(asset_type=asset_type, asset_id=asset_id)
)
self.assertEqual(len(versions), 1)
self.assertEqual(versions[0].version, "1")
asyncio.run(async_test())
def test_get_non_existent_specific_version(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
await self.store.create(asset_type, asset_id, b"data_v1")
non_existent_version_uri = AssetUri.build(
asset_type=asset_type,
asset_id=asset_id,
version="99", # Assuming version 99 won't exist
)
with self.assertRaises(FileNotFoundError):
await self.store.get(non_existent_version_uri)
asyncio.run(async_test())
def test_delete_last_version(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
uri_v1 = await self.store.create(asset_type, asset_id, b"v1_data")
uri_v2 = await self.store.update(uri_v1, b"v2_data")
await self.store.delete(uri_v2) # Delete latest version
with self.assertRaises(FileNotFoundError):
await self.store.get(uri_v2)
# v1 should still exist
self.assertEqual(await self.store.get(uri_v1), b"v1_data")
versions_after_v2_delete = await self.store.list_versions(uri_v1)
self.assertEqual(len(versions_after_v2_delete), 1)
self.assertEqual(versions_after_v2_delete[0].version, "1")
await self.store.delete(uri_v1) # Delete the now last version (v1)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri_v1)
versions_after_all_delete = await self.store.list_versions(uri_v1)
self.assertEqual(len(versions_after_all_delete), 0)
# Asset should not appear in list_assets
listed_assets = await self.store.list_assets(asset_type=asset_type)
self.assertFalse(any(a.asset_id == asset_id for a in listed_assets))
self.assertTrue(await self.store.is_empty(asset_type))
asyncio.run(async_test())
def test_get_latest_on_non_existent_asset(self):
async def async_test():
latest_uri = AssetUri.build(
asset_type="shape",
asset_id=f"non_existent_id_for_latest_{uuid4()}",
version="latest",
)
with self.assertRaises(
FileNotFoundError
): # Or custom NoVersionsFoundError if that's how store behaves
await self.store.get(latest_uri)
asyncio.run(async_test())
class TestPathToolFileStore(BaseTestPathToolAssetStore):
"""Test suite for FileStore with full versioning support."""
def setUp(self):
super().setUp()
asset_type_map = {
"*": "{asset_type}/{asset_id}/{version}",
"special1": "Especial/{asset_id}/{version}",
"special2": "my/super/{asset_id}.spcl",
}
self.store = FileStore("versioned", self.tmp_path, asset_type_map)
def test_get_latest_version(self):
async def async_test():
asset_type = f"latest_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, b"v1")
await self.store.update(uri1, b"v2")
latest_uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="latest")
self.assertEqual(await self.store.get(latest_uri), b"v2")
asyncio.run(async_test())
def test_delete_all_versions(self):
async def async_test():
asset_type = f"delete_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, b"v1")
await self.store.update(uri1, b"v2")
uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id)
await self.store.delete(uri)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri1)
asyncio.run(async_test())
class TestPathToolMemoryStore(BaseTestPathToolAssetStore):
"""Test suite for MemoryStore."""
def setUp(self):
super().setUp()
self.store = MemoryStore("memory_test")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,101 @@
import unittest
from Path.Tool.assets.uri import AssetUri
class TestPathToolAssetUri(unittest.TestCase):
"""
Test suite for the AssetUri utility class.
"""
def test_uri_parsing_full(self):
uri_string = "remote://asset_id/version?" "param1=value1&param2=value2"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "remote")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "version")
self.assertEqual(uri.params, {"param1": ["value1"], "param2": ["value2"]})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_parsing_local(self):
uri_string = "local://id/2?param=value"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "local")
self.assertEqual(uri.asset_id, "id")
self.assertEqual(uri.version, "2")
self.assertEqual(uri.params, {"param": ["value"]})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_parsing_no_params(self):
uri_string = "file://asset_id/1"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "file")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "1")
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_version_missing(self):
uri_string = "foo://asset"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "foo")
self.assertEqual(uri.asset_id, "asset")
self.assertIsNone(uri.version)
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
def test_uri_parsing_with_version(self):
"""
Test parsing a URI string with asset_type, asset_id, and version.
"""
uri_string = "test_type://test_id/1"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "test_type")
self.assertEqual(uri.asset_id, "test_id")
self.assertEqual(uri.version, "1")
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_build_full(self):
expected_uri_string = "local://asset_id/version?param1=value1"
uri = AssetUri.build(
asset_type="local", asset_id="asset_id", version="version", params={"param1": "value1"}
)
self.assertEqual(str(uri), expected_uri_string)
self.assertEqual(uri.asset_type, "local")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "version")
self.assertEqual(uri.params, {"param1": ["value1"]}) # parse_qs always returns list
def test_uri_build_latest_version_no_params(self):
expected_uri_string = "remote://id/latest"
uri = AssetUri.build(asset_type="remote", asset_id="id", version="latest")
self.assertEqual(str(uri), expected_uri_string)
self.assertEqual(uri.asset_type, "remote")
self.assertEqual(uri.asset_id, "id")
self.assertEqual(uri.version, "latest")
self.assertEqual(uri.params, {})
def test_uri_equality(self):
uri1 = AssetUri("local://asset/version")
uri2 = AssetUri("local://asset/version")
uri3 = AssetUri("local://asset/another_version")
self.assertEqual(uri1, uri2)
self.assertNotEqual(uri1, uri3)
self.assertNotEqual(uri1, "not a uri")
def test_uri_parsing_invalid_path_structure(self):
"""
Test that parsing a URI string with an invalid path structure
(more than one component) raises a ValueError.
"""
uri_string = "local://foo/bar/1"
with self.assertRaisesRegex(ValueError, "Invalid URI path structure:"):
AssetUri(uri_string)
if __name__ == "__main__":
unittest.main()

View File

@@ -20,134 +20,62 @@
# * *
# ***************************************************************************
import Path.Tool.Bit as PathToolBit
import CAMTests.PathTestUtils as PathTestUtils
import glob
from typing import cast
import os
TestToolDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "Tools")
TestInvalidDir = os.path.join(
TestToolDir, "some", "silly", "path", "that", "should", "not", "exist"
)
TestToolBitName = "test-path-tool-bit-bit-00.fctb"
TestToolShapeName = "test-path-tool-bit-shape-00.fcstd"
TestToolLibraryName = "test-path-tool-bit-library-00.fctl"
import uuid
import pathlib
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.library import Library
from Path.Tool.shape import ToolBitShapeBullnose
from Path.Tool.toolbit import ToolBitEndmill, ToolBitBullnose
def testToolShape(path=TestToolDir, name=TestToolShapeName):
return os.path.join(path, "Shape", name)
TOOL_DIR = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools"
SHAPE_DIR = TOOL_DIR / "Shape"
BIT_DIR = TOOL_DIR / "Bit"
def testToolBit(path=TestToolDir, name=TestToolBitName):
return os.path.join(path, "Bit", name)
def testToolLibrary(path=TestToolDir, name=TestToolLibraryName):
return os.path.join(path, "Library", name)
def printTree(path, indent):
print("{} {}".format(indent, os.path.basename(path)))
if os.path.isdir(path):
if os.path.basename(path).startswith("__"):
print("{} ...".format(indent))
else:
for foo in sorted(glob.glob(os.path.join(path, "*"))):
printTree(foo, "{} ".format(indent))
class TestPathToolBit(PathTestUtils.PathTestBase):
def test(self):
"""Log test setup directory structure"""
# Enable this test if there are errors showing up in the build system with the
# paths that work OK locally. It'll print out the directory tree, and if it
# doesn't look right you know where to look for it
print()
print("realpath : {}".format(os.path.realpath(__file__)))
print(" Tools : {}".format(TestToolDir))
print(" dir : {}".format(os.path.dirname(os.path.realpath(__file__))))
printTree(os.path.dirname(os.path.realpath(__file__)), " :")
def test00(self):
"""Find a tool shape from file name"""
path = PathToolBit.findToolShape("endmill.fcstd")
self.assertIsNot(path, None)
self.assertNotEqual(path, "endmill.fcstd")
def test01(self):
"""Not find a relative path shape if not stored in default location"""
path = PathToolBit.findToolShape(TestToolShapeName)
self.assertIsNone(path)
def test02(self):
"""Find a relative path shape if it's local to a bit path"""
path = PathToolBit.findToolShape(TestToolShapeName, testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolShape())
def test03(self):
"""Not find a tool shape from an invalid absolute path."""
path = PathToolBit.findToolShape(testToolShape(TestInvalidDir))
self.assertIsNone(path)
def test04(self):
"""Find a tool shape from a valid absolute path."""
path = PathToolBit.findToolShape(testToolShape())
self.assertIsNot(path, None)
self.assertEqual(path, testToolShape())
def test10(self):
class TestPathToolBit(PathTestWithAssets):
def testGetToolBit(self):
"""Find a tool bit from file name"""
path = PathToolBit.findToolBit("5mm_Endmill.fctb")
self.assertIsNot(path, None)
self.assertNotEqual(path, "5mm_Endmill.fctb")
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.assertIsInstance(toolbit, ToolBitEndmill)
self.assertEqual(toolbit.id, "5mm_Endmill")
def test11(self):
"""Not find a relative path bit if not stored in default location"""
path = PathToolBit.findToolBit(TestToolBitName)
self.assertIsNone(path)
def test12(self):
"""Find a relative path bit if it's local to a library path"""
path = PathToolBit.findToolBit(TestToolBitName, testToolLibrary())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
def test13(self):
"""Not find a tool bit from an invalid absolute path."""
path = PathToolBit.findToolBit(testToolBit(TestInvalidDir))
self.assertIsNone(path)
def test14(self):
"""Find a tool bit from a valid absolute path."""
path = PathToolBit.findToolBit(testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
def test20(self):
def testGetLibrary(self):
"""Find a tool library from file name"""
path = PathToolBit.findToolLibrary("Default.fctl")
self.assertIsNot(path, None)
self.assertNotEqual(path, "Default.fctl")
library = self.assets.get("toolbitlibrary://Default")
self.assertIsInstance(library, Library)
self.assertEqual(library.id, "Default")
def test21(self):
"""Not find a relative path library if not stored in default location"""
path = PathToolBit.findToolLibrary(TestToolLibraryName)
self.assertIsNone(path)
def testBullnose(self):
"""Test ToolBitBullnose basic parameters"""
shape = self.assets.get("toolbitshape://bullnose")
shape = cast(ToolBitShapeBullnose, shape)
def test22(self):
"""[skipped] Find a relative path library if it's local to <what?>"""
# this is not a valid test for libraries because t
self.assertTrue(True)
bullnose_bit = ToolBitBullnose(shape, id="mybullnose")
self.assertEqual(bullnose_bit.get_id(), "mybullnose")
def test23(self):
"""Not find a tool library from an invalid absolute path."""
path = PathToolBit.findToolLibrary(testToolLibrary(TestInvalidDir))
self.assertIsNone(path)
bullnose_bit = ToolBitBullnose(shape)
uuid.UUID(bullnose_bit.get_id()) # will raise if not valid UUID
def test24(self):
"""Find a tool library from a valid absolute path."""
path = PathToolBit.findToolBit(testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
# Parameters should be loaded from the shape file and set on the tool bit's object
self.assertEqual(bullnose_bit.obj.Diameter, FreeCAD.Units.Quantity("5.0 mm"))
self.assertEqual(bullnose_bit.obj.FlatRadius, FreeCAD.Units.Quantity("1.5 mm"))
def testToolBitPickle(self):
"""Test if ToolBit is picklable"""
import pickle
shape = self.assets.get("toolbitshape://bullnose")
shape = cast(ToolBitShapeBullnose, shape)
bullnose_bit = ToolBitBullnose(shape, id="mybullnose")
try:
pickled_bit = pickle.dumps(bullnose_bit)
unpickled_bit = pickle.loads(pickled_bit)
self.assertIsInstance(unpickled_bit, ToolBitBullnose)
self.assertEqual(unpickled_bit.get_id(), "mybullnose")
# Add more assertions here to check if other attributes are preserved
except Exception as e:
self.fail(f"ToolBit is not picklable: {e}")

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENSE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the FreeCAD *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitBrowserWidget."""
import unittest
from unittest.mock import MagicMock
from typing import cast
from Path.Tool.toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole
from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell
from Path.Tool.toolbit.models.base import ToolBit
from .PathTestUtils import PathTestWithAssets
class TestToolBitBrowserWidget(PathTestWithAssets):
"""Tests for ToolBitBrowserWidget using real assets and widgets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
# The browser widget uses the global cam_assets, which is set up
# by PathTestWithAssets.
self.widget = ToolBitBrowserWidget(self.assets)
def test_initial_fetch(self):
# Verify that the list widget is populated after initialization
# The default test assets include some toolbits.
self.assertGreater(self.widget._tool_list_widget.count(), 0)
# Verify apply_filter was called on the list widget with empty string
# We can check the search_highlight property on the cell widgets
# as apply_filter sets this.
for i in range(self.widget._tool_list_widget.count()):
item = self.widget._tool_list_widget.item(i)
cell = self.widget._tool_list_widget.itemWidget(item)
self.assertIsInstance(cell, TwoLineTableCell)
self.assertEqual(cell.search_highlight, "")
def test_search_filtering(self):
# Simulate typing in the search box
search_term = "Endmill"
self.widget._search_edit.setText(search_term)
# Directly trigger the fetch and filtering logic
self.widget._trigger_fetch()
# Verify that the filter was applied to the list widget
# We can check if items are hidden/shown based on the filter term
# This requires knowing the content of the test assets.
# Assuming '5mm_Endmill' and '10mm_Endmill' contain "Endmill"
# and 'BallEndmill_3mm' does not.
# Re-fetch assets to know their labels/names for verification
all_assets = self.assets.fetch(asset_type="toolbit", depth=0)
expected_visible_uris = set()
for asset in all_assets:
tb = cast(ToolBit, asset)
is_expected = (
search_term.lower() in tb.label.lower() or search_term.lower() in tb.summary.lower()
)
if is_expected:
expected_visible_uris.add(str(tb.get_uri()))
actual_visible_uris = set()
for i in range(self.widget._tool_list_widget.count()):
item = self.widget._tool_list_widget.item(i)
cell = self.widget._tool_list_widget.itemWidget(item)
self.assertIsInstance(cell, TwoLineTableCell)
item_uri = item.data(ToolBitUriRole)
# Verify highlight was called on all cells
self.assertEqual(cell.search_highlight, search_term)
if not item.isHidden():
actual_visible_uris.add(item_uri)
self.assertEqual(actual_visible_uris, expected_visible_uris)
def test_lazy_loading_on_scroll(self):
# This test requires more than self._batch_size toolbits to be effective.
# The default test assets might not have enough.
# We'll assume there are enough for the test structure.
initial_count = self.widget._tool_list_widget.count()
if initial_count < self.widget._batch_size:
self.skipTest("Not enough toolbits for lazy loading test.")
# Simulate scrolling to the bottom by emitting the signal
scrollbar = self.widget._tool_list_widget.verticalScrollBar()
# Set the scrollbar value to its maximum to simulate reaching the end
scrollbar.valueChanged.emit(scrollbar.maximum())
# Verify that more items were loaded
new_count = self.widget._tool_list_widget.count()
self.assertGreater(new_count, initial_count)
# Verify that the number of new items is approximately the batch size
self.assertAlmostEqual(
new_count - initial_count, self.widget._batch_size, delta=5
) # Allow small delta
def test_tool_selected_signal(self):
mock_slot = MagicMock()
self.widget.toolSelected.connect(mock_slot)
# Select the first item in the list widget
if self.widget._tool_list_widget.count() == 0:
self.skipTest("Not enough toolbits for selection test.")
first_item = self.widget._tool_list_widget.item(0)
self.widget._tool_list_widget.setCurrentItem(first_item)
# Verify signal was emitted with the correct URI
expected_uri = first_item.data(ToolBitUriRole)
mock_slot.assert_called_once_with(expected_uri)
def test_tool_edit_requested_signal(self):
mock_slot = MagicMock()
self.widget.itemDoubleClicked.connect(mock_slot)
# Double-click the first item in the list widget
if self.widget._tool_list_widget.count() == 0:
self.skipTest("Not enough toolbits for double-click test.")
first_item = self.widget._tool_list_widget.item(0)
# Simulate double-click signal emission from the list widget
self.widget._tool_list_widget.itemDoubleClicked.emit(first_item)
# Verify signal was emitted with the correct URI
expected_uri = first_item.data(ToolBitUriRole)
mock_slot.assert_called_once_with(expected_uri)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENSE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the FreeCAD *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitEditorWidget."""
import unittest
from unittest.mock import MagicMock
from Path.Tool.toolbit.ui.editor import ToolBitPropertiesWidget
from Path.Tool.toolbit.models.base import ToolBit
from Path.Tool.shape.ui.shapewidget import ShapeWidget
from Path.Tool.ui.property import BasePropertyEditorWidget
from .PathTestUtils import PathTestWithAssets
class TestToolBitPropertiesWidget(PathTestWithAssets):
"""Tests for ToolBitEditorWidget using real assets and widgets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
self.widget = ToolBitPropertiesWidget()
def test_load_toolbit(self):
# Get a real ToolBit asset
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.assertIsInstance(toolbit, ToolBit)
self.widget.load_toolbit(toolbit)
# Verify label and ID are populated
self.assertEqual(self.widget._label_edit.text(), toolbit.obj.Label)
self.assertEqual(self.widget._id_label.text(), toolbit.get_id())
# Verify DocumentObjectEditorWidget is populated
self.assertEqual(self.widget._property_editor._obj, toolbit.obj)
# Check if properties were passed to the property editor
self.assertGreater(len(self.widget._property_editor._properties_to_show), 0)
# Verify ShapeWidget is created and populated
self.assertIsNotNone(self.widget._shape_widget)
self.assertIsInstance(self.widget._shape_widget, ShapeWidget)
# We can't easily check the internal shape of ShapeWidget without mocks,
# but we can verify it was created.
def test_label_changed_updates_object(self):
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
new_label = "Updated Endmill"
self.widget._label_edit.setText(new_label)
# Simulate editing finished signal
self.widget._label_edit.editingFinished.emit()
# Verify the toolbit object's label is updated
self.assertEqual(toolbit.obj.Label, new_label)
def test_property_changed_signal_emitted(self):
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
mock_slot = MagicMock()
self.widget.toolBitChanged.connect(mock_slot)
# Simulate a property change in the DocumentObjectEditorWidget
# We need to trigger the signal from the real property editor.
# This requires accessing a child editor and emitting its signal.
# Find a child editor (e.g., the first one)
if self.widget._property_editor._property_editors:
first_prop_name = list(self.widget._property_editor._property_editors.keys())[0]
child_editor = self.widget._property_editor._property_editors[first_prop_name]
self.assertIsInstance(child_editor, BasePropertyEditorWidget)
# Emit the propertyChanged signal from the child editor
child_editor.propertyChanged.emit()
# Verify the ToolBitEditorWidget's signal was emitted
mock_slot.assert_called_once()
else:
self.skipTest("DocumentObjectEditorWidget has no property editors.")
def test_save_toolbit(self):
# The save_toolbit method primarily ensures the label is updated
# and potentially calls updateObject on the property editor.
# Since property changes are signal-driven, this method is more
# for explicit save actions.
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
initial_label = toolbit.obj.Label
new_label = "Another Label"
self.widget._label_edit.setText(new_label)
# Call save_toolbit
self.widget.save_toolbit()
# Verify the label was updated
self.assertEqual(toolbit.obj.Label, new_label)
# We can't easily verify if updateObject was called on the property
# editor without mocks, but we trust the implementation based on
# the DocumentObjectEditorWidget's own tests.
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENSE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitListWidget."""
import unittest
from Path.Tool.toolbit.ui.toollist import ToolBitListWidget, ToolBitUriRole
from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell
from .PathTestUtils import PathTestWithAssets # Import the base test class
class TestToolBitListWidget(PathTestWithAssets):
"""Tests for ToolBitListWidget using real assets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
self.widget = ToolBitListWidget()
def test_add_toolbit(self):
# Get a real ToolBit asset
toolbit = self.assets.get("toolbit://5mm_Endmill")
tool_no = 1
self.widget.add_toolbit(toolbit, str(tool_no))
self.assertEqual(self.widget.count(), 1)
item = self.widget.item(0)
self.assertIsNotNone(item)
cell_widget = self.widget.itemWidget(item)
self.assertIsInstance(cell_widget, TwoLineTableCell) # Check against real class
# Verify cell widget properties are set correctly
self.assertEqual(cell_widget.tool_no, str(tool_no))
self.assertEqual(cell_widget.upper_text, toolbit.label)
# Assuming the 5mm_Endmill asset has a shape named 'Endmill'
self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge")
# Verify URI is stored in item data
stored_uri = item.data(ToolBitUriRole)
self.assertEqual(stored_uri, str(toolbit.get_uri()))
def test_clear_list(self):
# Add some real items first
toolbit1 = self.assets.get("toolbit://5mm_Endmill")
toolbit2 = self.assets.get("toolbit://slittingsaw")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
self.assertEqual(self.widget.count(), 2)
self.widget.clear_list()
self.assertEqual(self.widget.count(), 0)
def test_apply_filter(self):
# Add items with distinct text for filtering
toolbit1 = self.assets.get("toolbit://5mm_Endmill")
toolbit2 = self.assets.get("toolbit://slittingsaw")
toolbit3 = self.assets.get("toolbit://probe")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
self.widget.add_toolbit(toolbit3, 3)
items = [self.widget.item(i) for i in range(self.widget.count())]
cells = [self.widget.itemWidget(item) for item in items]
# Test filter "Endmill"
self.widget.apply_filter("Endmill")
self.assertFalse(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called on all cells
for cell in cells:
self.assertEqual(cell.search_highlight, "Endmill")
# Test filter "Ballnose"
self.widget.apply_filter("Ballnose")
self.assertTrue(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called again
for cell in cells:
self.assertEqual(cell.search_highlight, "Ballnose")
# Test filter "3mm"
self.widget.apply_filter("3mm")
self.assertTrue(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called again
for cell in cells:
self.assertEqual(cell.search_highlight, "3mm")
def test_get_selected_toolbit_uri(self):
toolbit1 = self.assets.get("toolbit://5mm_Endmill")
toolbit2 = self.assets.get("toolbit://slittingsaw")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
# No selection initially
self.assertIsNone(self.widget.get_selected_toolbit_uri())
# Select the first item
self.widget.setCurrentItem(self.widget.item(0))
self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit1.get_uri()))
# Select the second item
self.widget.setCurrentItem(self.widget.item(1))
self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit2.get_uri()))
# Clear selection (simulate by setting current item to None)
self.widget.setCurrentItem(None)
self.assertIsNone(self.widget.get_selected_toolbit_uri())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENSE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the Property Editor Widgets."""
import unittest
import FreeCAD
from Path.Tool.ui.property import (
BasePropertyEditorWidget,
QuantityPropertyEditorWidget,
BoolPropertyEditorWidget,
IntPropertyEditorWidget,
EnumPropertyEditorWidget,
LabelPropertyEditorWidget,
)
from Path.Tool.toolbit.docobject import DetachedDocumentObject
class TestPropertyEditorFactory(unittest.TestCase):
"""Tests the BasePropertyEditorWidget.for_property factory method."""
def setUp(self):
# Use the real DetachedDocumentObject
self.obj = DetachedDocumentObject()
# Add properties using the DetachedDocumentObject API with correct signature
self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property")
self.obj.Length = FreeCAD.Units.Quantity(10.0) # Set value separately
self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property")
self.obj.IsEnabled = True # Set value separately
self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property")
self.obj.Count = 5 # Set value separately
self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property")
# Set enums and initial value separately
self.obj.Mode = ["Auto", "Manual"]
self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property")
self.obj.Comment = "Test" # Set value separately
def test_quantity_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Length")
self.assertIsInstance(widget, QuantityPropertyEditorWidget)
def test_bool_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "IsEnabled")
self.assertIsInstance(widget, BoolPropertyEditorWidget)
def test_int_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Count")
self.assertIsInstance(widget, IntPropertyEditorWidget)
def test_enum_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Mode")
self.assertIsInstance(widget, EnumPropertyEditorWidget)
def test_label_creation_for_string(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Comment")
self.assertIsInstance(widget, LabelPropertyEditorWidget)
def test_label_creation_for_invalid_prop(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "NonExistent")
self.assertIsInstance(widget, LabelPropertyEditorWidget)
class TestQuantityPropertyEditorWidget(unittest.TestCase):
"""Tests for QuantityPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property")
self.obj.Length = FreeCAD.Units.Quantity("10.0 mm")
self.widget = QuantityPropertyEditorWidget(self.obj, "Length")
# Access the real editor widget
self.editor = self.widget._editor_widget
self.widget.updateWidget()
def test_update_property(self):
# Check if the real widget's value is updated
self.assertEqual(self.editor.property("rawValue"), 10.0)
# Check if the real widget's value and unit are updated
self.assertEqual(self.editor.property("value").UserString, "10.00 mm")
# Simulate changing the raw value and check if the object's value updates
self.editor.lineEdit().setText("12.0")
self.widget.updateProperty()
self.assertEqual(self.obj.Length.Value, 12.0)
self.assertEqual(self.obj.Length.UserString, "12.00 mm")
# Try assignment with unit.
self.editor.lineEdit().setText("15.5 in")
self.widget.updateProperty()
updated_value = self.obj.getPropertyByName("Length")
self.assertIsInstance(updated_value, FreeCAD.Units.Quantity)
self.assertEqual(updated_value, FreeCAD.Units.Quantity("15.5 in"))
class TestBoolPropertyEditorWidget(unittest.TestCase):
"""Tests for BoolPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property")
self.obj.IsEnabled = True
self.widget = BoolPropertyEditorWidget(self.obj, "IsEnabled")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.currentIndex(), 1) # True is index 1
self.obj.setPropertyByName("IsEnabled", False)
self.widget.updateWidget()
self.assertEqual(self.editor.currentIndex(), 0) # False is index 0
def test_update_property(self):
# Simulate user changing value in the combobox
self.editor.setCurrentIndex(0) # Select False
self.widget._on_index_changed(0)
self.assertEqual(self.obj.getPropertyByName("IsEnabled"), False)
self.editor.setCurrentIndex(1) # Select True
self.widget._on_index_changed(1)
self.assertEqual(self.obj.getPropertyByName("IsEnabled"), True)
class TestIntPropertyEditorWidget(unittest.TestCase):
"""Tests for IntPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property")
self.obj.Count = 5
self.widget = IntPropertyEditorWidget(self.obj, "Count")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.value(), 5)
self.obj.setPropertyByName("Count", 100)
self.widget.updateWidget()
self.assertEqual(self.editor.value(), 100)
def test_update_property(self):
# Simulate user changing value in the spinbox
self.editor.setValue(42)
self.widget.updateProperty()
self.assertEqual(self.obj.getPropertyByName("Count"), 42)
class TestEnumPropertyEditorWidget(unittest.TestCase):
"""Tests for EnumPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property")
self.obj.Mode = ["Auto", "Manual", "Semi"] # Set enums and initial value
self.widget = EnumPropertyEditorWidget(self.obj, "Mode")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_populate_enum(self):
# Check if the real widget is populated
self.assertEqual(self.editor.count(), 3)
self.assertEqual(self.editor.itemText(0), "Auto")
self.assertEqual(self.editor.itemText(1), "Manual")
self.assertEqual(self.editor.itemText(2), "Semi")
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.currentText(), "Auto")
self.obj.setPropertyByName("Mode", "Manual")
self.widget.updateWidget()
self.assertEqual(self.editor.currentText(), "Manual")
def test_update_property(self):
# Simulate user changing value in the combobox
self.editor.setCurrentIndex(1) # Select Manual
self.widget._on_index_changed(1)
self.assertEqual(self.obj.getPropertyByName("Mode"), "Manual")
self.editor.setCurrentIndex(2) # Select Semi
self.widget._on_index_changed(2)
self.assertEqual(self.obj.getPropertyByName("Mode"), "Semi")
class TestLabelPropertyEditorWidget(unittest.TestCase):
"""Tests for LabelPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property")
self.obj.Comment = "Test Comment"
self.widget = LabelPropertyEditorWidget(self.obj, "Comment")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.text(), "Test Comment")
self.obj.setPropertyByName("Comment", "New Comment")
self.widget.updateWidget()
self.assertEqual(self.editor.text(), "New Comment")
def test_update_property_is_noop(self):
# updateProperty should do nothing for a read-only label
self.widget.updateProperty()
# No assertions needed, just ensure it doesn't raise errors
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
import json
from typing import Type, cast
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.toolbit import ToolBit, ToolBitEndmill
from Path.Tool.toolbit.serializers import (
FCTBSerializer,
CamoticsToolBitSerializer,
)
from Path.Tool.assets.asset import Asset
from Path.Tool.assets.serializer import AssetSerializer
from Path.Tool.assets.uri import AssetUri
from Path.Tool.shape import ToolBitShapeEndmill
from typing import Mapping
class _BaseToolBitSerializerTestCase(PathTestWithAssets):
"""Base test case for ToolBit Serializers."""
__test__ = False
serializer_class: Type[AssetSerializer]
test_tool_bit: ToolBit
def setUp(self):
"""Create a tool bit for each test."""
super().setUp()
if self.serializer_class is None or not issubclass(self.serializer_class, AssetSerializer):
raise NotImplementedError("Subclasses must define a valid serializer_class")
self.test_tool_bit = cast(ToolBitEndmill, self.assets.get("toolbit://5mm_Endmill"))
self.test_tool_bit.label = "Test Tool"
self.test_tool_bit.set_diameter(FreeCAD.Units.Quantity("4.12 mm"))
self.test_tool_bit.set_length(FreeCAD.Units.Quantity("15.0 mm"))
def test_serialize(self):
"""Test serialization of a toolbit."""
if self.test_tool_bit is None:
raise NotImplementedError("Subclasses must define a test_tool_bit")
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
self.assertIsInstance(serialized_data, bytes)
def test_extract_dependencies(self):
"""Test dependency extraction."""
# This test assumes that the serializers don't have dependencies
# and can be overridden in subclasses if needed.
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
dependencies = self.serializer_class.extract_dependencies(serialized_data)
self.assertIsInstance(dependencies, list)
self.assertEqual(len(dependencies), 0)
class TestCamoticsToolBitSerializer(_BaseToolBitSerializerTestCase):
serializer_class = CamoticsToolBitSerializer
def test_serialize(self):
super().test_serialize()
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
# Camotics specific assertions
expected_substrings = [
b'"units": "metric"',
b'"shape": "Cylindrical"',
b'"length": 15',
b'"diameter": 4.12',
b'"description": "Test Tool"',
]
for substring in expected_substrings:
self.assertIn(substring, serialized_data)
def test_deserialize(self):
# Create a known serialized data string based on the Camotics format
camotics_data = (
b'{"units": "metric", "shape": "Cylindrical", "length": 15, '
b'"diameter": 4.12, "description": "Test Tool"}'
)
deserialized_bit = cast(
ToolBitEndmill,
self.serializer_class.deserialize(camotics_data, id="test_id", dependencies=None),
)
self.assertIsInstance(deserialized_bit, ToolBit)
self.assertEqual(deserialized_bit.label, "Test Tool")
self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm")
self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm")
self.assertEqual(deserialized_bit.get_shape_name(), "Endmill")
class TestFCTBSerializer(_BaseToolBitSerializerTestCase):
serializer_class = FCTBSerializer
def test_serialize(self):
super().test_serialize()
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
# FCTB specific assertions (JSON format)
data = json.loads(serialized_data.decode("utf-8"))
self.assertEqual(data.get("name"), "Test Tool")
self.assertEqual(data.get("shape"), "endmill.fcstd")
self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm")
self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm", data)
def test_extract_dependencies(self):
"""Test dependency extraction for FCTB."""
fctb_data = (
b'{"name": "Test Tool", "pocket": null, "shape": "endmill", '
b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}'
)
dependencies = self.serializer_class.extract_dependencies(fctb_data)
self.assertIsInstance(dependencies, list)
self.assertEqual(len(dependencies), 1)
self.assertEqual(dependencies[0], AssetUri.build("toolbitshape", "endmill"))
def test_deserialize(self):
# Create a known serialized data string based on the FCTB format
fctb_data = (
b'{"name": "Test Tool", "pocket": null, "shape": "endmill", '
b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}'
)
# Create a ToolBitShapeEndmill instance for 'endmill'
shape = ToolBitShapeEndmill("endmill")
# Create the dependencies dictionary with the shape instance
dependencies: Mapping[AssetUri, Asset] = {AssetUri.build("toolbitshape", "endmill"): shape}
# Provide dummy id and dependencies for deserialization test
deserialized_bit = cast(
ToolBitEndmill,
self.serializer_class.deserialize(fctb_data, id="test_id", dependencies=dependencies),
)
self.assertIsInstance(deserialized_bit, ToolBit)
self.assertEqual(deserialized_bit.label, "Test Tool")
self.assertEqual(deserialized_bit.get_shape_name(), "Endmill")
self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm")
self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm")

View File

@@ -21,10 +21,8 @@
# ***************************************************************************
import FreeCAD
import Path
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Tool.Controller as PathToolController
from CAMTests.PathTestUtils import PathTestBase
@@ -39,12 +37,14 @@ class TestPathToolController(PathTestBase):
def createTool(self, name="t1", diameter=1.75):
attrs = {
"shape": None,
"name": name,
"name": name or "t1",
"shape": "endmill.fcstd",
"parameter": {"Diameter": diameter},
"attribute": [],
"attribute": {},
}
return PathToolBit.Factory.CreateFromAttrs(attrs, name)
print(f"Debug: attrs['attribute'] is {attrs['attribute']}")
toolbit = ToolBit.from_dict(attrs)
return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
def test00(self):
"""Verify ToolController templateAttrs"""
@@ -72,7 +72,7 @@ class TestPathToolController(PathTestBase):
self.assertEqual(attrs["hrapid"], "28.0 mm/s")
self.assertEqual(attrs["dir"], "Reverse")
self.assertEqual(attrs["speed"], 12000)
self.assertEqual(attrs["tool"], t.Proxy.templateAttrs(t))
self.assertEqual(attrs["tool"], t.Proxy.to_dict())
return tc

View File

@@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENSE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the DocumentObjectEditorWidget."""
import unittest
from unittest.mock import MagicMock
import FreeCAD
from PySide import QtGui
from Path.Tool.ui.property import (
BasePropertyEditorWidget,
QuantityPropertyEditorWidget,
BoolPropertyEditorWidget,
IntPropertyEditorWidget,
EnumPropertyEditorWidget,
LabelPropertyEditorWidget,
)
from Path.Tool.ui.docobject import DocumentObjectEditorWidget, _get_label_text
from Path.Tool.toolbit.docobject import DetachedDocumentObject
class TestDocumentObjectEditorWidget(unittest.TestCase):
"""Tests for DocumentObjectEditorWidget."""
def test_populate_form(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
obj.addProperty("App::PropertyLength", "Prop3", "Group1", "Doc3")
obj.Prop3 = FreeCAD.Units.Quantity(5.0, "mm")
obj.addProperty("App::PropertyBool", "Prop4", "Group1", "Doc4")
obj.Prop4 = False
obj.addProperty("App::PropertyEnumeration", "Prop5", "Group1", "Doc5")
obj.Prop5 = ["OptionA", "OptionB"]
properties_to_show = ["Prop1", "Prop2", "Prop3", "Prop4", "Prop5", "NonExistent"]
property_suffixes = {"Prop1": "Suffix1", "Prop3": "Len"}
widget = DocumentObjectEditorWidget(
obj=obj, properties_to_show=properties_to_show, property_suffixes=property_suffixes
)
# Verify the layout contains the correct number of rows (excluding non-existent)
expected_row_count = len([p for p in properties_to_show if hasattr(obj, p)])
self.assertEqual(widget._layout.rowCount(), expected_row_count)
# Verify labels and widgets are added correctly and are of the expected types
prop_names_in_layout = []
for i in range(widget._layout.rowCount()):
label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole)
field_item = widget._layout.itemAt(i, QtGui.QFormLayout.FieldRole)
self.assertIsNotNone(label_item)
self.assertIsNotNone(field_item)
label_widget = label_item.widget()
field_widget = field_item.widget()
self.assertIsInstance(label_widget, QtGui.QLabel)
self.assertIsInstance(
field_widget, BasePropertyEditorWidget
) # Check against base class
# Determine the property name from the label text (reverse of _get_label_text)
# This is a bit fragile, but necessary without storing prop_name in the label widget
label_text = label_widget.text()
prop_name = None
for original_prop_name in properties_to_show:
expected_label = _get_label_text(original_prop_name)
suffix = property_suffixes.get(original_prop_name)
if suffix:
expected_label = f"{expected_label} ({suffix}):"
else:
expected_label = f"{expected_label}:"
if label_text == expected_label:
prop_name = original_prop_name
break
self.assertIsNotNone(
prop_name, f"Could not determine property name for label: {label_text}"
)
prop_names_in_layout.append(prop_name)
# Verify widget type based on property type
if prop_name == "Prop1":
self.assertIsInstance(field_widget, LabelPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop1 (Suffix1):")
elif prop_name == "Prop2":
self.assertIsInstance(field_widget, IntPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop2:")
elif prop_name == "Prop3":
self.assertIsInstance(field_widget, QuantityPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop3 (Len):")
elif prop_name == "Prop4":
self.assertIsInstance(field_widget, BoolPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop4:")
elif prop_name == "Prop5":
self.assertIsInstance(field_widget, EnumPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop5:")
# Verify property editors are stored
self.assertEqual(len(widget._property_editors), expected_row_count)
for prop_name in prop_names_in_layout:
self.assertIn(prop_name, widget._property_editors)
self.assertIsInstance(widget._property_editors[prop_name], BasePropertyEditorWidget)
def test_set_object(self):
obj1 = DetachedDocumentObject()
obj1.addProperty("App::PropertyString", "PropA", "GroupA", "DocA")
obj1.PropA = "ValueA"
obj2 = DetachedDocumentObject()
obj2.addProperty("App::PropertyString", "PropA", "GroupA", "DocA")
obj2.PropA = "ValueB"
properties_to_show = ["PropA"]
widget = DocumentObjectEditorWidget(obj=obj1, properties_to_show=properties_to_show)
# Get the initial editor widget instance
initial_editor = widget._property_editors["PropA"]
self.assertIsInstance(initial_editor, BasePropertyEditorWidget)
# Set a new object
widget.setObject(obj2)
# Verify that the editor widget instance is the same
self.assertEqual(widget._property_editors["PropA"], initial_editor)
# Verify that attachTo was called on the existing editor widget
# This requires the real attachTo method to be implemented correctly
# and the editor widget to update its internal object reference.
# We can't easily assert the internal state change without mocks,
# but we can trust the implementation of attachTo in PropertyEditorWidget.
# We can verify updateWidget was called.
# Note: This test relies on the side effect of attachTo calling updateWidget
# in the real implementation.
# We can't directly assert method calls without mocks, so we'll rely on
# the fact that setting the object and then updating the UI should
# reflect the new object's values if attachTo worked.
widget.updateUI()
# Check if the child widget's display reflects obj2's value
# This requires accessing the child widget's internal editor widget
# which might be fragile. A better approach is to trust the unit tests
# for the individual PropertyEditorWidgets and focus on the
# DocumentObjectEditorWidget's logic of calling attachTo and updateUI.
def test_set_properties_to_show(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"])
# Store the initial editor instance for Prop1
initial_prop1_editor = widget._property_editors["Prop1"]
# Set new properties to show
new_properties_to_show = ["Prop1", "Prop2"]
new_suffixes = {"Prop2": "Suffix2"}
widget.setPropertiesToShow(new_properties_to_show, new_suffixes)
# Verify that the form was repopulated with the new properties
self.assertEqual(widget._layout.rowCount(), len(new_properties_to_show))
# Verify property editors are updated and new ones created
self.assertEqual(len(widget._property_editors), len(new_properties_to_show))
self.assertIn("Prop1", widget._property_editors)
self.assertIn("Prop2", widget._property_editors)
# Verify that the editor for Prop1 is a *new* instance after repopulation
self.assertIsNot(widget._property_editors["Prop1"], initial_prop1_editor)
self.assertIsInstance(widget._property_editors["Prop2"], IntPropertyEditorWidget)
# Verify labels including suffixes
prop_names_in_layout = []
for i in range(widget._layout.rowCount()):
label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole)
label_widget = label_item.widget()
label_text = label_widget.text()
prop_name = None
for original_prop_name in new_properties_to_show:
expected_label = _get_label_text(original_prop_name)
suffix = new_suffixes.get(original_prop_name)
if suffix:
expected_label = f"{expected_label} ({suffix}):"
else:
expected_label = f"{expected_label}:"
if label_text == expected_label:
prop_name = original_prop_name
break
prop_names_in_layout.append(prop_name)
self.assertIn("Prop1", prop_names_in_layout)
self.assertIn("Prop2", prop_names_in_layout)
self.assertEqual(
widget._layout.itemAt(0, QtGui.QFormLayout.LabelRole).widget().text(), "Prop1:"
)
self.assertEqual(
widget._layout.itemAt(1, QtGui.QFormLayout.LabelRole).widget().text(),
"Prop2 (Suffix2):",
)
def test_property_changed_signal(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"])
# Connect to the widget's propertyChanged signal
mock_slot = MagicMock()
widget.propertyChanged.connect(mock_slot)
# Get the real child editor widget
child_editor = widget._property_editors["Prop1"]
self.assertIsInstance(child_editor, BasePropertyEditorWidget)
# Emit the signal from the real child editor
child_editor.propertyChanged.emit()
# Verify that the widget's signal was emitted
mock_slot.assert_called_once()
def test_update_ui(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
properties_to_show = ["Prop1", "Prop2"]
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show)
# Get the real child editor widgets
editor1 = widget._property_editors["Prop1"]
editor2 = widget._property_editors["Prop2"]
# Mock their updateWidget methods to check if they are called
editor1.updateWidget = MagicMock()
editor2.updateWidget = MagicMock()
# Call updateUI
widget.updateUI()
# Verify that updateWidget was called on all child editors
editor1.updateWidget.assert_called_once()
editor2.updateWidget.assert_called_once()
def test_update_object(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
properties_to_show = ["Prop1", "Prop2"]
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show)
# Get the real child editor widgets
editor1 = widget._property_editors["Prop1"]
editor2 = widget._property_editors["Prop2"]
# Mock their updateProperty methods to check if they are called
editor1.updateProperty = MagicMock()
editor2.updateProperty = MagicMock()
# Call updateObject
widget.updateObject()
# Verify that updateProperty was called on all child editors
editor1.updateProperty.assert_called_once()
editor2.updateProperty.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,381 @@
import unittest
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBitEndmill, ToolBitDrill, ToolBitVBit
from Path.Tool.shape import (
ToolBitShapeEndmill,
ToolBitShapeDrill,
ToolBitShapeVBit,
)
class TestPathToolLibrary(unittest.TestCase):
def test_init(self):
library = Library("Test Library")
self.assertEqual(library.label, "Test Library")
self.assertIsNotNone(library.id)
self.assertEqual(len(library._bits), 0)
self.assertEqual(len(library._bit_nos), 0)
def test_str(self):
library = Library("My Library", "123-abc")
self.assertEqual(str(library), '123-abc "My Library"')
def test_eq(self):
library1 = Library("Lib1", "same-id")
library2 = Library("Lib2", "same-id")
library3 = Library("Lib3", "different-id")
self.assertEqual(library1, library2)
self.assertNotEqual(library1, library3)
def test_iter(self):
library = Library("Test Library")
# Create ToolBitShape instances with required parameters
shape1 = ToolBitShapeEndmill(
id="dummy_endmill",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
library.add_bit(bit2)
_bits_list = list(library)
self.assertEqual(len(_bits_list), 2)
self.assertIn(bit1, _bits_list)
self.assertIn(bit2, _bits_list)
def test_get_next_bit_no(self):
library = Library("Test Library")
self.assertEqual(library.get_next_bit_no(), 1)
# Using ToolBit instances in _bit_nos with ToolBitShape
shape_a = ToolBitShapeEndmill(
id="dummy_a",
CuttingEdgeHeight=1.0,
Diameter=1.0,
Flutes=1,
Length=10.0,
ShankDiameter=1.0,
)
shape_b = ToolBitShapeDrill(
id="dummy_b",
Diameter=2.0,
Length=20.0,
ShankDiameter=2.0,
Flutes=2,
TipAngle=118.0,
)
shape_c = ToolBitShapeVBit(
id="dummy_c",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
library._bit_nos = {
1: ToolBitEndmill(shape_a),
5: ToolBitDrill(shape_b),
2: ToolBitVBit(shape_c),
}
self.assertEqual(library.get_next_bit_no(), 6)
library._bit_nos = {}
self.assertEqual(library.get_next_bit_no(), 1)
def test_get_bit_no_from_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_1",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_1",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1, 1)
library.add_bit(bit2, 2)
self.assertEqual(library.get_bit_no_from_bit(bit1), 1)
self.assertEqual(library.get_bit_no_from_bit(bit2), 2)
shape_cutter = ToolBitShapeVBit(
id="dummy_cutter_1",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
self.assertIsNone(library.get_bit_no_from_bit(ToolBitVBit(shape_cutter)))
def test_assign_new_bit_no(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_2",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_2",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
# Assign bit1 without specifying number (should get 1)
library.add_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertEqual(len(library._bit_nos), 1)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
# Assign bit2 to number 1 (should reassign bit1)
library.add_bit(bit2, 1)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit2)
# Check if bit1 was reassigned to a new bit number (should be 2)
self.assertIn(2, library._bit_nos)
self.assertEqual(library._bit_nos[2], bit1)
# Assign bit2 to number 10
library.assign_new_bit_no(bit2, 10)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit2)
self.assertNotIn(1, library._bit_nos) # bit2 should no longer be at 1
self.assertIn(2, library._bit_nos)
self.assertEqual(library._bit_nos[2], bit1)
# Assign bit1 to number 5
library.assign_new_bit_no(bit1, 5)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit1)
self.assertNotIn(2, library._bit_nos) # bit1 should no longer be at 2
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit2)
def test_add_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_3",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_3",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertIn(bit1, library._bits)
self.assertEqual(len(library._bit_nos), 1)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
library.add_bit(bit2, 5)
self.assertEqual(len(library._bits), 2)
self.assertIn(bit1, library._bits)
self.assertIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit2)
# Add bit1 again (should not increase bit count in _bits list)
library.add_bit(bit1, 10)
self.assertEqual(len(library._bits), 2)
self.assertIn(bit1, library._bits)
self.assertIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2) # _bit_nos count remains 2
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit1)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit2)
self.assertNotIn(1, library._bit_nos) # bit1 should no longer be at 1
def test_get_bits(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_4",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_4",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
self.assertEqual(library.get_bits(), [])
library.add_bit(bit1)
library.add_bit(bit2)
_bits_list = library.get_bits()
self.assertEqual(len(_bits_list), 2)
self.assertIn(bit1, _bits_list)
self.assertIn(bit2, _bits_list)
def test_has_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_5",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_5",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
self.assertTrue(library.has_bit(bit1))
self.assertFalse(library.has_bit(bit2))
# Create a new ToolBit with the same properties but different instance
shape1_copy = ToolBitShapeEndmill(
id="dummy_endmill_5_copy", # Use a different ID for the copy
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
bit1_copy = ToolBitEndmill(shape1_copy)
self.assertFalse(library.has_bit(bit1_copy))
def test_remove_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_6",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_6",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
shape3 = ToolBitShapeVBit(
id="dummy_cutter_6",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
bit3 = ToolBitVBit(shape3)
library.add_bit(bit1, 1)
library.add_bit(bit2, 2)
library.add_bit(bit3, 3)
self.assertEqual(len(library._bits), 3)
self.assertEqual(len(library._bit_nos), 3)
library.remove_bit(bit2)
self.assertEqual(len(library._bits), 2)
self.assertNotIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2)
self.assertNotIn(2, library._bit_nos)
self.assertNotIn(bit2, library._bit_nos.values())
library.remove_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertNotIn(bit1, library._bits)
self.assertEqual(len(library._bit_nos), 1)
self.assertNotIn(1, library._bit_nos)
self.assertNotIn(bit1, library._bit_nos.values())
library.remove_bit(bit3)
self.assertEqual(len(library._bits), 0)
self.assertNotIn(bit3, library._bits)
self.assertEqual(len(library._bit_nos), 0)
self.assertNotIn(3, library._bit_nos)
self.assertNotIn(bit3, library._bit_nos.values())
# Removing a non-existent bit should not raise an error
shape_nonexistent = ToolBitShapeEndmill(
id="dummy_nonexistent_6",
CuttingEdgeHeight=99.0,
Diameter=99.0,
Flutes=1,
Length=99.0,
ShankDiameter=99.0,
)
library.remove_bit(ToolBitEndmill(shape_nonexistent))
self.assertEqual(len(library._bits), 0)
self.assertEqual(len(library._bit_nos), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,128 @@
import unittest
import json
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBitEndmill
from Path.Tool.shape import ToolBitShapeEndmill
from Path.Tool.library.serializers import CamoticsLibrarySerializer, LinuxCNCSerializer
class TestPathToolLibrarySerializerBase(PathTestWithAssets):
"""Base class for Library serializer tests."""
def setUp(self):
super().setUp()
self.test_library_id = "test_library"
self.test_library_label = "Test Library"
self.test_library = Library(self.test_library_label, id=self.test_library_id)
# Create some dummy tool bits
shape1 = ToolBitShapeEndmill("endmill_1")
shape1.set_parameter("Diameter", FreeCAD.Units.Quantity("6.0 mm"))
shape1.set_parameter("Length", FreeCAD.Units.Quantity("20.0 mm"))
tool1 = ToolBitEndmill(shape1, id="tool_1")
tool1.label = "Endmill 6mm"
shape2 = ToolBitShapeEndmill("endmill_2")
shape2.set_parameter("Diameter", FreeCAD.Units.Quantity("3.0 mm"))
shape2.set_parameter("Length", FreeCAD.Units.Quantity("15.0 mm"))
tool2 = ToolBitEndmill(shape2, id="tool_2")
tool2.label = "Endmill 3mm"
self.test_library.add_bit(tool1, 1)
self.test_library.add_bit(tool2, 2)
class TestCamoticsLibrarySerializer(TestPathToolLibrarySerializerBase):
"""Tests for the CamoticsLibrarySerializer."""
def test_camotics_serialize(self):
serializer = CamoticsLibrarySerializer
serialized_data = serializer.serialize(self.test_library)
self.assertIsInstance(serialized_data, bytes)
# Verify the content structure (basic check)
data_dict = json.loads(serialized_data.decode("utf-8"))
self.assertIn("1", data_dict)
self.assertIn("2", data_dict)
self.assertEqual(data_dict["1"]["description"], self.test_library._bit_nos[1].label)
self.assertEqual(
data_dict["2"]["diameter"],
self.test_library._bit_nos[2]._tool_bit_shape.get_parameter("Diameter"),
)
def test_camotics_deserialize(self):
serializer = CamoticsLibrarySerializer
# Create a dummy serialized data matching the expected format
dummy_data = {
"10": {
"units": "metric",
"shape": "Ballnose",
"length": 25,
"diameter": 8,
"description": "Ballnose 8mm",
},
"20": {
"units": "metric",
"shape": "Cylindrical",
"length": 30,
"diameter": 10,
"description": "Endmill 10mm",
},
}
dummy_bytes = json.dumps(dummy_data, indent=2).encode("utf-8")
# Deserialize the data
deserialized_library = serializer.deserialize(dummy_bytes, "deserialized_lib", {})
self.assertIsInstance(deserialized_library, Library)
self.assertEqual(deserialized_library.get_id(), "deserialized_lib")
self.assertEqual(len(deserialized_library._bit_nos), 2)
tool_10 = deserialized_library._bit_nos.get(10)
assert tool_10 is not None, "tool not in the library"
self.assertEqual(tool_10.label, "Ballnose 8mm")
self.assertEqual(tool_10._tool_bit_shape.name, "Ballend")
self.assertEqual(
tool_10._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("8 mm")
)
self.assertEqual(
tool_10._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("25 mm")
)
tool_20 = deserialized_library._bit_nos.get(20)
assert tool_20 is not None, "tool not in the library"
self.assertEqual(tool_20.label, "Endmill 10mm")
self.assertEqual(tool_20._tool_bit_shape.name, "Endmill")
self.assertEqual(
tool_20._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("10 mm")
)
self.assertEqual(
tool_20._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("30 mm")
)
class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase):
"""Tests for the LinuxCNCLibrarySerializer."""
def test_linuxcnc_serialize(self):
serializer = LinuxCNCSerializer
serialized_data = serializer.serialize(self.test_library)
self.assertIsInstance(serialized_data, bytes)
# Verify the content format (basic check)
lines = serialized_data.decode("ascii", "ignore").strip().split("\n")
self.assertEqual(len(lines), 2)
self.assertTrue(lines[0].startswith("T1 P D6.0 ;Endmill 6mm"))
self.assertTrue(lines[1].startswith("T2 P D3.0 ;Endmill 3mm"))
def test_linuxcnc_deserialize_not_implemented(self):
serializer = LinuxCNCSerializer
dummy_data = b"T1 D6.0 ;Endmill 6mm\n"
with self.assertRaises(NotImplementedError):
serializer.deserialize(dummy_data, "dummy_id", {})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,206 @@
import unittest
import FreeCAD
from Path.Tool.machine.models.machine import Machine
class TestPathToolMachine(unittest.TestCase):
def setUp(self):
self.default_machine = Machine()
def test_initialization_defaults(self):
self.assertEqual(self.default_machine.label, "Machine")
self.assertAlmostEqual(self.default_machine.max_power.getValueAs("W").Value, 2000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 3000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 60000)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 1)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 2000)
expected_peak_torque_rpm = 60000 / 3
self.assertAlmostEqual(
self.default_machine.get_peak_torque_rpm_value(),
expected_peak_torque_rpm,
)
expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm
self.assertAlmostEqual(
self.default_machine.max_torque.getValueAs("Nm").Value,
expected_max_torque_nm,
)
self.assertIsNotNone(self.default_machine.id)
def test_initialization_custom_values(self):
custom_machine = Machine(
label="Custom Machine",
max_power=5,
min_rpm=1000,
max_rpm=20000,
max_torque=50,
peak_torque_rpm=15000,
min_feed=10,
max_feed=5000,
id="custom-id",
)
self.assertEqual(custom_machine.label, "Custom Machine")
self.assertAlmostEqual(custom_machine.max_power.getValueAs("W").Value, 5000)
self.assertAlmostEqual(custom_machine.get_min_rpm_value(), 1000)
self.assertAlmostEqual(custom_machine.get_max_rpm_value(), 20000)
self.assertAlmostEqual(custom_machine.max_torque.getValueAs("Nm").Value, 50)
self.assertAlmostEqual(custom_machine.get_peak_torque_rpm_value(), 15000)
self.assertAlmostEqual(custom_machine.min_feed.getValueAs("mm/min").Value, 10)
self.assertAlmostEqual(custom_machine.max_feed.getValueAs("mm/min").Value, 5000)
self.assertEqual(custom_machine.id, "custom-id")
def test_initialization_custom_torque_quantity(self):
custom_torque_machine = Machine(max_torque=FreeCAD.Units.Quantity(100, "Nm"))
self.assertAlmostEqual(custom_torque_machine.max_torque.getValueAs("Nm").Value, 100)
def test_validate_valid(self):
try:
self.default_machine.validate()
except AttributeError as e:
self.fail(f"Validation failed unexpectedly: {e}")
def test_validate_missing_label(self):
self.default_machine.label = ""
with self.assertRaisesRegex(AttributeError, "Machine name is required"):
self.default_machine.validate()
def test_validate_peak_torque_rpm_greater_than_max_rpm(self):
self.default_machine.set_peak_torque_rpm(70000)
with self.assertRaisesRegex(AttributeError, "Peak Torque RPM.*must be less than max RPM"):
self.default_machine.validate()
def test_validate_max_rpm_less_than_min_rpm(self):
self.default_machine = Machine()
self.default_machine.set_min_rpm(4000) # min_rpm = 4000 RPM
self.default_machine.set_peak_torque_rpm(1000) # peak_torque_rpm = 1000 RPM
self.default_machine._max_rpm = 2000 / 60.0 # max_rpm = 2000 RPM (33.33 1/s)
self.assertLess(
self.default_machine.get_max_rpm_value(),
self.default_machine.get_min_rpm_value(),
)
with self.assertRaisesRegex(AttributeError, "Max RPM must be larger than min RPM"):
self.default_machine.validate()
def test_validate_max_feed_less_than_min_feed(self):
self.default_machine.set_min_feed(1000)
self.default_machine._max_feed = 500
with self.assertRaisesRegex(AttributeError, "Max feed must be larger than min feed"):
self.default_machine.validate()
def test_get_torque_at_rpm(self):
torque_below_peak = self.default_machine.get_torque_at_rpm(10000)
expected_peak_torque_rpm = 60000 / 3
expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm
expected_torque_below_peak = expected_max_torque_nm / expected_peak_torque_rpm * 10000
self.assertAlmostEqual(torque_below_peak, expected_torque_below_peak)
torque_at_peak = self.default_machine.get_torque_at_rpm(
self.default_machine.get_peak_torque_rpm_value()
)
self.assertAlmostEqual(
torque_at_peak,
self.default_machine.max_torque.getValueAs("Nm").Value,
)
torque_above_peak = self.default_machine.get_torque_at_rpm(50000)
expected_torque_above_peak = 2000 * 9.5488 / 50000
self.assertAlmostEqual(torque_above_peak, expected_torque_above_peak)
def test_set_label(self):
self.default_machine.label = "New Label"
self.assertEqual(self.default_machine.label, "New Label")
def test_set_max_power(self):
self.default_machine = Machine()
self.default_machine.set_max_power(5, "hp")
self.assertAlmostEqual(
self.default_machine.max_power.getValueAs("W").Value,
5 * 745.7,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Max power must be positive"):
self.default_machine.set_max_power(0)
def test_set_min_rpm(self):
self.default_machine = Machine()
self.default_machine.set_min_rpm(5000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 5000)
with self.assertRaisesRegex(AttributeError, "Min RPM cannot be negative"):
self.default_machine.set_min_rpm(-100)
self.default_machine = Machine()
self.default_machine.set_min_rpm(70000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 70000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 70001)
def test_set_max_rpm(self):
self.default_machine = Machine()
self.default_machine.set_max_rpm(50000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 50000)
with self.assertRaisesRegex(AttributeError, "Max RPM must be positive"):
self.default_machine.set_max_rpm(0)
self.default_machine = Machine()
self.default_machine.set_max_rpm(2000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 2000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 1999)
self.default_machine = Machine()
self.default_machine.set_max_rpm(0.5)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 0.5)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 0)
def test_set_min_feed(self):
self.default_machine = Machine()
self.default_machine.set_min_feed(500, "inch/min")
self.assertAlmostEqual(
self.default_machine.min_feed.getValueAs("mm/min").Value,
500 * 25.4,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Min feed cannot be negative"):
self.default_machine.set_min_feed(-10)
self.default_machine = Machine()
self.default_machine.set_min_feed(3000)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 3000)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3001)
def test_set_max_feed(self):
self.default_machine = Machine()
self.default_machine.set_max_feed(3000)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3000)
with self.assertRaisesRegex(AttributeError, "Max feed must be positive"):
self.default_machine.set_max_feed(0)
self.default_machine = Machine()
self.default_machine.set_min_feed(600)
self.default_machine.set_max_feed(500)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 500)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 499)
self.default_machine = Machine()
self.default_machine.set_max_feed(0.5)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 0.5)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 0)
def test_set_peak_torque_rpm(self):
self.default_machine = Machine()
self.default_machine.set_peak_torque_rpm(40000)
self.assertAlmostEqual(self.default_machine.get_peak_torque_rpm_value(), 40000)
with self.assertRaisesRegex(AttributeError, "Peak torque RPM cannot be negative"):
self.default_machine.set_peak_torque_rpm(-100)
def test_set_max_torque(self):
self.default_machine = Machine()
self.default_machine.set_max_torque(200, "in-lbf")
self.assertAlmostEqual(
self.default_machine.max_torque.getValueAs("Nm").Value,
200 * 0.112985,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Max torque must be positive"):
self.default_machine.set_max_torque(0)
def test_dump(self):
try:
self.default_machine.dump(False)
except Exception as e:
self.fail(f"dump() method failed unexpectedly: {e}")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
# 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(str(shape.get_parameter("Param1")), "10.0 mm")
self.assertEqual(str(shape.get_parameter("Param2")), "5.0 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("Ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("VBit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("slitting-saw").asset_id, "slittingsaw")
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("threadmill")
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("threadmill")
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["FlatRadius"].Value, 1.5)
self.assertEqual(unit(shape["FlatRadius"]), "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("FlatRadius"), "Torus radius")
def test_toolbitshapevbit_defaults(self):
"""Test ToolBitShapeVBit default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("vbit")
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("vbit")
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)

View File

@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
# Unit tests for the Path.Tool.Shape module and its document utilities.
import unittest
from unittest.mock import patch, MagicMock, call
from Path.Tool.shape import doc
import os
mock_freecad = MagicMock(Name="FreeCAD_Mock")
mock_freecad.Console = MagicMock()
mock_freecad.Console.PrintWarning = MagicMock()
mock_freecad.Console.PrintError = MagicMock()
mock_obj = MagicMock(Name="Object_Mock")
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"
mock_doc = MagicMock(Name="Document_Mock")
mock_doc.Objects = [mock_obj]
class TestPathToolShapeDoc(unittest.TestCase):
def setUp(self):
"""Reset mocks before each test."""
# Resetting the top-level mock recursively resets its children
# (newDocument, getDocument, openDocument, closeDocument, Console, etc.)
# and their call counts, return_values, side_effects.
mock_freecad.reset_mock()
mock_doc.reset_mock()
mock_obj.reset_mock()
# Re-establish default state/attributes potentially cleared by reset_mock
# or needed for tests.
mock_doc.Objects = [mock_obj]
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"
# Ensure mock_doc also has a Name attribute used in tests/code
mock_doc.Name = "Document_Mock" # Used in closeDocument calls
# Clear attributes potentially added by setattr in previous tests.
# reset_mock() doesn't remove attributes added this way.
# Focus on attributes known to be added by tests in this file.
for attr_name in ["Diameter", "Length", "Height"]:
if hasattr(mock_obj, attr_name):
try:
delattr(mock_obj, attr_name)
except AttributeError:
pass # Ignore if already gone
"""Tests for the document utility functions in Path/Tool/Shape/doc.py"""
def test_doc_find_shape_object_body_priority(self):
"""Test find_shape_object prioritizes PartDesign::Body."""
body_obj = MagicMock(Name="Body_Mock")
body_obj.isDerivedFrom = lambda typeName: typeName == "PartDesign::Body"
part_obj = MagicMock(Name="Part_Mock")
part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
mock_doc.Objects = [part_obj, body_obj]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, body_obj)
def test_doc_find_shape_object_part_fallback(self):
"""Test find_shape_object falls back to Part::Feature."""
part_obj = MagicMock(Name="Part_Mock")
part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
other_obj = MagicMock(Name="Other_Mock")
other_obj.isDerivedFrom = lambda typeName: False
mock_doc.Objects = [other_obj, part_obj]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, part_obj)
def test_doc_find_shape_object_first_obj_fallback(self):
"""Test find_shape_object falls back to the first object."""
other_obj1 = MagicMock(Name="Other1_Mock")
other_obj1.isDerivedFrom = lambda typeName: False
other_obj2 = MagicMock(Name="Other2_Mock")
other_obj2.isDerivedFrom = lambda typeName: False
mock_doc.Objects = [other_obj1, other_obj2]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, other_obj1)
def test_doc_find_shape_object_no_objects(self):
"""Test find_shape_object returns None if document has no objects."""
mock_doc.Objects = []
found = doc.find_shape_object(mock_doc)
self.assertIsNone(found)
def test_doc_get_object_properties_found(self):
"""Test get_object_properties extracts existing properties."""
setattr(mock_obj, "Diameter", "10 mm")
setattr(mock_obj, "Length", "50 mm")
params = doc.get_object_properties(mock_obj, ["Diameter", "Length"])
# Expecting just the values, not tuples
self.assertEqual(params, {"Diameter": "10 mm", "Length": "50 mm"})
mock_freecad.Console.PrintWarning.assert_not_called()
@patch("Path.Tool.shape.doc.FreeCAD", new=mock_freecad)
def test_doc_get_object_properties_missing(self):
"""Test get_object_properties handles missing properties with warning."""
# Re-import doc within the patch context to use the mocked FreeCAD
import Path.Tool.shape.doc as doc_patched
setattr(mock_obj, "Diameter", "10 mm")
# Explicitly delete Height to ensure hasattr returns False for MagicMock
if hasattr(mock_obj, "Height"):
delattr(mock_obj, "Height")
params = doc_patched.get_object_properties(mock_obj, ["Diameter", "Height"])
# Expecting just the values, not tuples
self.assertEqual(params, {"Diameter": "10 mm", "Height": None}) # Height is missing
expected_calls = [
# The 'Could not get type' warning is from base.py's set_parameter,
# not get_object_properties. Removing it from expected calls here.
call(
"Parameter 'Height' not found on object 'MockObjectLabel' "
"(MockObjectName). Default value will be used by the shape "
"class.\n"
)
]
mock_freecad.Console.PrintWarning.assert_has_calls(expected_calls, any_order=True)
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes loads doc from a byte string."""
content = b"fake_content"
mock_opened_doc = MagicMock(Name="OpenedDoc_Mock")
mock_get_doc.return_value = mock_opened_doc
temp_file_path = None
try:
with doc.ShapeDocFromBytes(content=content) as temp_doc:
# Verify temp file creation and content
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
self.assertEqual(temp_doc, mock_open_doc.return_value)
# Verify cleanup after exiting the context
mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
self.assertFalse(os.path.exists(temp_file_path))
finally:
# Ensure cleanup even if test fails before assertion
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes_open_exception(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes propagates exceptions and cleans up."""
content = b"fake_content_exception"
load_error = Exception("Fake load error")
mock_open_doc.side_effect = load_error
temp_file_path = None
try:
with self.assertRaises(Exception) as cm:
with doc.ShapeDocFromBytes(content=content):
pass
self.assertEqual(cm.exception, load_error)
# Verify temp file was created before the exception
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
mock_get_doc.assert_not_called()
# closeDocument is called in __exit__ only if _doc is not None,
# which it will be if openDocument failed.
mock_close_doc.assert_not_called()
finally:
# Verify cleanup after exiting the context (even with exception)
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
# Assert removal only if temp_file_path was set
if temp_file_path:
self.assertFalse(os.path.exists(temp_file_path))
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes_exit_cleans_up(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes __exit__ cleans up temp file."""
content = b"fake_content_cleanup"
mock_opened_doc = MagicMock(Name="OpenedDoc_Cleanup_Mock")
mock_get_doc.return_value = mock_opened_doc
temp_file_path = None
try:
with doc.ShapeDocFromBytes(content=content):
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
# No assertions on the returned doc here, focus is on cleanup
pass # Exit the context
# Verify cleanup after exiting the context
mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
self.assertFalse(os.path.exists(temp_file_path))
finally:
# Ensure cleanup even if test fails before assertion
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
# Test execution
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
import unittest
import unittest.mock
import pathlib
from tempfile import TemporaryDirectory
from PySide import QtCore, QtGui
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.assets import DummyAssetSerializer
from Path.Tool.shape.models.icon import (
ToolBitShapeIcon,
ToolBitShapeSvgIcon,
ToolBitShapePngIcon,
)
class TestToolBitShapeIconBase(PathTestWithAssets):
"""Base class for ToolBitShapeIcon tests."""
ICON_CLASS = ToolBitShapeIcon
def setUp(self):
super().setUp()
# Ensure a QApplication exists for QPixmap tests
self.app = QtGui.QApplication.instance()
# Create a test shape and a test SVG icon.
self.test_shape = self.assets.get("toolbitshape://ballend")
self.test_svg = self.test_shape.icon
assert self.test_svg is not None
self.icon = self.ICON_CLASS("test_icon_base", b"")
def tearDown(self):
self.app = None
return super().tearDown()
def test_create_instance(self):
# Test basic instance creation
icon_id = "test_icon_123.dat"
icon = self.ICON_CLASS(icon_id, b"")
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, b"")
self.assertIsInstance(icon.abbreviations, dict)
def test_to_bytes(self):
# Test serializing to bytes
icon_id = "test_to_bytes.bin"
icon_data = b"some_binary_data"
icon = ToolBitShapeIcon(icon_id, icon_data)
self.assertEqual(icon.to_bytes(DummyAssetSerializer), icon_data)
def test_get_size_in_bytes(self):
# Test getting icon data length
icon_with_data = ToolBitShapeIcon("with_data.bin", b"abc")
self.assertEqual(icon_with_data.get_size_in_bytes(), 3)
icon_no_data = ToolBitShapeIcon("no_data.dat", b"")
self.assertEqual(icon_no_data.get_size_in_bytes(), 0)
@unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data")
def test_from_shape_data_success(self, mock_create_thumbnail):
# Test creating instance from shape data - success case
shape_id = "test_shape"
thumbnail_data = b"png thumbnail data"
mock_create_thumbnail.return_value = thumbnail_data
icon = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id)
mock_create_thumbnail.assert_called_once_with(self.test_svg.data)
self.assertIsNotNone(icon)
self.assertIsInstance(icon, ToolBitShapePngIcon)
self.assertEqual(icon.get_id(), shape_id)
self.assertEqual(icon.data, thumbnail_data)
self.assertEqual(icon.abbreviations, {})
@unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data")
def test_from_shape_data_failure(self, mock_create_thumbnail):
# Test creating instance from shape data - failure case
shape_id = "test_shape"
mock_create_thumbnail.return_value = None
icon_failed = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id)
mock_create_thumbnail.assert_called_once_with(self.test_svg.data)
self.assertIsNone(icon_failed)
def test_get_png(self):
if not self.app:
self.skipTest("QApplication not available, skipping test_get_png")
if type(self) is TestToolBitShapeIconBase:
self.skipTest("Skipping test on abstract base class")
# Test getting PNG data from the icon
icon_size = QtCore.QSize(16, 16)
png_data = self.icon.get_png(icon_size)
self.assertIsInstance(png_data, bytes)
self.assertTrue(len(png_data) > 0)
def test_get_qpixmap(self):
if not self.app:
self.skipTest("QApplication not available, skipping test_get_qpixmap")
if type(self) is TestToolBitShapeIconBase:
self.skipTest("Skipping test on abstract base class")
# Test getting QPixmap from the icon
icon_size = QtCore.QSize(31, 32)
pixmap = self.icon.get_qpixmap(icon_size)
self.assertIsInstance(pixmap, QtGui.QPixmap)
self.assertFalse(pixmap.isNull())
self.assertEqual(pixmap.size().width(), 31)
self.assertEqual(pixmap.size().height(), 32)
class TestToolBitShapeSvgIcon(TestToolBitShapeIconBase):
"""Tests specifically for ToolBitShapeSvgIcon."""
ICON_CLASS = ToolBitShapeSvgIcon
def setUp(self):
super().setUp()
self.icon = ToolBitShapeSvgIcon("test_icon_svg", self.test_svg.data)
def test_from_bytes_svg(self):
# Test creating instance from bytes with SVG
icon_svg = ToolBitShapeSvgIcon.from_bytes(
self.test_svg.data, "test_from_bytes.svg", {}, DummyAssetSerializer
)
self.assertEqual(icon_svg.get_id(), "test_from_bytes.svg")
self.assertEqual(icon_svg.data, self.test_svg.data)
self.assertIsInstance(icon_svg.abbreviations, dict)
def test_round_trip_serialization_svg(self):
# Test serialization and deserialization round trip for SVG
svg_id = "round_trip_svg.svg"
icon_svg = ToolBitShapeSvgIcon(svg_id, self.test_svg.data)
serialized_svg = icon_svg.to_bytes(DummyAssetSerializer)
deserialized_svg = ToolBitShapeSvgIcon.from_bytes(
serialized_svg, svg_id, {}, DummyAssetSerializer
)
self.assertEqual(deserialized_svg.get_id(), svg_id)
self.assertEqual(deserialized_svg.data, self.test_svg.data)
# Abbreviations are extracted on access, so we don't check the dict directly
self.assertIsInstance(deserialized_svg.abbreviations, dict)
def test_from_file_svg(self):
# We cannot use NamedTemporaryFile on Windows, because there
# we may not have permission to read the tempfile while it is
# still open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with TemporaryDirectory() as thedir:
tempfile = pathlib.Path(thedir, "test.svg")
tempfile.write_bytes(self.test_svg.data)
icon_id = "dummy_icon"
icon = ToolBitShapeIcon.from_file(tempfile, icon_id)
self.assertIsInstance(icon, ToolBitShapeSvgIcon)
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, self.test_svg.data)
self.assertIsInstance(icon.abbreviations, dict)
def test_abbreviations_cached_property_svg(self):
# Test abbreviations property and caching for SVG
icon_svg = ToolBitShapeSvgIcon("cached_abbr.svg", self.test_svg.data)
# Accessing the property should call the static method
with unittest.mock.patch.object(
ToolBitShapeSvgIcon, "get_abbreviations_from_svg"
) as mock_get_abbr:
mock_get_abbr.return_value = {"param1": "A1"}
abbr1 = icon_svg.abbreviations
abbr2 = icon_svg.abbreviations
mock_get_abbr.assert_called_once_with(self.test_svg.data)
self.assertEqual(abbr1, {"param1": "A1"})
self.assertEqual(abbr2, {"param1": "A1"})
def test_get_abbr_svg(self):
# Test getting abbreviations for SVG
icon_data = self.test_svg.data
icon = ToolBitShapeSvgIcon("abbr_test.svg", icon_data)
# Assuming the test_svg data has 'diameter' and 'length' ids
self.assertIsNotNone(icon.get_abbr("Diameter"))
self.assertIsNotNone(icon.get_abbr("Length"))
self.assertIsNone(icon.get_abbr("NonExistent"))
def test_get_abbreviations_from_svg_static(self):
# Test static method get_abbreviations_from_svg
svg_content = self.test_svg.data
abbr = ToolBitShapeSvgIcon.get_abbreviations_from_svg(svg_content)
# Assuming the test_svg data has 'diameter' and 'length' ids
self.assertIn("diameter", abbr)
self.assertIn("length", abbr)
# Test with invalid SVG
invalid_svg = b"<svg><text id='param1'>A1</text>" # Missing closing tag
abbr_invalid = ToolBitShapeSvgIcon.get_abbreviations_from_svg(invalid_svg)
self.assertEqual(abbr_invalid, {})
# Test with no text elements
no_text_svg = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
abbr_no_text = ToolBitShapeSvgIcon.get_abbreviations_from_svg(no_text_svg)
self.assertEqual(abbr_no_text, {})
class TestToolBitShapePngIcon(TestToolBitShapeIconBase):
"""Tests specifically for ToolBitShapePngIcon."""
ICON_CLASS = ToolBitShapePngIcon
def setUp(self):
super().setUp()
self.png_data = b"\x89PNG\r\n\x1a\n" # Basic PNG signature
self.icon = ToolBitShapePngIcon("test_icon_png", self.png_data)
def test_from_bytes_png(self):
# Test creating instance from bytes with PNG
icon_png = ToolBitShapePngIcon.from_bytes(
self.png_data, "test_from_bytes.png", {}, DummyAssetSerializer
)
self.assertEqual(icon_png.get_id(), "test_from_bytes.png")
self.assertEqual(icon_png.data, self.png_data)
self.assertEqual(icon_png.abbreviations, {}) # No abbreviations for PNG
def test_round_trip_serialization_png(self):
# Test serialization and deserialization round trip for PNG
png_id = "round_trip_png.png"
serialized_png = self.icon.to_bytes(DummyAssetSerializer)
deserialized_png = ToolBitShapePngIcon.from_bytes(
serialized_png, png_id, {}, DummyAssetSerializer
)
self.assertEqual(deserialized_png.get_id(), png_id)
self.assertEqual(deserialized_png.data, self.png_data)
self.assertEqual(deserialized_png.abbreviations, {})
def test_from_file_png(self):
png_data = b"\\x89PNG\\r\\n\\x1a\\n"
# We cannot use NamedTemporaryFile on Windows, because there
# we may not have permission to read the tempfile while it is
# still open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with TemporaryDirectory() as thedir:
tempfile = pathlib.Path(thedir, "test.png")
tempfile.write_bytes(png_data)
icon_id = "dummy_icon"
icon = ToolBitShapeIcon.from_file(tempfile, icon_id)
self.assertIsInstance(icon, ToolBitShapePngIcon)
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, png_data)
self.assertEqual(icon.abbreviations, {})
def test_abbreviations_cached_property_png(self):
# Test abbreviations property and caching for PNG
self.assertEqual(self.icon.abbreviations, {})
def test_get_abbr_png(self):
# Test getting abbreviations for PNG
self.assertIsNone(self.icon.get_abbr("Diameter"))
def test_get_qpixmap(self):
self.skipTest("Skipping test, have no test data")
if __name__ == "__main__":
unittest.main()

View File

@@ -24,10 +24,8 @@ import FreeCAD
import Part
import Path.Main.Job as PathJob
import Path.Op.Vcarve as PathVcarve
import Path.Tool.Bit as PathToolBit
import math
from CAMTests.PathTestUtils import PathTestBase
from CAMTests.PathTestUtils import PathTestWithAssets
class VbitTool(object):
@@ -43,10 +41,11 @@ Scale45 = 2.414214
Scale60 = math.sqrt(3)
class TestPathVcarve(PathTestBase):
class TestPathVcarve(PathTestWithAssets):
"""Test Vcarve milling basics."""
def tearDown(self):
super().tearDown()
if hasattr(self, "doc"):
FreeCAD.closeDocument(self.doc.Name)
@@ -56,8 +55,9 @@ class TestPathVcarve(PathTestBase):
rect = Part.makePolygon([(0, 0, 0), (5, 0, 0), (5, 10, 0), (0, 10, 0), (0, 0, 0)])
part.Shape = Part.makeFace(rect, "Part::FaceMakerSimple")
job = PathJob.Create("Job", [part])
tool_file = PathToolBit.findToolBit("60degree_Vbit.fctb")
job.Tools.Group[0].Tool = PathToolBit.Factory.CreateFrom(tool_file)
toolbit = self.assets.get("toolbit://60degree_Vbit")
loaded_tool = toolbit.attach_to_doc(doc=job.Document)
job.Tools.Group[0].Tool = loaded_tool
op = PathVcarve.Create("TestVCarve")
op.Base = job.Model.Group[0]