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