Aetheria/tests/mutation_tests_stats_power_cut8.py
Meta Vens 395494f6a7 Cut 7: refuse a stat naming a role its design lacks
StatValidation.ValidateRoleUsage walks every PerformanceStat field on every
behaviour and refuses a StatSource.Quality term whose Role is not in the
design's own declared Roles -- wired into AetheriaStores.Open and
CultRecordRefs.Upsert alongside the existing heat/modifier/power-supply
checks. Lot.QualityForRole already falls back to workmanship for a role an
old lot never rolled (forward-compat for a role added after minting); this
is the other direction: a term naming a role the design never declared at
all is an authoring mistake, not a lot predating the role, and must fail
loudly.

RoleAuthoringTests.cs pins three rules with both a positive and negative
control: Upsert refuses an absent-role term but accepts a declared one, and
two products of one design with different ProductRole means mint lots that
resolve a role-termed stat to different, deterministic values.
tests/mutation_tests_stats_power_cut8.py (this repo's own numbering follows
cut7.py's brownout work) mutates ValidateRoleUsage in both directions
(never-refuse, always-refuse) and QualityForRole's fallback, plus
ItemManager.CreateLot's mean read -- all six behave as declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:01:17 +02:00

182 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Mutation tests for docs/stats-and-power-cut.md's Cut 7 ("Roles authored") -- this repository's own Cut 8,
following Cut 7's brownout work (mutation_tests_stats_power_cut7.py). This is the content cut: designs declare
roles, stats name the role they read, and products author per-role quality so two products of one design differ
by being good at different parts. Three rules are pinned here:
1. ValidateRoleUsage (ItemData.cs) must refuse a StatSource.Quality term naming a role its design does not
declare -- and must not refuse one naming a role the design DOES declare (two mutations, opposite
directions, so neither "always refuse" nor "never refuse" can pass both).
2. Lot.QualityForRole (Provenance.cs) must resolve the matching RoleFill's own quality, not fall back to the
lot's workmanship quality regardless of role.
3. ItemManager.CreateLot (ItemManager.cs) must roll each role's quality from that product's own ProductRole
mean/deviation, not a fixed default -- so two products of one design with different means mint lots that
resolve a role-termed stat to different values.
A no-op control mutation proves the read/replace/restore path is transparent.
Usage:
python tests/mutation_tests_stats_power_cut8.py --cultlib-root <path-to-CultLib-45c2f40-worktree>
Only touches files under this repository; every mutation is reversed before the script exits, including on
failure.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
@dataclass
class Mutation:
rule: str
file: str
anchor: str
mutated: str
test: str
expect: str # "red" (mutant must be killed) or "green" (the no-op control)
MUTATIONS: list[Mutation] = [
# --- No-op control: proves the anchor/replace/restore mechanism is transparent ---
Mutation(
rule="control (no-op round trip)",
file="Assets/Scripts/ServerShared/ItemData.cs",
anchor=" if (!declared.Contains(term.Role))",
mutated=" if (!declared.Contains(term.Role))",
test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks",
expect="green",
),
# --- ValidateRoleUsage must actually refuse an undeclared role, not merely appear to. Weakening the check to
# --- "never refuse" is the failure the ruling names directly: "validation already refuses a stat naming a
# --- role its design lacks." ---
Mutation(
rule="ValidateRoleUsage must refuse a StatSource.Quality term naming a role its design does not declare",
file="Assets/Scripts/ServerShared/ItemData.cs",
anchor=" if (!declared.Contains(term.Role))",
mutated=" if (false)",
test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks",
expect="red",
),
# --- The opposite direction: a check that refuses every role-bearing stat, declared or not, would also pass
# --- the mutation above. This mutant refuses everything, and must kill the positive control instead. ---
Mutation(
rule="ValidateRoleUsage must not refuse a StatSource.Quality term naming a role the design DOES declare",
file="Assets/Scripts/ServerShared/ItemData.cs",
anchor=" if (!declared.Contains(term.Role))",
mutated=" if (true)",
test="RoleAuthoringTests.UpsertAcceptsAStatNamingADeclaredRole",
expect="red",
),
# --- Lot.QualityForRole must resolve the named role's own RoleFill, not fall back to the lot's workmanship
# --- regardless of which role (or none) a term names. Commenting out the match makes every role read the
# --- same number StatsReadTheLot's "noRole" case reads, collapsing the very distinction Cut 7 exists to buy. ---
Mutation(
rule="QualityForRole must resolve the matching RoleFill, not fall back to workmanship for every role",
file="Assets/Scripts/ServerShared/Provenance.cs",
anchor=" foreach (var fill in Roles)\n if (fill.Role == role) return fill.Quality;",
mutated=" foreach (var fill in Roles)\n if (false) return fill.Quality;",
test="LoadoutTests.StatsReadTheLot",
expect="red",
),
# --- ItemManager.CreateLot must roll each role from that product's own declared mean, not a constant. This
# --- is the mechanism "products author quality, designs author roles" depends on: without it, authoring a
# --- ProductRole spread on a design's role would do nothing at mint time. ---
Mutation(
rule="CreateLot must roll a role's quality from the product's own ProductRole.Mean, not a fixed default",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)",
mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)",
test="LoadoutTests.CreateLotFillsRolesFromProductSpread",
expect="red",
),
# --- The same rule, proved the way the operator's market-segmentation ruling states it: two products of one
# --- design with different role means must mint lots that resolve a role-termed stat to different values. ---
Mutation(
rule="Two products of one design with different role means must resolve a role-termed stat differently",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)",
mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)",
test="RoleAuthoringTests.TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues",
expect="red",
),
]
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
original = path.read_text(encoding="utf-8")
count = original.count(anchor)
if count != 1:
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
def run_test(cultlib_root: str, test: str) -> tuple[int, str]:
proc = subprocess.run(
["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}",
"--filter", f"FullyQualifiedName~{test}"],
capture_output=True, text=True, timeout=300,
)
return proc.returncode, proc.stdout + proc.stderr
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cultlib-root", required=True)
args = parser.parse_args()
failures: list[str] = []
originals: dict[str, str] = {}
for m in MUTATIONS:
if m.file not in originals:
originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8")
def restore_all() -> None:
for file, text in originals.items():
(REPO_ROOT / file).write_text(text, encoding="utf-8")
try:
for m in MUTATIONS:
print(f"\n=== {m.rule} (expect {m.expect}) ===")
path = REPO_ROOT / m.file
replace_unique(path, m.anchor, m.mutated)
code, output = run_test(args.cultlib_root, m.test)
passed = "Passed!" in output and code == 0
if m.expect == "green":
if not passed:
failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}")
else:
print(f"OK: {m.test} passed under a no-op mutation, as expected")
else:
if passed:
failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed")
else:
print(f"OK: {m.test} went red under the mutation, as expected")
# Restore this file immediately so the next mutation starts from a clean tree.
path.write_text(originals[m.file], encoding="utf-8")
finally:
restore_all()
if failures:
print("\n=== FAILURES ===")
for f in failures:
print(f"- {f}")
return 1
print("\nAll mutations behaved as declared.")
return 0
if __name__ == "__main__":
sys.exit(main())