chore: prune low-value tests per CLAUDE_SPEC.md §10 + WORKFLOW §8
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.
Removed across `solitaire_data` and `solitaire_core`:
settings.rs −22 default-value, round-trip, legacy-format,
and per-field sanitized clamp tests. Adjust
and load-error tests retained — those exercise
real method logic.
progress.rs −1 generic round-trip on plain struct.
challenge.rs −1 challenge_count() returns CHALLENGE_SEEDS.len()
literally — testing it asserts the implementation
against itself.
game_state.rs −3 undo_count starts at 0, GameMode default is
Classic, time_attack score starts at 0 — all
default-value tests on freshly-constructed state.
card.rs −5 rank_value_ace + rank_value_king subsumed by
rank_values_are_sequential; suit_red + suit_black
consolidated into one complementarity test;
card_face_up_field_reflects_construction was
testing the struct literal.
Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.
Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,16 +77,6 @@ pub struct Card {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_ace_is_one() {
|
|
||||||
assert_eq!(Rank::Ace.value(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_king_is_thirteen() {
|
|
||||||
assert_eq!(Rank::King.value(), 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_values_are_sequential() {
|
fn rank_values_are_sequential() {
|
||||||
let ranks = [
|
let ranks = [
|
||||||
@@ -100,26 +90,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_red_is_diamonds_and_hearts() {
|
fn suit_red_and_black_are_complementary() {
|
||||||
assert!(Suit::Diamonds.is_red());
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
assert!(Suit::Hearts.is_red());
|
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||||
assert!(!Suit::Clubs.is_red());
|
}
|
||||||
assert!(!Suit::Spades.is_red());
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
}
|
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn suit_black_is_clubs_and_spades() {
|
|
||||||
assert!(Suit::Clubs.is_black());
|
|
||||||
assert!(Suit::Spades.is_black());
|
|
||||||
assert!(!Suit::Diamonds.is_black());
|
|
||||||
assert!(!Suit::Hearts.is_black());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn card_face_up_field_reflects_construction() {
|
|
||||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
|
||||||
assert!(!card.face_up);
|
|
||||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
|
||||||
assert!(card2.face_up);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -815,11 +815,6 @@ mod tests {
|
|||||||
assert!(g.undo_stack_len() <= 64);
|
assert!(g.undo_stack_len() <= 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undo_count_starts_at_zero() {
|
|
||||||
assert_eq!(new_game().undo_count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_count_increments_on_each_undo() {
|
fn undo_count_increments_on_each_undo() {
|
||||||
let mut g = new_game();
|
let mut g = new_game();
|
||||||
@@ -900,11 +895,6 @@ mod tests {
|
|||||||
assert_eq!(g.score, 0);
|
assert_eq!(g.score, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zen_mode_default_is_classic_via_default_trait() {
|
|
||||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zen_mode_field_persists_through_construction() {
|
fn zen_mode_field_persists_through_construction() {
|
||||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||||
@@ -956,12 +946,6 @@ mod tests {
|
|||||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn time_attack_score_starts_at_zero() {
|
|
||||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
|
||||||
assert_eq!(g.score, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn time_attack_draw_three_combination() {
|
fn time_attack_draw_three_combination() {
|
||||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||||
|
|||||||
@@ -90,9 +90,4 @@ mod tests {
|
|||||||
seeds.dedup();
|
seeds.dedup();
|
||||||
assert_eq!(seeds.len(), len_before);
|
assert_eq!(seeds.len(), len_before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_count_matches_seed_list_length() {
|
|
||||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,21 +162,6 @@ mod tests {
|
|||||||
|
|
||||||
// --- Persistence ---
|
// --- Persistence ---
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut p = PlayerProgress::default();
|
|
||||||
p.add_xp(1234);
|
|
||||||
p.unlocked_card_backs.push(2);
|
|
||||||
save_progress_to(&path, &p).expect("save");
|
|
||||||
let loaded = load_progress_from(&path);
|
|
||||||
assert_eq!(loaded.total_xp, 1234);
|
|
||||||
assert_eq!(loaded.level, p.level);
|
|
||||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
|
|||||||
@@ -422,19 +422,6 @@ mod tests {
|
|||||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_reasonable() {
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert!(!s.first_run_complete);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_sfx_volume_clamps() {
|
fn adjust_sfx_volume_clamps() {
|
||||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||||
@@ -467,77 +454,6 @@ mod tests {
|
|||||||
assert!(s.first_run_complete);
|
assert!(s.first_run_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitized_clamps_music_volume() {
|
|
||||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s.music_volume, 1.0);
|
|
||||||
|
|
||||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s2.music_volume, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
sfx_volume: 0.42,
|
|
||||||
first_run_complete: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load_full_settings() {
|
|
||||||
let path = tmp_path("round_trip_full");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
draw_mode: DrawMode::DrawThree,
|
|
||||||
sfx_volume: 0.3,
|
|
||||||
music_volume: 0.7,
|
|
||||||
animation_speed: AnimSpeed::Fast,
|
|
||||||
theme: Theme::Dark,
|
|
||||||
sync_backend: SyncBackend::SolitaireServer {
|
|
||||||
url: "https://example.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
},
|
|
||||||
selected_card_back: 0,
|
|
||||||
selected_background: 0,
|
|
||||||
first_run_complete: true,
|
|
||||||
color_blind_mode: false,
|
|
||||||
window_geometry: None,
|
|
||||||
selected_theme_id: "default".to_string(),
|
|
||||||
shown_achievement_onboarding: false,
|
|
||||||
tooltip_delay_secs: default_tooltip_delay(),
|
|
||||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
|
||||||
winnable_deals_only: false,
|
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
|
||||||
// selected_card_back and selected_background must survive save→load with
|
|
||||||
// non-zero values — zero is the default and not a meaningful regression check.
|
|
||||||
let path = tmp_path("cosmetic_selections");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 3,
|
|
||||||
selected_background: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 3);
|
|
||||||
assert_eq!(loaded.selected_background, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
@@ -554,250 +470,6 @@ mod tests {
|
|||||||
assert_eq!(s, Settings::default());
|
assert_eq!(s, Settings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
|
||||||
// Simulate a settings.json written by an older version that only had
|
|
||||||
// sfx_volume and first_run_complete.
|
|
||||||
let path = tmp_path("old_format");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
let s = load_settings_from(&path);
|
|
||||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
|
||||||
assert!(s.first_run_complete);
|
|
||||||
// New fields should fall back to their defaults.
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
|
||||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
|
||||||
// Simulate a JSON file that has no color_blind_mode field.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_blind_mode_round_trips() {
|
|
||||||
let path = tmp_path("color_blind");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
color_blind_mode: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #62 — selected_card_back
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_card_back_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_card_back, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_card_back_serializes_round_trip() {
|
|
||||||
let path = tmp_path("card_back_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #63 — selected_background
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_background, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_serializes_round_trip() {
|
|
||||||
let path = tmp_path("background_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_background: 3,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// window_geometry — persisted window size/position
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_window_geometry_default_is_none() {
|
|
||||||
assert!(
|
|
||||||
Settings::default().window_geometry.is_none(),
|
|
||||||
"default window_geometry must be None so first launch uses platform defaults"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_with_window_geometry_round_trip() {
|
|
||||||
let path = tmp_path("window_geometry_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let geom = WindowGeometry {
|
|
||||||
width: 1440,
|
|
||||||
height: 900,
|
|
||||||
x: 120,
|
|
||||||
y: 80,
|
|
||||||
};
|
|
||||||
let s = Settings {
|
|
||||||
window_geometry: Some(geom),
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(
|
|
||||||
loaded.window_geometry,
|
|
||||||
Some(geom),
|
|
||||||
"window_geometry must survive serde round-trip"
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
|
||||||
// A settings.json written by an older version of the game will be
|
|
||||||
// missing this field entirely. `#[serde(default)]` on the field
|
|
||||||
// must yield `None` rather than failing the whole deserialise.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
s.window_geometry.is_none(),
|
|
||||||
"legacy settings.json missing window_geometry must deserialize to None"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_geometry_explicit_null_deserializes_to_none() {
|
|
||||||
// An explicit `"window_geometry": null` is also valid input that
|
|
||||||
// must yield None — keeps tooling that hand-edits the file safe.
|
|
||||||
let json = br#"{ "window_geometry": null }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(s.window_geometry.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// shown_achievement_onboarding — first-win cue one-shot guard
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_shown_achievement_onboarding_default_is_false() {
|
|
||||||
assert!(
|
|
||||||
!Settings::default().shown_achievement_onboarding,
|
|
||||||
"default shown_achievement_onboarding must be false so the cue fires once"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_shown_achievement_onboarding_round_trip() {
|
|
||||||
let path = tmp_path("achievement_onboarding_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
shown_achievement_onboarding: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
loaded.shown_achievement_onboarding,
|
|
||||||
"shown_achievement_onboarding must survive serde round-trip"
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
|
||||||
// A settings.json written by an older version of the game will be
|
|
||||||
// missing this field entirely. `#[serde(default)]` on the field
|
|
||||||
// must yield `false` — the cue then fires on the next win, but
|
|
||||||
// only when stats.games_won == 1, so existing players who have
|
|
||||||
// already won past their first game won't see the toast either.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
!s.shown_achievement_onboarding,
|
|
||||||
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// tooltip_delay_secs — player-tunable tooltip hover delay
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_tooltip_delay_default_is_existing_baseline() {
|
|
||||||
// The existing baseline pre-slider is 0.5 s, matching the
|
|
||||||
// `MOTION_TOOLTIP_DELAY_SECS` constant in
|
|
||||||
// `solitaire_engine::ui_theme`. The default must not regress.
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!(
|
|
||||||
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
|
|
||||||
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
|
|
||||||
s.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_tooltip_delay_round_trip() {
|
|
||||||
let path = tmp_path("tooltip_delay_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
tooltip_delay_secs: 1.2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
|
|
||||||
"tooltip_delay_secs must survive serde round-trip; got {}",
|
|
||||||
loaded.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to the existing 0.5 s baseline rather
|
|
||||||
// than failing the whole load or yielding a zero value.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
|
|
||||||
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
|
|
||||||
default_tooltip_delay(),
|
|
||||||
s.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_tooltip_delay_clamps_to_range() {
|
fn adjust_tooltip_delay_clamps_to_range() {
|
||||||
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||||
@@ -811,90 +483,6 @@ mod tests {
|
|||||||
assert_eq!(s.tooltip_delay_secs, 0.0);
|
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitized_clamps_out_of_range_tooltip_delay() {
|
|
||||||
// Negative or oversized values from a hand-edited file must be
|
|
||||||
// clamped on load.
|
|
||||||
let s = Settings {
|
|
||||||
tooltip_delay_secs: -0.4,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
|
|
||||||
|
|
||||||
let s2 = Settings {
|
|
||||||
tooltip_delay_secs: 99.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_default_is_one() {
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!(
|
|
||||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
|
||||||
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
|
|
||||||
s.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_round_trip() {
|
|
||||||
let path = tmp_path("time_bonus_multiplier_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
time_bonus_multiplier: 1.5,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
|
|
||||||
"time_bonus_multiplier must survive serde round-trip; got {}",
|
|
||||||
loaded.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to the existing 1.0 baseline so old
|
|
||||||
// players see no change to their win-modal bonuses.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
|
||||||
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
|
|
||||||
s.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_clamps_to_range() {
|
|
||||||
// Negative or oversized values from a hand-edited file must be
|
|
||||||
// clamped on load.
|
|
||||||
let s = Settings {
|
|
||||||
time_bonus_multiplier: -0.5,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
|
|
||||||
|
|
||||||
let s2 = Settings {
|
|
||||||
time_bonus_multiplier: 99.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||||
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||||
@@ -923,121 +511,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// winnable_deals_only — solver-backed deal filter toggle
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_winnable_deals_only_default_is_false() {
|
|
||||||
// Off by default — the solver adds latency we shouldn't impose
|
|
||||||
// on every player without their consent.
|
|
||||||
assert!(
|
|
||||||
!Settings::default().winnable_deals_only,
|
|
||||||
"default winnable_deals_only must be false"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_winnable_deals_only_round_trip() {
|
|
||||||
let path = tmp_path("winnable_deals_only_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
winnable_deals_only: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
loaded.winnable_deals_only,
|
|
||||||
"winnable_deals_only must survive serde round-trip"
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to `false` (the default-off behaviour)
|
|
||||||
// rather than failing the whole load or surprising the player
|
|
||||||
// by switching the toggle on.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
!s.winnable_deals_only,
|
|
||||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// replay_move_interval_secs — player-tunable replay playback speed
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_replay_move_interval_default_is_zero_point_four_five() {
|
|
||||||
// The pre-slider baseline is 0.45 s/move, matching
|
|
||||||
// `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`.
|
|
||||||
// The default must not regress for players who never touch
|
|
||||||
// the slider.
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!(
|
|
||||||
(s.replay_move_interval_secs - 0.45).abs() < 1e-6,
|
|
||||||
"replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}",
|
|
||||||
s.replay_move_interval_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_replay_move_interval_round_trip() {
|
|
||||||
let path = tmp_path("replay_move_interval_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
replay_move_interval_secs: 0.20,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
(loaded.replay_move_interval_secs - 0.20).abs() < 1e-6,
|
|
||||||
"replay_move_interval_secs must survive serde round-trip; got {}",
|
|
||||||
loaded.replay_move_interval_secs
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_replay_move_interval_deserializes_to_default() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to the existing 0.45 s baseline so old
|
|
||||||
// players see no change to replay playback speed.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
(s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6,
|
|
||||||
"legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}",
|
|
||||||
default_replay_move_interval_secs(),
|
|
||||||
s.replay_move_interval_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_replay_move_interval_clamps_to_range() {
|
|
||||||
// Negative or oversized values from a hand-edited file must be
|
|
||||||
// clamped on load.
|
|
||||||
let s = Settings {
|
|
||||||
replay_move_interval_secs: 5.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
|
||||||
|
|
||||||
let s2 = Settings {
|
|
||||||
replay_move_interval_secs: -1.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_replay_move_interval_clamps_and_rounds() {
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||||
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||||
|
|||||||
Reference in New Issue
Block a user