Init
This commit is contained in:
331
ptdiag/testcase.py
Normal file
331
ptdiag/testcase.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user