d4448bf0cd
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home owns mode selection, so the popover's Play section carries a Home row firing the new ToggleHomeRequestEvent (read by toggle_home_screen alongside the existing M accelerator). Section headers are quiet caption-size labels inside the existing panel widget, not a new widget. The action-bar Modes button and its popover are untouched (their removal is Phase B territory when Home gains hierarchy). - MenuOption: Modes variant replaced by Home; rows grouped Play (Home) · You (Profile, Stats, Achievements) · Community (Leaderboard) · System (Settings, Help) - handle_menu_option_click no longer chains into spawn_modes_popover - Tests: tooltip sweep updated (7 rows still), new toggle_home_event_opens_home_screen covers the popover's open path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
902 lines
30 KiB
Rust
902 lines
30 KiB
Rust
use super::*;
|
|
use crate::auto_complete_plugin::AutoCompleteState;
|
|
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 [
|
|
"Pick a mode, continue, or start a new game.",
|
|
"Show controls, rules, and keyboard shortcuts.",
|
|
"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"
|
|
);
|
|
}
|