diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 6e7870a9be..22c0cab703 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -28,6 +28,7 @@ - [Gui Module Build](./development/gui-build-integration.md) - [Package.xml Schema Extensions](./development/package-xml-schema.md) - [Writing an Addon](./development/writing-an-addon.md) +- [Testing](./development/testing.md) # Silo Server diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md new file mode 100644 index 0000000000..016e15c725 --- /dev/null +++ b/docs/src/development/testing.md @@ -0,0 +1,128 @@ +# Testing + +Kindred Create has a multi-tier testing system that separates fast pure-logic tests from tests requiring the FreeCAD runtime. + +## Quick reference + +```bash +pixi run test-kindred # Python Tier 1 tests (no FreeCAD binary) +pixi run test # C++ tests via ctest (requires build) +``` + +Or run directly without pixi: + +```bash +python3 tests/run_kindred_tests.py # Tier 1 only +python3 tests/run_kindred_tests.py --all # Tier 1 + Tier 2 (needs FreeCADCmd) +``` + +## Tier 1: Pure Python tests + +These tests exercise standalone Python functions **without** a FreeCAD binary. They run with the system Python interpreter and complete in under a second. + +**Framework:** `unittest` (standard library) + +**Test files:** + +| File | Module under test | Tests | +|------|-------------------|-------| +| `tests/test_kindred_pure.py` | `update_checker`, `silo_commands`, `silo_start`, `silo_origin` | ~39 | +| `tests/test_kindred_addon_loader.py` | `addon_loader` | ~71 | + +**Discovery:** The test runner uses `unittest.TestLoader().discover()` with the pattern `test_kindred_*.py`. New test files matching this pattern are automatically discovered. + +### How mocking works + +Modules under test import `FreeCAD` at the top level, which is unavailable outside the FreeCAD runtime. Each test file mocks the FreeCAD ecosystem **before** importing any target modules: + +```python +from unittest import mock + +_fc = mock.MagicMock() +_fc.Console = mock.MagicMock() + +# Insert mocks before any import touches these modules +for mod_name in ("FreeCAD", "FreeCADGui"): + sys.modules.setdefault(mod_name, mock.MagicMock()) +sys.modules["FreeCAD"] = _fc + +# Now safe to import +sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create")) +from addon_loader import parse_manifest, validate_manifest # etc. +``` + +For modules that need working FreeCAD subsystems (like the parameter store), functional stubs replace `MagicMock`. See `_MockParamGroup` in `test_kindred_pure.py` for an example. + +### Addon loader tests + +`test_kindred_addon_loader.py` covers the entire addon loading pipeline: + +| Test class | What it covers | +|------------|----------------| +| `TestAddonState` | Enum members and values | +| `TestValidVersion` | `_valid_version()` dotted-numeric regex | +| `TestParseVersionLoader` | `_parse_version()` string-to-tuple conversion | +| `TestAddonRegistry` | Registry CRUD, filtering by state, load order, context registration | +| `TestParseManifest` | XML parsing, field extraction, `` extensions, validation errors (bad priority, bad version format, invalid context IDs, malformed XML) | +| `TestValidateDependencies` | Cross-addon dependency name checking | +| `TestValidateManifest` | Version bounds, workbench path, Init.py presence, error accumulation | +| `TestScanAddons` | Directory discovery at depth 1 and 2 | +| `TestResolveLoadOrder` | Topological sort, priority ties, cycle detection, legacy fallback | + +Tests that need files on disk use `tempfile.TemporaryDirectory` with a `_write_package_xml()` helper that generates manifest XML programmatically. Tests that only need populated `AddonManifest` objects use a `_make_manifest()` shortcut. + +### Writing a new Tier 1 test file + +1. Create `tests/test_kindred_.py` +2. Mock FreeCAD modules before imports (copy the pattern from an existing file) +3. Add the source directory to `sys.path` +4. Import and test pure-logic functions +5. The runner discovers the file automatically via `test_kindred_*.py` + +Run your new file directly during development: + +```bash +python3 tests/test_kindred_.py -v +``` + +## Tier 2: FreeCAD headless tests + +These tests run inside `FreeCADCmd` (the headless FreeCAD binary) and can exercise code that depends on the full application context — document creation, GUI commands, origin resolution, etc. + +**Runner:** `tests/run_kindred_tests.py --all` + +**Status:** Infrastructure is in place but no Tier 2 test modules have been implemented yet. The runner searches for `FreeCADCmd` in `PATH` and the build directories (`build/debug/bin/`, `build/release/bin/`). If not found, Tier 2 is skipped without failure. + +## C++ tests + +C++ unit tests use [GoogleTest](https://github.com/google/googletest) (submodule at `tests/lib/`). + +**Runner:** `ctest` via pixi + +```bash +pixi run test # debug build +pixi run test-release # release build +``` + +Test source files live in `tests/src/` mirroring the main source layout: + +``` +tests/src/ +├── App/ # FreeCADApp tests +├── Base/ # Base library tests (Quantity, Matrix, Axis, etc.) +├── Gui/ # GUI tests (OriginManager, InputHint, etc.) +├── Mod/ # Module tests (Part, Sketcher, Assembly, etc.) +└── Misc/ # Miscellaneous tests +``` + +Each directory builds a separate test executable (e.g., `Base_tests_run`, `Gui_tests_run`) linked against GoogleTest and the relevant FreeCAD libraries. + +## What to test where + +| Scenario | Tier | Example | +|----------|------|---------| +| Pure function with no FreeCAD deps | Tier 1 | Version parsing, XML parsing, topological sort | +| Logic that only needs mock stubs | Tier 1 | Parameter reading, URL construction | +| Code that creates/modifies documents | Tier 2 | Origin ownership detection, document observer | +| Code that needs the GUI event loop | Tier 2 | Context resolution, toolbar visibility | +| C++ classes and algorithms | C++ | OriginManager, Base::Quantity, Sketcher solver | diff --git a/tests/run_kindred_tests.py b/tests/run_kindred_tests.py index 130c3d84e3..f4fdc190aa 100644 --- a/tests/run_kindred_tests.py +++ b/tests/run_kindred_tests.py @@ -29,7 +29,7 @@ def run_pure_tests() -> bool: loader = unittest.TestLoader() suite = loader.discover( start_dir=str(REPO_ROOT / "tests"), - pattern="test_kindred_pure.py", + pattern="test_kindred_*.py", top_level_dir=str(REPO_ROOT / "tests"), ) runner = unittest.TextTestRunner(verbosity=2) diff --git a/tests/test_kindred_addon_loader.py b/tests/test_kindred_addon_loader.py new file mode 100644 index 0000000000..d201a5993c --- /dev/null +++ b/tests/test_kindred_addon_loader.py @@ -0,0 +1,843 @@ +"""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()