From 3b1ea05c3bbb7e9ba51e9b455f1b94b1a9297cc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:16:18 +0000 Subject: [PATCH] test(pksave): kill the fixed-point mutation survivors The first verification pass showed five survivors whose original test data made the mutated arithmetic a fixed point: swap at slot 0 (+0 == -0), a tail fill starting at party index 2 (2+2 == 2*2), species-list writes never asserted after swap_party_box/replace_raw, and the item quantity byte only reaching a diagnostic no test checked. Cover each with data where the mutation diverges: swap(1,2) with a species-list assert, species-list asserts on both sides of swap_party_box, a withdraw into a one-mon party pinning sentinel and zero fill, and a W-ITEMS-QTY-ZERO span test. Also records the sixth +0-constant equivalent (party_append_raw's count-byte write) in .cargo/mutants.toml. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BSxh62iGqh4bffn8hRcTME --- .cargo/mutants.toml | 9 +++-- crates/pksave/tests/boxes.rs | 71 ++++++++++++++++++++++++++++++++++++ crates/pksave/tests/items.rs | 20 ++++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 9886bf2..6bc3310 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -8,10 +8,11 @@ # blanket: every `|` in each listed function is a disjoint-operand pack. # # Known-equivalent but NOT excluded (kept visible on purpose, expect -# them as "missed"): five `+ with -` mutants in validate.rs (party, -# boxes, level_exp_coherence x2, text_terminators) that add the layout -# constant COUNT == 0, where +0 == -0. A function-scoped exclude would -# also hide future real span-arithmetic regressions in those functions. +# them as "missed"): six `+ with -` mutants that add the layout +# constant COUNT == 0, where +0 == -0 — five in validate.rs (party, +# boxes, level_exp_coherence x2, text_terminators) and one in +# boxes.rs party_append_raw (the count-byte write). A function-scoped +# exclude would also hide future real arithmetic regressions there. exclude_re = [ 'replace \| with \^ in encode', # bcd: (hi<<4) | lo, lo < 16 'replace \| with \^ in SaveFile::set_current_box_number', # (byte & 0x80) | n, n < 12 diff --git a/crates/pksave/tests/boxes.rs b/crates/pksave/tests/boxes.rs index 0d61e4a..64dba8a 100644 --- a/crates/pksave/tests/boxes.rs +++ b/crates/pksave/tests/boxes.rs @@ -905,3 +905,74 @@ fn box_is_empty_reports_both_polarities_via_view_and_mut() { assert!(!save.box_(0).is_empty()); assert!(!save.box_mut(0).is_empty()); } + +#[test] +fn box_swap_between_nonzero_slots_moves_the_species_list() { + // swap(1, 2) rather than swap(0, _): at slot 0 the species-list + // index arithmetic is a fixed point of +/- mutations. + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + { + let mut b = save.box_mut(0); + for dex in [1, 4, 7] { + b.add(&make_box_mon(dex, 9), "RED", "X").expect("room"); + } + b.swap(1, 2); + } + assert_eq!( + save.box_(0).species_list(), + &[DEX_TO_INDEX[1], DEX_TO_INDEX[7], DEX_TO_INDEX[4]] + ); +} + +#[test] +fn swap_party_box_updates_both_species_lists() { + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + for dex in [1, 4, 7] { + save.party_mut() + .add(&make_party_mon(dex, 8), "ASH", "P") + .expect("room"); + } + for dex in [25, 39, 52] { + save.box_mut(0) + .add(&make_box_mon(dex, 21), "GARY", "B") + .expect("room"); + } + save.swap_party_box(2, 0, 2).expect("both occupied"); + assert_eq!( + save.party().species_list(), + &[DEX_TO_INDEX[1], DEX_TO_INDEX[4], DEX_TO_INDEX[52]], + "party species list entry 2 follows the swap" + ); + assert_eq!( + save.box_(0).species_list(), + &[DEX_TO_INDEX[25], DEX_TO_INDEX[39], DEX_TO_INDEX[7]], + "box species list entry 2 follows the swap" + ); +} + +#[test] +fn withdraw_into_a_one_mon_party_repacks_the_species_list() { + // At party index 2 the tail-fill start `list + i + 2` is a fixed + // point of the +/* mutation (2+2 == 2*2); index 1 is not. + let mut save = SaveFile::new_empty(GameVariant::RedBlue); + save.set_current_box_number(1); + save.party_mut() + .add(&make_party_mon(1, 8), "ASH", "P1") + .expect("room"); + save.box_mut(0) + .add(&make_box_mon(7, 31), "OTIS", "BOXY") + .expect("room"); + save.withdraw(0, 0).expect("party has room"); + + let bytes = save.to_bytes(); + let list = offsets::PARTY + 1; + assert_eq!(bytes[offsets::PARTY], 2, "party count byte"); + assert_eq!(bytes[list + 1], DEX_TO_INDEX[7], "species list entry 1"); + assert_eq!(bytes[list + 2], 0xFF, "species list sentinel"); + assert!( + bytes[list + 3..list + 7].iter().all(|&b| b == 0), + "species list tail is zero-filled" + ); +} diff --git a/crates/pksave/tests/items.rs b/crates/pksave/tests/items.rs index 9c00c68..861cd06 100644 --- a/crates/pksave/tests/items.rs +++ b/crates/pksave/tests/items.rs @@ -343,3 +343,23 @@ fn unknown_item_id_span_points_at_that_entry() { "span names entry 1's id byte" ); } + +#[test] +fn zero_quantity_reads_the_qty_byte_not_the_id() { + // Entry 1: valid id, quantity 0 — W-ITEMS-QTY-ZERO must fire (the + // check reads the qty byte at `at + 1`, not the id byte). + let mut save = blank(); + save.bag_items_mut().add(POTION, 1).expect("room"); + save.bag_items_mut().add(POTION, 1).expect("room"); + save.set_byte(offsets::BAG_ITEMS + 3, 0).expect("in range"); + let diags = save.diagnostics(); + let diag = diags + .iter() + .find(|d| d.code == "W-ITEMS-QTY-ZERO") + .expect("zero quantity flagged"); + assert_eq!( + diag.span, + Some(offsets::BAG_ITEMS + 3..offsets::BAG_ITEMS + 4), + "span names entry 1's quantity byte" + ); +}