Compare commits

..

8 Commits

Author SHA1 Message Date
funman300 0e7a34d6bf test(server): verify merge-on-push keeps higher stats across two pushes
Pushes games_played=20, then pushes games_played=5 (lower). Pulls and
asserts games_played is still 20 — confirming the server merges (takes
the max) rather than overwriting with the lower value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:54:47 +00:00
funman300 3014b65c92 test(core): add scoring boundary tests for non-waste destinations
Three new tests: non-waste→tableau scores zero (tableau restack and
impossible foundation→tableau), move→stock/waste scores zero (guard
against non-obvious destinations panicking), and time_bonus capped at
i32::MAX via the .min() guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:54:11 +00:00
funman300 721c17e9f8 test(core): add undo_count boundary tests
Three tests: undo_count starts at zero, increments on each undo call,
and saturates at u32::MAX without panicking. The undo_count field is
read by ProgressPlugin to determine the no-undo XP bonus but had no
coverage in game_state tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:52:36 +00:00
funman300 60e853f52b test(engine): add InfoToastEvent test for locked challenge X-key press
Verifies that pressing X when the player's level is below
CHALLENGE_UNLOCK_LEVEL emits an InfoToastEvent containing the unlock
level, rather than silently doing nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:48:55 +00:00
funman300 be4cefe79a test(engine): add XpAwardedEvent and LevelUpEvent total_xp coverage
Two tests in progress_plugin: verify XpAwardedEvent fires with the
correct amount on a slow no-undo win (75 XP), and verify LevelUpEvent's
total_xp field matches the ProgressResource after the win.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:46:11 +00:00
root 74fa6c7cff fix(engine): Draw-Three waste fan hit-testing; add HUD and input coverage
fix(input_plugin): card_position() now applies the same X-fan offset for
Draw-Three waste cards as card_plugin uses for rendering. Previously the
top waste card appeared at base_x + 0.56 * card_width but was only
hittable at base_x, making it impossible to drag from its visual position.

test(hud_plugin): add five behaviour tests — score/moves/time display
format, Zen mode score suppression, Draw-Three mode badge.

test(input_plugin): add find_draggable test that clicks the top fanned
waste card at its visual X position and confirms it hits in Draw-Three.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:42:25 +00:00
root c06458cf80 test(engine): add missing coverage for settings and animation plugins
settings_plugin: tests for cycle_unlocked (wrap, advance, single-element,
unknown-current, empty), volume floor clamping, and O-key screen toggle.

animation_plugin: tests for anim_speed_to_secs mapping (Fast < Normal,
Instant = 0), toast auto-dismiss on expired timer, toast survival when
timer positive, InfoToastEvent spawning a ToastOverlay, and
SettingsChangedEvent updating EffectiveSlideDuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:37:32 +00:00
root de01566e47 fix(engine): eliminate waste-pile bleed-through on card draw
Only the top N waste cards are now passed through card_positions():
Draw-One renders 1 card, Draw-Three renders up to 3 fanned in X
(standard Klondike layout). Non-top waste card entities are despawned,
so nothing is visible at the waste position while the newly drawn card
slides in from the stock — the bleed-through bug is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:31:26 +00:00
10 changed files with 466 additions and 6 deletions
+26
View File
@@ -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]
+23
View File
@@ -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");
}
} }
+76
View File
@@ -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();
+92 -5
View File
@@ -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);
+19
View File
@@ -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"
);
}
} }
+56
View File
@@ -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");
}
} }
+34 -1
View File
@@ -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);
+36
View File
@@ -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);
}
} }
+60
View File
@@ -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);
}
} }
+44
View File
@@ -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() {