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:
@@ -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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user