All checks were successful
Build and Test / build (pull_request) Successful in 24m19s
Add 71 tests covering the entire addon loader pipeline in a new test_kindred_addon_loader.py file: - TestAddonState (2): enum members and values - TestValidVersion (11): _valid_version() regex matching - TestParseVersionLoader (7): _parse_version() string-to-tuple - TestAddonRegistry (10): register/get/filter/order/contexts - TestParseManifest (13): XML parsing, field extraction, validation errors for bad priority, bad version format, invalid context IDs - TestValidateDependencies (4): cross-addon dependency checking - TestValidateManifest (9): version bounds, paths, error accumulation - TestScanAddons (6): directory walking at depth 1 and 2 - TestResolveLoadOrder (9): topological sort, priority ties, cycle detection, legacy fallback Update test runner discovery pattern from 'test_kindred_pure.py' to 'test_kindred_*.py' so both test files are auto-discovered. All 110 tests pass (71 new + 39 existing). Refs #396
844 lines
28 KiB
Python
844 lines
28 KiB
Python
"""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()
|