This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

331
ptdiag/testcase.py Normal file
View 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)