c8fc3e7a09
Touch layout only (desktop bar unchanged): a floating glass pill (Undo / Draw / Hint / Pause) plus a detached circular Menu button, margin above the bottom edge on top of the safe-area inset. Draw is the persistently expanded accent pill (icon + label); other buttons are icon-only and slide their label out while pressed, snapping under reduce-motion. Buttons keep the existing marker components so click handlers, tooltips, focus ring, and chrome toggle work unchanged. Icons: None degrades to text-fallback labels (MinimalPlugins tests / SVG regression), keeping the bar fully usable. Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the legacy touch bar spawn path and primary-button metrics are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1136 lines
38 KiB
Rust
1136 lines
38 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. Hold to keep undoing. 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"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase F: touch action bar + hold-to-repeat undo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
|
/// buttons the tested spawn function creates — `headless_app` would
|
|
/// pre-spawn the platform-default bar and pollute the counts.
|
|
///
|
|
/// The touch branch spawns the glass tab bar with `icons: None`
|
|
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
|
|
/// text-fallback path — same markers, labels pinned expanded.
|
|
fn bar_only_app(touch: bool) -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins);
|
|
app.update();
|
|
let font = TextFont::default();
|
|
let world = app.world_mut();
|
|
let mut commands = world.commands();
|
|
if touch {
|
|
spawn_glass_tab_bar(&mut commands, &font, None);
|
|
} else {
|
|
commands.spawn(Node::default()).with_children(|row| {
|
|
spawn_desktop_action_bar(row, &font);
|
|
});
|
|
}
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn count_buttons<C: Component>(app: &mut App) -> usize {
|
|
app.world_mut()
|
|
.query_filtered::<(), With<C>>()
|
|
.iter(app.world())
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
|
|
let mut app = bar_only_app(true);
|
|
|
|
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
|
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
|
|
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
|
|
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
|
|
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
|
|
|
|
// Utility actions live elsewhere on touch: Help in Menu → System,
|
|
// modes on the Home screen, New Game on Home's hero button.
|
|
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
|
|
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
|
|
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
|
}
|
|
|
|
/// The glass bar floats: its container must carry a bottom margin (not be
|
|
/// docked at 0) both before and after safe-area insets arrive.
|
|
#[test]
|
|
fn glass_tab_bar_floats_above_bottom_edge() {
|
|
let mut app = bar_only_app(true);
|
|
let world = app.world_mut();
|
|
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
|
|
let (node, anchor) = q.single(world).expect("glass bar container exists");
|
|
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
|
|
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
|
|
}
|
|
|
|
/// Draw is the persistently-expanded active pill (user decision
|
|
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
|
|
/// text fallback, so instead assert on the *persistent* flag's effect:
|
|
/// all wrappers spawn at full reveal and target full reveal.
|
|
#[test]
|
|
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
|
|
let mut app = bar_only_app(true);
|
|
let world = app.world_mut();
|
|
let mut q = world.query::<&TabLabelWrap>();
|
|
let wraps: Vec<_> = q.iter(world).collect();
|
|
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
|
|
for wrap in wraps {
|
|
assert_eq!(wrap.progress, 1.0);
|
|
assert_eq!(wrap.target, 1.0);
|
|
}
|
|
}
|
|
|
|
/// Focus order must match visual reading order: pill buttons left to
|
|
/// right, then the detached Menu circle.
|
|
#[test]
|
|
fn glass_tab_bar_focus_order_reads_left_to_right() {
|
|
let mut app = bar_only_app(true);
|
|
|
|
for (f, expected) in [
|
|
(focusable_for::<UndoButton>(&mut app), 0),
|
|
(focusable_for::<DrawButton>(&mut app), 1),
|
|
(focusable_for::<HintButton>(&mut app), 2),
|
|
(focusable_for::<PauseButton>(&mut app), 3),
|
|
(focusable_for::<MenuButton>(&mut app), 4),
|
|
] {
|
|
assert_eq!(f.group, FocusGroup::Hud);
|
|
assert_eq!(f.order, expected);
|
|
}
|
|
}
|
|
|
|
/// `expansion_width` is the single source of truth for the label reveal:
|
|
/// zero when collapsed, lead + monospace advance when expanded, linear
|
|
/// in between.
|
|
#[test]
|
|
fn expansion_width_is_linear_in_progress() {
|
|
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
|
|
let full = expansion_width(4, 16.0, 1.0);
|
|
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
|
|
let half = expansion_width(4, 16.0, 0.5);
|
|
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
|
|
// Out-of-range progress clamps rather than extrapolating.
|
|
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
|
}
|
|
|
|
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
|
/// and snap instantly under reduce-motion.
|
|
#[test]
|
|
fn step_expansion_tweens_and_respects_reduce_motion() {
|
|
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
|
|
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
|
|
assert!(one_frame > 0.0 && one_frame < 0.2);
|
|
// A huge dt clamps at the target instead of overshooting.
|
|
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
|
|
// Collapse works symmetrically.
|
|
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
|
|
// Reduce-motion snaps regardless of dt.
|
|
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
|
let mut app = bar_only_app(false);
|
|
|
|
for (count, name) in [
|
|
(count_buttons::<MenuButton>(&mut app), "Menu"),
|
|
(count_buttons::<UndoButton>(&mut app), "Undo"),
|
|
(count_buttons::<PauseButton>(&mut app), "Pause"),
|
|
(count_buttons::<HelpButton>(&mut app), "Help"),
|
|
(count_buttons::<HintButton>(&mut app), "Hint"),
|
|
(count_buttons::<ModesButton>(&mut app), "Modes"),
|
|
(count_buttons::<NewGameButton>(&mut app), "New Game"),
|
|
] {
|
|
assert_eq!(count, 1, "desktop bar must keep its {name} button");
|
|
}
|
|
assert_eq!(
|
|
count_buttons::<DrawButton>(&mut app),
|
|
0,
|
|
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_button_press_fires_draw_request() {
|
|
let mut app = headless_app();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
|
.clear();
|
|
|
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
assert_eq!(
|
|
cursor.read(events).count(),
|
|
1,
|
|
"pressing the Draw button must fire exactly one DrawRequestEvent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_button_press_is_noop_while_paused() {
|
|
let mut app = headless_app();
|
|
app.insert_resource(PausedResource(true));
|
|
app.world_mut()
|
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
|
.clear();
|
|
|
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
assert_eq!(
|
|
cursor.read(events).count(),
|
|
0,
|
|
"the Draw button must be inert while paused (mirrors the stock-click guard)"
|
|
);
|
|
}
|
|
|
|
/// The timing contract for hold-to-repeat undo, exercised through the
|
|
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
|
|
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
|
|
#[test]
|
|
fn undo_hold_boundary_math() {
|
|
// A tap never repeats.
|
|
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
|
|
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
|
|
// Crossing the initial delay fires the first repeat.
|
|
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
|
|
// Inside the first repeat interval: quiet.
|
|
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
|
|
// Each interval boundary fires exactly once.
|
|
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
|
|
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
|
|
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
|
|
}
|
|
|
|
#[test]
|
|
fn undo_hold_state_accumulates_and_resets_on_release() {
|
|
let mut app = headless_app();
|
|
let button = app
|
|
.world_mut()
|
|
.spawn((UndoButton, Interaction::Pressed))
|
|
.id();
|
|
app.update();
|
|
app.update();
|
|
app.update();
|
|
|
|
assert!(
|
|
app.world().resource::<UndoHoldState>().held_secs > 0.0,
|
|
"held time must accumulate while the Undo button stays pressed"
|
|
);
|
|
|
|
app.world_mut().entity_mut(button).insert(Interaction::None);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<UndoHoldState>().held_secs,
|
|
0.0,
|
|
"releasing the Undo button must reset the hold state"
|
|
);
|
|
}
|