diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a643752..d49466b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,5 +38,11 @@ jobs: - name: Format with ruff run: uv run ruff format --check + - name: Clone Blizzard UI + run: | + git clone https://github.com/Gethe/wow-ui-source.git + cd wow-ui-source + git checkout beta + - name: Test with pytest run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index c4ee203..2d99ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,8 @@ exclude = [ "dist", "site-packages", "venv", - "pyproject.toml" + "pyproject.toml", + "wow-ui-source" ] line-length = 160 indent-width = 4 diff --git a/src/pytoc/context.py b/src/pytoc/context.py new file mode 100644 index 0000000..65aa92b --- /dev/null +++ b/src/pytoc/context.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .enums import * + + +@dataclass +class TOCEvaluationContext: + GameType: TOCGameType + Environment: TOCEnvironment + TextLocale: TOCTextLocale + LoadedAddons: dict[str, bool] = field(default_factory=dict) + + @property + def Family(self) -> TOCFamily: + try: + return TOC_GAME_TYPE_TO_FAMILY[self.GameType] + except KeyError: + raise KeyError(f"Unknown GameType specified: {self.GameType}") + + def load_addon(self, addon_name: str): + self.LoadedAddons[addon_name] = True + + def unload_addon(self, addon_name: str): + if addon_name in self.LoadedAddons: + self.LoadedAddons.pop(addon_name) + + def is_addon_loaded(self, addon_name: str) -> bool: + return self.LoadedAddons.get(addon_name, False) diff --git a/src/pytoc/enums.py b/src/pytoc/enums.py index 09dda0c..52ed697 100644 --- a/src/pytoc/enums.py +++ b/src/pytoc/enums.py @@ -1,4 +1,4 @@ -from enum import StrEnum +from enum import StrEnum, Enum class TOCGameType(StrEnum): @@ -55,3 +55,11 @@ class TOCTextLocale(StrEnum): TOCGameType.Wrath: TOCFamily.Classic, TOCGameType.Mists: TOCFamily.Classic, } + + +class TOCAddonLoadError(Enum): + Success = 1 + WrongGameType = 2 + WrongEnvironment = 3 + WrongTextLocale = 4 + MissingDependency = 5 diff --git a/src/pytoc/file_entry.py b/src/pytoc/file_entry.py index 7030615..4713891 100644 --- a/src/pytoc/file_entry.py +++ b/src/pytoc/file_entry.py @@ -5,23 +5,7 @@ from typing import Optional, Any from .enums import * - -# TOC eval context - - -@dataclass(frozen=True) -class TOCEvaluationContext: - GameType: TOCGameType - Environment: TOCEnvironment - TextLocale: TOCTextLocale - - @property - def Family(self) -> TOCFamily: - try: - return TOC_GAME_TYPE_TO_FAMILY[self.GameType] - except KeyError: - raise KeyError(f"Unknown GameType specified: {self.GameType}") - +from .context import TOCEvaluationContext # TOC conditions diff --git a/src/pytoc/toc.py b/src/pytoc/toc.py index 69f9293..088f954 100644 --- a/src/pytoc/toc.py +++ b/src/pytoc/toc.py @@ -8,7 +8,7 @@ from .meta import TypedClass from .file_entry import * -DO_NOT_EXPORT_FIELDS = {"ClientType"} +DO_NOT_EXPORT_FIELDS = {"ClientType", "FilePath"} CONDITION_VARIABLE_PATTERN = re.compile(r"\[([^\]]+)\]") @@ -48,12 +48,13 @@ def StringToBoolean(string: str, defaultReturn: bool = False): @dataclass -class Dependency: +class TOCDependency: Name: str Required: bool class TOCFile(TypedClass): + FilePath: Optional[Path] = None ClientType: Optional[TOCGameType] = None # target client for client-specific TOC files. i.e. MyAddon_Standard.toc Interface: Optional[Union[int, list[int]]] = None Title: Optional[str] = None @@ -77,7 +78,7 @@ class TOCFile(TypedClass): LoadWith: Optional[list[str]] = None LoadFirst: Optional[bool] = None LoadManagers: Optional[list[str]] = None - Dependencies: Optional[list[Dependency]] = None + Dependencies: Optional[list[TOCDependency]] = None DefaultState: Optional[bool] = None OnlyBetaAndPTR: Optional[bool] = None LoadSavedVariablesFirst: Optional[bool] = None @@ -94,6 +95,7 @@ def __init__(self, file_path: Optional[Union[Path, str]] = None): if not isinstance(file_path, Path): file_path = Path(file_path) + self.FilePath = file_path self.parse_toc_file(file_path) def has_attr(self, attr: str) -> bool: @@ -107,7 +109,10 @@ def get_target_client_from_path(self, file_path: Path) -> Optional[TOCGameType]: path_split = str_path.split("_") suffix = path_split[-1].removesuffix(".toc") if suffix.lower() in TOCGameType: - return TOCGameType[suffix.title()] + if suffix.title() in TOCGameType._member_names_: + return TOCGameType[suffix.title()] + elif suffix.upper() in TOCGameType._member_names_: + return TOCGameType[suffix.upper()] return None @@ -234,9 +239,9 @@ def add_dependency(self, name: str, required: bool): if isinstance(name, list): for _name in name: - self.Dependencies.append(Dependency(_name, required)) + self.Dependencies.append(TOCDependency(_name, required)) else: - self.Dependencies.append(Dependency(name, required)) + self.Dependencies.append(TOCDependency(name, required)) def add_localized_directive(self, directive: str, value: str, locale: str): # localized directive will be accessible via the `.Localized` attribute @@ -336,3 +341,25 @@ def get_raw_files(self) -> list[str]: raw_files.append(file.export()) return raw_files + + def can_load_addon(self, context: TOCEvaluationContext) -> tuple[bool, TOCAddonLoadError]: + if self.Dependencies and len(self.Dependencies) > 0: + deps_fulfilled = True + for dep in self.Dependencies: + if dep.Required and not context.is_addon_loaded(dep.Name): + deps_fulfilled = False + break + + if not deps_fulfilled: + return False, TOCAddonLoadError.MissingDependency + + if self.AllowLoad and not self.AllowLoad.evaluate(context): + return False, TOCAddonLoadError.WrongEnvironment + elif self.AllowLoadEnvironment and not self.AllowLoadEnvironment.evaluate(context): + return False, TOCAddonLoadError.WrongEnvironment + elif self.AllowLoadGameType and not self.AllowLoadGameType.evaluate(context): + return False, TOCAddonLoadError.WrongGameType + elif self.AllowLoadTextLocale and not self.AllowLoadTextLocale.evaluate(context): + return False, TOCAddonLoadError.WrongTextLocale + + return True, TOCAddonLoadError.Success diff --git a/tests/test_toc.py b/tests/test_toc.py index 9822784..52e35f9 100644 --- a/tests/test_toc.py +++ b/tests/test_toc.py @@ -1,7 +1,9 @@ import os import pytest -from pytoc import TOCFile, Dependency +from pathlib import Path + +from pytoc import * PWD = os.path.dirname(os.path.realpath(__file__)) @@ -72,7 +74,7 @@ def test_parser(): } for dep in file.Dependencies: - dep: Dependency + dep: TOCDependency if expected_deps[dep.Name] == dep.Required: expected_deps.pop(dep.Name) @@ -148,3 +150,130 @@ def test_read_export(): assert toc.OnlyBetaAndPTR == True assert toc.DefaultState == True assert toc.get_raw_files() == ["file1.lua", "file2.xml"] + + +def test_addon_loading(): + ctx = TOCEvaluationContext(TOCGameType.Wowhack, TOCEnvironment.Global, TOCTextLocale.enUS) + assert ctx.LoadedAddons == {} + + addon_name = "Dragon" + assert not ctx.is_addon_loaded(addon_name) + + ctx.load_addon(addon_name) + assert ctx.is_addon_loaded(addon_name) + + ctx.unload_addon(addon_name) + assert not ctx.is_addon_loaded(addon_name) + + +def test_ctx_family(): + ctx = TOCEvaluationContext(TOCGameType.Wowhack, TOCEnvironment.Global, TOCTextLocale.enUS) + assert ctx.Family == TOCFamily.Mainline + + ctx.GameType = TOCGameType.Mists + assert ctx.Family == TOCFamily.Classic + + +def discover_toc_files(path: Path) -> list[Path]: + toc_files = [] + for root, _, files in path.walk(): + for file in files: + if file.endswith(".toc"): + toc_path = Path(root) / file + toc_files.append(toc_path) + + return toc_files + + +def test_blizzard_ui_conformance(): + ui_source_path = Path("wow-ui-source") / "Interface" + if not ui_source_path.exists(): + ui_source_path = Path(os.getenv("WOW_UI_SOURCE_PATH")) / "Interface" + + assert ui_source_path.exists(), "Unable to find UI source path" + + failures = [] + + # collect all the toc files + toc_files = discover_toc_files(ui_source_path) + for file in toc_files: + try: + TOCFile(file) + except Exception as e: + failures.append((file, e)) + + assert not failures, failures + + +def test_addon_load_conditions(): + ctx = TOCEvaluationContext(TOCGameType.Mainline, TOCEnvironment.Global, TOCTextLocale.enUS) + + toc = TOCFile() + + toc.AllowLoadGameType = TOCAllowLoadGameType({TOCGameType.Wowhack}) + can_load, err = toc.can_load_addon(ctx) + assert (not can_load) and (err == TOCAddonLoadError.WrongGameType), err.name + + toc.AllowLoad = TOCAllowLoad({TOCEnvironment.Both}) + toc.AllowLoadEnvironment = TOCAllowLoadEnvironment({TOCEnvironment.Global}) + toc.AllowLoadGameType = TOCAllowLoadGameType({TOCGameType.Mainline}) + toc.AllowLoadTextLocale = TOCAllowLoadTextLocale({TOCTextLocale.enUS}) + + can_load, err = toc.can_load_addon(ctx) + assert can_load, err.name + + dep_name = "Blackjack" + dep_required = True + toc.add_dependency(dep_name, dep_required) + + can_load, err = toc.can_load_addon(ctx) + assert (not can_load) and (err == TOCAddonLoadError.MissingDependency), err.name + + ctx.load_addon(dep_name) + can_load, err = toc.can_load_addon(ctx) + assert can_load, err.name + + +def test_plain_file_entry(): + ctx = TOCEvaluationContext(TOCGameType.Mainline, TOCEnvironment.Global, TOCTextLocale.enUS) + + path = "Dragon/Dragon.lua" + file = TOCFileEntry(path) + assert str(file) == path + assert file.resolve_path(ctx) == path + assert file.should_load(ctx) + assert file.export() == path + + +def test_variable_file_entry(): + ctx = TOCEvaluationContext(TOCGameType.Mainline, TOCEnvironment.Global, TOCTextLocale.enUS) + + path = "[Family]/Dragon.lua" + file = TOCFileEntry(path) + assert str(file) == path + assert file.resolve_path(ctx) == f"{TOCFamily.Mainline}/Dragon.lua" + assert file.should_load(ctx) + assert file.export() == path + + +def test_conditional_file_entry(): + ctx = TOCEvaluationContext(TOCGameType.Wowhack, TOCEnvironment.Global, TOCTextLocale.enUS) + + path = "[Family]/Dragon.lua" + conditions = [TOCAllowLoadGameType({TOCGameType.Plunderstorm}), TOCAllowLoadEnvironment({TOCEnvironment.Both})] + file = TOCFileEntry(path, conditions) + assert str(file) == path + assert file.resolve_path(ctx) == f"{TOCFamily.Mainline}/Dragon.lua" + assert not file.should_load(ctx) + assert file.export() == "[Family]/Dragon.lua [AllowLoadGameType plunderstorm] [AllowLoadEnvironment Both]" + + +def test_textlocale_file_entry(): + ctx = TOCEvaluationContext(TOCGameType.Mists, TOCEnvironment.Global, TOCTextLocale.enUS) + + path = "[TextLocale]/Dragon.lua" + file = TOCFileEntry(path) + assert str(file) == path + assert file.resolve_path(ctx) == f"{TOCTextLocale.enUS}/Dragon.lua" + assert file.should_load(ctx) + assert file.export() == path