|
|
|
@@ -422,19 +422,6 @@ mod tests {
|
|
|
|
|
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]
|
|
|
|
|
fn adjust_sfx_volume_clamps() {
|
|
|
|
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
|
|
|
@@ -467,77 +454,6 @@ mod tests {
|
|
|
|
|
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]
|
|
|
|
|
fn load_from_missing_file_returns_default() {
|
|
|
|
|
let path = tmp_path("missing_xyz");
|
|
|
|
@@ -554,250 +470,6 @@ mod tests {
|
|
|
|
|
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]
|
|
|
|
|
fn adjust_tooltip_delay_clamps_to_range() {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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]
|
|
|
|
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
|
|
|
|
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]
|
|
|
|
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
|
|
|
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
|
|
|
|