Merge pull request 'test(addon-loader): add Tier 1 pure Python tests for addon loader (#396)' (#408) from feat/addon-loader-tests into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 49s
Build and Test / build (push) Has been cancelled

Reviewed-on: #408
This commit was merged in pull request #408.
This commit is contained in:
2026-03-05 16:12:06 +00:00
4 changed files with 973 additions and 1 deletions

View File

@@ -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

View File

@@ -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, `<kindred>` 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_<module>.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_<module>.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 |

View File

@@ -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)

View File

@@ -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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<package format="1">',
f" <name>{name}</name>",
f" <version>{version}</version>",
" <description>Test addon</description>",
]
if subdirectory is not None:
lines += [
" <content>",
" <workbench>",
f" <subdirectory>{subdirectory}</subdirectory>",
" </workbench>",
" </content>",
]
if kindred is not None:
lines.append(" <kindred>")
for key in ("min_create_version", "max_create_version", "load_priority"):
if key in kindred:
lines.append(f" <{key}>{kindred[key]}</{key}>")
if "dependencies" in kindred:
lines.append(" <dependencies>")
for dep in kindred["dependencies"]:
lines.append(f" <dependency>{dep}</dependency>")
lines.append(" </dependencies>")
if "contexts" in kindred:
lines.append(" <contexts>")
for ctx in kindred["contexts"]:
lines.append(f" <context>{ctx}</context>")
lines.append(" </contexts>")
lines.append(" </kindred>")
lines.append("</package>")
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=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package format="1">\n'
" <version>1.0.0</version>\n"
"</package>"
),
)
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=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package format="1">\n'
" <name>test</name>\n"
"</package>"
),
)
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="<<<not 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 <kindred>, 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()