test(adapter): squad mutation battery (14 mutants, all killed)

mutation-battery.sh injects each of the 14 required wrong behaviours into
the committed source, runs the one invariant test that must catch it, and
requires a non-zero exit (mutant killed), reverting via git after each.

Kills: kit-by-slot, captain-as-resourceId, chemistry-reconciled,
custom-regenerated, index-derived, stale-accepted, missing-fabricated,
faked-asset-id, duplicate-instance-collapse, PUT-as-slot-diff,
wire-id-in-canonical, projector-bypasses-shared-shaper, schema-version-
ignored, player-state-keyed-by-definition. 14/14 killed.
This commit is contained in:
funman300
2026-08-12 02:30:38 +00:00
parent e09344490f
commit 46a81e7a07
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# FIFA 17 squad-adapter mutation battery.
#
# Each mutation injects a specific WRONG behaviour into the committed source,
# runs the one test that defends the invariant, and requires that test to FAIL
# (non-zero exit) — i.e. the mutant is killed. The source is reverted via
# `git checkout` after every mutation, so the tree is left untouched.
#
# A mutant that SURVIVES (its guard test still passes) means the invariant is
# not actually defended; the battery then exits non-zero.
#
# Run from the adapter crate root: bash mutation-battery.sh
set -u
cd "$(dirname "$0")"
FUT=src/fut
PASS=0
FAIL=0
declare -a SURVIVORS=()
# mutate <file> <literal-old> <literal-new> (literal, multiline-safe)
mutate() {
OLD="$2" NEW="$3" perl -0777 -pi -e \
's/\Q$ENV{OLD}\E/$ENV{NEW}/g or die "MUTATION PATTERN NOT FOUND in '"$1"'\n"' "$1"
}
# kill <n> <label> <test-filter> <file> <old> <new>
kill_test() {
local n="$1" label="$2" filter="$3" file="$4" old="$5" new="$6"
git checkout -- "$file"
mutate "$file" "$old" "$new" || { echo " [#$n] SETUP ERROR"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label (setup)"); return; }
if cargo test --quiet "$filter" >/dev/null 2>&1; then
echo " [#$n] SURVIVED — $label (test '$filter' still passed)"
FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label")
else
echo " [#$n] killed — $label"
PASS=$((PASS+1))
fi
git checkout -- "$file"
}
echo "== FIFA17 squad adapter mutation battery =="
kill_test 1 "kitNumber keyed by slot index, not owned item" \
kit_number_is_keyed_by_owned_item_not_slot "$FUT/squad_ext.rs" \
'(s.owned_card_id.clone(), s.kit_number)' '(s.index.to_string(), s.kit_number)'
kill_test 2 "captain emitted using resourceId (asset), not wire id" \
fresh_projects_full_23_slot_array_with_captain_wire_id "$FUT/squad_projection.rs" \
'captain_wire = id.item_id as i64;' 'captain_wire = id.asset_id as i64;'
kill_test 3 "client chemistry silently reconciled (shadow lost)" \
swap_moves_two_players_with_their_kits_and_round_trips "$FUT/squad_projection.rs" \
'"chemistry": ext.client_reported.chemistry,' '"chemistry": 0,'
kill_test 4 "custom[] regenerated instead of round-tripped verbatim" \
custom_is_preserved_byte_for_byte "$FUT/squad_ext.rs" \
'custom: put.custom.clone(),' 'custom: Some("[]".to_string()),'
kill_test 5 "index derived (zeroed) instead of round-tripped" \
baseline_projects_the_known_squad_round_trip "$FUT/squad.rs" \
'index: p.index,' 'index: 0,'
kill_test 6 "stale extension accepted and projected" \
stale_is_never_applied "$FUT/squad_projection.rs" \
'SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),' 'SquadExtInput::Stale(ext) => ext,'
kill_test 7 "missing extension fabricates default fields" \
missing_is_explicit_never_fabricated "$FUT/squad_projection.rs" \
'SquadExtInput::Missing => return Ok(SquadProjection::Missing),' \
'SquadExtInput::Missing => return Ok(SquadProjection::Projected(json!({"custom":"[]","manager":[]}))),'
kill_test 8 "shaper fabricates asset id, bypassing real FIFA identity" \
shapes_real_identity_and_reverse_entity_ids "$FUT/item.rs" \
'let asset = id.asset_id;' 'let asset = 0;'
kill_test 9 "duplicate definition collapses owned instances (id=asset)" \
two_owned_copies_of_one_definition_stay_distinct_on_the_wire "$FUT/item.rs" \
'"id": id.item_id,' '"id": id.asset_id,'
kill_test 10 "PUT treated as a partial slot diff (drops slots)" \
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
'if p.item_data.id == 0 {' 'if p.item_data.id == 0 || p.index > 1 {'
kill_test 11 "FIFA wire item id stored in canonical replacement" \
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
'ProposedSlot {
owned_card_id,' 'ProposedSlot {
owned_card_id: p.item_data.id.to_string(),'
kill_test 12 "projector rebuilds items independently of shared shaper" \
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
kill_test 13 "extension schema version ignored on read" \
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
'if schema_version != EXT_SCHEMA_VERSION {' 'if false {'
kill_test 14 "player state keyed by CardDefinitionId not OwnedItemId" \
two_owned_copies_of_one_definition_project_as_distinct_players "$FUT/squad_projection.rs" \
'ext.kit_numbers.get(&slot.owned_card_id)' 'ext.kit_numbers.get(&item.card_id)'
echo "== mutants killed: $PASS / $((PASS+FAIL)) =="
if [ "$FAIL" -ne 0 ]; then
printf 'SURVIVORS:\n'; printf ' - %s\n' "${SURVIVORS[@]}"
exit 1
fi
echo "all mutants killed"