Un-clobber the item-provenance mutation harness; own file for this cut
My prior commit overwrote tests/mutation_tests.py wholesale instead of
noticing it already belongs to docs/item-provenance-cut.md (this
branch's primary cut, run by a concurrent session sharing this working
tree). Restored it byte-for-byte from 1fdf8151 and moved the
addressables-cut mutation coverage to its own
tests/mutation_tests_addressables_cut.py, since that harness's
runner only knows how to drive `dotnet test` against a source-anchor
mutation and has no path to a Unity batchmode check, which
EngineAssetCheck requires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
67cbf34b25
commit
2d348d64ca
2 changed files with 809 additions and 247 deletions
|
|
@ -1,275 +1,553 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/addressables-cut.md Cut 2.
|
||||
"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md).
|
||||
|
||||
EngineAssetCheck (Assets/Scripts/Editor/EngineAssetCheck.cs) is the rule that keeps every stored
|
||||
[CultInspectorAssetGuid] value honest. A check nobody can break is a check nobody can trust: each
|
||||
case here mutates the *real* catalog (GameData/Aetheria.cc) or the source in one specific way the
|
||||
cut promises to catch, runs EngineAssetCheck.Run in Unity batchmode, asserts it fails naming the
|
||||
record, then restores the catalog/source exactly. A "control" case first proves the unmutated
|
||||
catalog passes, so a case that "fails" for the wrong reason (a bad harness, not a caught mutation)
|
||||
cannot hide.
|
||||
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.
|
||||
|
||||
Run from the repo root: python tests/mutation_tests.py
|
||||
Exit code 0 only if every case behaved as the cut's own verification section (Cut 2) demands.
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG = REPO_ROOT / "GameData" / "Aetheria.cc"
|
||||
UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe"
|
||||
ITEM_DATA_CS = REPO_ROOT / "Assets" / "Scripts" / "ServerShared" / "ItemData.cs"
|
||||
DIRECTORY_BUILD_PROPS = REPO_ROOT / "Directory.Build.props"
|
||||
|
||||
# A known-good record to mutate: HullData "LonginusX", whose Prefab already resolves (see the Cut 2
|
||||
# migration report). Texture2D guid is its own Schematic (schema_Longinus.png), still a real,
|
||||
# addressable asset, just the wrong type for Prefab (which wants a GameObject+EntityInstance).
|
||||
TEXTURE_GUID = "51702555e534a7a4fb39eb105b80bbaf"
|
||||
# A real, existing, non-addressable asset (a .cs script under Assets/Scripts, outside Assets/Content).
|
||||
NON_ADDRESSABLE_GUID = "3f3cca818d26268459d28af1cdb469a5"
|
||||
RANDOM_GUID = "deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
OLD_PATH = "Assets/Resources/Prefabs/Ships/Longinus.prefab"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
print(f"$ {' '.join(str(c) for c in cmd)}")
|
||||
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
||||
@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)
|
||||
|
||||
|
||||
def pinned_cultlib_revision() -> str:
|
||||
text = DIRECTORY_BUILD_PROPS.read_text(encoding="utf-8")
|
||||
match = re.search(r"<CultLibRevision>([0-9a-f]{40})</CultLibRevision>", text)
|
||||
if not match:
|
||||
raise SystemExit("could not find CultLibRevision in Directory.Build.props")
|
||||
return match.group(1)
|
||||
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 resolve_cultlib_root(scratch: Path) -> str:
|
||||
"""The mutator (like the rest of the .NET build) must build against the exact CultLib
|
||||
revision Directory.Build.props pins, not whatever CultLib main happens to be on disk right
|
||||
now. Reuse CULTLIB_ROOT / the sibling checkout when it already matches; otherwise check out a
|
||||
detached scratch worktree at the pinned revision, same as the cut's own verification pass."""
|
||||
revision = pinned_cultlib_revision()
|
||||
|
||||
candidates = []
|
||||
if os.environ.get("CULTLIB_ROOT"):
|
||||
candidates.append(Path(os.environ["CULTLIB_ROOT"]))
|
||||
candidates.append(REPO_ROOT.parent / "CultLib")
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate.exists():
|
||||
continue
|
||||
head = run(["git", "-C", str(candidate), "rev-parse", "HEAD"]).stdout.strip()
|
||||
dirty = run(["git", "-C", str(candidate), "status", "--porcelain"]).stdout.strip()
|
||||
if head == revision and not dirty:
|
||||
return str(candidate)
|
||||
|
||||
worktree = scratch / f"cultlib-{revision[:12]}"
|
||||
if not worktree.exists():
|
||||
result = run(["git", "-C", str(REPO_ROOT.parent / "CultLib"), "worktree", "add", "--detach", str(worktree), revision])
|
||||
if result.returncode != 0:
|
||||
print(result.stdout, result.stderr)
|
||||
raise SystemExit("failed to create pinned CultLib worktree for the mutator build")
|
||||
return str(worktree)
|
||||
|
||||
|
||||
def build_mutator(build_dir: Path, cultlib_root: str) -> Path:
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "Mutate.csproj").write_text(
|
||||
"""<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="%s" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""" % (REPO_ROOT / "Aetheria.Shared" / "Aetheria.Shared.csproj"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(build_dir / "Program.cs").write_text(
|
||||
r"""using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
|
||||
// Scratch-only (not landed): sets HullData "LonginusX".Prefab to an arbitrary string in a copy of
|
||||
// the catalog, for tests/mutation_tests.py to feed to EngineAssetCheck and prove it fails.
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
var catalogPath = args[0];
|
||||
var newValue = args[1];
|
||||
var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
|
||||
try
|
||||
{
|
||||
var stored = cache.AllStoredDocuments.First(s =>
|
||||
s.Descriptor.DocumentType.Name == "HullData" &&
|
||||
(string)s.Descriptor.DocumentType.GetField("Name").GetValue(s.Document) == "LonginusX");
|
||||
var field = stored.Descriptor.DocumentType.GetField("Prefab", BindingFlags.Public | BindingFlags.Instance);
|
||||
field.SetValue(stored.Document, newValue);
|
||||
cache.UpsertAsync(stored.Descriptor.DocumentType, stored.Document, stored.Key).Wait();
|
||||
cache.FlushAllBackingStores();
|
||||
Console.WriteLine($"Set HullData \"LonginusX\".Prefab = \"{newValue}\"");
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
build = run([
|
||||
"dotnet", "build", str(build_dir / "Mutate.csproj"),
|
||||
"-c", "Release", "-o", str(build_dir / "out"),
|
||||
f"-p:CultLibRoot={cultlib_root}",
|
||||
])
|
||||
if build.returncode != 0:
|
||||
print(build.stdout, build.stderr)
|
||||
raise SystemExit("mutator build failed")
|
||||
return build_dir / "out" / "Mutate.dll"
|
||||
|
||||
|
||||
def mutate_catalog(mutator_dll: Path, scratch_catalog: Path, value: str):
|
||||
shutil.copyfile(CATALOG, scratch_catalog)
|
||||
result = run(["dotnet", str(mutator_dll), str(scratch_catalog), value])
|
||||
if result.returncode != 0:
|
||||
print(result.stdout, result.stderr)
|
||||
raise SystemExit("mutation failed to apply")
|
||||
|
||||
|
||||
def run_engine_asset_check(log_path: Path) -> tuple[int, str]:
|
||||
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(
|
||||
[UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT),
|
||||
"-executeMethod", "EngineAssetCheck.Run", "-logFile", str(log_path)],
|
||||
timeout=600,
|
||||
[
|
||||
"dotnet", "test", str(TEST_PROJECT),
|
||||
f"-p:CultLibRoot={cultlib_root}",
|
||||
"--filter", f"FullyQualifiedName~{filter_expr}",
|
||||
"--nologo",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
return proc.returncode, text
|
||||
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 assert_contains(log_text: str, needle: str, case_name: str):
|
||||
if needle not in log_text:
|
||||
raise SystemExit(f"[{case_name}] FAIL: expected log to contain {needle!r}; it did not")
|
||||
def detect_newline(original: bytes) -> bytes:
|
||||
return b"\r\n" if b"\r\n" in original else b"\n"
|
||||
|
||||
|
||||
def main():
|
||||
if not CATALOG.exists():
|
||||
raise SystemExit(f"catalog not found: {CATALOG}")
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cultlib-root", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
original_catalog = CATALOG.read_bytes()
|
||||
original_item_data = ITEM_DATA_CS.read_text(encoding="utf-8")
|
||||
scratch = Path(tempfile.mkdtemp(prefix="aetheria-mutation-"))
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
try:
|
||||
cultlib_root = resolve_cultlib_root(scratch)
|
||||
print(f"CultLibRoot for the mutator build: {cultlib_root}")
|
||||
mutator_dll = build_mutator(scratch / "mutator", cultlib_root)
|
||||
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
|
||||
|
||||
# Control: the real, unmutated catalog must pass with 0 failures. If this fails, every
|
||||
# other case below is meaningless (the harness itself, not the mutation, would be at fault).
|
||||
print("\n=== control: unmutated catalog ===")
|
||||
log = scratch / "control.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code != 0 or "0 failure" not in text and "stored reference(s) OK" not in text:
|
||||
failures.append("control: expected exit 0 and a clean report on the real catalog")
|
||||
else:
|
||||
print("control OK: exit 0, no failures")
|
||||
|
||||
cases = [
|
||||
("wrong-type (Texture2D guid for a Prefab field)", TEXTURE_GUID, "not a GameObject"),
|
||||
("non-addressable guid (real asset outside Assets/Content)", NON_ADDRESSABLE_GUID, "is not addressable"),
|
||||
("random 32-hex guid (no such asset)", RANDOM_GUID, "names no existing file"),
|
||||
("old Resources path (pre-migration form)", OLD_PATH, "not 32 lowercase hex characters"),
|
||||
]
|
||||
|
||||
for name, value, expect in cases:
|
||||
print(f"\n=== mutation: {name} ===")
|
||||
try:
|
||||
mutated = scratch / "mutated.cc"
|
||||
mutate_catalog(mutator_dll, mutated, value)
|
||||
shutil.copyfile(mutated, CATALOG)
|
||||
|
||||
log = scratch / (name.split()[0] + ".log")
|
||||
code, text = run_engine_asset_check(log)
|
||||
|
||||
if code == 0:
|
||||
failures.append(f"{name}: EngineAssetCheck exited 0; the mutation was not caught")
|
||||
continue
|
||||
if "LonginusX" not in text:
|
||||
failures.append(f"{name}: failure did not name the record (LonginusX)")
|
||||
continue
|
||||
assert_contains(text, expect, name)
|
||||
print(f"OK: caught, exit {code}, named LonginusX, mentioned {expect!r}")
|
||||
except SystemExit as exc:
|
||||
failures.append(f"{name}: {exc}")
|
||||
finally:
|
||||
CATALOG.write_bytes(original_catalog)
|
||||
|
||||
# Missing consumer contract: a new [CultInspectorAssetGuid] member EngineAssetCheck's table
|
||||
# doesn't know about must fail loudly, not be silently skipped.
|
||||
print("\n=== mutation: attributed member missing from the consumer contract table ===")
|
||||
mutated_text = text.replace(anchor, mutated, 1)
|
||||
path.write_bytes(mutated_text.encode("utf-8"))
|
||||
try:
|
||||
marker = " [Inspectable, JsonProperty(\"hardpoints\"), Key(23)] \n public List<HardpointData> Hardpoints = new List<HardpointData>();"
|
||||
replacement = marker + (
|
||||
"\n\n // Scratch mutation (tests/mutation_tests.py): an attributed member with no "
|
||||
"row in EngineAssetCheck.ConsumerContracts. Must fail the check, not be skipped.\n"
|
||||
" [Inspectable, CultInspectorAssetGuid, JsonProperty(\"scratchMutation\"), Key(30)]\n"
|
||||
f" public string ScratchMutationField = \"{TEXTURE_GUID}\";"
|
||||
)
|
||||
if marker not in original_item_data:
|
||||
raise SystemExit("anchor text for the scratch field insertion was not found in ItemData.cs")
|
||||
mutated_source = original_item_data.replace(marker, replacement, 1)
|
||||
ITEM_DATA_CS.write_text(mutated_source, encoding="utf-8")
|
||||
|
||||
log = scratch / "missing-contract.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code == 0:
|
||||
failures.append("missing-contract: EngineAssetCheck exited 0; an untabled member was not caught")
|
||||
elif "ScratchMutationField" not in text or "no consumer contract registered" not in text:
|
||||
failures.append("missing-contract: failure did not name the untabled member and reason")
|
||||
else:
|
||||
print("OK: caught, exit", code, "named ScratchMutationField with no registered contract")
|
||||
status_code, output = run_test(args.cultlib_root, m.test)
|
||||
finally:
|
||||
ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8")
|
||||
# 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
|
||||
|
||||
# Restoration proof: after reverting the source, a fresh batchmode compile + check must be
|
||||
# clean again — the mutation must leave no residue once undone.
|
||||
print("\n=== restoration check: reverted source recompiles clean ===")
|
||||
log = scratch / "restored.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code != 0:
|
||||
failures.append("restoration: EngineAssetCheck did not exit 0 after reverting the scratch field")
|
||||
else:
|
||||
print("OK: restored source compiles and the check is clean again")
|
||||
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)
|
||||
|
||||
finally:
|
||||
CATALOG.write_bytes(original_catalog)
|
||||
ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8")
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
print(f"\nrestored {CATALOG} and {ITEM_DATA_CS} to their original content.")
|
||||
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("\n=== MUTATION TESTS FAILED ===")
|
||||
print(f"\n{len(failures)} mutation(s) did not behave as expected:")
|
||||
for f in failures:
|
||||
print(" -", f)
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
|
||||
print("\n=== all mutation tests passed ===")
|
||||
print(f"\nAll {len(results)} mutations behaved as expected.")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
284
tests/mutation_tests_addressables_cut.py
Normal file
284
tests/mutation_tests_addressables_cut.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/addressables-cut.md Cut 2.
|
||||
|
||||
EngineAssetCheck (Assets/Scripts/Editor/EngineAssetCheck.cs) is the rule that keeps every stored
|
||||
[CultInspectorAssetGuid] value honest. A check nobody can break is a check nobody can trust: each
|
||||
case here mutates the *real* catalog (GameData/Aetheria.cc) or the source in one specific way the
|
||||
cut promises to catch, runs EngineAssetCheck.Run in Unity batchmode, asserts it fails naming the
|
||||
record, then restores the catalog/source exactly. A "control" case first proves the unmutated
|
||||
catalog passes, so a case that "fails" for the wrong reason (a bad harness, not a caught mutation)
|
||||
cannot hide.
|
||||
|
||||
Run from the repo root: python tests/mutation_tests_addressables_cut.py
|
||||
Exit code 0 only if every case behaved as the cut's own verification section (Cut 2) demands.
|
||||
|
||||
This is a separate script rather than an addition to tests/mutation_tests.py: that file is the
|
||||
item-provenance cut's own harness (docs/item-provenance-cut.md), a source-anchor-mutation-plus-
|
||||
`dotnet test` runner with no path to driving a Unity batchmode check, which is what
|
||||
EngineAssetCheck requires. A concurrent session sharing this working tree landed asset moves
|
||||
under an unrelated commit message (see docs/addressables-cut.md history around 2026-09-18); this
|
||||
file's name avoids repeating that collision on the one shared harness filename.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG = REPO_ROOT / "GameData" / "Aetheria.cc"
|
||||
UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe"
|
||||
ITEM_DATA_CS = REPO_ROOT / "Assets" / "Scripts" / "ServerShared" / "ItemData.cs"
|
||||
DIRECTORY_BUILD_PROPS = REPO_ROOT / "Directory.Build.props"
|
||||
|
||||
# A known-good record to mutate: HullData "LonginusX", whose Prefab already resolves (see the Cut 2
|
||||
# migration report). Texture2D guid is its own Schematic (schema_Longinus.png), still a real,
|
||||
# addressable asset, just the wrong type for Prefab (which wants a GameObject+EntityInstance).
|
||||
TEXTURE_GUID = "51702555e534a7a4fb39eb105b80bbaf"
|
||||
# A real, existing, non-addressable asset (a .cs script under Assets/Scripts, outside Assets/Content).
|
||||
NON_ADDRESSABLE_GUID = "3f3cca818d26268459d28af1cdb469a5"
|
||||
RANDOM_GUID = "deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
OLD_PATH = "Assets/Resources/Prefabs/Ships/Longinus.prefab"
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
print(f"$ {' '.join(str(c) for c in cmd)}")
|
||||
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
||||
|
||||
|
||||
def pinned_cultlib_revision() -> str:
|
||||
text = DIRECTORY_BUILD_PROPS.read_text(encoding="utf-8")
|
||||
match = re.search(r"<CultLibRevision>([0-9a-f]{40})</CultLibRevision>", text)
|
||||
if not match:
|
||||
raise SystemExit("could not find CultLibRevision in Directory.Build.props")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def resolve_cultlib_root(scratch: Path) -> str:
|
||||
"""The mutator (like the rest of the .NET build) must build against the exact CultLib
|
||||
revision Directory.Build.props pins, not whatever CultLib main happens to be on disk right
|
||||
now. Reuse CULTLIB_ROOT / the sibling checkout when it already matches; otherwise check out a
|
||||
detached scratch worktree at the pinned revision, same as the cut's own verification pass."""
|
||||
revision = pinned_cultlib_revision()
|
||||
|
||||
candidates = []
|
||||
if os.environ.get("CULTLIB_ROOT"):
|
||||
candidates.append(Path(os.environ["CULTLIB_ROOT"]))
|
||||
candidates.append(REPO_ROOT.parent / "CultLib")
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate.exists():
|
||||
continue
|
||||
head = run(["git", "-C", str(candidate), "rev-parse", "HEAD"]).stdout.strip()
|
||||
dirty = run(["git", "-C", str(candidate), "status", "--porcelain"]).stdout.strip()
|
||||
if head == revision and not dirty:
|
||||
return str(candidate)
|
||||
|
||||
worktree = scratch / f"cultlib-{revision[:12]}"
|
||||
if not worktree.exists():
|
||||
result = run(["git", "-C", str(REPO_ROOT.parent / "CultLib"), "worktree", "add", "--detach", str(worktree), revision])
|
||||
if result.returncode != 0:
|
||||
print(result.stdout, result.stderr)
|
||||
raise SystemExit("failed to create pinned CultLib worktree for the mutator build")
|
||||
return str(worktree)
|
||||
|
||||
|
||||
def build_mutator(build_dir: Path, cultlib_root: str) -> Path:
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "Mutate.csproj").write_text(
|
||||
"""<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="%s" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""" % (REPO_ROOT / "Aetheria.Shared" / "Aetheria.Shared.csproj"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(build_dir / "Program.cs").write_text(
|
||||
r"""using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
|
||||
// Scratch-only (not landed): sets HullData "LonginusX".Prefab to an arbitrary string in a copy of
|
||||
// the catalog, for tests/mutation_tests.py to feed to EngineAssetCheck and prove it fails.
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
var catalogPath = args[0];
|
||||
var newValue = args[1];
|
||||
var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
|
||||
try
|
||||
{
|
||||
var stored = cache.AllStoredDocuments.First(s =>
|
||||
s.Descriptor.DocumentType.Name == "HullData" &&
|
||||
(string)s.Descriptor.DocumentType.GetField("Name").GetValue(s.Document) == "LonginusX");
|
||||
var field = stored.Descriptor.DocumentType.GetField("Prefab", BindingFlags.Public | BindingFlags.Instance);
|
||||
field.SetValue(stored.Document, newValue);
|
||||
cache.UpsertAsync(stored.Descriptor.DocumentType, stored.Document, stored.Key).Wait();
|
||||
cache.FlushAllBackingStores();
|
||||
Console.WriteLine($"Set HullData \"LonginusX\".Prefab = \"{newValue}\"");
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
build = run([
|
||||
"dotnet", "build", str(build_dir / "Mutate.csproj"),
|
||||
"-c", "Release", "-o", str(build_dir / "out"),
|
||||
f"-p:CultLibRoot={cultlib_root}",
|
||||
])
|
||||
if build.returncode != 0:
|
||||
print(build.stdout, build.stderr)
|
||||
raise SystemExit("mutator build failed")
|
||||
return build_dir / "out" / "Mutate.dll"
|
||||
|
||||
|
||||
def mutate_catalog(mutator_dll: Path, scratch_catalog: Path, value: str):
|
||||
shutil.copyfile(CATALOG, scratch_catalog)
|
||||
result = run(["dotnet", str(mutator_dll), str(scratch_catalog), value])
|
||||
if result.returncode != 0:
|
||||
print(result.stdout, result.stderr)
|
||||
raise SystemExit("mutation failed to apply")
|
||||
|
||||
|
||||
def run_engine_asset_check(log_path: Path) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
[UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT),
|
||||
"-executeMethod", "EngineAssetCheck.Run", "-logFile", str(log_path)],
|
||||
timeout=600,
|
||||
)
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
return proc.returncode, text
|
||||
|
||||
|
||||
def assert_contains(log_text: str, needle: str, case_name: str):
|
||||
if needle not in log_text:
|
||||
raise SystemExit(f"[{case_name}] FAIL: expected log to contain {needle!r}; it did not")
|
||||
|
||||
|
||||
def main():
|
||||
if not CATALOG.exists():
|
||||
raise SystemExit(f"catalog not found: {CATALOG}")
|
||||
|
||||
original_catalog = CATALOG.read_bytes()
|
||||
original_item_data = ITEM_DATA_CS.read_text(encoding="utf-8")
|
||||
scratch = Path(tempfile.mkdtemp(prefix="aetheria-mutation-"))
|
||||
failures = []
|
||||
|
||||
try:
|
||||
cultlib_root = resolve_cultlib_root(scratch)
|
||||
print(f"CultLibRoot for the mutator build: {cultlib_root}")
|
||||
mutator_dll = build_mutator(scratch / "mutator", cultlib_root)
|
||||
|
||||
# Control: the real, unmutated catalog must pass with 0 failures. If this fails, every
|
||||
# other case below is meaningless (the harness itself, not the mutation, would be at fault).
|
||||
print("\n=== control: unmutated catalog ===")
|
||||
log = scratch / "control.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code != 0 or "0 failure" not in text and "stored reference(s) OK" not in text:
|
||||
failures.append("control: expected exit 0 and a clean report on the real catalog")
|
||||
else:
|
||||
print("control OK: exit 0, no failures")
|
||||
|
||||
cases = [
|
||||
("wrong-type (Texture2D guid for a Prefab field)", TEXTURE_GUID, "not a GameObject"),
|
||||
("non-addressable guid (real asset outside Assets/Content)", NON_ADDRESSABLE_GUID, "is not addressable"),
|
||||
("random 32-hex guid (no such asset)", RANDOM_GUID, "names no existing file"),
|
||||
("old Resources path (pre-migration form)", OLD_PATH, "not 32 lowercase hex characters"),
|
||||
]
|
||||
|
||||
for name, value, expect in cases:
|
||||
print(f"\n=== mutation: {name} ===")
|
||||
try:
|
||||
mutated = scratch / "mutated.cc"
|
||||
mutate_catalog(mutator_dll, mutated, value)
|
||||
shutil.copyfile(mutated, CATALOG)
|
||||
|
||||
log = scratch / (name.split()[0] + ".log")
|
||||
code, text = run_engine_asset_check(log)
|
||||
|
||||
if code == 0:
|
||||
failures.append(f"{name}: EngineAssetCheck exited 0; the mutation was not caught")
|
||||
continue
|
||||
if "LonginusX" not in text:
|
||||
failures.append(f"{name}: failure did not name the record (LonginusX)")
|
||||
continue
|
||||
assert_contains(text, expect, name)
|
||||
print(f"OK: caught, exit {code}, named LonginusX, mentioned {expect!r}")
|
||||
except SystemExit as exc:
|
||||
failures.append(f"{name}: {exc}")
|
||||
finally:
|
||||
CATALOG.write_bytes(original_catalog)
|
||||
|
||||
# Missing consumer contract: a new [CultInspectorAssetGuid] member EngineAssetCheck's table
|
||||
# doesn't know about must fail loudly, not be silently skipped.
|
||||
print("\n=== mutation: attributed member missing from the consumer contract table ===")
|
||||
try:
|
||||
marker = " [Inspectable, JsonProperty(\"hardpoints\"), Key(23)] \n public List<HardpointData> Hardpoints = new List<HardpointData>();"
|
||||
replacement = marker + (
|
||||
"\n\n // Scratch mutation (tests/mutation_tests.py): an attributed member with no "
|
||||
"row in EngineAssetCheck.ConsumerContracts. Must fail the check, not be skipped.\n"
|
||||
" [Inspectable, CultInspectorAssetGuid, JsonProperty(\"scratchMutation\"), Key(30)]\n"
|
||||
f" public string ScratchMutationField = \"{TEXTURE_GUID}\";"
|
||||
)
|
||||
if marker not in original_item_data:
|
||||
raise SystemExit("anchor text for the scratch field insertion was not found in ItemData.cs")
|
||||
mutated_source = original_item_data.replace(marker, replacement, 1)
|
||||
ITEM_DATA_CS.write_text(mutated_source, encoding="utf-8")
|
||||
|
||||
log = scratch / "missing-contract.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code == 0:
|
||||
failures.append("missing-contract: EngineAssetCheck exited 0; an untabled member was not caught")
|
||||
elif "ScratchMutationField" not in text or "no consumer contract registered" not in text:
|
||||
failures.append("missing-contract: failure did not name the untabled member and reason")
|
||||
else:
|
||||
print("OK: caught, exit", code, "named ScratchMutationField with no registered contract")
|
||||
finally:
|
||||
ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8")
|
||||
|
||||
# Restoration proof: after reverting the source, a fresh batchmode compile + check must be
|
||||
# clean again — the mutation must leave no residue once undone.
|
||||
print("\n=== restoration check: reverted source recompiles clean ===")
|
||||
log = scratch / "restored.log"
|
||||
code, text = run_engine_asset_check(log)
|
||||
if code != 0:
|
||||
failures.append("restoration: EngineAssetCheck did not exit 0 after reverting the scratch field")
|
||||
else:
|
||||
print("OK: restored source compiles and the check is clean again")
|
||||
|
||||
finally:
|
||||
CATALOG.write_bytes(original_catalog)
|
||||
ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8")
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
print(f"\nrestored {CATALOG} and {ITEM_DATA_CS} to their original content.")
|
||||
|
||||
if failures:
|
||||
print("\n=== MUTATION TESTS FAILED ===")
|
||||
for f in failures:
|
||||
print(" -", f)
|
||||
return 1
|
||||
|
||||
print("\n=== all mutation tests passed ===")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue