From 0797e9a9935e51c04c6dc5c07973a56cb5fffe76 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 6 Jul 2026 13:16:21 -0700 Subject: [PATCH] refactor(engine): move card_plugin tests into card_plugin/tests.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of the module-split plan: card_plugin.rs becomes card_plugin/{mod.rs, tests.rs} (2,536 + 1,592 lines), following the replay_overlay/ pattern. Pure mechanical move via git mv — no runtime code changes; all 70 tests preserved and passing. Refs #118 Co-Authored-By: Claude Fable 5 --- .../{card_plugin.rs => card_plugin/mod.rs} | 1595 +---------------- solitaire_engine/src/card_plugin/tests.rs | 1592 ++++++++++++++++ 2 files changed, 1593 insertions(+), 1594 deletions(-) rename solitaire_engine/src/{card_plugin.rs => card_plugin/mod.rs} (61%) create mode 100644 solitaire_engine/src/card_plugin/tests.rs diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin/mod.rs similarity index 61% rename from solitaire_engine/src/card_plugin.rs rename to solitaire_engine/src/card_plugin/mod.rs index 3825831..fc5c8d5 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin/mod.rs @@ -2533,1597 +2533,4 @@ fn fill_tableau_fan_on_startup( #[cfg(test)] -mod tests { - use super::*; - use crate::game_plugin::GamePlugin; - use crate::layout::TABLEAU_FAN_FRAC; - use crate::table_plugin::TablePlugin; - use solitaire_core::Deck; - - /// 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" - ); - } -} +mod tests; diff --git a/solitaire_engine/src/card_plugin/tests.rs b/solitaire_engine/src/card_plugin/tests.rs new file mode 100644 index 0000000..c838cc7 --- /dev/null +++ b/solitaire_engine/src/card_plugin/tests.rs @@ -0,0 +1,1592 @@ +use super::*; +use crate::game_plugin::GamePlugin; +use crate::layout::TABLEAU_FAN_FRAC; +use crate::table_plugin::TablePlugin; +use solitaire_core::Deck; + +/// 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" + ); +} -- 2.47.3