"""Tier 1 — Pure-logic tests for Kindred Create addon loader. These tests exercise the manifest-driven addon loader pipeline (addon_loader.py) WITHOUT requiring a FreeCAD binary. Run directly: python3 tests/test_kindred_addon_loader.py Via runner: python3 tests/run_kindred_tests.py Via pixi: pixi run test-kindred """ import os import shutil import sys import tempfile import unittest from pathlib import Path from unittest import mock # --------------------------------------------------------------------------- # Mock the FreeCAD ecosystem BEFORE importing addon_loader. # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parent.parent _fc = mock.MagicMock() _fc.Console = mock.MagicMock() for mod_name in ("FreeCAD", "FreeCADGui"): sys.modules.setdefault(mod_name, mock.MagicMock()) sys.modules["FreeCAD"] = _fc # Add the Create module to sys.path sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create")) # --------------------------------------------------------------------------- # Import module under test # --------------------------------------------------------------------------- from addon_loader import ( # noqa: E402 AddonManifest, AddonRegistry, AddonState, _parse_version, _valid_version, parse_manifest, resolve_load_order, scan_addons, validate_dependencies, validate_manifest, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _write_package_xml( parent_dir, name="testaddon", version="1.0.0", subdirectory=None, kindred=None, create_init=False, create_initgui=False, raw_xml=None, ): """Write a package.xml and optionally Init.py/InitGui.py. Returns the parent_dir (addon root). """ pkg_path = os.path.join(parent_dir, "package.xml") if raw_xml is not None: with open(pkg_path, "w") as f: f.write(raw_xml) return parent_dir lines = [ '', '', f" {name}", f" {version}", " Test addon", ] if subdirectory is not None: lines += [ " ", " ", f" {subdirectory}", " ", " ", ] if kindred is not None: lines.append(" ") for key in ("min_create_version", "max_create_version", "load_priority"): if key in kindred: lines.append(f" <{key}>{kindred[key]}") if "dependencies" in kindred: lines.append(" ") for dep in kindred["dependencies"]: lines.append(f" {dep}") lines.append(" ") if "contexts" in kindred: lines.append(" ") for ctx in kindred["contexts"]: lines.append(f" {ctx}") lines.append(" ") lines.append(" ") lines.append("") with open(pkg_path, "w") as f: f.write("\n".join(lines)) # Create Init files in the workbench directory wb_dir = parent_dir if subdirectory and subdirectory not in (".", "./"): wb_dir = os.path.join(parent_dir, subdirectory) os.makedirs(wb_dir, exist_ok=True) if create_init: with open(os.path.join(wb_dir, "Init.py"), "w") as f: f.write("# test\n") if create_initgui: with open(os.path.join(wb_dir, "InitGui.py"), "w") as f: f.write("# test\n") return parent_dir def _make_manifest(name="test", version="1.0.0", state=AddonState.DISCOVERED, **kwargs): """Create an AddonManifest with sensible defaults for testing.""" return AddonManifest(name=name, version=version, state=state, **kwargs) # =================================================================== # Test: AddonState enum # =================================================================== class TestAddonState(unittest.TestCase): """Sanity checks for AddonState enum.""" def test_all_states_exist(self): expected = {"DISCOVERED", "VALIDATED", "LOADED", "SKIPPED", "FAILED"} actual = {s.name for s in AddonState} self.assertEqual(actual, expected) def test_values(self): self.assertEqual(AddonState.DISCOVERED.value, "discovered") self.assertEqual(AddonState.VALIDATED.value, "validated") self.assertEqual(AddonState.LOADED.value, "loaded") self.assertEqual(AddonState.SKIPPED.value, "skipped") self.assertEqual(AddonState.FAILED.value, "failed") # =================================================================== # Test: _valid_version # =================================================================== class TestValidVersion(unittest.TestCase): """Tests for _valid_version() regex matching.""" def test_simple_semver(self): self.assertTrue(_valid_version("1.2.3")) def test_single_digit(self): self.assertTrue(_valid_version("1")) def test_two_part(self): self.assertTrue(_valid_version("0.1")) def test_four_part(self): self.assertTrue(_valid_version("1.2.3.4")) def test_zeros(self): self.assertTrue(_valid_version("0.0.0")) def test_empty_string(self): self.assertFalse(_valid_version("")) def test_v_prefix_rejected(self): self.assertFalse(_valid_version("v1.0.0")) def test_alpha_suffix_rejected(self): self.assertFalse(_valid_version("1.0.0-beta")) def test_leading_dot_rejected(self): self.assertFalse(_valid_version(".1.0")) def test_trailing_dot_rejected(self): self.assertFalse(_valid_version("1.0.")) def test_letters_rejected(self): self.assertFalse(_valid_version("abc")) # =================================================================== # Test: _parse_version (addon_loader version, not update_checker) # =================================================================== class TestParseVersionLoader(unittest.TestCase): """Tests for addon_loader._parse_version().""" def test_standard_semver(self): self.assertEqual(_parse_version("1.2.3"), (1, 2, 3)) def test_two_part(self): self.assertEqual(_parse_version("0.1"), (0, 1)) def test_single(self): self.assertEqual(_parse_version("5"), (5,)) def test_comparison(self): self.assertLess(_parse_version("0.1.0"), _parse_version("0.2.0")) self.assertLess(_parse_version("0.2.0"), _parse_version("1.0.0")) def test_invalid_returns_fallback(self): self.assertEqual(_parse_version("abc"), (0, 0, 0)) def test_none_returns_fallback(self): self.assertEqual(_parse_version(None), (0, 0, 0)) def test_empty_returns_fallback(self): self.assertEqual(_parse_version(""), (0, 0, 0)) # =================================================================== # Test: AddonRegistry # =================================================================== class TestAddonRegistry(unittest.TestCase): """Tests for AddonRegistry class.""" def setUp(self): self.reg = AddonRegistry() def test_register_and_get(self): m = _make_manifest(name="sdk") self.reg.register(m) self.assertIs(self.reg.get("sdk"), m) def test_get_unknown_returns_none(self): self.assertIsNone(self.reg.get("nonexistent")) def test_all_returns_all(self): m1 = _make_manifest(name="a") m2 = _make_manifest(name="b") self.reg.register(m1) self.reg.register(m2) self.assertEqual(len(self.reg.all()), 2) def test_loaded_filters_by_state(self): m1 = _make_manifest(name="a", state=AddonState.LOADED) m2 = _make_manifest(name="b", state=AddonState.SKIPPED) self.reg.register(m1) self.reg.register(m2) self.reg.set_load_order(["a", "b"]) loaded = self.reg.loaded() self.assertEqual(len(loaded), 1) self.assertEqual(loaded[0].name, "a") def test_failed_filters_by_state(self): m1 = _make_manifest(name="a", state=AddonState.FAILED) m2 = _make_manifest(name="b", state=AddonState.LOADED) self.reg.register(m1) self.reg.register(m2) self.assertEqual(len(self.reg.failed()), 1) self.assertEqual(self.reg.failed()[0].name, "a") def test_skipped_filters_by_state(self): m = _make_manifest(name="a", state=AddonState.SKIPPED) self.reg.register(m) self.assertEqual(len(self.reg.skipped()), 1) def test_is_loaded(self): m = _make_manifest(name="sdk", state=AddonState.LOADED) self.reg.register(m) self.assertTrue(self.reg.is_loaded("sdk")) self.assertFalse(self.reg.is_loaded("other")) def test_load_order_preserved(self): m1 = _make_manifest(name="b", state=AddonState.LOADED) m2 = _make_manifest(name="a", state=AddonState.LOADED) self.reg.register(m1) self.reg.register(m2) self.reg.set_load_order(["a", "b"]) names = [m.name for m in self.reg.loaded()] self.assertEqual(names, ["a", "b"]) def test_register_context(self): m = _make_manifest(name="sdk") self.reg.register(m) self.reg.register_context("sdk", "partdesign.body") self.assertIn("partdesign.body", self.reg.get("sdk").contexts) def test_contexts_mapping(self): m = _make_manifest(name="sdk") self.reg.register(m) self.reg.register_context("sdk", "partdesign.body") self.reg.register_context("sdk", "sketcher.edit") ctxs = self.reg.contexts() self.assertIn("partdesign.body", ctxs) self.assertEqual(ctxs["partdesign.body"], ["sdk"]) # =================================================================== # Test: parse_manifest # =================================================================== class TestParseManifest(unittest.TestCase): """Tests for parse_manifest() XML parsing and validation.""" def setUp(self): self.tmpdir = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.tmpdir, ignore_errors=True) def _addon_dir(self, name="myaddon"): d = os.path.join(self.tmpdir, name) os.makedirs(d, exist_ok=True) return d def test_minimal_manifest(self): addon = self._addon_dir() _write_package_xml(addon, name="myaddon", version="1.0.0") m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.name, "myaddon") self.assertEqual(m.version, "1.0.0") self.assertEqual(m.state, AddonState.DISCOVERED) self.assertEqual(m.errors, []) def test_name_fallback_to_dirname(self): addon = self._addon_dir("mymod") _write_package_xml( addon, raw_xml=( '\n' '\n' " 1.0.0\n" "" ), ) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.name, "mymod") def test_version_defaults_to_000(self): addon = self._addon_dir() _write_package_xml( addon, raw_xml=( '\n' '\n' " test\n" "" ), ) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.version, "0.0.0") def test_workbench_subdirectory(self): addon = self._addon_dir() os.makedirs(os.path.join(addon, "freecad"), exist_ok=True) _write_package_xml(addon, subdirectory="freecad") m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.workbench_path, os.path.join(addon, "freecad")) def test_workbench_dot_slash(self): addon = self._addon_dir() _write_package_xml(addon, subdirectory="./") m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.workbench_path, addon) def test_kindred_versions(self): addon = self._addon_dir() _write_package_xml( addon, kindred={ "min_create_version": "0.1.0", "max_create_version": "1.0.0", }, ) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.min_create_version, "0.1.0") self.assertEqual(m.max_create_version, "1.0.0") self.assertTrue(m.has_kindred_element) self.assertEqual(m.errors, []) def test_kindred_load_priority(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"load_priority": "42"}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.load_priority, 42) def test_invalid_priority_records_error(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"load_priority": "abc"}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.load_priority, 100) # unchanged default self.assertEqual(len(m.errors), 1) self.assertIn("load_priority", m.errors[0]) def test_kindred_dependencies(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"dependencies": ["sdk", "solver"]}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.dependencies, ["sdk", "solver"]) def test_valid_context_ids(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"contexts": ["partdesign.body", "sketcher.edit"]}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.contexts, ["partdesign.body", "sketcher.edit"]) self.assertEqual(m.errors, []) def test_invalid_context_id_rejected(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"contexts": ["has spaces!", "good.id"]}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.contexts, ["good.id"]) self.assertEqual(len(m.errors), 1) self.assertIn("has spaces!", m.errors[0]) def test_malformed_xml_fails(self): addon = self._addon_dir() _write_package_xml(addon, raw_xml="<<>>") m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(m.state, AddonState.FAILED) self.assertTrue(len(m.errors) > 0) def test_invalid_version_format_records_error(self): addon = self._addon_dir() _write_package_xml(addon, kindred={"min_create_version": "v1.beta"}) m = AddonManifest( name="", version="", package_xml_path=os.path.join(addon, "package.xml"), addon_root=addon, ) parse_manifest(m) self.assertEqual(len(m.errors), 1) self.assertIn("min_create_version", m.errors[0]) # =================================================================== # Test: validate_dependencies # =================================================================== class TestValidateDependencies(unittest.TestCase): """Tests for validate_dependencies() cross-addon check.""" def test_known_dependency_no_error(self): sdk = _make_manifest(name="sdk") silo = _make_manifest(name="silo", dependencies=["sdk"]) validate_dependencies([sdk, silo]) self.assertEqual(silo.errors, []) def test_unknown_dependency_adds_error(self): m = _make_manifest(name="mymod", dependencies=["nonexistent"]) validate_dependencies([m]) self.assertEqual(len(m.errors), 1) self.assertIn("nonexistent", m.errors[0]) def test_failed_manifest_skipped(self): m = _make_manifest( name="broken", state=AddonState.FAILED, dependencies=["nonexistent"], ) validate_dependencies([m]) self.assertEqual(m.errors, []) def test_multiple_deps_mixed(self): sdk = _make_manifest(name="sdk") m = _make_manifest(name="mymod", dependencies=["sdk", "missing"]) validate_dependencies([sdk, m]) self.assertEqual(len(m.errors), 1) self.assertIn("missing", m.errors[0]) # =================================================================== # Test: validate_manifest # =================================================================== class TestValidateManifest(unittest.TestCase): """Tests for validate_manifest() version and path checks.""" def setUp(self): self.tmpdir = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.tmpdir, ignore_errors=True) def _wb_dir(self, name="wb"): d = os.path.join(self.tmpdir, name) os.makedirs(d, exist_ok=True) return d def test_valid_with_init_py(self): wb = self._wb_dir() with open(os.path.join(wb, "Init.py"), "w") as f: f.write("# test\n") m = _make_manifest(name="test", workbench_path=wb) result = validate_manifest(m, "0.1.5") self.assertTrue(result) self.assertEqual(m.state, AddonState.VALIDATED) self.assertEqual(m.errors, []) def test_valid_with_initgui_only(self): wb = self._wb_dir() with open(os.path.join(wb, "InitGui.py"), "w") as f: f.write("# test\n") m = _make_manifest(name="test", workbench_path=wb) result = validate_manifest(m, "0.1.5") self.assertTrue(result) def test_missing_workbench_dir(self): m = _make_manifest(name="test", workbench_path="/nonexistent/path") result = validate_manifest(m, "0.1.5") self.assertFalse(result) self.assertEqual(m.state, AddonState.SKIPPED) self.assertTrue(any("not found" in e for e in m.errors)) def test_no_init_files(self): wb = self._wb_dir() m = _make_manifest(name="test", workbench_path=wb) result = validate_manifest(m, "0.1.5") self.assertFalse(result) self.assertTrue(any("Init.py" in e for e in m.errors)) def test_min_version_too_new(self): wb = self._wb_dir() with open(os.path.join(wb, "Init.py"), "w") as f: f.write("# test\n") m = _make_manifest( name="test", workbench_path=wb, min_create_version="99.0.0", ) result = validate_manifest(m, "0.1.5") self.assertFalse(result) self.assertTrue(any(">=" in e for e in m.errors)) def test_max_version_too_old(self): wb = self._wb_dir() with open(os.path.join(wb, "Init.py"), "w") as f: f.write("# test\n") m = _make_manifest( name="test", workbench_path=wb, max_create_version="0.0.1", ) result = validate_manifest(m, "0.1.5") self.assertFalse(result) self.assertTrue(any("<=" in e for e in m.errors)) def test_version_in_range(self): wb = self._wb_dir() with open(os.path.join(wb, "Init.py"), "w") as f: f.write("# test\n") m = _make_manifest( name="test", workbench_path=wb, min_create_version="0.1.0", max_create_version="1.0.0", ) result = validate_manifest(m, "0.1.5") self.assertTrue(result) self.assertEqual(m.state, AddonState.VALIDATED) def test_already_failed_returns_false(self): m = _make_manifest(name="test", state=AddonState.FAILED) result = validate_manifest(m, "0.1.5") self.assertFalse(result) def test_multiple_errors_accumulated(self): m = _make_manifest( name="test", workbench_path="/nonexistent/path", min_create_version="99.0.0", ) result = validate_manifest(m, "0.1.5") self.assertFalse(result) self.assertGreaterEqual(len(m.errors), 2) # =================================================================== # Test: scan_addons # =================================================================== class TestScanAddons(unittest.TestCase): """Tests for scan_addons() directory discovery.""" def setUp(self): self.tmpdir = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.tmpdir, ignore_errors=True) def test_depth_1_discovery(self): addon = os.path.join(self.tmpdir, "sdk") os.makedirs(addon) _write_package_xml(addon, name="sdk") manifests = scan_addons(self.tmpdir) self.assertEqual(len(manifests), 1) self.assertEqual(manifests[0].addon_root, addon) def test_depth_2_discovery(self): outer = os.path.join(self.tmpdir, "gears") inner = os.path.join(outer, "freecad") os.makedirs(inner) _write_package_xml(inner, name="gears") manifests = scan_addons(self.tmpdir) self.assertEqual(len(manifests), 1) self.assertEqual(manifests[0].addon_root, inner) def test_nonexistent_mods_dir(self): manifests = scan_addons("/nonexistent/mods/dir") self.assertEqual(manifests, []) def test_files_ignored(self): # Regular file at top level should be ignored with open(os.path.join(self.tmpdir, "README.md"), "w") as f: f.write("# test\n") manifests = scan_addons(self.tmpdir) self.assertEqual(manifests, []) def test_no_package_xml(self): addon = os.path.join(self.tmpdir, "empty_addon") os.makedirs(addon) manifests = scan_addons(self.tmpdir) self.assertEqual(manifests, []) def test_depth_1_takes_priority(self): """If package.xml exists at depth 1, depth 2 is not scanned.""" addon = os.path.join(self.tmpdir, "mymod") os.makedirs(addon) _write_package_xml(addon, name="mymod") # Also create a depth-2 package.xml inner = os.path.join(addon, "subdir") os.makedirs(inner) _write_package_xml(inner, name="mymod-inner") manifests = scan_addons(self.tmpdir) # Only depth-1 should be found (continue after depth-1 match) self.assertEqual(len(manifests), 1) self.assertEqual(manifests[0].addon_root, addon) # =================================================================== # Test: resolve_load_order # =================================================================== class TestResolveLoadOrder(unittest.TestCase): """Tests for resolve_load_order() topological sort.""" def test_empty_list(self): result = resolve_load_order([], "/fake/mods") self.assertEqual(result, []) def test_single_addon(self): m = _make_manifest(name="sdk", has_kindred_element=True, state=AddonState.VALIDATED) result = resolve_load_order([m], "/fake/mods") self.assertEqual(len(result), 1) self.assertEqual(result[0].name, "sdk") def test_priority_ordering(self): a = _make_manifest( name="a", load_priority=40, has_kindred_element=True, state=AddonState.VALIDATED ) b = _make_manifest( name="b", load_priority=0, has_kindred_element=True, state=AddonState.VALIDATED ) c = _make_manifest( name="c", load_priority=60, has_kindred_element=True, state=AddonState.VALIDATED ) result = resolve_load_order([a, b, c], "/fake/mods") names = [m.name for m in result] self.assertEqual(names, ["b", "a", "c"]) def test_dependency_before_dependent(self): sdk = _make_manifest( name="sdk", load_priority=100, has_kindred_element=True, state=AddonState.VALIDATED ) silo = _make_manifest( name="silo", load_priority=0, has_kindred_element=True, dependencies=["sdk"], state=AddonState.VALIDATED, ) result = resolve_load_order([silo, sdk], "/fake/mods") names = [m.name for m in result] self.assertEqual(names, ["sdk", "silo"]) def test_alphabetical_tiebreak(self): a = _make_manifest( name="alpha", load_priority=50, has_kindred_element=True, state=AddonState.VALIDATED ) b = _make_manifest( name="beta", load_priority=50, has_kindred_element=True, state=AddonState.VALIDATED ) result = resolve_load_order([b, a], "/fake/mods") names = [m.name for m in result] self.assertEqual(names, ["alpha", "beta"]) def test_skipped_excluded(self): a = _make_manifest(name="a", has_kindred_element=True, state=AddonState.VALIDATED) b = _make_manifest(name="b", has_kindred_element=True, state=AddonState.SKIPPED) result = resolve_load_order([a, b], "/fake/mods") names = [m.name for m in result] self.assertEqual(names, ["a"]) def test_failed_excluded(self): a = _make_manifest(name="a", has_kindred_element=True, state=AddonState.VALIDATED) b = _make_manifest(name="b", has_kindred_element=True, state=AddonState.FAILED) result = resolve_load_order([a, b], "/fake/mods") names = [m.name for m in result] self.assertEqual(names, ["a"]) def test_legacy_fallback_no_kindred(self): """When no manifest has , use legacy order.""" with tempfile.TemporaryDirectory() as mods_dir: silo_root = os.path.join(mods_dir, "silo") alpha_root = os.path.join(mods_dir, "alpha") os.makedirs(silo_root) os.makedirs(alpha_root) silo = _make_manifest(name="silo", addon_root=silo_root, state=AddonState.VALIDATED) alpha = _make_manifest(name="alpha", addon_root=alpha_root, state=AddonState.VALIDATED) result = resolve_load_order([alpha, silo], mods_dir) names = [m.name for m in result] # "silo" is in _LEGACY_ORDER so comes first self.assertEqual(names, ["silo", "alpha"]) def test_cycle_falls_back_to_priority(self): a = _make_manifest( name="a", load_priority=20, has_kindred_element=True, dependencies=["b"], state=AddonState.VALIDATED, ) b = _make_manifest( name="b", load_priority=10, has_kindred_element=True, dependencies=["a"], state=AddonState.VALIDATED, ) result = resolve_load_order([a, b], "/fake/mods") names = [m.name for m in result] # Cycle detected -> falls back to priority sort self.assertEqual(names, ["b", "a"]) # =================================================================== if __name__ == "__main__": unittest.main()