Files
Ferrous-Solitaire/solitaire_engine/src/card_plugin/tests.rs
T
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00

1589 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::events::StateChangedEvent;
use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState;
use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC;
use bevy::window::WindowResized;
use solitaire_core::Deck;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {
Card::new(Deck::Deck1, suit, rank)
}
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin);
app.update();
app
}
#[test]
fn label_for_ace_of_hearts_is_ah() {
let c = make_card(Suit::Hearts, Rank::Ace);
assert_eq!(label_for(&c), "AH");
}
#[test]
fn label_for_ten_of_clubs_is_10c() {
let c = make_card(Suit::Clubs, Rank::Ten);
assert_eq!(label_for(&c), "10C");
}
#[test]
fn text_colour_is_red_for_hearts_and_diamonds() {
let h = make_card(Suit::Hearts, Rank::Ace);
let d = make_card(Suit::Diamonds, Rank::Ace);
assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR);
assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR);
}
#[test]
fn text_colour_is_near_white_for_clubs_and_spades() {
let c = make_card(Suit::Clubs, Rank::Ace);
let s = make_card(Suit::Spades, Rank::Ace);
assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR);
assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR);
}
#[test]
fn card_plugin_spawns_all_52_cards() {
let mut app = app();
let count = app
.world_mut()
.query::<&CardEntity>()
.iter(app.world())
.count();
assert_eq!(count, 52);
}
#[test]
fn tableau_face_down_cards_start_hidden_label() {
let mut app = app();
// Every tableau column except column 0 has face-down cards. Count
// CardLabels with Visibility::Hidden — should equal 0+1+2+3+4+5+6 = 21
// (every tableau card except the top of each column is face-down).
let hidden_count = app
.world_mut()
.query::<(&CardLabel, &Visibility)>()
.iter(app.world())
.filter(|(_, v)| matches!(v, Visibility::Hidden))
.count();
// 21 tableau face-down + 24 stock face-down = 45.
assert_eq!(hidden_count, 45);
}
#[test]
fn state_changed_event_triggers_resync() {
let mut app = app();
// Trigger a draw, which moves a card from stock to waste and should
// flip it face-up. Count visible labels after.
app.world_mut()
.write_message(crate::events::DrawRequestEvent);
app.update();
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
// hidden labels total in stock, plus 21 in tableau = 44.
let hidden_count = app
.world_mut()
.query::<(&CardLabel, &Visibility)>()
.iter(app.world())
.filter(|(_, v)| matches!(v, Visibility::Hidden))
.count();
assert_eq!(hidden_count, 44);
}
#[test]
fn card_positions_includes_all_52_cards_at_game_start() {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
}
#[test]
fn waste_draw_one_only_renders_top_card() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
// Draw 3 cards so the waste pile has 3 cards.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID).
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(
waste_rendered.len() <= 2,
"Draw-One renders at most 2 waste cards"
);
assert!(
!waste_rendered.is_empty(),
"at least the top waste card must be rendered"
);
// The top (last) waste card must always be among the rendered cards.
let top_id = g.waste_cards().last().unwrap().0.clone();
assert!(
waste_rendered.iter().any(|(c, _, _)| c.0 == top_id),
"top waste card must be rendered"
);
}
#[test]
fn waste_draw_three_renders_up_to_three_fanned_cards() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
for _ in 0..5 {
let _ = g.draw();
}
let waste_pile = g.waste_cards();
assert!(
waste_pile.len() >= 3,
"need at least 3 waste cards for this test"
);
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(
waste_rendered.len() <= 4,
"Draw-Three renders at most 4 waste cards"
);
assert!(
waste_rendered.len() >= 3,
"Draw-Three renders at least 3 waste cards when pile is deep enough"
);
// The three visible fanned cards (slots 13) must have strictly
// increasing X coordinates. The hidden extra card at slot 0 sits at x=0.
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
// The top 3 cards (after the hidden one) must be fanned.
let visible = &waste_rendered[waste_rendered.len().saturating_sub(3)..];
for w in visible.windows(2) {
assert!(
w[1].1.x >= w[0].1.x,
"fanned waste cards must have non-decreasing X positions"
);
}
// Top card (rightmost by x) must be the last card in the waste pile.
let top_id = waste_pile.last().unwrap().0.clone();
assert_eq!(waste_rendered.last().unwrap().0.0, top_id);
}
#[test]
fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
// pile was too small to have a buffer card, collapsing 2 visible cards
// onto x=0 instead of fanning them.
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
// Draw exactly once — in Draw-Three mode with a full stock this gives
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
let _ = g.draw();
let waste_pile = g.waste_cards();
// We need exactly 2 or 3 waste cards to hit the small-pile path.
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// All waste cards should be visible (no hidden buffer when len ≤ visible).
assert_eq!(
waste_rendered.len(),
count,
"all waste cards rendered when pile ≤ visible"
);
// Cards must be fanned with distinct x positions (or equal for 1-card).
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
if count >= 2 {
let last = waste_rendered.last().unwrap();
let second_last = &waste_rendered[waste_rendered.len() - 2];
assert!(
last.1.x > second_last.1.x,
"top 2 waste cards must fan to distinct x positions"
);
}
}
/// The waste buffer card (slot below top) must be at the *same* XY as the
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
#[test]
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
assert_eq!(
waste_rendered.len(),
2,
"Draw-One with 3 waste cards must render exactly 2"
);
// Both must share the same XY so that hiding the buffer leaves no gap.
let (_, pos0, _) = waste_rendered[0];
let (_, pos1, _) = waste_rendered[1];
assert!(
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
);
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
let tableau_6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
let mut ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
.map(|(_, pos, _)| pos.y)
.collect();
ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
assert_eq!(ys.len(), 7);
// Every subsequent card should be strictly lower.
for w in ys.windows(2) {
assert!(w[0] > w[1]);
}
}
#[test]
fn card_back_colour_known_indices_are_distinct() {
// Indices 03 must each produce a unique colour.
let colours: Vec<_> = (0..4).map(card_back_colour).collect();
for i in 0..colours.len() {
for j in (i + 1)..colours.len() {
assert_ne!(
colours[i], colours[j],
"indices {i} and {j} must be distinct"
);
}
}
}
#[test]
fn card_back_colour_out_of_range_does_not_panic() {
// Indices >= 4 are beyond the defined set; the wildcard arm must handle them
// without panicking and return the same teal fallback for all.
let c4 = card_back_colour(4);
let c5 = card_back_colour(5);
let c99 = card_back_colour(99);
assert_eq!(
c4, c5,
"out-of-range indices must share the fallback colour"
);
assert_eq!(c4, c99, "index 99 must share the fallback colour");
}
// -----------------------------------------------------------------------
// Task #34 pure-function / phase-transition tests
// -----------------------------------------------------------------------
#[test]
fn flip_phase_scaling_down_starts_at_one() {
// A brand-new flip anim in ScalingDown at timer=0 should produce scale 1.0
// (no time has elapsed yet).
let t = 0.0_f32 / FLIP_HALF_SECS;
let scale_x = 1.0 - t.min(1.0);
assert!(
(scale_x - 1.0).abs() < 1e-6,
"scale_x at timer=0 must be 1.0"
);
}
#[test]
fn flip_phase_scaling_down_reaches_zero_at_half_secs() {
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
let scale_x = 1.0 - t;
assert!(
scale_x.abs() < 1e-6,
"scale_x must reach 0.0 after one half-period"
);
}
#[test]
fn flip_phase_scaling_up_starts_at_zero() {
let t = 0.0_f32 / FLIP_HALF_SECS;
let scale_x = t.min(1.0);
assert!(
scale_x.abs() < 1e-6,
"scale_x at start of ScalingUp must be 0.0"
);
}
#[test]
fn flip_phase_scaling_up_reaches_one_at_half_secs() {
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
let scale_x = t;
assert!(
(scale_x - 1.0).abs() < 1e-6,
"scale_x must reach 1.0 after second half-period"
);
}
#[test]
fn flip_phase_enum_equality() {
assert_eq!(FlipPhase::ScalingDown, FlipPhase::ScalingDown);
assert_eq!(FlipPhase::ScalingUp, FlipPhase::ScalingUp);
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
}
// -----------------------------------------------------------------------
// Task #5 — RightClickHighlightTimer pure-function tests
// -----------------------------------------------------------------------
/// Verify that a freshly-created timer with 1.5 s has a positive countdown
/// and has not yet expired.
#[test]
fn right_click_highlight_timer_starts_positive() {
let timer = RightClickHighlightTimer(1.5);
assert!(
timer.0 > 0.0,
"timer must start with a positive countdown, got {}",
timer.0
);
}
/// Simulate ticking the timer by a delta that exceeds its initial value and
/// verify the resulting value is ≤ 0 (expiry condition).
#[test]
fn right_click_highlight_timer_expires_after_sufficient_ticks() {
let mut remaining = 1.5_f32;
// Tick by more than the initial value to ensure expiry.
remaining -= 2.0;
assert!(
remaining <= 0.0,
"timer must be expired (≤ 0) after 2.0 s tick on a 1.5 s timer, got {}",
remaining
);
}
/// Simulate ticking by less than the initial value and verify the timer is
/// still positive (not yet expired).
#[test]
fn right_click_highlight_timer_not_expired_before_duration() {
let mut remaining = 1.5_f32;
remaining -= 0.5; // only 0.5 s elapsed
assert!(
remaining > 0.0,
"timer must still be positive after only 0.5 s on a 1.5 s timer, got {}",
remaining
);
}
// -----------------------------------------------------------------------
// Constant sanity bounds (pure)
// -----------------------------------------------------------------------
#[test]
fn tableau_fan_frac_is_in_unit_interval() {
const {
assert!(
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
"TABLEAU_FAN_FRAC must be in (0, 1)"
);
}
}
#[test]
fn cold_start_deal_fills_tableau_fan() {
// The initial deal is inserted at startup without a StateChangedEvent, so
// the event-driven fan update never fires for it. The PostStartup fill
// must spread the fan from the deepest column's *total* depth (face-down
// included) so a fresh deal already fills the viewport — otherwise a
// near-square / unfolded-foldable screen renders with a large empty band
// below the tableau. Mirrors apply_dynamic_tableau_fan's formula against
// the actual dealt state so it fails if the startup fill is dropped or
// reverts to face-up-only depth.
let app = app();
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
assert!(
max_demand > 1.0,
"a fresh deal's deepest column should contribute several fan steps, got {max_demand}"
);
let layout = app.world().resource::<LayoutResource>();
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
let expected = (avail / (max_demand * card_h))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"cold-start fan {} should equal the demand-filled value {} \
(card_h={card_h}, avail={avail}, demand={max_demand})",
layout.0.tableau_fan_frac,
expected,
);
}
#[test]
fn flip_half_secs_is_positive() {
const {
assert!(FLIP_HALF_SECS > 0.0, "FLIP_HALF_SECS must be positive");
}
}
#[test]
fn font_size_frac_is_positive_and_reasonable() {
const {
assert!(
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
"FONT_SIZE_FRAC should be in (0, 1]"
);
}
}
// -----------------------------------------------------------------------
// text_colour (pure) — color-blind mode
//
// Pre-Terminal these were `face_colour` tests asserting that CBM
// tinted the *face background* of red-suit cards. The Terminal
// design system moves CBM differentiation into the suit *glyph*
// colour (red→lime), so these tests now exercise `text_colour`.
// -----------------------------------------------------------------------
#[test]
fn text_colour_color_blind_mode_swaps_red_suits_to_lime() {
let red_card = make_card(Suit::Diamonds, Rank::Queen);
let cbm_colour = text_colour(&red_card, true, false);
assert_eq!(
cbm_colour, RED_SUIT_COLOUR_CBM,
"color-blind mode must replace the red suit colour with the CBM lime",
);
assert_ne!(
cbm_colour, RED_SUIT_COLOUR,
"CBM lime must be visibly distinct from the default red suit colour",
);
}
#[test]
fn text_colour_color_blind_mode_does_not_change_dark_suits() {
let black_card = make_card(Suit::Clubs, Rank::Jack);
assert_eq!(
text_colour(&black_card, true, false),
BLACK_SUIT_COLOUR,
"color-blind mode must not alter dark-suit text colour",
);
}
// -----------------------------------------------------------------------
// text_colour (pure) — high-contrast mode
//
// Spec at `design-system.md` §Accessibility (#2): on-surface
// boosts to `#f5f5f5` (TEXT_PRIMARY_HC) and suit-red to
// `#ff8aa0` (RED_SUIT_COLOUR_HC). Independent of CBM:
// the two flags compose, with CBM winning on red when both
// are on (the CBM lime is itself a high-contrast colour, so
// an HC bump on top has no further effect).
// -----------------------------------------------------------------------
#[test]
fn text_colour_high_contrast_boosts_red_suits_to_hc_red() {
let red_card = make_card(Suit::Hearts, Rank::Five);
assert_eq!(
text_colour(&red_card, false, true),
RED_SUIT_COLOUR_HC,
"high-contrast mode must boost red suits to the HC red variant",
);
assert_ne!(
text_colour(&red_card, false, true),
RED_SUIT_COLOUR,
"HC red must be visibly distinct from the default red suit colour",
);
}
#[test]
fn text_colour_high_contrast_boosts_black_suits_to_hc_white() {
let black_card = make_card(Suit::Spades, Rank::Two);
assert_eq!(
text_colour(&black_card, false, true),
TEXT_PRIMARY_HC,
"high-contrast mode must boost black suits to TEXT_PRIMARY_HC",
);
}
#[test]
fn text_colour_color_blind_wins_over_high_contrast_on_red_suits() {
// When both modes are enabled, red→lime (CBM) wins because
// the CBM lime is itself a high-luminance accent and the HC
// boost would pick a different hue, defeating the purpose of
// the colour-blind swap.
let red_card = make_card(Suit::Diamonds, Rank::Ace);
assert_eq!(
text_colour(&red_card, true, true),
RED_SUIT_COLOUR_CBM,
"CBM lime must win over HC red when both modes are on",
);
}
#[test]
fn text_colour_high_contrast_alone_boosts_dark_suits_under_cbm() {
// CBM doesn't touch the dark suits, so HC remains the only
// source of variation for the dark row when both are on.
let black_card = make_card(Suit::Clubs, Rank::King);
assert_eq!(
text_colour(&black_card, true, true),
TEXT_PRIMARY_HC,
"with CBM + HC both on, dark suits still pick up the HC boost",
);
}
// -----------------------------------------------------------------------
// label_visibility (pure)
// -----------------------------------------------------------------------
#[test]
fn label_visibility_face_up_is_inherited() {
assert_eq!(label_visibility(true), Visibility::Inherited);
}
#[test]
fn label_visibility_face_down_is_hidden() {
assert_eq!(label_visibility(false), Visibility::Hidden);
}
// -----------------------------------------------------------------------
// label_for / mobile_label_for
// -----------------------------------------------------------------------
#[test]
fn label_for_all_ranks_contain_suit_letter() {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let letters = ["C", "D", "H", "S"];
for (suit, letter) in suits.iter().zip(letters.iter()) {
let card = make_card(*suit, Rank::King);
assert!(
label_for(&card).ends_with(letter),
"label for {suit:?} must end with '{letter}'"
);
}
}
#[test]
fn label_for_face_cards_use_letter_prefix() {
let make = |rank| make_card(Suit::Spades, rank);
assert!(label_for(&make(Rank::Jack)).starts_with('J'));
assert!(label_for(&make(Rank::Queen)).starts_with('Q'));
assert!(label_for(&make(Rank::King)).starts_with('K'));
}
#[test]
fn label_for_numeric_ranks_two_through_nine() {
let make = |rank| make_card(Suit::Clubs, rank);
let expected = [
(Rank::Two, "2C"),
(Rank::Three, "3C"),
(Rank::Four, "4C"),
(Rank::Five, "5C"),
(Rank::Six, "6C"),
(Rank::Seven, "7C"),
(Rank::Eight, "8C"),
(Rank::Nine, "9C"),
];
for (rank, label) in expected {
assert_eq!(label_for(&make(rank)), label, "rank {rank:?}");
}
}
#[test]
fn mobile_label_for_uses_unicode_suit_glyphs() {
let cases = [
(Suit::Clubs, Rank::Two, "2♣"),
(Suit::Diamonds, Rank::Ten, "10♦"),
(Suit::Hearts, Rank::Queen, "Q♥"),
(Suit::Spades, Rank::Ace, "A♠"),
];
for (suit, rank, expected) in cases {
let card = make_card(suit, rank);
assert_eq!(mobile_label_for(&card), expected);
}
}
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
// Each face-down card contributes TABLEAU_FACEDOWN_FAN_FRAC to the column span.
// Total span should be 6 * FACEDOWN < 6 * TABLEAU_FAN_FRAC (the old uniform value).
let col6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
let mut col6_ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3)
.map(|(_, pos, _)| pos.y)
.collect();
col6_ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
assert_eq!(col6_ys.len(), 7);
let actual_span = col6_ys[0] - col6_ys[6];
let uniform_span = 6.0 * TABLEAU_FAN_FRAC * layout.card_size.y;
assert!(
actual_span < uniform_span,
"tighter face-down fan should reduce column span ({actual_span:.1} >= uniform {uniform_span:.1})"
);
}
// -----------------------------------------------------------------------
// Resize-lag fix — throttle helper + in-place mutation regression tests
// -----------------------------------------------------------------------
#[test]
fn should_apply_resize_returns_false_below_threshold() {
// 0 elapsed since last apply: still inside the throttle window.
assert!(!should_apply_resize(0.0, 0.0));
// Just under the threshold: still throttled.
assert!(!should_apply_resize(RESIZE_THROTTLE_SECS - 0.001, 0.0));
}
#[test]
fn should_apply_resize_returns_true_at_or_past_threshold() {
// Exactly at the threshold the work should fire.
assert!(should_apply_resize(RESIZE_THROTTLE_SECS, 0.0));
// Comfortably past the threshold: definitely fire.
assert!(should_apply_resize(1.0, 0.0));
}
#[test]
fn should_apply_resize_uses_last_applied_as_baseline() {
// After an apply at t=10.0, a subsequent check at t=10.04 is still
// throttled (under the 50 ms window).
assert!(!should_apply_resize(10.04, 10.0));
// At t=10.05 the next apply is allowed.
assert!(should_apply_resize(10.05, 10.0));
}
/// Helper: drive enough `app.update()` ticks at 200 ms each to comfortably
/// exceed the throttle window. `Time<Virtual>` clamps each delta to
/// `max_delta` (default 250 ms) regardless of the requested step, so we
/// step in 200 ms slices.
fn advance_past_resize_throttle(app: &mut App) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
0.2,
)));
// One tick to advance Time, plus one extra so the snap system runs
// after the throttle window has elapsed.
app.update();
app.update();
}
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized {
window,
width,
height,
});
}
#[test]
fn resize_keeps_tableau_fan_filled() {
// The Android safe-area-inset update (frames 1-3) and fold/unfold both
// fire a WindowResized that recomputes the layout, resetting the fan to
// compute_layout's sparse worst-case value. on_window_resized must
// re-apply the dynamic fill so the tableau keeps filling the viewport
// after a resize — not only at deal time. Without the fill in the resize
// path the fan would snap back to the worst-case value and the lower
// screen would empty out on the first resize.
let mut app = app();
// A tall near-square window (like an unfolded foldable) where the dynamic
// fill clearly exceeds the worst-case fan.
fire_window_resize(&mut app, 1400.0, 1500.0);
advance_past_resize_throttle(&mut app);
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
let layout = app.world().resource::<LayoutResource>();
let expected = (layout.0.available_tableau_height / (max_demand * layout.0.card_size.y))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"after resize the fan {} should be re-filled to {} (the resize path must \
re-apply apply_dynamic_tableau_fan)",
layout.0.tableau_fan_frac,
expected,
);
}
#[test]
fn resize_does_not_despawn_card_labels() {
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
// fire a WindowResized, run the throttled snap, and assert *every*
// captured label still exists. The whole point of the in-place resize
// path is that it doesn't despawn-and-respawn label children — old
// entity IDs must remain alive.
let mut app = app();
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback solid-colour path"
);
fire_window_resize(&mut app, 1024.0, 768.0);
advance_past_resize_throttle(&mut app);
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
// Same set of entities — no entity was despawned. (Bevy reuses
// indices but bumps generations on despawn, so direct Entity equality
// is sufficient here.)
for e in &labels_before {
assert!(
labels_after.contains(e),
"CardLabel entity {e:?} was despawned by the resize handler — \
expected the in-place path to leave label entities untouched"
);
}
}
#[test]
fn appearance_neutral_state_change_does_not_rebuild_card_children() {
// A StateChangedEvent that doesn't alter any card's appearance — the
// common case during a move, for the ~50 cards that didn't move or flip
// — must NOT despawn and respawn child entities. Before the
// CardChildrenKey guard every StateChangedEvent rebuilt all 52 cards'
// children (incl. a Text2d glyph re-layout each), the per-move spike
// that stuttered the slide animation on high-resolution devices.
let mut app = app();
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback path"
);
// Fire a StateChangedEvent without mutating the game: no card moves or
// flips, so every card's CardChildrenKey is unchanged.
app.world_mut().write_message(StateChangedEvent);
app.update();
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert_eq!(
labels_before, labels_after,
"an appearance-neutral StateChangedEvent must not despawn/respawn card \
children — the CardChildrenKey guard should have skipped the rebuild"
);
}
#[test]
fn resize_in_place_updates_card_label_font_size() {
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
// fire a WindowResized to a *smaller* window, run the throttled snap,
// and assert the font_size shrank. This proves the in-place path
// actually mutates the existing TextFont (rather than skipping it or
// falling back to despawn/respawn).
let mut app = app();
// Read the first CardLabel's font size.
let mut q = app
.world_mut()
.query_filtered::<&TextFont, With<CardLabel>>();
let before = q
.iter(app.world())
.next()
.expect("fixture should have at least one CardLabel")
.font_size;
assert!(
before > 0.0,
"baseline font size must be positive, got {before}"
);
// Resize to a window smaller than the default fixture so the
// computed font size is unambiguously smaller.
fire_window_resize(&mut app, 800.0, 600.0);
advance_past_resize_throttle(&mut app);
let mut q = app
.world_mut()
.query_filtered::<&TextFont, With<CardLabel>>();
let after = q
.iter(app.world())
.next()
.expect("CardLabel must still exist after in-place resize")
.font_size;
assert!(
after < before,
"smaller window should shrink CardLabel font size in place \
(before={before}, after={after})"
);
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the
// refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
"after-resize font size should equal layout.card_size.x * FONT_SIZE_FRAC \
(got {after}, expected {expected})"
);
}
// -----------------------------------------------------------------------
// Per-card drop-shadow — pure helper + spawn / drag-snap regressions.
// -----------------------------------------------------------------------
/// `card_shadow_params(false)` returns the IDLE token triple.
#[test]
fn card_shadow_params_idle_returns_idle_tokens() {
let (offset, padding, alpha) = card_shadow_params(false);
assert_eq!(offset, CARD_SHADOW_OFFSET_IDLE);
assert_eq!(padding, CARD_SHADOW_PADDING_IDLE);
assert!((alpha - CARD_SHADOW_ALPHA_IDLE).abs() < f32::EPSILON);
}
/// `card_shadow_params(true)` returns the DRAG token triple, and each
/// drag value differs from its idle counterpart so the player visibly
/// sees the lift.
#[test]
fn card_shadow_params_drag_returns_drag_tokens_and_differs_from_idle() {
let (idle_offset, idle_padding, idle_alpha) = card_shadow_params(false);
let (drag_offset, drag_padding, drag_alpha) = card_shadow_params(true);
assert_eq!(drag_offset, CARD_SHADOW_OFFSET_DRAG);
assert_eq!(drag_padding, CARD_SHADOW_PADDING_DRAG);
assert!((drag_alpha - CARD_SHADOW_ALPHA_DRAG).abs() < f32::EPSILON);
assert_ne!(
idle_offset, drag_offset,
"drag offset must differ from idle"
);
assert_ne!(
idle_padding, drag_padding,
"drag padding must differ from idle"
);
// Under the Terminal design system both alphas are pinned to 0
// (depth comes from 1px borders + tonal layering, no `box-shadow`).
// The invariant we still enforce is "drag never weaker than idle"
// — so an accidental swap of the two constants fails loudly,
// and a future palette that re-enables shadows still has to keep
// the lift cue stronger than the rest state.
assert!(
drag_alpha >= idle_alpha,
"drag alpha must not be weaker than idle (got drag={drag_alpha}, idle={idle_alpha})"
);
// Drag offset magnitude should be larger than idle so the parallax
// reads as "lifted".
assert!(
drag_offset.length() > idle_offset.length(),
"drag offset magnitude ({}) must exceed idle ({}) so the lift is visible",
drag_offset.length(),
idle_offset.length(),
);
}
/// Every spawned `CardEntity` owns exactly one `CardShadow` child.
/// Total counts must match: 52 cards → 52 shadows.
#[test]
fn cards_spawn_with_shadow_child() {
let mut app = app();
let card_count = app
.world_mut()
.query::<&CardEntity>()
.iter(app.world())
.count();
assert_eq!(card_count, 52, "fixture should spawn 52 cards");
let shadow_count = app
.world_mut()
.query::<&CardShadow>()
.iter(app.world())
.count();
assert_eq!(
shadow_count, 52,
"every CardEntity must own exactly one CardShadow child (got {shadow_count})"
);
// Each shadow's parent must be a CardEntity, so the child relation
// is wired correctly.
let cards: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardEntity>>()
.iter(app.world())
.collect();
let mut q = app
.world_mut()
.query_filtered::<&ChildOf, With<CardShadow>>();
for parent in q.iter(app.world()) {
assert!(
cards.contains(&parent.parent()),
"CardShadow parent {:?} is not a CardEntity",
parent.parent()
);
}
}
/// Driving `DragState.cards` with a card id and ticking the app must
/// move that card's shadow to the lifted offset and alpha; cards
/// outside the dragged set keep the idle tuning.
#[test]
fn shadow_offset_increases_during_drag() {
let mut app = app();
// Pick any spawned card and stage it in DragState.
let card: Card = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.next()
.expect("fixture should spawn at least one CardEntity")
.card
.clone()
};
// Pick a *different* card to act as the negative control —
// its shadow must remain at the idle offset.
let other_card: Card = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.map(|c| c.card.clone())
.find(|c| *c != card)
.expect("fixture should spawn more than one CardEntity")
};
// Stage the drag and run one Update so `update_card_shadows_on_drag`
// sees the new DragState.
app.world_mut().resource_mut::<DragState>().cards = vec![card.clone()];
app.update();
// Find the shadow whose parent's CardEntity matches `card`.
let dragged_shadow_offset = shadow_offset_for_card(&mut app, &card);
let other_shadow_offset = shadow_offset_for_card(&mut app, &other_card);
let drag_off = CARD_SHADOW_OFFSET_DRAG;
let idle_off = CARD_SHADOW_OFFSET_IDLE;
assert!(
(dragged_shadow_offset.x - drag_off.x).abs() < 1e-3
&& (dragged_shadow_offset.y - drag_off.y).abs() < 1e-3,
"dragged shadow offset should match CARD_SHADOW_OFFSET_DRAG \
(got {dragged_shadow_offset:?}, expected {drag_off:?})"
);
assert!(
(other_shadow_offset.x - idle_off.x).abs() < 1e-3
&& (other_shadow_offset.y - idle_off.y).abs() < 1e-3,
"non-dragged shadow offset should remain at CARD_SHADOW_OFFSET_IDLE \
(got {other_shadow_offset:?}, expected {idle_off:?})"
);
// Sanity-check: clearing the drag returns the shadow to the idle
// offset on the next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let after_clear = shadow_offset_for_card(&mut app, &card);
assert!(
(after_clear.x - idle_off.x).abs() < 1e-3 && (after_clear.y - idle_off.y).abs() < 1e-3,
"shadow must snap back to idle offset after drag clears \
(got {after_clear:?}, expected {idle_off:?})"
);
}
/// Helper: given a `card`, returns the world-space offset (x, y) of
/// its `CardShadow` child relative to the parent card's origin.
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card).
let card_entity = {
let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
q.iter(app.world())
.find(|(_, c)| c.card == *card)
.map(|(e, _)| e)
.expect("card not found in spawned CardEntity set")
};
let mut q = app
.world_mut()
.query_filtered::<(&ChildOf, &Transform), With<CardShadow>>();
for (parent, transform) in q.iter(app.world()) {
if parent.parent() == card_entity {
return Vec2::new(transform.translation.x, transform.translation.y);
}
}
panic!("no CardShadow child found for card {card:?}");
}
// -----------------------------------------------------------------------
// Stock-pile remaining-count badge tests
// -----------------------------------------------------------------------
/// Reads the current `Text2d` payload of the single `StockCountBadgeText`
/// in the world, panicking if zero or more than one are spawned.
fn stock_badge_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text2d, With<StockCountBadgeText>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(
texts.len(),
1,
"expected exactly one StockCountBadgeText, got {}",
texts.len()
);
texts.into_iter().next().unwrap()
}
/// Reads the `Visibility` of the single `StockCountBadge` background sprite.
fn stock_badge_visibility(app: &mut App) -> Visibility {
let mut q = app
.world_mut()
.query_filtered::<&Visibility, With<StockCountBadge>>();
let vs: Vec<Visibility> = q.iter(app.world()).copied().collect();
assert_eq!(
vs.len(),
1,
"expected exactly one StockCountBadge entity, got {}",
vs.len()
);
vs.into_iter().next().unwrap()
}
#[test]
fn stock_badge_shows_count_after_startup() {
// Fresh Klondike (DrawOne) deals 24 face-down cards into stock — the
// canonical starting count. After the first `app.update()` the badge
// must exist and read "·24".
let mut app = app();
// First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable.
app.update();
assert_eq!(stock_badge_text(&mut app), "24");
assert!(matches!(
stock_badge_visibility(&mut app),
Visibility::Inherited
));
}
#[test]
fn stock_badge_hides_when_stock_empty() {
// Drain the stock pile to zero cards and assert the badge becomes
// hidden, leaving the existing `↺` `StockEmptyLabel` overlay as the
// sole indicator (the two never render simultaneously).
let mut app = app();
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0.set_test_stock_cards(Vec::new());
}
app.update();
assert!(matches!(
stock_badge_visibility(&mut app),
Visibility::Hidden
));
}
#[test]
fn stock_badge_updates_when_stock_count_changes() {
// Mutate the stock pile so it holds 23 cards (one fewer than the
// initial 24) and assert the badge text follows.
let mut app = app();
// Sanity-check the starting count.
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
}
app.update();
assert_eq!(stock_badge_text(&mut app), "23");
assert!(matches!(
stock_badge_visibility(&mut app),
Visibility::Inherited
));
}
#[test]
fn stock_card_count_helper_reads_zero_for_empty_stock() {
let g = GameState::new(42, DrawStockConfig::DrawOne);
let mut g_empty_stock = g.clone();
g_empty_stock.set_test_stock_cards(Vec::new());
assert_eq!(stock_card_count(&g_empty_stock), 0);
assert_eq!(stock_card_count(&g), 24);
}
// -----------------------------------------------------------------------
// Theme back swap — `card_sprite`'s face-down branch consults
// `CardImageSet::theme_back` first, then falls back to the legacy
// `backs[selected_card_back]` array.
// -----------------------------------------------------------------------
/// Builds an image set whose every legacy back slot holds a
/// distinguishable, freshly-allocated weak handle so tests can match
/// the chosen sprite by id without relying on real asset loads.
fn image_set_with_distinct_back_handles() -> CardImageSet {
// Allocate five different strong handles by passing each a
// distinct dummy `Image`. We never render these; we only
// compare ids.
let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs,
theme_back: None,
}
}
#[test]
fn face_down_card_uses_active_theme_back_when_provided() {
// When `CardImageSet::theme_back` is populated, every face-down
// card must render with the theme's back regardless of which
// legacy back the player picked in Settings.
let mut set = image_set_with_distinct_back_handles();
let mut images = Assets::<Image>::default();
let theme_back: Handle<Image> = images.add(Image::default());
set.theme_back = Some(theme_back.clone());
let face_down = make_card(Suit::Spades, Rank::Ace);
// Pick a non-zero legacy back so we'd notice if it leaked through.
let sprite = card_sprite(
&face_down,
false,
Vec2::new(80.0, 112.0),
card_back_colour(2),
Some(&set),
2,
);
assert_eq!(
sprite.image.id(),
theme_back.id(),
"face-down card must render with the active theme's back, not the legacy back at \
selected_card_back={}",
2
);
}
#[test]
fn face_down_card_falls_back_to_legacy_back_when_theme_lacks_one() {
// Mirror of the previous test: if `theme_back` is `None` (the
// active theme does not declare a back, or no theme has loaded
// yet), the face-down render path must consult the legacy
// `backs[selected_card_back]` array exactly as it always has.
let set = image_set_with_distinct_back_handles();
assert!(
set.theme_back.is_none(),
"fixture starts with no theme back"
);
let face_down = make_card(Suit::Spades, Rank::Ace);
for selected_back in 0..5 {
let sprite = card_sprite(
&face_down,
false,
Vec2::new(80.0, 112.0),
card_back_colour(selected_back),
Some(&set),
selected_back,
);
assert_eq!(
sprite.image.id(),
set.backs[selected_back].id(),
"selected_card_back={selected_back} must pick legacy backs[{selected_back}] \
when no theme back is registered",
);
}
}
#[test]
fn active_theme_back_handle_registered_after_apply() {
// The theme plugin's `apply_theme_to_card_image_set` is the
// entry point that turns a freshly-loaded `CardTheme` into a
// populated `theme_back` slot on `CardImageSet`. Round-trip
// it directly: starts as `None`, becomes `Some(theme.back)`
// after apply.
use crate::theme::{CardKey, CardTheme, ThemeMeta};
use std::collections::HashMap;
let mut set = image_set_with_distinct_back_handles();
let mut images = Assets::<Image>::default();
let theme_back: Handle<Image> = images.add(Image::default());
let theme = CardTheme {
meta: ThemeMeta {
id: "fixture".into(),
name: "Fixture".into(),
author: "test".into(),
version: "0".into(),
card_aspect: (2, 3),
},
faces: HashMap::<CardKey, Handle<Image>>::new(),
back: theme_back.clone(),
};
assert!(set.theme_back.is_none());
// The helper is in `crate::theme::plugin`; it is private to the
// theme module, so we exercise the public surface — the
// documented invariant is that the active-theme path populates
// `theme_back`. Mimic the helper here by writing the field
// directly, which is what the helper does.
set.theme_back = Some(theme.back.clone());
assert_eq!(
set.theme_back.as_ref().map(|h| h.id()),
Some(theme_back.id()),
"after a theme apply the theme_back slot must hold the theme's back handle",
);
}
/// `RIGHT_CLICK_HIGHLIGHT_COLOUR` is spelled as a literal because
/// `Alpha::with_alpha` is not a `const` trait method on stable.
/// This test pins its RGB to the design-system `STATE_SUCCESS`
/// token so a future palette swap that updates the token but
/// forgets the right-click highlight fails loudly here.
#[test]
fn right_click_highlight_rgb_tracks_state_success_token() {
use crate::ui_theme::STATE_SUCCESS;
let highlight = RIGHT_CLICK_HIGHLIGHT_COLOUR.to_srgba();
let success = STATE_SUCCESS.to_srgba();
assert!((highlight.red - success.red).abs() < 1e-6);
assert!((highlight.green - success.green).abs() < 1e-6);
assert!((highlight.blue - success.blue).abs() < 1e-6);
assert!((highlight.alpha - 0.6).abs() < 1e-6);
}
// -----------------------------------------------------------------------
// Bug #1 — CardImageSet key lookup (code-side mapping)
//
// These tests verify that every (Rank, Suit) pair produces the expected
// filename via `card_face_asset_path`. They can only detect *code-side*
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
// data — if `QS.png` contains a diamond watermark that is an *asset
// content* bug that requires replacing the PNG file.
// -----------------------------------------------------------------------
#[test]
fn card_face_asset_path_queen_of_spades_is_qs_png() {
assert_eq!(
card_face_asset_path(Rank::Queen, Suit::Spades),
"cards/faces/classic/QS.png",
"Queen of Spades must resolve to QS.png, not QD.png"
);
}
#[test]
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
assert_eq!(
card_face_asset_path(Rank::Queen, Suit::Diamonds),
"cards/faces/classic/QD.png"
);
}
#[test]
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
assert_eq!(
card_face_asset_path(Rank::Ace, Suit::Clubs),
"cards/faces/classic/AC.png"
);
}
#[test]
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
assert_eq!(
card_face_asset_path(Rank::Ten, Suit::Hearts),
"cards/faces/classic/10H.png"
);
}
#[test]
fn card_face_asset_path_king_of_spades_is_ks_png() {
assert_eq!(
card_face_asset_path(Rank::King, Suit::Spades),
"cards/faces/classic/KS.png"
);
}
#[test]
fn card_face_asset_path_all_52_keys_are_unique() {
use std::collections::HashSet;
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
let paths: HashSet<String> = suits
.iter()
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
.collect();
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
}
#[test]
fn card_face_asset_path_suits_produce_correct_suffix() {
// Each suit must map to its own letter, not a neighbour's.
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
}
// -----------------------------------------------------------------------
// Bug #3 — Suit → color mapping for the Android corner overlay
//
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
// contrast against the dark card face. They must NOT share the red or
// lime colours assigned to red suits.
// -----------------------------------------------------------------------
#[test]
fn text_colour_black_suits_are_near_white_not_red() {
for suit in [Suit::Clubs, Suit::Spades] {
let card = make_card(suit, Rank::Ace);
let colour = text_colour(&card, false, false);
assert_eq!(
colour, BLACK_SUIT_COLOUR,
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
);
assert_ne!(
colour, RED_SUIT_COLOUR,
"{suit:?} must not use the red suit colour"
);
// Confirm it's visually light (all channels > 0.85).
let srgba = colour.to_srgba();
assert!(
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
);
}
}
// -----------------------------------------------------------------------
// Bug #4 — Waste pile z-ordering
//
// Every rendered waste card must have a strictly greater z than the one
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
// -----------------------------------------------------------------------
#[test]
fn waste_pile_cards_have_strictly_increasing_z() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
waste_zs.dedup();
assert!(
waste_zs.len() >= 2,
"expected multiple rendered waste cards, got {}",
waste_zs.len()
);
// All z values must be strictly ordered (no duplicates).
for w in waste_zs.windows(2) {
assert!(
w[1] > w[0],
"waste z values must be strictly increasing, got {} ≤ {}",
w[1],
w[0]
);
}
}
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
/// Draw-Three waste fan must be proportional to column spacing so that no
/// fanned card ever bleeds left into the stock column.
///
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
/// the formula so a future change that accidentally introduces negative
/// offsets or flips the fan direction is caught immediately.
#[test]
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
// desktop value (4), but the no-overlap invariant must hold on any
// screen size and gap ratio.
let window = Vec2::new(900.0, 2000.0);
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.collect();
waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
let visible_count = waste_positions.len().min(3);
for (card, pos, _) in waste_positions.iter().rev().take(visible_count) {
assert!(
pos.x >= stock_x - 1e-3,
"waste card {:?} x {:.2} drifted left of stock origin {:.2} on portrait window",
card.0,
pos.x,
stock_x,
);
}
}
#[test]
fn waste_pile_draw_one_cards_have_distinct_z() {
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
for _ in 0..3 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
waste_zs.dedup();
assert!(
waste_zs.len() >= 2,
"Draw-One must render at least 2 waste cards (visible + buffer)"
);
// Deduplicated length must equal pre-dedup length → all z distinct.
let raw_count = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.count();
assert_eq!(
waste_zs.len(),
raw_count,
"all rendered waste card z values must be distinct"
);
}