Aetheria/tests/mutation_tests.py
Meta Vens ec3be4f4b5 Cut 0: mutation-test the entity-settings-copy rule
Adds a control (no-op) and a red mutation reverting Entity's constructor
to alias its settings argument instead of copying it. Both run against
EntitiesDoNotShareDefaultEntitySettings; the red mutation is killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:32:45 +02:00

555 lines
26 KiB
Python

#!/usr/bin/env python3
"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md).
For each rule this applies its named mutation to an exact, unique anchor of
source text, runs the one test that should catch it, restores the file
byte-for-byte, and reports whether the mutant was killed. A no-op control
mutation (anchor == replacement) proves the read/replace/write/restore path
itself is transparent: every test it touches must stay green.
Usage:
python tests/mutation_tests.py --cultlib-root <path-to-CultLib-a0813c6-worktree>
Requires the CultLib worktree described in docs/item-provenance-cut.md section 6.
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/Provenance.cs",
anchor=' [Key(0)] public int NextLot = 1;',
mutated=' [Key(0)] public int NextLot = 1;',
test="RunSaveTests.MissingLotIsLoud",
expect="green",
),
# --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot ---
Mutation(
rule="ProvenanceLedger indexer throws on an absent id",
file="Assets/Scripts/ServerShared/Provenance.cs",
anchor=(
" public Lot this[int id]\n"
" {\n"
" get\n"
" {\n"
" if (!Lots.TryGetValue(id, out var lot))\n"
" throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n"
" return lot;\n"
" }\n"
" }"
),
mutated=(
" public Lot this[int id]\n"
" {\n"
" get\n"
" {\n"
" Lots.TryGetValue(id, out var lot);\n"
" return lot;\n"
" }\n"
" }"
),
test="RunSaveTests.MissingLotIsLoud",
expect="red",
),
Mutation(
rule="Reachable throws on a missing root instead of skipping it",
file="Assets/Scripts/ServerShared/Provenance.cs",
anchor=" var lot = this[id];\n reached[id] = lot;",
mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;",
test="RunSaveTests.MissingLotIsLoud",
expect="red",
),
# --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot ---
Mutation(
rule="matching hardpoints share a lot, not a fresh mint",
file="Assets/Scripts/ServerShared/LoadoutGenerator.cs",
anchor=(
" // Matching hardpoints carry matching units: same lot\n"
" var item = (previousItem != null\n"
" ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n"
" : ItemManager.CreateInstance(entry.product)) as EquippableItem;"
),
mutated=(
" // Matching hardpoints carry matching units: same lot\n"
" var item = ItemManager.CreateInstance(entry.product) as EquippableItem;"
),
test="LoadoutTests.MatchingHardpointsShareALot",
expect="red",
),
# --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order ---
Mutation(
rule="Brand matches the lot's maker, not just the design",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=(
" var product = ItemData.GetAll<FactionProductData>()\n"
" .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))"
),
mutated=(
" var product = ItemData.GetAll<FactionProductData>()\n"
" .Where(p => p.Design.Key.Equals(item.Data.Key))"
),
test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot",
expect="red",
),
Mutation(
rule="Materialize resolves a design's product in record-key order",
file="Assets/Scripts/ServerShared/Loadout.cs",
anchor="var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();",
mutated="var products = cache.GetAll<FactionProductData>().ToArray();",
test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot",
expect="red",
),
# --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order ---
Mutation(
rule="Brand's tie-break among a maker's products for one design is ascending record-key order",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)",
mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)",
test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign",
expect="red",
),
# --- StatsReadTheLot: stats must read the lot, including its per-role fills ---
Mutation(
rule="Evaluate reads the lot's per-role fill for a role stat",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);",
mutated="var quality = pow(lot.Quality, stat.QualityExponent);",
test="LoadoutTests.StatsReadTheLot",
expect="red",
),
Mutation(
rule="QualityForRole falls back to the lot's own workmanship, not a fixed value",
file="Assets/Scripts/ServerShared/Provenance.cs",
anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;",
mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;",
test="LoadoutTests.StatsReadTheLot",
expect="red",
),
# --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root ---
Mutation(
rule="Items yields the Equipment root, not just the hull",
file="Assets/Scripts/ServerShared/EntitySerializer.cs",
anchor=(
" yield return pack.Hull;\n"
" foreach (var (_, item) in pack.Equipment) yield return item;"
),
mutated=" yield return pack.Hull;",
test="LoadoutTests.MaterializedLotsSurviveSaveAndReload",
expect="red",
),
# --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality ---
Mutation(
rule="GetTier reads the lot's quality, not a fixed value",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" var quality = GetLot(item).Quality;",
mutated=" var quality = .999f;",
test="LoadoutTests.StatsReadTheLot",
expect="red",
),
Mutation(
rule="CreateInstance(int) does not re-roll the lot's quality",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=(
" public CraftedItemInstance CreateInstance(int lot)\n"
" {\n"
" var l = Lots[lot];"
),
mutated=(
" public CraftedItemInstance CreateInstance(int lot)\n"
" {\n"
" var l = Lots[lot];\n"
" l.Quality = RollQuality();"
),
test="LoadoutTests.StatsReadTheLot",
expect="red",
),
# --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots ---
Mutation(
rule="GetPrice reads the lot's quality, not a fixed value",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);",
mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);",
test="LoadoutTests.GetPriceReadsLotQuality",
expect="red",
),
Mutation(
rule="Evaluate's durability exponent reads the lot's quality",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=(
" var durabilityExponent = lerp(\n"
" GameplaySettings.DurabilityQualityMin,\n"
" GameplaySettings.DurabilityQualityMax,\n"
" pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));"
),
mutated=(
" var durabilityExponent = lerp(\n"
" GameplaySettings.DurabilityQualityMin,\n"
" GameplaySettings.DurabilityQualityMax,\n"
" .5f);"
),
test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality",
expect="red",
),
Mutation(
rule="EquippedItem's thermal exponent reads the lot's quality",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=(
" ThermalExponent = lerp(\n"
" ItemManager.GameplaySettings.ThermalQualityMin,\n"
" ItemManager.GameplaySettings.ThermalQualityMax,\n"
" pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));"
),
mutated=(
" ThermalExponent = lerp(\n"
" ItemManager.GameplaySettings.ThermalQualityMin,\n"
" ItemManager.GameplaySettings.ThermalQualityMax,\n"
" .5f);"
),
test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality",
expect="red",
),
Mutation(
rule="CreateLot(product) fills roles from the product's own spread, not the design default",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();",
mutated=" var build = new ProductRole();",
test="LoadoutTests.CreateLotFillsRolesFromProductSpread",
expect="red",
),
Mutation(
rule="Brand reads the maker off a Produced origin too, not only Attributed",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=(
" var maker = lot.Origin switch\n"
" {\n"
" Attributed attributed => attributed.Faction,\n"
" Produced produced => produced.Faction,\n"
" _ => default\n"
" };"
),
mutated=(
" var maker = lot.Origin switch\n"
" {\n"
" Attributed attributed => attributed.Faction,\n"
" _ => default\n"
" };"
),
test="LoadoutTests.BrandReadsProducedFaction",
expect="red",
),
Mutation(
rule="RunSave.Commit takes GC roots from every zone, not just the first",
file="Assets/Scripts/ServerShared/SavedGame.cs",
anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())",
mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())",
test="RunSaveTests.CommitTakesRootsFromEveryZone",
expect="red",
),
# --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its
# round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. ---
Mutation(
rule="the catalog fixture's product carries the maker, not an unset reference",
file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs",
anchor=(
'cache.Upsert(new FactionProductData { Name = "Lance", '
"Design = new CultRecordRef<CraftedItemData>(lance.Key), Manufacturer = cache.RefOf(faction) });"
),
mutated=(
'cache.Upsert(new FactionProductData { Name = "Lance", '
"Design = new CultRecordRef<CraftedItemData>(lance.Key), Manufacturer = default });"
),
test="AetheriaStoresTests.FactionIsASingletonInstance",
expect="red",
),
# --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter-
# stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). ---
Mutation(
rule="an IFF override decides IsHostileTo outright at the top level",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
mutated=" if (false)\n return default;",
test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule",
expect="red",
),
Mutation(
rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride",
expect="red",
),
Mutation(
rule="leaving the zone clears this entity's IFF overrides on the departing entity",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))",
mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))",
test="IffAndCombatTests.LeavingZoneClearsOverrides",
expect="red",
),
Mutation(
rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));",
mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));",
test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule",
expect="red",
),
Mutation(
rule="non-player entities wire a grudge watch; player-controlled entities never do",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);",
mutated=" WatchForGrudge(entity);",
test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge",
expect="red",
),
Mutation(
rule="grudge only takes hold while this entity currently detects the marker",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))",
mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))",
test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker",
expect="red",
),
Mutation(
rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=(
" VisibleEntities.ObserveAdd()\n"
" .Where(add => add.Value == other)\n"
" .Subscribe(_ =>\n"
" {\n"
" if (other.EntityHostility.TryGetValue(this, out var hostile))\n"
" CheckGrudge(hostile);\n"
" })"
),
mutated=(
" VisibleEntities.ObserveAdd()\n"
" .Where(add => false)\n"
" .Subscribe(_ =>\n"
" {\n"
" if (other.EntityHostility.TryGetValue(this, out var hostile))\n"
" CheckGrudge(hostile);\n"
" })"
),
test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker",
expect="red",
),
Mutation(
rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);",
mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);",
test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral",
expect="red",
),
Mutation(
rule="PerceivedStanceOf is unknown (null) until this entity detects the other",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=(
" public bool? PerceivedStanceOf(Entity other) =>\n"
" VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n"
" ? hostile\n"
" : (bool?) null;"
),
mutated=(
" public bool? PerceivedStanceOf(Entity other) =>\n"
" other.EntityHostility.TryGetValue(this, out var hostile)\n"
" ? hostile\n"
" : (bool?) null;"
),
test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance",
expect="red",
),
Mutation(
rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true",
file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs",
anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);",
mutated=" public bool StanceAllowsFire => true;",
test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget",
expect="red",
),
Mutation(
rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst",
file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs",
anchor=" if (!StanceAllowsFire) return;",
mutated=" if (false) return;",
test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget",
expect="red",
),
Mutation(
rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse",
file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs",
anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))",
mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))",
test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets",
expect="red",
),
Mutation(
rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction",
file="Assets/Scripts/ServerShared/ZoneGenerator.cs",
anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();",
mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();",
test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest",
expect="red",
),
# --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event
# kind must reach only its own subscribers. ---
Mutation(
rule="control (no-op round trip) for the capability event routing test",
file="Assets/Scripts/ServerShared/CapabilityEvents.cs",
anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers",
expect="green",
),
Mutation(
rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well",
file="Assets/Scripts/ServerShared/CapabilityEvents.cs",
anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }",
test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers",
expect="red",
),
# --- Cut 0 (docs/settings-globals-cut.md): Entity must copy the EntitySettings it is given, so no
# entity aliases GameplaySettings.DefaultEntitySettings (or, once settings are a catalog global, the
# cached record itself). ---
Mutation(
rule="control (no-op round trip) for the entity-settings-copy test",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
mutated=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings",
expect="green",
),
Mutation(
rule="Entity's constructor copies the settings it is given, instead of aliasing the template",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
mutated=" Settings = settings;",
test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings",
expect="red",
),
]
def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]:
"""Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a
compile error is never a legitimate kill or survival — it means the mutation broke
something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output)."""
proc = subprocess.run(
[
"dotnet", "test", str(TEST_PROJECT),
f"-p:CultLibRoot={cultlib_root}",
"--filter", f"FullyQualifiedName~{filter_expr}",
"--nologo",
],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
output = proc.stdout + proc.stderr
# A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports
# "Build FAILED." and one or more "error CS..." lines instead.
if "Build FAILED" in output or "error CS" in output:
return "ERROR", output
passed = proc.returncode == 0 and "Failed!" not in output
return ("PASS" if passed else "FAIL"), output
def detect_newline(original: bytes) -> bytes:
return b"\r\n" if b"\r\n" in original else b"\n"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cultlib-root", required=True)
args = parser.parse_args()
results = []
failures = []
for m in MUTATIONS:
path = REPO_ROOT / m.file
original = path.read_bytes()
newline = detect_newline(original).decode("ascii")
# Anchors are authored with plain "\n"; translate to the file's own line ending
# before matching, so CRLF-checked-out files (this repo's default) still match.
anchor = m.anchor.replace("\n", newline)
mutated = m.mutated.replace("\n", newline)
text = original.decode("utf-8")
count = text.count(anchor)
if count != 1:
print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1")
failures.append(m.rule)
continue
mutated_text = text.replace(anchor, mutated, 1)
path.write_bytes(mutated_text.encode("utf-8"))
try:
status_code, output = run_test(args.cultlib_root, m.test)
finally:
# Reverse write: restore the exact original bytes regardless of outcome.
path.write_bytes(original)
restored = path.read_bytes()
if restored != original:
print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!")
return 2
if status_code == "ERROR":
status = "ERROR (build failure)"
failures.append(m.rule)
elif m.expect == "red":
killed = status_code == "FAIL"
status = "KILLED" if killed else "SURVIVED (bad)"
if not killed:
failures.append(m.rule)
else: # control: must stay green
passed = status_code == "PASS"
status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)"
if not passed:
failures.append(m.rule)
results.append((m.rule, m.test, status))
print(f"{status:22} | {m.test:55} | {m.rule}")
print()
print("Summary:")
for rule, test, status in results:
print(f" {status:22} {test:55} {rule}")
if failures:
print(f"\n{len(failures)} mutation(s) did not behave as expected:")
for f in failures:
print(f" - {f}")
return 1
print(f"\nAll {len(results)} mutations behaved as expected.")
return 0
if __name__ == "__main__":
sys.exit(main())