"""Tier 1 — Pure-logic tests for Kindred Create addons. These tests exercise standalone functions from update_checker, datum_commands, spreadsheet_commands, silo_commands, silo_start, and silo_origin WITHOUT requiring a FreeCAD binary, running GUI, or Silo server. Run directly: python tests/test_kindred_pure.py Via runner: python tests/run_kindred_tests.py Via pixi: pixi run test-kindred """ import math import os import sys import unittest from datetime import datetime, timedelta, timezone from pathlib import Path from unittest import mock # --------------------------------------------------------------------------- # Mock the FreeCAD ecosystem BEFORE importing any target modules. # Every module under test does `import FreeCAD` at the top level. # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parent.parent # Build a FreeCAD mock with a working ParamGet stub _param_store: dict = {} class _MockParamGroup: """Minimal stand-in for FreeCAD ParameterGrp.""" def __init__(self): self._data: dict = {} # Getters def GetBool(self, key, default=False): return self._data.get(key, default) def GetString(self, key, default=""): return self._data.get(key, default) def GetInt(self, key, default=0): return self._data.get(key, default) # Setters def SetBool(self, key, val): self._data[key] = val def SetString(self, key, val): self._data[key] = val def SetInt(self, key, val): self._data[key] = val def _mock_param_get(path): if path not in _param_store: _param_store[path] = _MockParamGroup() return _param_store[path] _fc = mock.MagicMock() _fc.ParamGet = _mock_param_get _fc.Console = mock.MagicMock() # Insert mocks for all FreeCAD-related modules for mod_name in ( "FreeCAD", "FreeCADGui", "Part", "PySide", "PySide.QtCore", "PySide.QtGui", "PySide.QtWidgets", "PySide.QtWebEngineWidgets", ): sys.modules.setdefault(mod_name, mock.MagicMock()) # Replace FreeCAD with our richer mock so ParamGet actually works sys.modules["FreeCAD"] = _fc # Mock silo_client with the specific names that silo_commands imports _silo_client_mock = mock.MagicMock() _silo_client_mock.CATEGORY_NAMES = { "EL": "Electrical", "ME": "Mechanical", "SW": "Software", } _silo_client_mock.parse_part_number = mock.MagicMock(return_value=("ME", "001")) _silo_client_mock.get_category_folder_name = mock.MagicMock(return_value="ME_Mechanical") _silo_client_mock.sanitize_filename = mock.MagicMock(side_effect=lambda s: s.replace(" ", "_")) _silo_client_mock.SiloClient = mock.MagicMock() _silo_client_mock.SiloSettings = type("SiloSettings", (), {}) sys.modules["silo_client"] = _silo_client_mock sys.modules["silo_client._ssl"] = mock.MagicMock() # Add addon source paths sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create")) sys.path.insert(0, str(_REPO_ROOT / "mods" / "sdk")) sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools")) sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad")) # --------------------------------------------------------------------------- # Now import the modules under test # --------------------------------------------------------------------------- from update_checker import _parse_version, _should_check # noqa: E402 # For datum_commands, the module registers Gui.addCommand at import time. # We need Gui.addCommand to be a no-op mock (already is via MagicMock). from ztools.commands.datum_commands import ( # noqa: E402 DatumCreatorTaskPanel, SelectionItem, ) from ztools.commands.spreadsheet_commands import column_to_index # noqa: E402 from silo_commands import _safe_float # noqa: E402 import silo_start # noqa: E402 import silo_origin # noqa: E402 # =================================================================== # Test: update_checker._parse_version # =================================================================== class TestParseVersion(unittest.TestCase): """Tests for update_checker._parse_version.""" def test_standard_version(self): self.assertEqual(_parse_version("0.1.3"), (0, 1, 3)) def test_v_prefix(self): self.assertEqual(_parse_version("v0.1.3"), (0, 1, 3)) def test_major_only_fails(self): self.assertIsNone(_parse_version("1")) def test_two_part_fails(self): self.assertIsNone(_parse_version("1.2")) def test_four_part_fails(self): self.assertIsNone(_parse_version("1.2.3.4")) def test_empty_string(self): self.assertIsNone(_parse_version("")) def test_latest_tag(self): self.assertIsNone(_parse_version("latest")) def test_large_numbers(self): self.assertEqual(_parse_version("v99.88.77"), (99, 88, 77)) def test_zero_version(self): self.assertEqual(_parse_version("0.0.0"), (0, 0, 0)) def test_alpha_suffix_fails(self): self.assertIsNone(_parse_version("v1.0.0-beta")) def test_comparison(self): v1 = _parse_version("v0.1.0") v2 = _parse_version("v0.2.0") v3 = _parse_version("v1.0.0") self.assertLess(v1, v2) self.assertLess(v2, v3) self.assertGreater(v3, v1) # =================================================================== # Test: update_checker._should_check # =================================================================== class TestShouldCheck(unittest.TestCase): """Tests for update_checker._should_check interval logic.""" def _make_param(self, **kwargs): p = _MockParamGroup() for k, v in kwargs.items(): p._data[k] = v return p def test_default_enabled_no_timestamp(self): p = self._make_param() self.assertTrue(_should_check(p)) def test_disabled(self): p = self._make_param(CheckEnabled=False) self.assertFalse(_should_check(p)) def test_recent_check_skipped(self): now = datetime.now(timezone.utc) p = self._make_param( CheckEnabled=True, LastCheckTimestamp=now.isoformat(), CheckIntervalDays=1, ) self.assertFalse(_should_check(p)) def test_old_check_triggers(self): old = datetime.now(timezone.utc) - timedelta(days=2) p = self._make_param( CheckEnabled=True, LastCheckTimestamp=old.isoformat(), CheckIntervalDays=1, ) self.assertTrue(_should_check(p)) def test_zero_interval_always_checks(self): now = datetime.now(timezone.utc) p = self._make_param( CheckEnabled=True, LastCheckTimestamp=now.isoformat(), CheckIntervalDays=0, ) self.assertTrue(_should_check(p)) def test_invalid_timestamp_triggers(self): p = self._make_param( CheckEnabled=True, LastCheckTimestamp="not-a-date", CheckIntervalDays=1, ) self.assertTrue(_should_check(p)) # =================================================================== # Test: datum_commands._match_score and _type_matches # =================================================================== class _StubPanel: """Minimal stub to access DatumCreatorTaskPanel methods without GUI.""" _match_score = DatumCreatorTaskPanel._match_score _type_matches = DatumCreatorTaskPanel._type_matches class TestTypeMatches(unittest.TestCase): """Tests for DatumCreatorTaskPanel._type_matches.""" def setUp(self): self.p = _StubPanel() def test_exact_face(self): self.assertTrue(self.p._type_matches("face", "face")) def test_exact_edge(self): self.assertTrue(self.p._type_matches("edge", "edge")) def test_exact_vertex(self): self.assertTrue(self.p._type_matches("vertex", "vertex")) def test_cylinder_matches_face(self): self.assertTrue(self.p._type_matches("cylinder", "face")) def test_circle_matches_edge(self): self.assertTrue(self.p._type_matches("circle", "edge")) def test_face_does_not_match_edge(self): self.assertFalse(self.p._type_matches("face", "edge")) def test_vertex_does_not_match_face(self): self.assertFalse(self.p._type_matches("vertex", "face")) def test_face_does_not_match_cylinder(self): # face is NOT a cylinder (cylinder IS a face, not reverse) self.assertFalse(self.p._type_matches("face", "cylinder")) def test_edge_does_not_match_circle(self): self.assertFalse(self.p._type_matches("edge", "circle")) def test_unknown_matches_nothing(self): self.assertFalse(self.p._type_matches("unknown", "face")) self.assertFalse(self.p._type_matches("unknown", "edge")) class TestMatchScore(unittest.TestCase): """Tests for DatumCreatorTaskPanel._match_score.""" def setUp(self): self.p = _StubPanel() def test_exact_single_face(self): score = self.p._match_score(("face",), ("face",)) self.assertEqual(score, 101) # 100 + 1 matched, exact count def test_exact_two_faces(self): score = self.p._match_score(("face", "face"), ("face", "face")) self.assertEqual(score, 102) # 100 + 2 def test_exact_three_vertices(self): score = self.p._match_score( ("vertex", "vertex", "vertex"), ("vertex", "vertex", "vertex"), ) self.assertEqual(score, 103) def test_surplus_selection_lower_score(self): exact = self.p._match_score(("face",), ("face",)) surplus = self.p._match_score(("face", "edge"), ("face",)) self.assertGreater(exact, surplus) self.assertGreater(surplus, 0) def test_not_enough_items_zero(self): score = self.p._match_score(("face",), ("face", "face")) self.assertEqual(score, 0) def test_wrong_type_zero(self): score = self.p._match_score(("vertex",), ("face",)) self.assertEqual(score, 0) def test_empty_selection_zero(self): score = self.p._match_score((), ("face",)) self.assertEqual(score, 0) def test_cylinder_satisfies_face(self): score = self.p._match_score(("cylinder",), ("face",)) self.assertEqual(score, 101) def test_face_and_edge(self): score = self.p._match_score(("face", "edge"), ("face", "edge")) self.assertEqual(score, 102) def test_order_independence(self): # edge,face should match face,edge requirement score = self.p._match_score(("edge", "face"), ("face", "edge")) self.assertEqual(score, 102) # =================================================================== # Test: datum_commands.SelectionItem properties # =================================================================== class TestSelectionItemProperties(unittest.TestCase): """Tests for SelectionItem.display_name and type_icon.""" def _make_item(self, label, subname, geo_type): obj = mock.MagicMock() obj.Label = label item = SelectionItem.__new__(SelectionItem) item.obj = obj item.subname = subname item.shape = None item.geo_type = geo_type return item def test_display_name_with_subname(self): item = self._make_item("Box", "Face1", "face") self.assertEqual(item.display_name, "Box.Face1") def test_display_name_without_subname(self): item = self._make_item("DatumPlane", "", "plane") self.assertEqual(item.display_name, "DatumPlane") def test_type_icon_face(self): item = self._make_item("X", "Face1", "face") self.assertEqual(item.type_icon, "▢") def test_type_icon_vertex(self): item = self._make_item("X", "Vertex1", "vertex") self.assertEqual(item.type_icon, "•") def test_type_icon_unknown(self): item = self._make_item("X", "", "unknown") self.assertEqual(item.type_icon, "?") # =================================================================== # Test: spreadsheet_commands.column_to_index # =================================================================== class TestColumnToIndex(unittest.TestCase): """Tests for spreadsheet column_to_index conversion.""" def test_a(self): self.assertEqual(column_to_index("A"), 0) def test_b(self): self.assertEqual(column_to_index("B"), 1) def test_z(self): self.assertEqual(column_to_index("Z"), 25) def test_aa(self): self.assertEqual(column_to_index("AA"), 26) def test_ab(self): self.assertEqual(column_to_index("AB"), 27) def test_az(self): self.assertEqual(column_to_index("AZ"), 51) def test_ba(self): self.assertEqual(column_to_index("BA"), 52) # =================================================================== # Test: silo_commands._safe_float # =================================================================== class TestSafeFloat(unittest.TestCase): """Tests for silo_commands._safe_float.""" def test_normal_float(self): self.assertEqual(_safe_float(3.14), 3.14) def test_nan(self): self.assertEqual(_safe_float(float("nan")), 0.0) def test_inf(self): self.assertEqual(_safe_float(float("inf")), 0.0) def test_neg_inf(self): self.assertEqual(_safe_float(float("-inf")), 0.0) def test_zero(self): self.assertEqual(_safe_float(0.0), 0.0) def test_integer_passthrough(self): self.assertEqual(_safe_float(42), 42) def test_string_passthrough(self): self.assertEqual(_safe_float("hello"), "hello") # =================================================================== # Test: silo_start._get_silo_base_url # =================================================================== class TestSiloBaseUrl(unittest.TestCase): """Tests for silo_start._get_silo_base_url URL construction.""" def setUp(self): # Reset the param store for the silo pref group _param_store.clear() def test_default_strips_api(self): url = silo_start._get_silo_base_url() # Default env var is http://localhost:8080/api -> strip /api self.assertFalse(url.endswith("/api"), f"URL should not end with /api: {url}") def test_preference_with_api_suffix(self): p = _mock_param_get(silo_start._PREF_GROUP) p.SetString("ApiUrl", "https://silo.example.com/api") url = silo_start._get_silo_base_url() self.assertEqual(url, "https://silo.example.com") def test_preference_without_api_suffix(self): p = _mock_param_get(silo_start._PREF_GROUP) p.SetString("ApiUrl", "https://silo.example.com") url = silo_start._get_silo_base_url() self.assertEqual(url, "https://silo.example.com") def test_trailing_slash_stripped(self): p = _mock_param_get(silo_start._PREF_GROUP) p.SetString("ApiUrl", "https://silo.example.com/api/") url = silo_start._get_silo_base_url() # After rstrip("/") and strip /api self.assertFalse(url.endswith("/")) # =================================================================== # Test: silo_origin.SiloOrigin capability constants # =================================================================== class TestSiloOriginCapabilities(unittest.TestCase): """Tests for SiloOrigin constant methods (no network, no FreeCAD doc).""" def setUp(self): self.origin = silo_origin.SiloOrigin() def test_id(self): self.assertEqual(self.origin.id(), "silo") def test_name(self): self.assertEqual(self.origin.name(), "Kindred Silo") def test_nickname(self): self.assertEqual(self.origin.nickname(), "Silo") def test_type_is_plm(self): self.assertEqual(self.origin.type(), 1) def test_tracks_externally(self): self.assertTrue(self.origin.tracksExternally()) def test_requires_authentication(self): self.assertTrue(self.origin.requiresAuthentication()) def test_supports_revisions(self): self.assertTrue(self.origin.supportsRevisions()) def test_supports_bom(self): self.assertTrue(self.origin.supportsBOM()) def test_supports_part_numbers(self): self.assertTrue(self.origin.supportsPartNumbers()) def test_supports_assemblies(self): self.assertTrue(self.origin.supportsAssemblies()) def test_custom_nickname(self): origin = silo_origin.SiloOrigin(origin_id="prod", nickname="Production") self.assertEqual(origin.id(), "prod") self.assertEqual(origin.nickname(), "Production") # =================================================================== # Test: DatumCreatorTaskPanel.MODES integrity # =================================================================== class TestDatumModes(unittest.TestCase): """Verify the MODES table is internally consistent.""" def test_all_modes_have_four_fields(self): for mode in DatumCreatorTaskPanel.MODES: self.assertEqual(len(mode), 4, f"Mode tuple wrong length: {mode}") def test_mode_ids_unique(self): ids = [m[1] for m in DatumCreatorTaskPanel.MODES] self.assertEqual(len(ids), len(set(ids)), "Duplicate mode IDs found") def test_categories_valid(self): valid = {"plane", "axis", "point"} for _, mode_id, _, category in DatumCreatorTaskPanel.MODES: self.assertIn(category, valid, f"Invalid category for {mode_id}") def test_required_types_are_tuples(self): for _, mode_id, req, _ in DatumCreatorTaskPanel.MODES: self.assertIsInstance(req, tuple, f"required_types not a tuple: {mode_id}") def test_plane_modes_count(self): planes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "plane"] self.assertEqual(len(planes), 7) def test_axis_modes_count(self): axes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "axis"] self.assertEqual(len(axes), 4) def test_point_modes_count(self): points = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "point"] self.assertEqual(len(points), 5) # =================================================================== if __name__ == "__main__": unittest.main()