332 lines
9.2 KiB
Python
332 lines
9.2 KiB
Python
"""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
|
|
patterns: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TestCapture:
|
|
process_maps: bool
|
|
cwd: bool
|
|
environment: bool
|
|
dll_overrides: bool
|
|
hashes: bool
|
|
dxvk_log: bool
|
|
native_log: 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"),
|
|
patterns=_string_tuple(watch, "patterns"),
|
|
),
|
|
capture=TestCapture(
|
|
process_maps=_boolean(capture, "process_maps"),
|
|
cwd=_boolean(capture, "cwd"),
|
|
environment=_boolean(capture, "environment"),
|
|
dll_overrides=_boolean(capture, "dll_overrides"),
|
|
hashes=_boolean(capture, "hashes"),
|
|
dxvk_log=_boolean(capture, "dxvk_log"),
|
|
native_log=_boolean(capture, "native_log"),
|
|
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)
|