"""Typed testcase loading with recursive TOML inheritance.""" from __future__ import annotations from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Mapping import tomllib from .config import project_root class TestcaseError(ValueError): """Raised when an experiment testcase is missing or invalid.""" @dataclass(frozen=True) class TestGame: args: tuple[str, ...] cwd: str @dataclass(frozen=True) class TestDisplay: virtual_desktop: bool size: str @dataclass(frozen=True) class TestWatch: require_new_native_log: bool @dataclass(frozen=True) class TestCapture: process_maps: bool dll_overrides: bool strace_files: bool performance_stats: bool focus_before_seconds: float focus_after_seconds: float @dataclass(frozen=True) class TestIniOverride: path: str section: str key: str expected: str value: str @dataclass(frozen=True) class TestModification: kind: str source: str destination: str texture_indices: tuple[int, ...] ini_overrides: tuple[TestIniOverride, ...] @dataclass(frozen=True) class Testcase: name: str description: str runner: str timeout_seconds: float stop_after_failure_seconds: float game: TestGame display: TestDisplay env: dict[str, str] watch: TestWatch capture: TestCapture modification: TestModification | None source: Path sources: tuple[Path, ...] def to_dict(self) -> dict[str, Any]: result = asdict(self) result["source"] = str(self.source) result["sources"] = [str(path) for path in self.sources] return result def resolve_testcase_path(value: str | Path) -> Path: path = Path(value) if path.suffix or path.parent != Path("."): return path.expanduser().absolute() return (project_root() / "testcases" / f"{path.name}.toml").absolute() def load_testcase(value: str | Path) -> Testcase: path = resolve_testcase_path(value) data, sources = _load_inherited(path, ()) game = _section(data, "game") display = _section(data, "display") environment = _section(data, "env") watch = _section(data, "watch") capture = _section(data, "capture") modification = _optional_section(data, "modification") return Testcase( name=_string(data, "name"), description=_string(data, "description"), runner=_string(data, "runner"), timeout_seconds=_positive_number(data, "timeout_seconds"), stop_after_failure_seconds=_nonnegative_number( data, "stop_after_failure_seconds" ), game=TestGame( args=_string_tuple(game, "args"), cwd=_string(game, "cwd"), ), display=TestDisplay( virtual_desktop=_boolean(display, "virtual_desktop"), size=_string(display, "size"), ), env=_string_mapping(environment, "env"), watch=TestWatch( require_new_native_log=_boolean(watch, "require_new_native_log"), ), capture=TestCapture( process_maps=_boolean(capture, "process_maps"), dll_overrides=_boolean(capture, "dll_overrides"), strace_files=_boolean(capture, "strace_files"), performance_stats=_optional_boolean(capture, "performance_stats", False), focus_before_seconds=_optional_nonnegative_number( capture, "focus_before_seconds", 4.0 ), focus_after_seconds=_optional_nonnegative_number( capture, "focus_after_seconds", 1.0 ), ), modification=( TestModification( kind=_string(modification, "kind"), source=_string(modification, "source"), destination=_string(modification, "destination"), texture_indices=_optional_integer_tuple( modification, "texture_indices", ), ini_overrides=_optional_ini_overrides(modification), ) if modification is not None else None ), source=path, sources=sources, ) def _load_inherited( path: Path, stack: tuple[Path, ...], ) -> tuple[dict[str, Any], tuple[Path, ...]]: resolved = path.resolve(strict=False) if resolved in stack: chain = " -> ".join(str(item) for item in (*stack, resolved)) raise TestcaseError(f"Testcase inheritance cycle: {chain}") try: with path.open("rb") as handle: data = tomllib.load(handle) except FileNotFoundError as exc: raise TestcaseError(f"Testcase not found: {path}") from exc except tomllib.TOMLDecodeError as exc: raise TestcaseError(f"Invalid TOML in {path}: {exc}") from exc if not isinstance(data, dict): raise TestcaseError(f"Testcase root must be a table: {path}") parent_name = data.pop("extends", None) if parent_name is None: return data, (resolved,) if not isinstance(parent_name, str) or not parent_name.strip(): raise TestcaseError(f"extends must be a non-empty string in {path}") parent_file = Path(parent_name) if parent_file.suffix == "": parent_file = parent_file.with_suffix(".toml") if not parent_file.is_absolute(): parent_file = path.parent / parent_file parent, sources = _load_inherited(parent_file, (*stack, resolved)) return _merge(parent, data), (*sources, resolved) def _merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: result: dict[str, Any] = dict(base) for key, value in override.items(): current = result.get(key) if isinstance(current, Mapping) and isinstance(value, Mapping): result[key] = _merge(current, value) else: result[key] = value return result def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]: value = data.get(name) if not isinstance(value, Mapping): raise TestcaseError(f"Missing or invalid [{name}] section") return value def _optional_section(data: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: value = data.get(name) if value is None: return None if not isinstance(value, Mapping): raise TestcaseError(f"Invalid [{name}] section") return value def _string(data: Mapping[str, Any], key: str) -> str: value = data.get(key) if not isinstance(value, str) or not value.strip(): raise TestcaseError(f"{key} must be a non-empty string") return value.strip() def _boolean(data: Mapping[str, Any], key: str) -> bool: value = data.get(key) if not isinstance(value, bool): raise TestcaseError(f"{key} must be true or false") return value def _optional_boolean(data: Mapping[str, Any], key: str, default: bool) -> bool: if key not in data: return default return _boolean(data, key) def _string_tuple(data: Mapping[str, Any], key: str) -> tuple[str, ...]: value = data.get(key) if not isinstance(value, list) or not value or not all( isinstance(item, str) and item for item in value ): raise TestcaseError(f"{key} must be a non-empty array of strings") return tuple(value) def _optional_integer_tuple(data: Mapping[str, Any], key: str) -> tuple[int, ...]: value = data.get(key, []) if not isinstance(value, list) or not all( isinstance(item, int) and not isinstance(item, bool) and item >= 0 for item in value ): raise TestcaseError(f"{key} must be an array of nonnegative integers") if len(value) != len(set(value)): raise TestcaseError(f"{key} must not contain duplicates") return tuple(value) def _optional_ini_overrides(data: Mapping[str, Any]) -> tuple[TestIniOverride, ...]: value = data.get("ini_overrides", []) if not isinstance(value, list): raise TestcaseError("ini_overrides must be an array of tables") result: list[TestIniOverride] = [] identities: set[tuple[str, str, str]] = set() for item in value: if not isinstance(item, Mapping): raise TestcaseError("ini_overrides must contain only tables") override = TestIniOverride( path=_string(item, "path"), section=_string(item, "section"), key=_string(item, "key"), expected=_string(item, "expected"), value=_string(item, "value"), ) identity = ( override.path.casefold(), override.section.casefold(), override.key.casefold(), ) if identity in identities: raise TestcaseError("ini_overrides must not contain duplicate keys") if override.expected == override.value: raise TestcaseError("INI expected and replacement values must differ") identities.add(identity) result.append(override) return tuple(result) def _string_mapping(data: Mapping[str, Any], name: str) -> dict[str, str]: result: dict[str, str] = {} for key, value in data.items(): if not isinstance(key, str) or not isinstance(value, str): raise TestcaseError(f"[{name}] must contain only string values") result[key] = value return result def _positive_number(data: Mapping[str, Any], key: str) -> float: value = _number(data, key) if value <= 0: raise TestcaseError(f"{key} must be positive") return value def _nonnegative_number(data: Mapping[str, Any], key: str) -> float: value = _number(data, key) if value < 0: raise TestcaseError(f"{key} must be nonnegative") return value def _optional_nonnegative_number( data: Mapping[str, Any], key: str, default: float, ) -> float: if key not in data: return default return _nonnegative_number(data, key) def _number(data: Mapping[str, Any], key: str) -> float: value = data.get(key) if isinstance(value, bool) or not isinstance(value, (int, float)): raise TestcaseError(f"{key} must be a number") return float(value)