"""Tier 1 — Pure-logic tests for Kindred Create addons. These tests exercise standalone functions from update_checker, 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" / "silo" / "freecad")) # --------------------------------------------------------------------------- # Now import the modules under test # --------------------------------------------------------------------------- import silo_origin # noqa: E402 import silo_start # noqa: E402 from silo_commands import _safe_float # noqa: E402 from update_checker import _parse_version, _should_check # 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: 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") # =================================================================== if __name__ == "__main__": unittest.main()