Files
create/tests/test_kindred_addon_loader.py
forbes-0023 7381675a6e
All checks were successful
Build and Test / build (pull_request) Successful in 24m19s
test(addon-loader): add Tier 1 pure Python tests for addon loader (#396)
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
2026-03-05 10:06:29 -06:00

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