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 = 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 = 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 1–3) 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 = 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 = 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 = 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 0–3 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::(); 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::() }) .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::(); 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 = 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` 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::(); 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::() }) .fold(0.0_f32, f32::max); let layout = app.world().resource::(); 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 = app .world_mut() .query_filtered::>() .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 = app .world_mut() .query_filtered::>() .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 = app .world_mut() .query_filtered::>() .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 = app .world_mut() .query_filtered::>() .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>(); 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>(); 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 = app .world_mut() .query_filtered::>() .iter(app.world()) .collect(); let mut q = app .world_mut() .query_filtered::<&ChildOf, With>(); 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::().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::().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>(); 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>(); let texts: Vec = 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>(); let vs: Vec = 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::(); 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::(); let mut stock: Vec = 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::::default(); let backs: [Handle; 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::::default(); let theme_back: Handle = 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::::default(); let theme_back: Handle = 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::>::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 = 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 = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = 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 = 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 = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = 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" ); }