All checks were successful
Build and Test / build (pull_request) Successful in 29m28s
- Remove ZTools install block from src/Mod/Create/CMakeLists.txt - Remove mods/ztools submodule entry from .gitmodules - Remove 'ztools' from legacy fallback order in addon_loader.py - Remove ztools imports and test classes from test_kindred_pure.py (TestTypeMatches, TestMatchScore, TestSelectionItemProperties, TestColumnToIndex, TestDatumModes) - Remove 'ztools Workbench' from issue template component lists - Remove mods/ztools submodule from git tracking ZTools will be archived to a reference folder in a separate step (#345). This is part of the UI/UX rework epic (#346).
343 lines
10 KiB
Python
343 lines
10 KiB
Python
"""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()
|