Compare commits
8 Commits
2a01ecdbfd
...
0e7a34d6bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e7a34d6bf | |||
| 3014b65c92 | |||
| 721c17e9f8 | |||
| 60e853f52b | |||
| be4cefe79a | |||
| 74fa6c7cff | |||
| c06458cf80 | |||
| de01566e47 |
@@ -665,6 +665,32 @@ mod tests {
|
|||||||
assert!(g.undo_stack_len() <= 64);
|
assert!(g.undo_stack_len() <= 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_count_starts_at_zero() {
|
||||||
|
assert_eq!(new_game().undo_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_count_increments_on_each_undo() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.draw().unwrap();
|
||||||
|
assert_eq!(g.undo_count, 0, "undo_count unchanged before calling undo");
|
||||||
|
g.undo().unwrap();
|
||||||
|
assert_eq!(g.undo_count, 1);
|
||||||
|
g.draw().unwrap();
|
||||||
|
g.undo().unwrap();
|
||||||
|
assert_eq!(g.undo_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_count_saturates_at_max() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.undo_count = u32::MAX;
|
||||||
|
g.draw().unwrap();
|
||||||
|
g.undo().unwrap();
|
||||||
|
assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Scoring ---
|
// --- Scoring ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -70,4 +70,27 @@ mod tests {
|
|||||||
fn time_bonus_at_one_second() {
|
fn time_bonus_at_one_second() {
|
||||||
assert_eq!(compute_time_bonus(1), 700_000);
|
assert_eq!(compute_time_bonus(1), 700_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_waste_to_tableau_scores_zero() {
|
||||||
|
// Foundation → Tableau is impossible in practice but must score 0.
|
||||||
|
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0);
|
||||||
|
// Tableau → Tableau (restack) scores 0.
|
||||||
|
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_to_stock_or_waste_scores_zero() {
|
||||||
|
// These destinations are illegal moves in practice, but the function
|
||||||
|
// must not panic and should return 0.
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||||
|
// Very short elapsed time would overflow without the .min() guard.
|
||||||
|
let bonus = compute_time_bonus(1);
|
||||||
|
assert!(bonus <= i32::MAX, "time bonus must fit in i32");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,82 @@ mod tests {
|
|||||||
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
|
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anim_speed_fast_is_less_than_normal() {
|
||||||
|
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anim_speed_instant_is_zero() {
|
||||||
|
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toast_dismissed_after_timer_reaches_zero() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
// Manually spawn a toast with a timer that's already expired.
|
||||||
|
app.world_mut().spawn((ToastOverlay, ToastTimer(-0.001)));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// The toast entity must have been despawned.
|
||||||
|
let remaining = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ToastTimer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(remaining, 0, "expired toast must be despawned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toast_not_dismissed_before_timer_reaches_zero() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
// Large positive timer — should survive one update.
|
||||||
|
app.world_mut().spawn((ToastOverlay, ToastTimer(100.0)));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let remaining = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ToastTimer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(remaining, 1, "unexpired toast must not be despawned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn info_toast_event_spawns_toast_overlay() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
app.world_mut().send_event(InfoToastEvent("hello".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ToastOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 1, "InfoToastEvent must spawn exactly one ToastOverlay");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_changed_event_updates_slide_duration() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
let mut fast_settings = Settings::default();
|
||||||
|
fast_settings.animation_speed = AnimSpeed::Fast;
|
||||||
|
app.world_mut().send_event(SettingsChangedEvent(fast_settings));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
|
||||||
|
assert!((dur - anim_speed_to_secs(&AnimSpeed::Fast)).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn win_cascade_adds_anim_to_all_52_cards() {
|
fn win_cascade_adds_anim_to_all_52_cards() {
|
||||||
let mut app = app_with_anim();
|
let mut app = app_with_anim();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
@@ -199,15 +199,36 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
||||||
|
let is_waste = matches!(pile_type, PileType::Waste);
|
||||||
|
|
||||||
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||||
// than face-up cards so the visible (playable) portion stands out.
|
// than face-up cards so the visible (playable) portion stands out.
|
||||||
// Non-tableau piles stack with a negligible offset.
|
// Non-tableau piles stack with a negligible offset.
|
||||||
|
//
|
||||||
|
// Waste pile: only the top N cards are rendered to prevent bleed-through
|
||||||
|
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
|
||||||
|
// shows up to 3 fanned in X (matching the standard Klondike presentation).
|
||||||
let cards = &pile.cards;
|
let cards = &pile.cards;
|
||||||
|
let render_start = if is_waste {
|
||||||
|
let visible = match game.draw_mode {
|
||||||
|
DrawMode::DrawOne => 1_usize,
|
||||||
|
DrawMode::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
cards.len().saturating_sub(visible)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
let mut y_offset = 0.0_f32;
|
let mut y_offset = 0.0_f32;
|
||||||
for (i, card) in cards.iter().enumerate() {
|
for (slot, card) in cards[render_start..].iter().enumerate() {
|
||||||
let pos = Vec2::new(base.x, base.y + y_offset);
|
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
|
||||||
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
|
// Fan left→right; top card (last slot) is rightmost and playable.
|
||||||
|
slot as f32 * layout.card_size.x * 0.28
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||||
|
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||||
out.push((card.clone(), pos, z));
|
out.push((card.clone(), pos, z));
|
||||||
if is_tableau {
|
if is_tableau {
|
||||||
let step = if card.face_up {
|
let step = if card.face_up {
|
||||||
@@ -483,7 +504,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_positions_includes_all_52_cards() {
|
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, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
let layout =
|
let layout =
|
||||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
@@ -491,6 +513,71 @@ mod tests {
|
|||||||
assert_eq!(positions.len(), 52);
|
assert_eq!(positions.len(), 52);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_draw_one_only_renders_top_card() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
// Draw 3 cards so the waste pile has 3 cards.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(waste_ids.len(), 3);
|
||||||
|
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
|
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.id))
|
||||||
|
.collect();
|
||||||
|
// Draw-One: only 1 waste card should be rendered regardless of pile depth.
|
||||||
|
assert_eq!(waste_rendered.len(), 1);
|
||||||
|
// The single rendered card must be the top (last) waste card.
|
||||||
|
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
|
||||||
|
assert_eq!(waste_rendered[0].0.id, top_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||||
|
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let waste_pile = &g.piles[&PileType::Waste].cards;
|
||||||
|
assert!(waste_pile.len() >= 3, "need at least 3 waste cards for this test");
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> =
|
||||||
|
waste_pile.iter().map(|c| c.id).collect();
|
||||||
|
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
let mut waste_rendered: Vec<_> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||||
|
.collect();
|
||||||
|
// Draw-Three: at most 3 waste cards rendered.
|
||||||
|
assert_eq!(waste_rendered.len(), 3);
|
||||||
|
|
||||||
|
// The three fanned cards must have strictly increasing X coordinates
|
||||||
|
// (left = oldest visible, right = top/playable).
|
||||||
|
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
|
||||||
|
for w in waste_rendered.windows(2) {
|
||||||
|
assert!(w[1].1.x > w[0].1.x, "fanned waste cards must have distinct X positions");
|
||||||
|
}
|
||||||
|
// Top card (rightmost) must be the last card in the waste pile.
|
||||||
|
let top_id = waste_pile.last().unwrap().id;
|
||||||
|
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
|
|||||||
@@ -198,4 +198,23 @@ mod tests {
|
|||||||
assert_eq!(challenge_progress_label(0), format!("1 / {total}"));
|
assert_eq!(challenge_progress_label(0), format!("1 / {total}"));
|
||||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_x_below_unlock_level_fires_info_toast() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Level 0 is below CHALLENGE_UNLOCK_LEVEL.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyX);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
|
assert_eq!(fired.len(), 1, "must show a toast explaining the lock");
|
||||||
|
assert!(
|
||||||
|
fired[0].0.contains(&CHALLENGE_UNLOCK_LEVEL.to_string()),
|
||||||
|
"toast must mention the unlock level"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,4 +164,60 @@ mod tests {
|
|||||||
GameState::new(42, DrawMode::DrawOne);
|
GameState::new(42, DrawMode::DrawOne);
|
||||||
app.update();
|
app.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<&Text, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|t| t.0.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_reflects_game_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_reflects_game_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 42;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_mode_shows_draw_3_badge() {
|
||||||
|
use solitaire_core::game_state::GameMode;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new_with_mode(42, DrawMode::DrawThree, GameMode::Classic);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zen_mode_hides_score() {
|
||||||
|
use solitaire_core::game_state::GameMode;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
|
||||||
|
app.update();
|
||||||
|
// Zen mode spec: "No score display" → text must be empty.
|
||||||
|
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_display_uses_mm_ss_format() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.elapsed_seconds = 125;
|
||||||
|
app.update();
|
||||||
|
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||||
|
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||||
@@ -341,8 +342,15 @@ fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index:
|
|||||||
if matches!(pile, PileType::Tableau(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||||
Vec2::new(base.x, base.y + fan * (stack_index as f32))
|
Vec2::new(base.x, base.y + fan * (stack_index as f32))
|
||||||
|
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||||
|
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||||
|
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
||||||
|
// so clicking the visually rightmost (top) card actually registers.
|
||||||
|
let pile_len = game.piles.get(&pile).map_or(0, |p| p.cards.len());
|
||||||
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
|
let slot = stack_index.saturating_sub(visible_start) as f32;
|
||||||
|
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||||
} else {
|
} else {
|
||||||
let _ = game;
|
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +653,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
|
||||||
|
let waste = game.piles.get_mut(&PileType::Waste).unwrap();
|
||||||
|
waste.cards.clear();
|
||||||
|
// Three waste cards; top (id=202) is rightmost in the fan.
|
||||||
|
waste.cards.push(Card { id: 200, suit: Suit::Spades, rank: Rank::Two, face_up: true });
|
||||||
|
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
|
||||||
|
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
|
let waste_base = layout.pile_positions[&PileType::Waste];
|
||||||
|
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||||
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||||
|
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||||
|
|
||||||
|
let result = find_draggable_at(cursor, &game, &layout);
|
||||||
|
assert!(result.is_some(), "top fanned waste card must be hittable at its visual X position");
|
||||||
|
let (pile, _start, ids) = result.unwrap();
|
||||||
|
assert_eq!(pile, PileType::Waste);
|
||||||
|
assert_eq!(ids, vec![202], "only the top card is draggable from waste");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
|||||||
@@ -208,4 +208,40 @@ mod tests {
|
|||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xp_awarded_event_fired_with_correct_amount() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Slow win, no undo → base 50 + no_undo 25 = 75
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<XpAwardedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].amount, 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levelup_event_total_xp_matches_progress_resource() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let total_xp = app.world().resource::<ProgressResource>().0.total_xp;
|
||||||
|
let events = app.world().resource::<Events<LevelUpEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].total_xp, total_xp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -882,4 +882,64 @@ mod tests {
|
|||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert_eq!(cursor.read(events).count(), 0);
|
assert_eq!(cursor.read(events).count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.0;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(after >= 0.0, "volume must not go below zero");
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_o_toggles_settings_screen_flag() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
assert!(!app.world().resource::<SettingsScreen>().0, "screen is closed initially");
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().resource::<SettingsScreen>().0, "O opens settings");
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(!app.world().resource::<SettingsScreen>().0, "second O closes settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycle_unlocked pure-function tests
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_wraps_at_end() {
|
||||||
|
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_advances_normally() {
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_single_element_stays() {
|
||||||
|
// Only one unlockable — cycling always returns it.
|
||||||
|
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||||
|
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_empty_returns_zero() {
|
||||||
|
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -986,6 +986,50 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A second push with lower stats must not overwrite the higher stored values —
|
||||||
|
/// the server merges (max wins) rather than blindly replacing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
|
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
|
||||||
|
// First push: 20 games_played.
|
||||||
|
let high_payload = make_payload(&user_id, 20);
|
||||||
|
let r1 = post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/sync/push",
|
||||||
|
&access,
|
||||||
|
serde_json::to_value(&high_payload).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r1.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Second push: 5 games_played (lower — should be ignored by merge).
|
||||||
|
let low_payload = make_payload(&user_id, 5);
|
||||||
|
let r2 = post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/sync/push",
|
||||||
|
&access,
|
||||||
|
serde_json::to_value(&low_payload).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r2.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Pull and verify the higher value survived.
|
||||||
|
let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
|
||||||
|
let body = body_json(pull_resp).await;
|
||||||
|
let games_played = body["merged"]["stats"]["games_played"]
|
||||||
|
.as_u64()
|
||||||
|
.expect("games_played must be present");
|
||||||
|
assert_eq!(
|
||||||
|
games_played, 20,
|
||||||
|
"server merge must keep the higher games_played value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Login with leading/trailing whitespace in the username still succeeds.
|
/// Login with leading/trailing whitespace in the username still succeeds.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_trims_whitespace_from_username() {
|
async fn login_trims_whitespace_from_username() {
|
||||||
|
|||||||
Reference in New Issue
Block a user