refactor(engine): move hud/settings/game/input plugin tests into tests.rs files
Continues #118 after card_plugin (PR #124): the four remaining oversized plugin files become module directories with their trailing #[cfg(test)] blocks extracted verbatim into sibling tests.rs files, following the replay_overlay/ pattern. hud_plugin: 3,598 -> 2,725 + 872 (44 tests) settings_plugin: 3,512 -> 2,857 + 654 (25 tests) game_plugin: 2,562 -> 1,310 + 1,251 (39 tests) input_plugin: 2,540 -> 1,832 + 707 (31 tests) Pure mechanical moves via git mv — no runtime code changes; all 139 tests preserved and passing. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2722,877 +2722,4 @@ fn toggle_hud_on_tap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::game_plugin::GamePlugin;
|
|
||||||
use crate::table_plugin::TablePlugin;
|
|
||||||
use chrono::Local;
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
|
|
||||||
fn headless_app() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_plugin_registers_without_panic() {
|
|
||||||
let _app = headless_app();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
app.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
|
||||||
app.world_mut()
|
|
||||||
.query_filtered::<&Text, With<M>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.map(|t| t.0.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn score_reflects_game_state() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn moves_reflects_game_state() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(42);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_three_mode_shows_draw_3_badge() {
|
|
||||||
use solitaire_core::game_state::GameMode;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zen_mode_hides_score() {
|
|
||||||
use solitaire_core::game_state::GameMode;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
|
||||||
app.update();
|
|
||||||
// Zen mode spec: "No score display" → text must be empty.
|
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn time_display_uses_mm_ss_format() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.elapsed_seconds = 125;
|
|
||||||
app.update();
|
|
||||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
|
||||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// format_time_limit (pure function)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_300_is_5_00() {
|
|
||||||
assert_eq!(format_time_limit(300), "5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_zero() {
|
|
||||||
assert_eq!(format_time_limit(0), "0:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_pads_seconds() {
|
|
||||||
assert_eq!(format_time_limit(65), "1:05");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// challenge_hud_text (pure function)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_shows_time_limit() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_shows_score_goal() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: Some(4000),
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_empty_when_no_constraints() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_above_60_is_info() {
|
|
||||||
let c = challenge_time_color(61);
|
|
||||||
assert_eq!(c, STATE_INFO);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_exactly_60_is_info() {
|
|
||||||
let c = challenge_time_color(60);
|
|
||||||
assert_eq!(c, STATE_INFO);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_59_is_warning() {
|
|
||||||
let c = challenge_time_color(59);
|
|
||||||
assert_eq!(c, STATE_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_30_is_warning() {
|
|
||||||
let c = challenge_time_color(30);
|
|
||||||
assert_eq!(c, STATE_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_29_is_danger() {
|
|
||||||
let c = challenge_time_color(29);
|
|
||||||
assert_eq!(c, STATE_DANGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_zero_is_danger() {
|
|
||||||
let c = challenge_time_color(0);
|
|
||||||
assert_eq!(c, STATE_DANGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudChallenge in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: Some("Win fast".to_string()),
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
});
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: Some(4000),
|
|
||||||
max_time_secs: None,
|
|
||||||
});
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_clears_on_win() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
});
|
|
||||||
// Mark the game as won — HudChallenge should be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudUndos in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undos_hud_empty_at_game_start() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undos_hud_shows_count_after_undo() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_undos(3);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudAutoComplete in-app tests (Task #56)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn headless_app_with_auto_complete() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.init_resource::<AutoCompleteState>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_complete_badge_shows_auto_when_active() {
|
|
||||||
let mut app = headless_app_with_auto_complete();
|
|
||||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
|
||||||
// Also trigger game state change so the update fires.
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(1);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_complete_badge_empty_when_inactive() {
|
|
||||||
let mut app = headless_app_with_auto_complete();
|
|
||||||
// active is false by default.
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(1);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudRecycles in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-One, no recycles yet — text must be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-Three, no recycles yet — text must also be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawThree);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_shows_count_draw_three() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
|
||||||
gs.force_test_recycles(3);
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_shows_count_draw_one() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
|
||||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
gs.force_test_recycles(2);
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Score-change feedback (G2)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
|
||||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
|
||||||
/// local to avoid coupling the two test modules.
|
|
||||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
|
||||||
use bevy::time::TimeUpdateStrategy;
|
|
||||||
use std::time::Duration;
|
|
||||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
|
||||||
secs,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Counts entities matching component `M` currently in the world.
|
|
||||||
fn count_with<M: Component>(app: &mut App) -> usize {
|
|
||||||
app.world_mut().query::<&M>().iter(app.world()).count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
|
||||||
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
|
||||||
/// component is also inserted on the score readout — both signals
|
|
||||||
/// fire from the same delta detection.
|
|
||||||
#[test]
|
|
||||||
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
|
||||||
// can be asserted exactly: with Automatic strategy a few ms
|
|
||||||
// of wall-clock time leaks in between updates and the alpha
|
|
||||||
// drifts below 1.0 by `dt / lifetime`.
|
|
||||||
set_manual_time_step(&mut app, 0.0);
|
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
|
||||||
// is the smallest jump that triggers the floater.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// One floater should now exist.
|
|
||||||
let count = count_with::<ScoreFloater>(&mut app);
|
|
||||||
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
|
||||||
|
|
||||||
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
|
||||||
// detect system spawns the floater coloured ACCENT_PRIMARY
|
|
||||||
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
|
||||||
let world = app.world_mut();
|
|
||||||
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
|
||||||
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
|
||||||
assert_eq!(color.0, ACCENT_PRIMARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
|
||||||
/// the floater has reached the end of its lifetime and despawned.
|
|
||||||
#[test]
|
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
|
||||||
|
|
||||||
// Advance by a delta well past the floater's lifetime — the
|
|
||||||
// single oversized tick clamps t at 1.0 and the entity is
|
|
||||||
// despawned in the same `Update`.
|
|
||||||
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
|
||||||
app.update();
|
|
||||||
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"floater should have despawned after its full lifetime"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small score change (below the threshold) inserts a pulse on
|
|
||||||
/// the readout but never spawns a floater — keeping the floating
|
|
||||||
/// "+N" reserved for meaningful score jumps.
|
|
||||||
#[test]
|
|
||||||
fn score_increase_below_threshold_does_not_spawn_floater() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"delta of +5 must not spawn a floater"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
|
||||||
/// returns to 1.0 at the endpoints. Pure-function check that
|
|
||||||
/// guards the curve shape against future tweaks.
|
|
||||||
#[test]
|
|
||||||
fn score_pulse_scale_is_triangular() {
|
|
||||||
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
|
||||||
// Values outside [0,1] are clamped before the curve runs.
|
|
||||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
|
||||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
|
||||||
/// curve test — the two animations share a triangular shape so a
|
|
||||||
/// future tweak that desyncs them shows up here.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
|
||||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
|
||||||
"streak flourish scale at t=0 must be 1.0",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
|
||||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
|
||||||
"streak flourish scale at t=duration must return to 1.0",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
|
||||||
/// foundation flourish's clamp behaviour so the score readout never
|
|
||||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_clamps_out_of_range() {
|
|
||||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
|
||||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
|
||||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
|
||||||
/// divides by zero.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_zero_duration_is_one() {
|
|
||||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
|
||||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
|
||||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
|
||||||
#[test]
|
|
||||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
|
||||||
use solitaire_data::Settings;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.insert_resource(SettingsResource(Settings {
|
|
||||||
reduce_motion_mode: true,
|
|
||||||
..Settings::default()
|
|
||||||
}));
|
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScorePulse>(&mut app),
|
|
||||||
0,
|
|
||||||
"ScorePulse must not spawn under reduce-motion"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"ScoreFloater must not spawn under reduce-motion"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Returns the `Focusable` carried by the unique entity matching
|
|
||||||
/// marker `M`. Helper for the HUD focus tests.
|
|
||||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
|
||||||
app.world_mut()
|
|
||||||
.query_filtered::<&Focusable, With<M>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_buttons_get_focusable_marker() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Every action-bar button is in `FocusGroup::Hud`.
|
|
||||||
for f in [
|
|
||||||
focusable_for::<MenuButton>(&mut app),
|
|
||||||
focusable_for::<UndoButton>(&mut app),
|
|
||||||
focusable_for::<PauseButton>(&mut app),
|
|
||||||
focusable_for::<HelpButton>(&mut app),
|
|
||||||
focusable_for::<HintButton>(&mut app),
|
|
||||||
focusable_for::<ModesButton>(&mut app),
|
|
||||||
focusable_for::<NewGameButton>(&mut app),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
f.group,
|
|
||||||
FocusGroup::Hud,
|
|
||||||
"every HUD action button must be in FocusGroup::Hud"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the tooltip string carried by the unique entity matching
|
|
||||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
|
||||||
/// which is the invariant we want to enforce for HUD readouts and
|
|
||||||
/// action buttons (each marker is spawned exactly once).
|
|
||||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
|
||||||
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
|
||||||
let world = app.world();
|
|
||||||
let mut iter = q.iter(world);
|
|
||||||
let first = iter
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"expected a Tooltip on the {} entity",
|
|
||||||
std::any::type_name::<M>()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.0
|
|
||||||
.clone()
|
|
||||||
.into_owned();
|
|
||||||
assert!(
|
|
||||||
iter.next().is_none(),
|
|
||||||
"expected exactly one Tooltip-bearing entity for {}",
|
|
||||||
std::any::type_name::<M>()
|
|
||||||
);
|
|
||||||
first
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
|
||||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
|
||||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
|
||||||
/// invariant — one marker entity, one tooltip, exact text — is
|
|
||||||
/// asserted consistently across every element.
|
|
||||||
#[test]
|
|
||||||
fn hud_elements_carry_expected_tooltip_strings() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// HUD readouts (left column, top to bottom).
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudScore>(&mut app),
|
|
||||||
"Points earned this game. Hidden in Zen mode."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudMoves>(&mut app),
|
|
||||||
"Moves you've made this game. Counts placements and stock draws."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudTime>(&mut app),
|
|
||||||
"Time on this game. Counts down in Time Attack."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudMode>(&mut app),
|
|
||||||
"Active game mode. Click Modes to switch."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudChallenge>(&mut app),
|
|
||||||
"Today's daily challenge target. Beat it for bonus XP."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudDrawCycle>(&mut app),
|
|
||||||
"Cards drawn on the next stock click in Draw-Three."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudUndos>(&mut app),
|
|
||||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudRecycles>(&mut app),
|
|
||||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudAutoComplete>(&mut app),
|
|
||||||
"Board is solvable from here. Press Enter to auto-finish."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudSelection>(&mut app),
|
|
||||||
"Pile selected with Tab. Use arrows or Enter to act."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Action bar (left to right).
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<MenuButton>(&mut app),
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<UndoButton>(&mut app),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<PauseButton>(&mut app),
|
|
||||||
"Pause the game and freeze the timer."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HelpButton>(&mut app),
|
|
||||||
"Show controls, rules, and keyboard shortcuts."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HintButton>(&mut app),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<ModesButton>(&mut app),
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<NewGameButton>(&mut app),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every interior row of the Modes and Menu popovers must carry a
|
|
||||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
|
||||||
/// tooltips are already covered above; this test extends the
|
|
||||||
/// invariant inward so hover discoverability is uniform across the
|
|
||||||
/// HUD's nested controls.
|
|
||||||
///
|
|
||||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
|
||||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
|
||||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
|
||||||
/// Headless click simulation isn't needed — the contract under
|
|
||||||
/// test is "every popover row spawns with a tooltip", which is a
|
|
||||||
/// property of the spawn helpers themselves.
|
|
||||||
#[test]
|
|
||||||
fn popover_rows_carry_tooltip_strings() {
|
|
||||||
use crate::progress_plugin::ProgressResource;
|
|
||||||
use solitaire_sync::progress::PlayerProgress;
|
|
||||||
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// Force every mode row to render: level past the challenge
|
|
||||||
// unlock threshold, plus a daily challenge resource so the
|
|
||||||
// Daily row appears.
|
|
||||||
let progress = ProgressResource(PlayerProgress {
|
|
||||||
level: CHALLENGE_UNLOCK_LEVEL,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let daily = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Spawn both popovers via their helpers. Mirrors how the click
|
|
||||||
// handlers invoke them in production — we just skip the click.
|
|
||||||
{
|
|
||||||
let world = app.world_mut();
|
|
||||||
let mut commands = world.commands();
|
|
||||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
|
||||||
spawn_menu_popover(&mut commands, None);
|
|
||||||
world.flush();
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
|
||||||
// and the count must match the five canonical modes.
|
|
||||||
let mut mode_q = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
|
||||||
let mode_tooltips: Vec<String> = mode_q
|
|
||||||
.iter(app.world())
|
|
||||||
.map(|t| t.0.clone().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
mode_tooltips.len(),
|
|
||||||
5,
|
|
||||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
|
||||||
mode_tooltips.len()
|
|
||||||
);
|
|
||||||
// Every approved mode tooltip string must be present somewhere
|
|
||||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
|
||||||
// order test elsewhere already covers that.
|
|
||||||
for expected in [
|
|
||||||
"Standard Klondike. Score, timer, and full progression.",
|
|
||||||
"Today's seeded deal. Same for every player worldwide.",
|
|
||||||
"No timer, no score, no penalties. Just play.",
|
|
||||||
"Hand-picked hard seeds. No undo allowed.",
|
|
||||||
"Win as many games as you can in ten minutes.",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
mode_tooltips.iter().any(|s| s == expected),
|
|
||||||
"missing mode tooltip: {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same contract for MenuOption rows: seven entries, each with a
|
|
||||||
// tooltip, exact strings matching the approved microcopy.
|
|
||||||
let mut menu_q = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
|
||||||
let menu_tooltips: Vec<String> = menu_q
|
|
||||||
.iter(app.world())
|
|
||||||
.map(|t| t.0.clone().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
menu_tooltips.len(),
|
|
||||||
7,
|
|
||||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
|
||||||
menu_tooltips.len()
|
|
||||||
);
|
|
||||||
for expected in [
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
|
||||||
"Browse unlocked achievements and the rewards still ahead.",
|
|
||||||
"Your level, XP progress, and sync status.",
|
|
||||||
"Audio, animations, theme, draw mode, and sync.",
|
|
||||||
"Top players from your sync server. Opt in from Profile.",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
menu_tooltips.iter().any(|s| s == expected),
|
|
||||||
"missing menu tooltip: {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_button_order_matches_spawn_order() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
|
||||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
|
||||||
// that order so Tab cycles them as the player reads them.
|
|
||||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
|
||||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
|
||||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
|
||||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
|
||||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
|
||||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
|
||||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_focus_only_engages_when_button_hovered() {
|
|
||||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
|
||||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
|
||||||
// this test:
|
|
||||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
|
||||||
// still holds when nothing is hovered).
|
|
||||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
|
||||||
// `FocusedButton` to a Hud-grouped entity.
|
|
||||||
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
|
||||||
use crate::ui_modal::UiModalPlugin;
|
|
||||||
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(UiModalPlugin)
|
|
||||||
.add_plugins(UiFocusPlugin)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
|
||||||
// modal open and no hover ⇒ FocusedButton stays None.
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<FocusedButton>().0.is_none(),
|
|
||||||
"no modal open, no auto-focus"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
|
||||||
// resolves no active group and returns early — Tab must not
|
|
||||||
// advance the HUD focus ring on its own.
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release_all();
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::Tab);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<FocusedButton>().0.is_none(),
|
|
||||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
|
||||||
);
|
|
||||||
|
|
||||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
|
||||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
|
||||||
let menu_entity = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<Entity, With<MenuButton>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.expect("MenuButton entity should exist");
|
|
||||||
app.world_mut()
|
|
||||||
.entity_mut(menu_entity)
|
|
||||||
.insert(Interaction::Hovered);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release_all();
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::Tab);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let focused = app
|
|
||||||
.world()
|
|
||||||
.resource::<FocusedButton>()
|
|
||||||
.0
|
|
||||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
|
||||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
|
||||||
// the action-bar buttons), not anything else in the world.
|
|
||||||
let focusable = app
|
|
||||||
.world()
|
|
||||||
.entity(focused)
|
|
||||||
.get::<Focusable>()
|
|
||||||
.expect("focused entity must carry Focusable");
|
|
||||||
assert_eq!(
|
|
||||||
focusable.group,
|
|
||||||
FocusGroup::Hud,
|
|
||||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,872 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use chrono::Local;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(HudPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hud_plugin_registers_without_panic() {
|
||||||
|
let _app = headless_app();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<&Text, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|t| t.0.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_reflects_game_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_reflects_game_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.set_test_move_count(42);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_mode_shows_draw_3_badge() {
|
||||||
|
use solitaire_core::game_state::GameMode;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zen_mode_hides_score() {
|
||||||
|
use solitaire_core::game_state::GameMode;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||||
|
app.update();
|
||||||
|
// Zen mode spec: "No score display" → text must be empty.
|
||||||
|
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_display_uses_mm_ss_format() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.elapsed_seconds = 125;
|
||||||
|
app.update();
|
||||||
|
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||||
|
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// format_time_limit (pure function)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_300_is_5_00() {
|
||||||
|
assert_eq!(format_time_limit(300), "5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_zero() {
|
||||||
|
assert_eq!(format_time_limit(0), "0:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_pads_seconds() {
|
||||||
|
assert_eq!(format_time_limit(65), "1:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// challenge_hud_text (pure function)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_shows_time_limit() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_shows_score_goal() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: Some(4000),
|
||||||
|
max_time_secs: None,
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_empty_when_no_constraints() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: None,
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_above_60_is_info() {
|
||||||
|
let c = challenge_time_color(61);
|
||||||
|
assert_eq!(c, STATE_INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_exactly_60_is_info() {
|
||||||
|
let c = challenge_time_color(60);
|
||||||
|
assert_eq!(c, STATE_INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_59_is_warning() {
|
||||||
|
let c = challenge_time_color(59);
|
||||||
|
assert_eq!(c, STATE_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_30_is_warning() {
|
||||||
|
let c = challenge_time_color(30);
|
||||||
|
assert_eq!(c, STATE_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_29_is_danger() {
|
||||||
|
let c = challenge_time_color(29);
|
||||||
|
assert_eq!(c, STATE_DANGER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_time_color_zero_is_danger() {
|
||||||
|
let c = challenge_time_color(0);
|
||||||
|
assert_eq!(c, STATE_DANGER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudChallenge in-app tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: Some("Win fast".to_string()),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
});
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: Some(4000),
|
||||||
|
max_time_secs: None,
|
||||||
|
});
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_clears_on_win() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
});
|
||||||
|
// Mark the game as won — HudChallenge should be empty.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudUndos in-app tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undos_hud_empty_at_game_start() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undos_hud_shows_count_after_undo() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_undos(3);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudAutoComplete in-app tests (Task #56)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn headless_app_with_auto_complete() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(HudPlugin);
|
||||||
|
app.init_resource::<AutoCompleteState>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_complete_badge_shows_auto_when_active() {
|
||||||
|
let mut app = headless_app_with_auto_complete();
|
||||||
|
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
||||||
|
// Also trigger game state change so the update fires.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.set_test_move_count(1);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_complete_badge_empty_when_inactive() {
|
||||||
|
let mut app = headless_app_with_auto_complete();
|
||||||
|
// active is false by default.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.set_test_move_count(1);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudRecycles in-app tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Draw-One, no recycles yet — text must be empty.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Draw-Three, no recycles yet — text must also be empty.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycles_hud_shows_count_draw_three() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
|
gs.force_test_recycles(3);
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycles_hud_shows_count_draw_one() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||||
|
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
gs.force_test_recycles(2);
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
||||||
|
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
||||||
|
/// local to avoid coupling the two test modules.
|
||||||
|
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||||
|
secs,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts entities matching component `M` currently in the world.
|
||||||
|
fn count_with<M: Component>(app: &mut App) -> usize {
|
||||||
|
app.world_mut().query::<&M>().iter(app.world()).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
||||||
|
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
||||||
|
/// component is also inserted on the score readout — both signals
|
||||||
|
/// fire from the same delta detection.
|
||||||
|
#[test]
|
||||||
|
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
||||||
|
// can be asserted exactly: with Automatic strategy a few ms
|
||||||
|
// of wall-clock time leaks in between updates and the alpha
|
||||||
|
// drifts below 1.0 by `dt / lifetime`.
|
||||||
|
set_manual_time_step(&mut app, 0.0);
|
||||||
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
|
// is the smallest jump that triggers the floater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// One floater should now exist.
|
||||||
|
let count = count_with::<ScoreFloater>(&mut app);
|
||||||
|
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
||||||
|
|
||||||
|
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
||||||
|
// detect system spawns the floater coloured ACCENT_PRIMARY
|
||||||
|
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
||||||
|
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
||||||
|
assert_eq!(color.0, ACCENT_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
||||||
|
/// the floater has reached the end of its lifetime and despawned.
|
||||||
|
#[test]
|
||||||
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
|
// Advance by a delta well past the floater's lifetime — the
|
||||||
|
// single oversized tick clamps t at 1.0 and the entity is
|
||||||
|
// despawned in the same `Update`.
|
||||||
|
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
||||||
|
app.update();
|
||||||
|
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"floater should have despawned after its full lifetime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small score change (below the threshold) inserts a pulse on
|
||||||
|
/// the readout but never spawns a floater — keeping the floating
|
||||||
|
/// "+N" reserved for meaningful score jumps.
|
||||||
|
#[test]
|
||||||
|
fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"delta of +5 must not spawn a floater"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
||||||
|
/// returns to 1.0 at the endpoints. Pure-function check that
|
||||||
|
/// guards the curve shape against future tweaks.
|
||||||
|
#[test]
|
||||||
|
fn score_pulse_scale_is_triangular() {
|
||||||
|
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
||||||
|
// Values outside [0,1] are clamped before the curve runs.
|
||||||
|
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||||
|
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||||
|
/// curve test — the two animations share a triangular shape so a
|
||||||
|
/// future tweak that desyncs them shows up here.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=0 must be 1.0",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||||
|
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=duration must return to 1.0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||||
|
/// foundation flourish's clamp behaviour so the score readout never
|
||||||
|
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_clamps_out_of_range() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||||
|
/// divides by zero.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_zero_duration_is_one() {
|
||||||
|
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||||
|
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||||
|
#[test]
|
||||||
|
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScorePulse must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScoreFloater must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the `Focusable` carried by the unique entity matching
|
||||||
|
/// marker `M`. Helper for the HUD focus tests.
|
||||||
|
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<&Focusable, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hud_buttons_get_focusable_marker() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Every action-bar button is in `FocusGroup::Hud`.
|
||||||
|
for f in [
|
||||||
|
focusable_for::<MenuButton>(&mut app),
|
||||||
|
focusable_for::<UndoButton>(&mut app),
|
||||||
|
focusable_for::<PauseButton>(&mut app),
|
||||||
|
focusable_for::<HelpButton>(&mut app),
|
||||||
|
focusable_for::<HintButton>(&mut app),
|
||||||
|
focusable_for::<ModesButton>(&mut app),
|
||||||
|
focusable_for::<NewGameButton>(&mut app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
f.group,
|
||||||
|
FocusGroup::Hud,
|
||||||
|
"every HUD action button must be in FocusGroup::Hud"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the tooltip string carried by the unique entity matching
|
||||||
|
/// marker `M`. Panics if zero or more than one such entity exists,
|
||||||
|
/// which is the invariant we want to enforce for HUD readouts and
|
||||||
|
/// action buttons (each marker is spawned exactly once).
|
||||||
|
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
||||||
|
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
||||||
|
let world = app.world();
|
||||||
|
let mut iter = q.iter(world);
|
||||||
|
let first = iter
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"expected a Tooltip on the {} entity",
|
||||||
|
std::any::type_name::<M>()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.0
|
||||||
|
.clone()
|
||||||
|
.into_owned();
|
||||||
|
assert!(
|
||||||
|
iter.next().is_none(),
|
||||||
|
"expected exactly one Tooltip-bearing entity for {}",
|
||||||
|
std::any::type_name::<M>()
|
||||||
|
);
|
||||||
|
first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every HUD readout and action button must spawn with a `Tooltip`
|
||||||
|
/// carrying the approved canonical microcopy. Mirrors the structure
|
||||||
|
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
||||||
|
/// invariant — one marker entity, one tooltip, exact text — is
|
||||||
|
/// asserted consistently across every element.
|
||||||
|
#[test]
|
||||||
|
fn hud_elements_carry_expected_tooltip_strings() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// HUD readouts (left column, top to bottom).
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudScore>(&mut app),
|
||||||
|
"Points earned this game. Hidden in Zen mode."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudMoves>(&mut app),
|
||||||
|
"Moves you've made this game. Counts placements and stock draws."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudTime>(&mut app),
|
||||||
|
"Time on this game. Counts down in Time Attack."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudMode>(&mut app),
|
||||||
|
"Active game mode. Click Modes to switch."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudChallenge>(&mut app),
|
||||||
|
"Today's daily challenge target. Beat it for bonus XP."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudDrawCycle>(&mut app),
|
||||||
|
"Cards drawn on the next stock click in Draw-Three."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudUndos>(&mut app),
|
||||||
|
"Undos used this game. Any undo blocks the No Undo achievement."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudRecycles>(&mut app),
|
||||||
|
"Times you've recycled the stock. Three or more unlocks Comeback."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudAutoComplete>(&mut app),
|
||||||
|
"Board is solvable from here. Press Enter to auto-finish."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HudSelection>(&mut app),
|
||||||
|
"Pile selected with Tab. Use arrows or Enter to act."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Action bar (left to right).
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<MenuButton>(&mut app),
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<UndoButton>(&mut app),
|
||||||
|
"Take back your last move. Costs points and blocks No Undo."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<PauseButton>(&mut app),
|
||||||
|
"Pause the game and freeze the timer."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HelpButton>(&mut app),
|
||||||
|
"Show controls, rules, and keyboard shortcuts."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<HintButton>(&mut app),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<ModesButton>(&mut app),
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tooltip_for::<NewGameButton>(&mut app),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every interior row of the Modes and Menu popovers must carry a
|
||||||
|
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
||||||
|
/// tooltips are already covered above; this test extends the
|
||||||
|
/// invariant inward so hover discoverability is uniform across the
|
||||||
|
/// HUD's nested controls.
|
||||||
|
///
|
||||||
|
/// We invoke the popover spawn helpers directly with a maxed-out
|
||||||
|
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
||||||
|
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
||||||
|
/// Headless click simulation isn't needed — the contract under
|
||||||
|
/// test is "every popover row spawns with a tooltip", which is a
|
||||||
|
/// property of the spawn helpers themselves.
|
||||||
|
#[test]
|
||||||
|
fn popover_rows_carry_tooltip_strings() {
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use solitaire_sync::progress::PlayerProgress;
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Force every mode row to render: level past the challenge
|
||||||
|
// unlock threshold, plus a daily challenge resource so the
|
||||||
|
// Daily row appears.
|
||||||
|
let progress = ProgressResource(PlayerProgress {
|
||||||
|
level: CHALLENGE_UNLOCK_LEVEL,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let daily = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn both popovers via their helpers. Mirrors how the click
|
||||||
|
// handlers invoke them in production — we just skip the click.
|
||||||
|
{
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
||||||
|
spawn_menu_popover(&mut commands, None);
|
||||||
|
world.flush();
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Every ModeOption-tagged entity must also carry a Tooltip,
|
||||||
|
// and the count must match the five canonical modes.
|
||||||
|
let mut mode_q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Tooltip, With<ModeOption>>();
|
||||||
|
let mode_tooltips: Vec<String> = mode_q
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|t| t.0.clone().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
mode_tooltips.len(),
|
||||||
|
5,
|
||||||
|
"expected a tooltip on each of the 5 mode rows, got {}",
|
||||||
|
mode_tooltips.len()
|
||||||
|
);
|
||||||
|
// Every approved mode tooltip string must be present somewhere
|
||||||
|
// among the ModeOption rows. Order isn't asserted — the spawn
|
||||||
|
// order test elsewhere already covers that.
|
||||||
|
for expected in [
|
||||||
|
"Standard Klondike. Score, timer, and full progression.",
|
||||||
|
"Today's seeded deal. Same for every player worldwide.",
|
||||||
|
"No timer, no score, no penalties. Just play.",
|
||||||
|
"Hand-picked hard seeds. No undo allowed.",
|
||||||
|
"Win as many games as you can in ten minutes.",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
mode_tooltips.iter().any(|s| s == expected),
|
||||||
|
"missing mode tooltip: {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same contract for MenuOption rows: seven entries, each with a
|
||||||
|
// tooltip, exact strings matching the approved microcopy.
|
||||||
|
let mut menu_q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Tooltip, With<MenuOption>>();
|
||||||
|
let menu_tooltips: Vec<String> = menu_q
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|t| t.0.clone().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
menu_tooltips.len(),
|
||||||
|
7,
|
||||||
|
"expected a tooltip on each of the 7 menu rows, got {}",
|
||||||
|
menu_tooltips.len()
|
||||||
|
);
|
||||||
|
for expected in [
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||||
|
"Browse unlocked achievements and the rewards still ahead.",
|
||||||
|
"Your level, XP progress, and sync status.",
|
||||||
|
"Audio, animations, theme, draw mode, and sync.",
|
||||||
|
"Top players from your sync server. Opt in from Profile.",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
menu_tooltips.iter().any(|s| s == expected),
|
||||||
|
"missing menu tooltip: {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hud_button_order_matches_spawn_order() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||||
|
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
||||||
|
// that order so Tab cycles them as the player reads them.
|
||||||
|
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||||
|
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||||
|
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||||
|
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||||
|
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||||
|
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||||
|
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hud_focus_only_engages_when_button_hovered() {
|
||||||
|
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
||||||
|
// engagement rule lives in `handle_focus_keys`. Two halves to
|
||||||
|
// this test:
|
||||||
|
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
||||||
|
// still holds when nothing is hovered).
|
||||||
|
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
||||||
|
// `FocusedButton` to a Hud-grouped entity.
|
||||||
|
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(HudPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||||
|
// modal open and no hover ⇒ FocusedButton stays None.
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<FocusedButton>().0.is_none(),
|
||||||
|
"no modal open, no auto-focus"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
||||||
|
// resolves no active group and returns early — Tab must not
|
||||||
|
// advance the HUD focus ring on its own.
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release_all();
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::Tab);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<FocusedButton>().0.is_none(),
|
||||||
|
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (b) Hover the Menu button — the leftmost HUD action — and
|
||||||
|
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
||||||
|
let menu_entity = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<MenuButton>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("MenuButton entity should exist");
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(menu_entity)
|
||||||
|
.insert(Interaction::Hovered);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release_all();
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::Tab);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let focused = app
|
||||||
|
.world()
|
||||||
|
.resource::<FocusedButton>()
|
||||||
|
.0
|
||||||
|
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
||||||
|
// The focused entity must itself be Hud-grouped (i.e. one of
|
||||||
|
// the action-bar buttons), not anything else in the world.
|
||||||
|
let focusable = app
|
||||||
|
.world()
|
||||||
|
.entity(focused)
|
||||||
|
.get::<Focusable>()
|
||||||
|
.expect("focused entity must carry Focusable");
|
||||||
|
assert_eq!(
|
||||||
|
focusable.group,
|
||||||
|
FocusGroup::Hud,
|
||||||
|
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1829,712 +1829,4 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
|||||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::layout::compute_layout;
|
|
||||||
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
|
|
||||||
fn clear_test_piles(game: &mut GameState) {
|
|
||||||
game.set_test_stock_cards(Vec::new());
|
|
||||||
game.set_test_waste_cards(Vec::new());
|
|
||||||
for foundation in [
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
] {
|
|
||||||
game.set_test_foundation_cards(foundation, Vec::new());
|
|
||||||
}
|
|
||||||
for tableau in [
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
game.set_test_tableau_cards(tableau, Vec::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dragged_card_z_matches_resting_stack_step() {
|
|
||||||
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
|
||||||
let step = dragged_card_z(1) - dragged_card_z(0);
|
|
||||||
assert!(
|
|
||||||
step > 0.02,
|
|
||||||
"drag step must exceed Android overlay local_z, got {step}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
step + 1e-4 >= STACK_FAN_FRAC,
|
|
||||||
"drag step must stay aligned with resting stack spacing, got {step}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_inside_returns_true() {
|
|
||||||
let center = Vec2::new(10.0, 20.0);
|
|
||||||
let size = Vec2::new(40.0, 60.0);
|
|
||||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_on_edge_returns_true() {
|
|
||||||
let center = Vec2::ZERO;
|
|
||||||
let size = Vec2::new(10.0, 10.0);
|
|
||||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_outside_returns_false() {
|
|
||||||
let center = Vec2::ZERO;
|
|
||||||
let size = Vec2::new(10.0, 10.0);
|
|
||||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
|
||||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
|
||||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_picks_top_of_tableau() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
|
||||||
// Its position: base.y + fan * 6.
|
|
||||||
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
||||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
||||||
assert_eq!(result.1, 6);
|
|
||||||
assert_eq!(result.2.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|
||||||
// Reproduces the reported "drags the wrong waste card" bug: with several
|
|
||||||
// cards in the waste, clicking the visible top must pick the actual top
|
|
||||||
// (last index), not the buffer card underneath it.
|
|
||||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
|
||||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
|
||||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
|
||||||
game.set_test_waste_cards(waste.clone());
|
|
||||||
|
|
||||||
let top_index = waste.len() - 1; // 2 = the visible top
|
|
||||||
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
|
||||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
|
||||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
|
||||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
|
||||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_picks_lone_waste_card() {
|
|
||||||
// "can't play the first card in the stock" — a waste of one card must
|
|
||||||
// still be draggable.
|
|
||||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
|
||||||
game.set_test_waste_cards(vec![card.clone()]);
|
|
||||||
|
|
||||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
|
||||||
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
|
||||||
assert_eq!(result.0, KlondikePile::Stock);
|
|
||||||
assert_eq!(result.1, 0);
|
|
||||||
assert_eq!(result.2, vec![card]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
|
||||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
|
||||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
|
||||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
|
||||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
|
||||||
// fanned card's click target lands on the card beneath it — so dragging
|
|
||||||
// the visible top card plays the wrong one.
|
|
||||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
|
||||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
|
||||||
let cw = layout.card_size.x;
|
|
||||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
|
||||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
||||||
layout.pile_positions.insert(
|
|
||||||
KlondikePile::Tableau(Tableau::Tableau2),
|
|
||||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
|
||||||
);
|
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
let waste = vec![
|
|
||||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
|
||||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
|
||||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
|
||||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
|
||||||
];
|
|
||||||
game.set_test_waste_cards(waste.clone());
|
|
||||||
|
|
||||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
|
||||||
let top_index = waste.len() - 1;
|
|
||||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
|
||||||
|
|
||||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
|
||||||
assert!(
|
|
||||||
(pos.x - expected).abs() < 1e-3,
|
|
||||||
"hit-test must use the shared waste fan step"
|
|
||||||
);
|
|
||||||
// The old fixed constant would have drifted from the renderer here.
|
|
||||||
let old = base.x + 2.0 * cw * 0.28;
|
|
||||||
assert!(
|
|
||||||
(pos.x - old).abs() > 1.0,
|
|
||||||
"shared step must differ from the old fixed step under tight spacing"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_skips_face_down_cards() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
|
||||||
// the bottom (index 6). Click at the topmost face-down card's
|
|
||||||
// position — its full body is partly visible above the fanned
|
|
||||||
// face-up card, but the iterator should skip face-down cards and
|
|
||||||
// the cursor sits above the face-up card's AABB, so the result
|
|
||||||
// is None.
|
|
||||||
let face_down_pos =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
|
||||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
|
||||||
// Regression test for the bug where input_plugin's hit-testing used
|
|
||||||
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
|
||||||
// at 0.12 — so for any column with face-down cards above the
|
|
||||||
// face-up bottom card, clicking the visible card face missed the
|
|
||||||
// hit-test box and only the bottom strip of the card responded.
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
|
||||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
|
||||||
let face_up_pos =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
||||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
||||||
assert_eq!(result.1, 6);
|
|
||||||
assert_eq!(result.2.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
|
||||||
// Manually construct a tableau with three face-up cards all stacked.
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
|
||||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
|
||||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
|
||||||
game.set_test_tableau_cards(
|
|
||||||
Tableau::Tableau1,
|
|
||||||
vec![king, queen.clone(), jack.clone()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
|
||||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
|
||||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
|
||||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
|
||||||
let queen_center =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
|
||||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
assert_eq!(start, 1);
|
|
||||||
assert_eq!(ids, vec![queen, jack]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_skips_non_top_waste_card() {
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
||||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
|
||||||
// the visually top card (three_hearts), with count = 1.
|
|
||||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
|
||||||
assert_eq!(start, 1);
|
|
||||||
assert_eq!(ids, vec![three_hearts]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
|
||||||
let mut game = game;
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
||||||
let target = find_drop_target(
|
|
||||||
pos,
|
|
||||||
&game,
|
|
||||||
&layout,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
|
||||||
);
|
|
||||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_drop_target_returns_none_for_origin() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
|
||||||
let target = find_drop_target(
|
|
||||||
pos,
|
|
||||||
&game,
|
|
||||||
&layout,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
|
||||||
);
|
|
||||||
assert_eq!(target, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Tableau 6 has 7 cards.
|
|
||||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
|
||||||
// Expected: card_height + 6 fan steps.
|
|
||||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
|
||||||
assert!(
|
|
||||||
(size.y - expected).abs() < 1e-3,
|
|
||||||
"expected {expected}, got {}",
|
|
||||||
size.y
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
|
||||||
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
|
||||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
||||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
|
||||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
|
||||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
|
||||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
|
||||||
let cursor = Vec2::new(top_card_x, waste_base.y);
|
|
||||||
|
|
||||||
let result = find_draggable_at(cursor, &game, &layout);
|
|
||||||
assert!(
|
|
||||||
result.is_some(),
|
|
||||||
"top fanned waste card must be hittable at its visual X position"
|
|
||||||
);
|
|
||||||
let (pile, _start, ids) = result.unwrap();
|
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
|
||||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
|
||||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Clear tableau 0 so it's an empty slot.
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
||||||
let result = find_draggable_at(pos, &game, &layout);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"clicking an empty pile must not produce a draggable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
for pile in [
|
|
||||||
KlondikePile::Stock,
|
|
||||||
KlondikePile::Foundation(Foundation::Foundation3),
|
|
||||||
] {
|
|
||||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
|
||||||
assert_eq!(size, layout.card_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #27 — best_destination pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_destination_returns_none_when_no_legal_move() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
// Clear everything except one card that has nowhere to go.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
|
||||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
||||||
assert!(best_destination(&card, &game).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// best_tableau_destination_for_stack pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// Only tableau 0 has anything; every other column is empty.
|
|
||||||
// A King is the only card that can go on an empty tableau column.
|
|
||||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
|
||||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
|
||||||
|
|
||||||
let result = best_tableau_destination_for_stack(
|
|
||||||
&king,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
&game,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
// Result must be some other empty tableau column, never the source.
|
|
||||||
if let Some((dest, _)) = result {
|
|
||||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
|
||||||
// All other piles are empty — no legal tableau target.
|
|
||||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
|
||||||
|
|
||||||
let result = best_tableau_destination_for_stack(
|
|
||||||
&two_clubs,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
&game,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"Two of Clubs has no legal tableau destination on empty piles"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #28 — find_hint pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_hint_finds_ace_to_foundation() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
// Place Ace of Clubs on top of tableau 0.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
|
||||||
|
|
||||||
let hint = find_hint(&game);
|
|
||||||
assert!(hint.is_some(), "should find a hint");
|
|
||||||
let (from, to) = hint.unwrap();
|
|
||||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
|
||||||
/// the "is there actually a game?" gating lives in
|
|
||||||
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
|
||||||
/// "No game to forfeit" toast instead of failing silently.
|
|
||||||
#[test]
|
|
||||||
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
|
||||||
// Build the system param state by hand so we don't rely on a
|
|
||||||
// full Bevy app: the assertion is that the function returns
|
|
||||||
// early on the paused branch without calling write_message.
|
|
||||||
// This is verified by the plain `if paused { return; }` shape;
|
|
||||||
// the body is small enough to inspect by reading.
|
|
||||||
// (Higher-level integration coverage lives in the pause-plugin
|
|
||||||
// tests where `forfeit_app` simulates the full flow.)
|
|
||||||
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// all_hints / new-game window — pure-function tests added during refactor
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
|
||||||
/// are no other moves and the stock is non-empty.
|
|
||||||
#[test]
|
|
||||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
|
||||||
// move exists. Leave one card in the stock.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
|
||||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
|
||||||
|
|
||||||
let hints = all_hints(&game);
|
|
||||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
|
||||||
let (from, to) = &hints[0];
|
|
||||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
|
||||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
|
||||||
}
|
|
||||||
|
|
||||||
// `all_hints` must be empty when both stock and waste are empty and no
|
|
||||||
// pile-to-pile move exists — the game is truly stuck.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
|
||||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
|
||||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
|
||||||
// to a smooth ease-out glide back to the origin pile.
|
|
||||||
//
|
|
||||||
// These tests build the component values exactly as `end_drag` and
|
|
||||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
|
||||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
|
||||||
// and mouse-button input, so we exercise the data path the same way the
|
|
||||||
// legacy `ShakeAnim` tests did.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
|
||||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
|
||||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
|
||||||
fn build_drag_reject_animation(
|
|
||||||
drag_pos: Vec2,
|
|
||||||
drag_z: f32,
|
|
||||||
target_pos: Vec2,
|
|
||||||
stack_index: usize,
|
|
||||||
) -> CardAnimation {
|
|
||||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
||||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
|
||||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
|
||||||
/// rejection. With the shake → tween migration, the assertion changes
|
|
||||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
|
||||||
/// gets a CardAnimation" — same coverage, new component.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
|
||||||
use solitaire_core::Deck as D;
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
// Simulate a stack drag of two cards.
|
|
||||||
let dragged_cards: Vec<Card> = vec![
|
|
||||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
|
||||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut animated: Vec<Card> = Vec::new();
|
|
||||||
for card in &dragged_cards {
|
|
||||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
|
||||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
|
||||||
// must exactly match the dragged set.
|
|
||||||
animated.push(card.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
animated, dragged_cards,
|
|
||||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `end` field of the inserted tween must equal the card's resting
|
|
||||||
/// slot in its origin pile — the position the card belongs at after a
|
|
||||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
|
||||||
/// and `sync_cards` would have to fight it back.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
|
||||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
|
||||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
|
||||||
let anim =
|
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
(anim.end - target_pos).length() < 1e-6,
|
|
||||||
"CardAnimation.end must match the origin slot's resting position. \
|
|
||||||
Expected {target_pos:?}, got {:?}",
|
|
||||||
anim.end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `start` field of the inserted tween must equal the card's
|
|
||||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
|
||||||
/// released the card. This is what makes the glide feel like a
|
|
||||||
/// continuous return rather than a teleport-then-shake.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_starts_from_drag_position() {
|
|
||||||
let drag_pos = Vec2::new(640.0, 200.0);
|
|
||||||
let target_pos = Vec2::new(80.0, -120.0);
|
|
||||||
let anim =
|
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
(anim.start - drag_pos).length() < 1e-6,
|
|
||||||
"CardAnimation.start must match the drop-time transform position \
|
|
||||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
|
||||||
anim.start
|
|
||||||
);
|
|
||||||
// And the start must be visibly distinct from the origin slot — the
|
|
||||||
// whole point of the tween is that it visibly travels.
|
|
||||||
assert!(
|
|
||||||
(anim.start - anim.end).length() > 1.0,
|
|
||||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
|
||||||
anim.start,
|
|
||||||
anim.end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tween duration is taken from the project-wide motion token so
|
|
||||||
/// designers can retune the feel from one place. Keeps the constant and
|
|
||||||
/// the call site honest.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_uses_correct_duration() {
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
|
||||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
|
||||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
|
||||||
anim.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
|
||||||
/// cleanly into its rest position — overshoot on a rejection feels
|
|
||||||
/// jittery rather than forgiving.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_uses_responsive_curve() {
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
anim.curve,
|
|
||||||
MotionCurve::Responsive,
|
|
||||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
|
||||||
so the card snaps back without bouncing past the slot"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `start_z` of the tween must equal the card's drop-time z
|
|
||||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
|
||||||
/// travels home, then settles at the correct resting z.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
|
||||||
let stack_index = 2_usize;
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
stack_index,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
|
||||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
|
||||||
);
|
|
||||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
||||||
assert!(
|
|
||||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
|
||||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
|
||||||
anim.end_z
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Hint system — async port (v0.18.0+)
|
|
||||||
//
|
|
||||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
|
||||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
|
||||||
// system in `pending_hint` turns into hint visuals one frame
|
|
||||||
// later. The behaviour contract this section pins is "pressing H
|
|
||||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
|
||||||
// covered end-to-end in `pending_hint::tests`.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pressing H on a non-paused, non-won game with a live
|
|
||||||
/// `GameStateResource` + `LayoutResource` must populate
|
|
||||||
/// `PendingHintTask`. The polling system, exercised in
|
|
||||||
/// `pending_hint::tests`, drives the result to a visual event.
|
|
||||||
#[test]
|
|
||||||
fn pressing_h_spawns_pending_hint_task() {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins);
|
|
||||||
app.add_message::<InfoToastEvent>();
|
|
||||||
app.add_message::<HintVisualEvent>();
|
|
||||||
app.init_resource::<HintCycleIndex>();
|
|
||||||
app.init_resource::<HintSolverConfig>();
|
|
||||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.insert_resource(LayoutResource(
|
|
||||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
|
||||||
));
|
|
||||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
|
||||||
|
|
||||||
// Simulate the H key being pressed this frame.
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release(KeyCode::KeyH);
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::KeyH);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<crate::pending_hint::PendingHintTask>()
|
|
||||||
.is_pending(),
|
|
||||||
"pressing H must spawn an async hint task",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::layout::compute_layout;
|
||||||
|
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
fn clear_test_piles(game: &mut GameState) {
|
||||||
|
game.set_test_stock_cards(Vec::new());
|
||||||
|
game.set_test_waste_cards(Vec::new());
|
||||||
|
for foundation in [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
] {
|
||||||
|
game.set_test_foundation_cards(foundation, Vec::new());
|
||||||
|
}
|
||||||
|
for tableau in [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
] {
|
||||||
|
game.set_test_tableau_cards(tableau, Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragged_card_z_matches_resting_stack_step() {
|
||||||
|
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||||
|
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||||
|
assert!(
|
||||||
|
step > 0.02,
|
||||||
|
"drag step must exceed Android overlay local_z, got {step}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
step + 1e-4 >= STACK_FAN_FRAC,
|
||||||
|
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_inside_returns_true() {
|
||||||
|
let center = Vec2::new(10.0, 20.0);
|
||||||
|
let size = Vec2::new(40.0, 60.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_on_edge_returns_true() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_outside_returns_false() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_top_of_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||||
|
// Its position: base.y + fan * 6.
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||||
|
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||||
|
// cards in the waste, clicking the visible top must pick the actual top
|
||||||
|
// (last index), not the buffer card underneath it.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
let top_index = waste.len() - 1; // 2 = the visible top
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||||
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||||
|
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_lone_waste_card() {
|
||||||
|
// "can't play the first card in the stock" — a waste of one card must
|
||||||
|
// still be draggable.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||||
|
game.set_test_waste_cards(vec![card.clone()]);
|
||||||
|
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock);
|
||||||
|
assert_eq!(result.1, 0);
|
||||||
|
assert_eq!(result.2, vec![card]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||||
|
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||||
|
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||||
|
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||||
|
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||||
|
// fanned card's click target lands on the card beneath it — so dragging
|
||||||
|
// the visible top card plays the wrong one.
|
||||||
|
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||||
|
let cw = layout.card_size.x;
|
||||||
|
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
layout.pile_positions.insert(
|
||||||
|
KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![
|
||||||
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||||
|
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||||
|
];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||||
|
let top_index = waste.len() - 1;
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
|
||||||
|
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||||
|
assert!(
|
||||||
|
(pos.x - expected).abs() < 1e-3,
|
||||||
|
"hit-test must use the shared waste fan step"
|
||||||
|
);
|
||||||
|
// The old fixed constant would have drifted from the renderer here.
|
||||||
|
let old = base.x + 2.0 * cw * 0.28;
|
||||||
|
assert!(
|
||||||
|
(pos.x - old).abs() > 1.0,
|
||||||
|
"shared step must differ from the old fixed step under tight spacing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_face_down_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||||
|
// the bottom (index 6). Click at the topmost face-down card's
|
||||||
|
// position — its full body is partly visible above the fanned
|
||||||
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
|
// is None.
|
||||||
|
let face_down_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||||
|
// Regression test for the bug where input_plugin's hit-testing used
|
||||||
|
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||||
|
// at 0.12 — so for any column with face-down cards above the
|
||||||
|
// face-up bottom card, clicking the visible card face missed the
|
||||||
|
// hit-test box and only the bottom strip of the card responded.
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
|
let face_up_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||||
|
// Manually construct a tableau with three face-up cards all stacked.
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
|
game.set_test_tableau_cards(
|
||||||
|
Tableau::Tableau1,
|
||||||
|
vec![king, queen.clone(), jack.clone()],
|
||||||
|
);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
|
let queen_center =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![queen, jack]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_non_top_waste_card() {
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||||
|
// the visually top card (three_hearts), with count = 1.
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![three_hearts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||||
|
let mut game = game;
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
|
);
|
||||||
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_returns_none_for_origin() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
|
);
|
||||||
|
assert_eq!(target, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Tableau 6 has 7 cards.
|
||||||
|
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||||
|
// Expected: card_height + 6 fan steps.
|
||||||
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||||
|
assert!(
|
||||||
|
(size.y - expected).abs() < 1e-3,
|
||||||
|
"expected {expected}, got {}",
|
||||||
|
size.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
|
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||||
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||||
|
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||||
|
|
||||||
|
let result = find_draggable_at(cursor, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"top fanned waste card must be hittable at its visual X position"
|
||||||
|
);
|
||||||
|
let (pile, _start, ids) = result.unwrap();
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Clear tableau 0 so it's an empty slot.
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let result = find_draggable_at(pos, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"clicking an empty pile must not produce a draggable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
for pile in [
|
||||||
|
KlondikePile::Stock,
|
||||||
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
|
] {
|
||||||
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||||
|
assert_eq!(size, layout.card_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #27 — best_destination pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Clear everything except one card that has nowhere to go.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||||
|
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
assert!(best_destination(&card, &game).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// best_tableau_destination_for_stack pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Only tableau 0 has anything; every other column is empty.
|
||||||
|
// A King is the only card that can go on an empty tableau column.
|
||||||
|
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||||
|
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&king,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Result must be some other empty tableau column, never the source.
|
||||||
|
if let Some((dest, _)) = result {
|
||||||
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||||
|
// All other piles are empty — no legal tableau target.
|
||||||
|
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&two_clubs,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Two of Clubs has no legal tableau destination on empty piles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #28 — find_hint pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_hint_finds_ace_to_foundation() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Place Ace of Clubs on top of tableau 0.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||||
|
|
||||||
|
let hint = find_hint(&game);
|
||||||
|
assert!(hint.is_some(), "should find a hint");
|
||||||
|
let (from, to) = hint.unwrap();
|
||||||
|
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||||
|
/// the "is there actually a game?" gating lives in
|
||||||
|
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||||
|
/// "No game to forfeit" toast instead of failing silently.
|
||||||
|
#[test]
|
||||||
|
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||||
|
// Build the system param state by hand so we don't rely on a
|
||||||
|
// full Bevy app: the assertion is that the function returns
|
||||||
|
// early on the paused branch without calling write_message.
|
||||||
|
// This is verified by the plain `if paused { return; }` shape;
|
||||||
|
// the body is small enough to inspect by reading.
|
||||||
|
// (Higher-level integration coverage lives in the pause-plugin
|
||||||
|
// tests where `forfeit_app` simulates the full flow.)
|
||||||
|
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// all_hints / new-game window — pure-function tests added during refactor
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||||
|
/// are no other moves and the stock is non-empty.
|
||||||
|
#[test]
|
||||||
|
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||||
|
// move exists. Leave one card in the stock.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||||
|
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||||
|
|
||||||
|
let hints = all_hints(&game);
|
||||||
|
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||||
|
let (from, to) = &hints[0];
|
||||||
|
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||||
|
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `all_hints` must be empty when both stock and waste are empty and no
|
||||||
|
// pile-to-pile move exists — the game is truly stuck.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||||
|
// to a smooth ease-out glide back to the origin pile.
|
||||||
|
//
|
||||||
|
// These tests build the component values exactly as `end_drag` and
|
||||||
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||||
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||||
|
// and mouse-button input, so we exercise the data path the same way the
|
||||||
|
// legacy `ShakeAnim` tests did.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||||
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||||
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||||
|
fn build_drag_reject_animation(
|
||||||
|
drag_pos: Vec2,
|
||||||
|
drag_z: f32,
|
||||||
|
target_pos: Vec2,
|
||||||
|
stack_index: usize,
|
||||||
|
) -> CardAnimation {
|
||||||
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||||
|
/// rejection. With the shake → tween migration, the assertion changes
|
||||||
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||||
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
// Simulate a stack drag of two cards.
|
||||||
|
let dragged_cards: Vec<Card> = vec![
|
||||||
|
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||||
|
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut animated: Vec<Card> = Vec::new();
|
||||||
|
for card in &dragged_cards {
|
||||||
|
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||||
|
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||||
|
// must exactly match the dragged set.
|
||||||
|
animated.push(card.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
animated, dragged_cards,
|
||||||
|
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `end` field of the inserted tween must equal the card's resting
|
||||||
|
/// slot in its origin pile — the position the card belongs at after a
|
||||||
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||||
|
/// and `sync_cards` would have to fight it back.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.end must match the origin slot's resting position. \
|
||||||
|
Expected {target_pos:?}, got {:?}",
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start` field of the inserted tween must equal the card's
|
||||||
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||||
|
/// released the card. This is what makes the glide feel like a
|
||||||
|
/// continuous return rather than a teleport-then-shake.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.start must match the drop-time transform position \
|
||||||
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||||
|
anim.start
|
||||||
|
);
|
||||||
|
// And the start must be visibly distinct from the origin slot — the
|
||||||
|
// whole point of the tween is that it visibly travels.
|
||||||
|
assert!(
|
||||||
|
(anim.start - anim.end).length() > 1.0,
|
||||||
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||||
|
anim.start,
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween duration is taken from the project-wide motion token so
|
||||||
|
/// designers can retune the feel from one place. Keeps the constant and
|
||||||
|
/// the call site honest.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||||
|
anim.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||||
|
/// cleanly into its rest position — overshoot on a rejection feels
|
||||||
|
/// jittery rather than forgiving.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||||
|
so the card snaps back without bouncing past the slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start_z` of the tween must equal the card's drop-time z
|
||||||
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||||
|
/// travels home, then settles at the correct resting z.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||||
|
let stack_index = 2_usize;
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
stack_index,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||||
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||||
|
);
|
||||||
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
assert!(
|
||||||
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||||
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||||
|
anim.end_z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hint system — async port (v0.18.0+)
|
||||||
|
//
|
||||||
|
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||||
|
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||||
|
// system in `pending_hint` turns into hint visuals one frame
|
||||||
|
// later. The behaviour contract this section pins is "pressing H
|
||||||
|
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||||
|
// covered end-to-end in `pending_hint::tests`.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pressing H on a non-paused, non-won game with a live
|
||||||
|
/// `GameStateResource` + `LayoutResource` must populate
|
||||||
|
/// `PendingHintTask`. The polling system, exercised in
|
||||||
|
/// `pending_hint::tests`, drives the result to a visual event.
|
||||||
|
#[test]
|
||||||
|
fn pressing_h_spawns_pending_hint_task() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.insert_resource(LayoutResource(
|
||||||
|
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||||
|
));
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||||
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
|
// Simulate the H key being pressed this frame.
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyH);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::KeyH);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<crate::pending_hint::PendingHintTask>()
|
||||||
|
.is_pending(),
|
||||||
|
"pressing H must spawn an async hint task",
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-656
@@ -2854,659 +2854,4 @@ fn icon_button(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn headless_app() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(SettingsPlugin::headless());
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
fn press(app: &mut App, key: KeyCode) {
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release(key);
|
|
||||||
input.clear();
|
|
||||||
input.press(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_loaded() {
|
|
||||||
let app = headless_app();
|
|
||||||
assert_eq!(
|
|
||||||
app.world().resource::<SettingsResource>().0,
|
|
||||||
Settings::default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketLeft);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!(after < before);
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(cursor.read(events).count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_right_bracket_increases_volume() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 0.5;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!((after - 0.6).abs() < 1e-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clamped_change_does_not_emit_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 1.0;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(cursor.read(events).count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn volume_clamped_at_zero_does_not_emit_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 0.0;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketLeft);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!(after >= 0.0, "volume must not go below zero");
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(
|
|
||||||
cursor.read(events).count(),
|
|
||||||
0,
|
|
||||||
"no event when clamped at floor"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_o_toggles_settings_screen_flag() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
assert!(
|
|
||||||
!app.world().resource::<SettingsScreen>().0,
|
|
||||||
"screen is closed initially"
|
|
||||||
);
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::KeyO);
|
|
||||||
app.update();
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<SettingsScreen>().0,
|
|
||||||
"O opens settings"
|
|
||||||
);
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::KeyO);
|
|
||||||
app.update();
|
|
||||||
assert!(
|
|
||||||
!app.world().resource::<SettingsScreen>().0,
|
|
||||||
"second O closes settings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cycle_unlocked pure-function tests
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_wraps_at_end() {
|
|
||||||
// [0, 1, 2] → cycling from 2 wraps to 0
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_advances_normally() {
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_single_element_stays() {
|
|
||||||
// Only one unlockable — cycling always returns it.
|
|
||||||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
|
||||||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_empty_returns_zero() {
|
|
||||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_is_noop_when_settings_panel_closed() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
|
||||||
.id();
|
|
||||||
// Send a downward scroll event while the panel is closed.
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: -3.0,
|
|
||||||
window: Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_moves_offset_when_panel_open() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Open the panel.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((
|
|
||||||
SettingsPanelScrollable,
|
|
||||||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
|
||||||
))
|
|
||||||
.id();
|
|
||||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: -2.0,
|
|
||||||
window: Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert!(
|
|
||||||
(offset - 200.0).abs() < 1e-3,
|
|
||||||
"scrolling down should increase offset_y; got {offset}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
|
||||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
|
||||||
/// auto-tag systems fire end-to-end without writing to disk.
|
|
||||||
fn headless_app_with_focus() -> App {
|
|
||||||
use crate::ui_focus::UiFocusPlugin;
|
|
||||||
use crate::ui_modal::UiModalPlugin;
|
|
||||||
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(UiModalPlugin)
|
|
||||||
.add_plugins(UiFocusPlugin)
|
|
||||||
.add_plugins(SettingsPlugin {
|
|
||||||
// No persistence — keep the test isolated.
|
|
||||||
storage_path: None,
|
|
||||||
ui_enabled: true,
|
|
||||||
});
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_buttons_get_focusable_marker() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
// Open the panel.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
|
||||||
// and queues the spawn commands; the second flushes them and
|
|
||||||
// runs `attach_focusable_to_settings_buttons`.
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
|
||||||
// `ModalButton`) must carry a `Focusable`.
|
|
||||||
let untagged: Vec<&SettingsButton> = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
untagged.is_empty(),
|
|
||||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
|
||||||
untagged
|
|
||||||
);
|
|
||||||
|
|
||||||
// And there must be at least one tagged `SettingsButton` so the
|
|
||||||
// assertion above isn't vacuously true (the panel really did
|
|
||||||
// spawn).
|
|
||||||
let tagged_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tagged_count >= 6,
|
|
||||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
|
||||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
|
||||||
/// indexed swatches carry hover-reveal context. Mirrors
|
|
||||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
|
||||||
/// the invariant — every interactive Settings element except the
|
|
||||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
|
||||||
#[test]
|
|
||||||
fn settings_buttons_carry_tooltip() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
// Open the panel and let spawn + child-flush run.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
|
||||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
|
||||||
let untipped: Vec<&SettingsButton> = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
untipped.is_empty(),
|
|
||||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
|
||||||
untipped
|
|
||||||
);
|
|
||||||
|
|
||||||
// And there must be at least 6 tipped buttons so the assertion
|
|
||||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
|
||||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
|
||||||
// card-back and one background swatch — well over the floor.
|
|
||||||
let tipped_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tipped_count >= 6,
|
|
||||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: with default (Local) settings the Connect button
|
|
||||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
|
||||||
let connect_tip = app
|
|
||||||
.world_mut()
|
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.find_map(|(btn, tip)| {
|
|
||||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
|
||||||
})
|
|
||||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
|
||||||
assert_eq!(
|
|
||||||
connect_tip.as_ref(),
|
|
||||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
|
||||||
"ConnectSync tooltip must use the canonical microcopy"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_picker_rows_get_focus_row_marker() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Two picker rows are spawned (card-back + background); each
|
|
||||||
// must carry the FocusRow marker.
|
|
||||||
let row_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<Entity, With<FocusRow>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
row_count >= 2,
|
|
||||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
|
||||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
|
||||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
|
||||||
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
|
||||||
/// `ImageNode` for each preview slot.
|
|
||||||
#[test]
|
|
||||||
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
|
||||||
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
|
||||||
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
// Prime an Assets<Image> resource so we can mint stable handles
|
|
||||||
// for the synthetic thumbnail pair.
|
|
||||||
app.init_resource::<Assets<Image>>();
|
|
||||||
let (ace_handle, back_handle) = {
|
|
||||||
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
|
||||||
let ace = images.add(Image::default());
|
|
||||||
let back = images.add(Image::default());
|
|
||||||
(ace, back)
|
|
||||||
};
|
|
||||||
// Inject one theme entry + a matching thumbnail pair.
|
|
||||||
app.insert_resource(ThemeRegistry {
|
|
||||||
entries: vec![ThemeEntry {
|
|
||||||
id: "test_theme".into(),
|
|
||||||
display_name: "Test Theme".into(),
|
|
||||||
manifest_url: "themes://test_theme/theme.ron".into(),
|
|
||||||
meta: crate::theme::ThemeMeta {
|
|
||||||
id: "test_theme".into(),
|
|
||||||
name: "Test Theme".into(),
|
|
||||||
author: "x".into(),
|
|
||||||
version: "x".into(),
|
|
||||||
card_aspect: (2, 3),
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
let mut cache = ThemeThumbnailCache::default();
|
|
||||||
cache.entries.insert(
|
|
||||||
"test_theme".into(),
|
|
||||||
ThemeThumbnailPair {
|
|
||||||
ace: ace_handle.clone(),
|
|
||||||
back: back_handle.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
app.insert_resource(cache);
|
|
||||||
|
|
||||||
// Open the panel and let the spawn + child-flush systems run.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
|
||||||
// theme picker chip for "test_theme" must contribute exactly
|
|
||||||
// two of them (ace + back).
|
|
||||||
let thumbnail_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
thumbnail_count >= 2,
|
|
||||||
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: at least one thumbnail's image handle matches one
|
|
||||||
// of the ones we inserted into the cache. This guards against a
|
|
||||||
// future refactor that accidentally clones the wrong handle.
|
|
||||||
let any_matches = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.any(|node| node.image == ace_handle || node.image == back_handle);
|
|
||||||
assert!(
|
|
||||||
any_matches,
|
|
||||||
"at least one rendered thumbnail must reuse the cached handle"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Window geometry persistence
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn should_persist_geometry_respects_debounce_window() {
|
|
||||||
// Within the debounce window: not yet.
|
|
||||||
assert!(!should_persist_geometry(10.0, 9.7));
|
|
||||||
assert!(!should_persist_geometry(
|
|
||||||
10.0,
|
|
||||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
|
||||||
));
|
|
||||||
// Exactly the debounce window: allowed (>= comparison).
|
|
||||||
assert!(should_persist_geometry(
|
|
||||||
10.0,
|
|
||||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
|
||||||
));
|
|
||||||
// Well past the debounce window: allowed.
|
|
||||||
assert!(should_persist_geometry(20.0, 10.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_geometry_uses_existing_when_event_components_missing() {
|
|
||||||
let existing = WindowGeometry {
|
|
||||||
width: 1280,
|
|
||||||
height: 800,
|
|
||||||
x: 100,
|
|
||||||
y: 50,
|
|
||||||
};
|
|
||||||
// Position-only event keeps existing size.
|
|
||||||
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
|
||||||
assert_eq!(merged.width, 1280);
|
|
||||||
assert_eq!(merged.height, 800);
|
|
||||||
assert_eq!(merged.x, 200);
|
|
||||||
assert_eq!(merged.y, 75);
|
|
||||||
// Size-only event keeps existing position.
|
|
||||||
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
|
||||||
assert_eq!(merged.width, 1024);
|
|
||||||
assert_eq!(merged.height, 768);
|
|
||||||
assert_eq!(merged.x, 100);
|
|
||||||
assert_eq!(merged.y, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_geometry_returns_none_when_size_unknown() {
|
|
||||||
// No existing geometry, no size in the event → can't fabricate one.
|
|
||||||
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
|
||||||
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
|
||||||
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
|
||||||
/// slices and run enough ticks to comfortably exceed the debounce
|
|
||||||
/// window after the first record tick has set `last_changed_secs`.
|
|
||||||
fn advance_past_geometry_debounce(app: &mut App) {
|
|
||||||
use bevy::time::TimeUpdateStrategy;
|
|
||||||
use std::time::Duration;
|
|
||||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
|
||||||
0.15,
|
|
||||||
)));
|
|
||||||
// Tick 1 sets last_changed_secs from any pending events. Each
|
|
||||||
// subsequent tick advances the clock by 150 ms; five ticks total
|
|
||||||
// buys 0.75 s of elapsed time relative to the record tick — well
|
|
||||||
// past the 0.5 s debounce window.
|
|
||||||
for _ in 0..5 {
|
|
||||||
app.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
|
||||||
app.world_mut().write_message(WindowResized {
|
|
||||||
window: Entity::PLACEHOLDER,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
|
||||||
app.world_mut().write_message(WindowMoved {
|
|
||||||
window: Entity::PLACEHOLDER,
|
|
||||||
position: IVec2::new(x, y),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resize_event_then_quiet_persists_window_geometry() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Sanity: geometry starts unset (default).
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fire a resize, then go quiet for past the debounce.
|
|
||||||
fire_resize(&mut app, 1500.0, 950.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.expect("geometry should be persisted after debounce");
|
|
||||||
assert_eq!(geom.width, 1500);
|
|
||||||
assert_eq!(geom.height, 950);
|
|
||||||
// Position not yet observed → defaults to 0, 0 since there was
|
|
||||||
// no existing geometry to fall back on.
|
|
||||||
assert_eq!(geom.x, 0);
|
|
||||||
assert_eq!(geom.y, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_event_after_resize_updates_position_only() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// First, establish a baseline geometry via a resize event.
|
|
||||||
fire_resize(&mut app, 1280.0, 800.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
let baseline = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(baseline.width, 1280);
|
|
||||||
|
|
||||||
// Now fire a move-only event — size must be preserved from the
|
|
||||||
// existing geometry.
|
|
||||||
fire_move(&mut app, 250, 175);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
geom.width, 1280,
|
|
||||||
"size must be preserved across a move-only update"
|
|
||||||
);
|
|
||||||
assert_eq!(geom.height, 800);
|
|
||||||
assert_eq!(geom.x, 250);
|
|
||||||
assert_eq!(geom.y, 175);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rapid_resize_storm_only_persists_final_size() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// Burst of resize events on a single frame — only the last one
|
|
||||||
// should be the eventually-persisted size.
|
|
||||||
fire_resize(&mut app, 900.0, 600.0);
|
|
||||||
fire_resize(&mut app, 1100.0, 700.0);
|
|
||||||
fire_resize(&mut app, 1400.0, 850.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!((geom.width, geom.height), (1400, 850));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_window_events_no_geometry_change() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Just advance time — without any events, settings must stay clean.
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_clamps_offset_to_zero_at_top() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
// Entity starts at 10 px offset.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((
|
|
||||||
SettingsPanelScrollable,
|
|
||||||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
|
||||||
))
|
|
||||||
.id();
|
|
||||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: 5.0,
|
|
||||||
window: Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert_eq!(
|
|
||||||
offset, 0.0,
|
|
||||||
"scrolling past top must clamp to 0, got {offset}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(SettingsPlugin::headless());
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_loaded() {
|
||||||
|
let app = headless_app();
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<SettingsResource>().0,
|
||||||
|
Settings::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(after < before);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(cursor.read(events).count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_right_bracket_increases_volume() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.5;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketRight);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!((after - 0.6).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamped_change_does_not_emit_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 1.0;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketRight);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(cursor.read(events).count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.0;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(after >= 0.0, "volume must not go below zero");
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
0,
|
||||||
|
"no event when clamped at floor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_o_toggles_settings_screen_flag() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"screen is closed initially"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsScreen>().0,
|
||||||
|
"O opens settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"second O closes settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycle_unlocked pure-function tests
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_wraps_at_end() {
|
||||||
|
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_advances_normally() {
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_single_element_stays() {
|
||||||
|
// Only one unlockable — cycling always returns it.
|
||||||
|
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||||
|
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_empty_returns_zero() {
|
||||||
|
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_is_noop_when_settings_panel_closed() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||||
|
.id();
|
||||||
|
// Send a downward scroll event while the panel is closed.
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -3.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_moves_offset_when_panel_open() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Open the panel.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -2.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert!(
|
||||||
|
(offset - 200.0).abs() < 1e-3,
|
||||||
|
"scrolling down should increase offset_y; got {offset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||||
|
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||||
|
/// auto-tag systems fire end-to-end without writing to disk.
|
||||||
|
fn headless_app_with_focus() -> App {
|
||||||
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(SettingsPlugin {
|
||||||
|
// No persistence — keep the test isolated.
|
||||||
|
storage_path: None,
|
||||||
|
ui_enabled: true,
|
||||||
|
});
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_buttons_get_focusable_marker() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
// Open the panel.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||||
|
// and queues the spawn commands; the second flushes them and
|
||||||
|
// runs `attach_focusable_to_settings_buttons`.
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||||
|
// `ModalButton`) must carry a `Focusable`.
|
||||||
|
let untagged: Vec<&SettingsButton> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
untagged.is_empty(),
|
||||||
|
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||||
|
untagged
|
||||||
|
);
|
||||||
|
|
||||||
|
// And there must be at least one tagged `SettingsButton` so the
|
||||||
|
// assertion above isn't vacuously true (the panel really did
|
||||||
|
// spawn).
|
||||||
|
let tagged_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
tagged_count >= 6,
|
||||||
|
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||||
|
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||||
|
/// indexed swatches carry hover-reveal context. Mirrors
|
||||||
|
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||||
|
/// the invariant — every interactive Settings element except the
|
||||||
|
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||||
|
#[test]
|
||||||
|
fn settings_buttons_carry_tooltip() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
// Open the panel and let spawn + child-flush run.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||||
|
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||||
|
let untipped: Vec<&SettingsButton> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
untipped.is_empty(),
|
||||||
|
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||||
|
untipped
|
||||||
|
);
|
||||||
|
|
||||||
|
// And there must be at least 6 tipped buttons so the assertion
|
||||||
|
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||||
|
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||||
|
// card-back and one background swatch — well over the floor.
|
||||||
|
let tipped_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
tipped_count >= 6,
|
||||||
|
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spot-check: with default (Local) settings the Connect button
|
||||||
|
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||||
|
let connect_tip = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.find_map(|(btn, tip)| {
|
||||||
|
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||||
|
})
|
||||||
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
|
assert_eq!(
|
||||||
|
connect_tip.as_ref(),
|
||||||
|
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||||
|
"ConnectSync tooltip must use the canonical microcopy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_picker_rows_get_focus_row_marker() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Two picker rows are spawned (card-back + background); each
|
||||||
|
// must carry the FocusRow marker.
|
||||||
|
let row_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<FocusRow>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
row_count >= 2,
|
||||||
|
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||||
|
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||||
|
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||||
|
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||||
|
/// `ImageNode` for each preview slot.
|
||||||
|
#[test]
|
||||||
|
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||||
|
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||||
|
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
// Prime an Assets<Image> resource so we can mint stable handles
|
||||||
|
// for the synthetic thumbnail pair.
|
||||||
|
app.init_resource::<Assets<Image>>();
|
||||||
|
let (ace_handle, back_handle) = {
|
||||||
|
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||||
|
let ace = images.add(Image::default());
|
||||||
|
let back = images.add(Image::default());
|
||||||
|
(ace, back)
|
||||||
|
};
|
||||||
|
// Inject one theme entry + a matching thumbnail pair.
|
||||||
|
app.insert_resource(ThemeRegistry {
|
||||||
|
entries: vec![ThemeEntry {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
display_name: "Test Theme".into(),
|
||||||
|
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||||
|
meta: crate::theme::ThemeMeta {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
name: "Test Theme".into(),
|
||||||
|
author: "x".into(),
|
||||||
|
version: "x".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let mut cache = ThemeThumbnailCache::default();
|
||||||
|
cache.entries.insert(
|
||||||
|
"test_theme".into(),
|
||||||
|
ThemeThumbnailPair {
|
||||||
|
ace: ace_handle.clone(),
|
||||||
|
back: back_handle.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.insert_resource(cache);
|
||||||
|
|
||||||
|
// Open the panel and let the spawn + child-flush systems run.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||||
|
// theme picker chip for "test_theme" must contribute exactly
|
||||||
|
// two of them (ace + back).
|
||||||
|
let thumbnail_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
thumbnail_count >= 2,
|
||||||
|
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spot-check: at least one thumbnail's image handle matches one
|
||||||
|
// of the ones we inserted into the cache. This guards against a
|
||||||
|
// future refactor that accidentally clones the wrong handle.
|
||||||
|
let any_matches = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||||
|
assert!(
|
||||||
|
any_matches,
|
||||||
|
"at least one rendered thumbnail must reuse the cached handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_persist_geometry_respects_debounce_window() {
|
||||||
|
// Within the debounce window: not yet.
|
||||||
|
assert!(!should_persist_geometry(10.0, 9.7));
|
||||||
|
assert!(!should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||||
|
));
|
||||||
|
// Exactly the debounce window: allowed (>= comparison).
|
||||||
|
assert!(should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
));
|
||||||
|
// Well past the debounce window: allowed.
|
||||||
|
assert!(should_persist_geometry(20.0, 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||||
|
let existing = WindowGeometry {
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
x: 100,
|
||||||
|
y: 50,
|
||||||
|
};
|
||||||
|
// Position-only event keeps existing size.
|
||||||
|
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||||
|
assert_eq!(merged.width, 1280);
|
||||||
|
assert_eq!(merged.height, 800);
|
||||||
|
assert_eq!(merged.x, 200);
|
||||||
|
assert_eq!(merged.y, 75);
|
||||||
|
// Size-only event keeps existing position.
|
||||||
|
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||||
|
assert_eq!(merged.width, 1024);
|
||||||
|
assert_eq!(merged.height, 768);
|
||||||
|
assert_eq!(merged.x, 100);
|
||||||
|
assert_eq!(merged.y, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_returns_none_when_size_unknown() {
|
||||||
|
// No existing geometry, no size in the event → can't fabricate one.
|
||||||
|
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||||
|
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||||
|
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||||
|
/// slices and run enough ticks to comfortably exceed the debounce
|
||||||
|
/// window after the first record tick has set `last_changed_secs`.
|
||||||
|
fn advance_past_geometry_debounce(app: &mut App) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||||
|
0.15,
|
||||||
|
)));
|
||||||
|
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||||
|
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||||
|
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||||
|
// past the 0.5 s debounce window.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||||
|
app.world_mut().write_message(WindowResized {
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||||
|
app.world_mut().write_message(WindowMoved {
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
position: IVec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_event_then_quiet_persists_window_geometry() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Sanity: geometry starts unset (default).
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire a resize, then go quiet for past the debounce.
|
||||||
|
fire_resize(&mut app, 1500.0, 950.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.expect("geometry should be persisted after debounce");
|
||||||
|
assert_eq!(geom.width, 1500);
|
||||||
|
assert_eq!(geom.height, 950);
|
||||||
|
// Position not yet observed → defaults to 0, 0 since there was
|
||||||
|
// no existing geometry to fall back on.
|
||||||
|
assert_eq!(geom.x, 0);
|
||||||
|
assert_eq!(geom.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_event_after_resize_updates_position_only() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// First, establish a baseline geometry via a resize event.
|
||||||
|
fire_resize(&mut app, 1280.0, 800.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
let baseline = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(baseline.width, 1280);
|
||||||
|
|
||||||
|
// Now fire a move-only event — size must be preserved from the
|
||||||
|
// existing geometry.
|
||||||
|
fire_move(&mut app, 250, 175);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
geom.width, 1280,
|
||||||
|
"size must be preserved across a move-only update"
|
||||||
|
);
|
||||||
|
assert_eq!(geom.height, 800);
|
||||||
|
assert_eq!(geom.x, 250);
|
||||||
|
assert_eq!(geom.y, 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_resize_storm_only_persists_final_size() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Burst of resize events on a single frame — only the last one
|
||||||
|
// should be the eventually-persisted size.
|
||||||
|
fire_resize(&mut app, 900.0, 600.0);
|
||||||
|
fire_resize(&mut app, 1100.0, 700.0);
|
||||||
|
fire_resize(&mut app, 1400.0, 850.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_window_events_no_geometry_change() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Just advance time — without any events, settings must stay clean.
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Entity starts at 10 px offset.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: 5.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert_eq!(
|
||||||
|
offset, 0.0,
|
||||||
|
"scrolling past top must clamp to 0, got {offset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user