Mutation harness for Cut 5's tier rules
Nine mutations (one no-op control, eight rule-breaking) against PowerBus.AllocateTiers and EquippedItem's tier-seeding guard: tier walk order, committing a fed tier's grant before the next tier is considered, proportional-within-tier rationing, an empty tier reading as met rather than starved, allocating against TotalGrant rather than raw TotalDemand, remaining vs. the original total (the no-leak boundary), reading the item's live stored tier every Step, and never re-seeding a kind default once set. All nine behave as declared against PowerTierTests.cs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d07652b5bb
commit
8e4d847dc6
1 changed files with 218 additions and 0 deletions
218
tests/mutation_tests_stats_power_cut5.py
Normal file
218
tests/mutation_tests_stats_power_cut5.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 5 (priority tiers).
|
||||
|
||||
Cut 5 replaces PowerBus's single shared GrantRatio with priority tiers (PowerTiers.cs): critical is fed in full
|
||||
before the next tier sees anything, each following tier divides what remains, and a tier is a stored player
|
||||
choice (EquippableItem.PowerTier) rather than something the bus derives every tick. This script mutates each
|
||||
rule's own line of code and asserts the named test in PowerTierTests.cs goes red for exactly that reason. A
|
||||
no-op control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut5.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/PowerBus.cs",
|
||||
anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
mutated=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- Cut 5's headline rule: critical must be satisfied in full before the next tier receives anything.
|
||||
# --- Walking tiers in descending order instead of ascending feeds Utility first, so the fixed generation
|
||||
# --- that exactly covers Critical's own demand gets spent on Utility instead, starving Critical -- the
|
||||
# --- opposite of the ruling ("critical is fed first"). ---
|
||||
Mutation(
|
||||
rule="tiers must be walked lowest-number (highest priority) first",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" for (var tier = 0; tier < PowerTiers.Count; tier++)",
|
||||
mutated=" for (var tier = PowerTiers.Count - 1; tier >= 0; tier--)",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A satisfied tier must commit what it took, so a lower tier can never see it again. Skipping the
|
||||
# --- subtraction lets every tier see the SAME remaining pool -- Utility would compute against the full 30,
|
||||
# --- not the 0 left after Critical, so it would stop reading as fully starved. ---
|
||||
Mutation(
|
||||
rule="a fed tier must commit its grant against remaining, or a lower tier could see it twice",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" remaining -= grant;",
|
||||
mutated=" // remaining -= grant;",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Within a tier, every consumer must ration by the SAME fraction of its OWN request -- that is what
|
||||
# --- makes two unequal requests divide proportionally instead of splitting the tier's grant evenly per
|
||||
# --- consumer regardless of how much each asked for. ---
|
||||
Mutation(
|
||||
rule="within a tier, the ratio must be grant/demand (proportional), not an even per-consumer split",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" ratios[tier] = saturate(grant / demand);",
|
||||
mutated=" ratios[tier] = saturate(grant / tierDemand.Length);",
|
||||
test="PowerTierTests.TwoConsumersInOneTierAreRationedProportionally",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- An empty tier must read as fully met (ratio 1, remaining untouched) so its share passes straight to
|
||||
# --- the next tier down. Forcing it to 0 instead makes an unused tier look starved and -- because nothing
|
||||
# --- reduces remaining for a 0-demand tier either way -- has no effect on the real consumer, but the
|
||||
# --- assertion on the empty tiers' own ratio catches the wrong value directly. ---
|
||||
Mutation(
|
||||
rule="a tier with no demand must read as fully met (1f), not starved (0f)",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
mutated=" if (demand <= 1e-4f) { ratios[tier] = 0f; continue; }",
|
||||
test="PowerTierTests.ATierWithNoDemandPassesItsShareDown",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- TotalGrant is the ceiling tiering allocates -- it must still reflect what generation and stored charge
|
||||
# --- can actually cover, not the raw total request. Feeding the allocator TotalDemand instead (pretending
|
||||
# --- supply always covers everything) hands Utility the untouched 80 instead of the 0 left after Critical's
|
||||
# --- own 30 is committed, so it reads as fully fed instead of starved. ---
|
||||
Mutation(
|
||||
rule="tiering must allocate against TotalGrant (what supply covers), not TotalDemand (what was asked)",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var remaining = TotalGrant;",
|
||||
mutated=" var remaining = TotalDemand;",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The boundary rule itself: once a higher tier's own shortfall has consumed all of remaining, a lower
|
||||
# --- tier must see 0, not some leftover computed from the higher tier's unmet demand. Handing every tier
|
||||
# --- the ORIGINAL TotalGrant instead of the shrinking remaining pool would let Utility compute its own
|
||||
# --- ratio against the full grant as if Critical had never been served first -- exactly the leak §1.3
|
||||
# --- forbids. ---
|
||||
Mutation(
|
||||
rule="each tier must divide what is actually left (remaining), not the original total grant again",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var grant = min(demand, remaining);",
|
||||
mutated=" var grant = min(demand, TotalGrant);",
|
||||
test="PowerTierTests.LowerTierGetsExactlyZeroWhenAHigherTierAloneStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The tier must be read fresh from the stored field every Step, not cached at equip time -- otherwise a
|
||||
# --- player's tier change on the schematic UI would need an unequip/re-equip round trip to take effect,
|
||||
# --- which the map's own verification bullet rules out. ---
|
||||
Mutation(
|
||||
rule="the bus must read the item's CURRENT stored PowerTier every Step, not one captured once at equip",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var tier = item.EquippableItem.PowerTier;",
|
||||
mutated=" var tier = consumer.DefaultPowerTier;",
|
||||
test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- EquippedItem's constructor must seed a kind default only once, the first time a unit is equipped
|
||||
# --- (PowerTiers.Unassigned) -- never re-seeding it on every equip is what makes the tier a genuinely stored
|
||||
# --- choice instead of one silently reset back to the kind default whenever a unit changes hands. Removing
|
||||
# --- the guard reassigns Utility's own default even after a mutating test has moved it to Critical.
|
||||
Mutation(
|
||||
rule="the kind default must only be seeded when PowerTier is still Unassigned, never re-seeded",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (EquippableItem.PowerTier == PowerTiers.Unassigned)\n {\n var consumerTiers = Behaviors.OfType<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }",
|
||||
mutated=" {\n var consumerTiers = Behaviors.OfType<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }",
|
||||
test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip",
|
||||
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())
|
||||
Loading…
Reference in a new issue