Merge pull request 'test(addon-loader): add Tier 1 pure Python tests for addon loader (#396)' (#408) from feat/addon-loader-tests into main
Reviewed-on: #408
This commit was merged in pull request #408.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
128
docs/src/development/testing.md
Normal file
128
docs/src/development/testing.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
843
tests/test_kindred_addon_loader.py
Normal file
843
tests/test_kindred_addon_loader.py
Normal 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()
|
||||
Reference in New Issue
Block a user