Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f86d66ffe | |||
| 87aec5bdf2 | |||
| 6f5cebdb02 | |||
| 9c96e2fade |
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: 858012d9
|
||||
newTag: eb6c93fb
|
||||
|
||||
@@ -238,6 +238,12 @@ pub struct Settings {
|
||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub leaderboard_display_name: Option<String>,
|
||||
/// `true` once the player has successfully opted in to the leaderboard on
|
||||
/// the server. Used to decide whether a display-name change should also
|
||||
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
|
||||
/// deserialize cleanly to `false` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub leaderboard_opted_in: bool,
|
||||
/// When `true`, the player may drag the top card of a foundation pile back
|
||||
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
||||
/// rules). Older `settings.json` files without this key deserialize to
|
||||
@@ -387,6 +393,7 @@ impl Default for Settings {
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
last_difficulty: None,
|
||||
leaderboard_display_name: None,
|
||||
leaderboard_opted_in: false,
|
||||
take_from_foundation: true,
|
||||
analytics_enabled: false,
|
||||
matomo_url: None,
|
||||
|
||||
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
fn start_shake_anim(
|
||||
mut events: MessageReader<MoveRejectedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
for ev in events.read() {
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||
fn start_foundation_flourish(
|
||||
mut events: MessageReader<FoundationCompletedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
for ev in events.read() {
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let pile_type = PileType::Foundation(ev.slot);
|
||||
// Top card of the completed foundation is the King.
|
||||
let Some(king_id) = game
|
||||
@@ -785,7 +795,7 @@ mod tests {
|
||||
#[test]
|
||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||
// 52 cards should produce more than a couple distinct jitter factors;
|
||||
// a constant function would return one value for all ids.
|
||||
// a constant function would return one function for all ids.
|
||||
use std::collections::HashSet;
|
||||
let unique: HashSet<u64> = (0u32..52)
|
||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||
@@ -796,4 +806,96 @@ mod tests {
|
||||
unique.len()
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ShakeAnim, FoundationFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
|
||||
/// is on, even when the event targets a pile that has card entities present.
|
||||
#[test]
|
||||
fn shake_anim_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.update();
|
||||
|
||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||
let dest_pile = PileType::Tableau(0);
|
||||
let card_id = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(&dest_pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||
|
||||
// Spawn a minimal CardEntity matching that id so the system would
|
||||
// find it and insert ShakeAnim if the gate were absent.
|
||||
app.world_mut().spawn((
|
||||
CardEntity { card_id },
|
||||
Transform::default(),
|
||||
));
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||
.write(MoveRejectedEvent {
|
||||
from: PileType::Stock,
|
||||
to: dest_pile,
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let shake_count = app
|
||||
.world_mut()
|
||||
.query::<&ShakeAnim>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
|
||||
}
|
||||
|
||||
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
|
||||
/// `reduce_motion_mode` is on.
|
||||
#[test]
|
||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.update();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||
.write(FoundationCompletedEvent {
|
||||
slot: 0,
|
||||
suit: solitaire_core::card::Suit::Spades,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let flourish_count = app
|
||||
.world_mut()
|
||||
.query::<&FoundationFlourish>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1755,6 +1755,11 @@ fn detect_score_change(
|
||||
return;
|
||||
}
|
||||
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
if reduce_motion {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = settings
|
||||
.as_ref()
|
||||
.map(|s| s.0.animation_speed)
|
||||
@@ -1928,6 +1933,9 @@ fn start_streak_flourish(
|
||||
let Some(latest) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||
return;
|
||||
}
|
||||
let speed = settings
|
||||
.as_ref()
|
||||
.map(|s| s.0.animation_speed)
|
||||
@@ -3011,6 +3019,35 @@ mod tests {
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||
#[test]
|
||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
0,
|
||||
"ScorePulse must not spawn under reduce-motion"
|
||||
);
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"ScoreFloater must not spawn under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
@@ -138,6 +138,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<OptOutTask>()
|
||||
.init_resource::<DisplayNameBuffer>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||
@@ -159,6 +160,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
handle_display_name_text_input,
|
||||
handle_display_name_confirm,
|
||||
handle_display_name_cancel,
|
||||
update_leaderboard_public_name_label,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
@@ -361,10 +363,13 @@ fn handle_opt_in_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
||||
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
|
||||
fn poll_opt_in_task(
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
@@ -372,10 +377,18 @@ fn poll_opt_in_task(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||
if let Some(mut s) = settings {
|
||||
s.0.leaderboard_opted_in = true;
|
||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||
&& let Err(e) = save_settings_to(path, &s.0)
|
||||
{
|
||||
warn!("failed to save settings after opt-in: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-in failed: {e}");
|
||||
toast.write(InfoToastEvent("Failed to join leaderboard".to_string()));
|
||||
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,10 +414,13 @@ fn handle_opt_out_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
||||
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
|
||||
fn poll_opt_out_task(
|
||||
mut task_res: ResMut<OptOutTask>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
@@ -412,10 +428,18 @@ fn poll_opt_out_task(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||
if let Some(mut s) = settings {
|
||||
s.0.leaderboard_opted_in = false;
|
||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||
&& let Err(e) = save_settings_to(path, &s.0)
|
||||
{
|
||||
warn!("failed to save settings after opt-out: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-out failed: {e}");
|
||||
toast.write(InfoToastEvent("Failed to leave leaderboard".to_string()));
|
||||
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,6 +452,12 @@ fn poll_opt_out_task(
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardCloseButton;
|
||||
|
||||
/// Marker on the "Public name: …" label inside the leaderboard panel so it
|
||||
/// can be updated reactively when the player changes their display name
|
||||
/// without a full panel rebuild.
|
||||
#[derive(Component, Debug)]
|
||||
struct LeaderboardPublicNameText;
|
||||
|
||||
fn spawn_leaderboard_screen(
|
||||
commands: &mut Commands,
|
||||
data: &LeaderboardResource,
|
||||
@@ -481,6 +511,7 @@ fn spawn_leaderboard_screen(
|
||||
None => "Public name: (same as username)".to_string(),
|
||||
};
|
||||
row.spawn((
|
||||
LeaderboardPublicNameText,
|
||||
Text::new(label),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -733,7 +764,9 @@ fn handle_display_name_text_input(
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
||||
/// Saves the typed display name to `SettingsResource`, closes the modal, and
|
||||
/// pushes the new name to the server when the player is already opted in.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_display_name_confirm(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
@@ -741,6 +774,8 @@ fn handle_display_name_confirm(
|
||||
buf: Res<DisplayNameBuffer>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
@@ -750,13 +785,47 @@ fn handle_display_name_confirm(
|
||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
Some(trimmed.clone())
|
||||
};
|
||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
warn!("failed to save settings: {e}");
|
||||
}
|
||||
|
||||
// Push updated name to the server when already opted in and no task
|
||||
// is in flight. The server's opt-in endpoint is an upsert, so calling
|
||||
// it a second time only updates the display_name column.
|
||||
let is_remote = provider
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.backend_name() != "local");
|
||||
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
|
||||
let display_name = settings
|
||||
.0
|
||||
.leaderboard_display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||
ref username,
|
||||
..
|
||||
} = settings.0.sync_backend
|
||||
{
|
||||
username.chars().take(32).collect()
|
||||
} else {
|
||||
"Player".to_string()
|
||||
}
|
||||
});
|
||||
if let Some(p) = provider {
|
||||
let provider = p.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
provider
|
||||
.opt_in_leaderboard(&display_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
@@ -857,6 +926,25 @@ fn spawn_display_name_modal(
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
|
||||
/// `SettingsResource` after the player saves a new display name. No-op when
|
||||
/// the panel is closed (`labels.is_empty()` exits immediately).
|
||||
fn update_leaderboard_public_name_label(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
|
||||
) {
|
||||
if labels.is_empty() {
|
||||
return;
|
||||
}
|
||||
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
|
||||
Some(n) => format!("Public name: {n}"),
|
||||
None => "Public name: (same as username)".to_string(),
|
||||
};
|
||||
for mut text in &mut labels {
|
||||
text.0 = new_label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||
fn printable_char_dn(text: &str) -> Option<char> {
|
||||
let ch = text.chars().next()?;
|
||||
@@ -1048,4 +1136,171 @@ mod tests {
|
||||
// 65 seconds = 1:05, not 1:5
|
||||
assert_eq!(format_secs(65), "1:05");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bug-fix regression tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn headless_app_with_settings() -> App {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
|
||||
app
|
||||
}
|
||||
|
||||
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||
#[test]
|
||||
fn opt_in_error_fires_warning_toast() {
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = headless_app_with_settings();
|
||||
|
||||
// Inject a pre-resolved failed task directly into OptInTask.
|
||||
let failed_task = AsyncComputeTaskPool::get()
|
||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||
|
||||
// Allow the task to complete and be polled.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(
|
||||
cursor.read(msgs).next().is_some(),
|
||||
"WarningToastEvent must be fired when opt-in fails"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||
#[test]
|
||||
fn opt_out_error_fires_warning_toast() {
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = headless_app_with_settings();
|
||||
|
||||
let failed_task = AsyncComputeTaskPool::get()
|
||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(
|
||||
cursor.read(msgs).next().is_some(),
|
||||
"WarningToastEvent must be fired when opt-out fails"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
|
||||
#[test]
|
||||
fn opt_in_success_sets_opted_in_flag() {
|
||||
let mut app = headless_app_with_settings();
|
||||
|
||||
// Confirm the flag starts false.
|
||||
assert!(!app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in);
|
||||
|
||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in,
|
||||
"leaderboard_opted_in must be true after successful opt-in"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
|
||||
#[test]
|
||||
fn opt_out_success_clears_opted_in_flag() {
|
||||
let mut app = headless_app_with_settings();
|
||||
|
||||
// Seed as opted in.
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in = true;
|
||||
|
||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in,
|
||||
"leaderboard_opted_in must be false after successful opt-out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
|
||||
/// change applied to `SettingsResource` without a panel rebuild.
|
||||
#[test]
|
||||
fn public_name_label_updates_reactively() {
|
||||
let mut app = headless_app_with_settings();
|
||||
|
||||
// Open the panel.
|
||||
press(&mut app, KeyCode::KeyL);
|
||||
app.update();
|
||||
|
||||
// Verify the label starts with the default copy.
|
||||
let initial: String = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("LeaderboardPublicNameText must exist while panel is open")
|
||||
.0
|
||||
.clone();
|
||||
assert!(
|
||||
initial.contains("same as username"),
|
||||
"initial label should say '(same as username)' when no display name is set"
|
||||
);
|
||||
|
||||
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
|
||||
// re-fire in the next frame (MinimalPlugins has no input-tick system).
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyL);
|
||||
input.clear();
|
||||
}
|
||||
|
||||
// Update the display name in SettingsResource.
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_display_name = Some("TestPlayer".to_string());
|
||||
|
||||
app.update();
|
||||
|
||||
let updated: String = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("LeaderboardPublicNameText must still exist")
|
||||
.0
|
||||
.clone();
|
||||
assert!(
|
||||
updated.contains("TestPlayer"),
|
||||
"label must reflect new display name after settings change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{
|
||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
||||
SyncConfigureRequestEvent,
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||
WarningToastEvent,
|
||||
};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
@@ -109,7 +109,7 @@ impl Plugin for SyncPlugin {
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -191,7 +191,7 @@ fn poll_pull_result(
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else {
|
||||
return;
|
||||
@@ -245,13 +245,13 @@ fn poll_pull_result(
|
||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||
};
|
||||
warning_toast.write(WarningToastEvent(msg.clone()));
|
||||
// On auth failure, reopen the Connect modal so the player can
|
||||
// re-enter credentials without having to navigate through Settings.
|
||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||
// the modal is already on screen, so repeated pull failures don't
|
||||
// stack multiple modals.
|
||||
if matches!(e, SyncError::Auth(_)) {
|
||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
status.0 = SyncStatus::Error(msg.clone());
|
||||
@@ -550,6 +550,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_failure_fires_warning_toast() {
|
||||
use bevy::ecs::message::Messages;
|
||||
let mut app = headless_app_with(FailingProvider);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
if matches!(
|
||||
app.world().resource::<SyncStatusResource>().0,
|
||||
SyncStatus::Error(_)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(
|
||||
cursor.read(msgs).next().is_some(),
|
||||
"a WarningToastEvent must fire when the pull fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_payload_sets_nil_user_id() {
|
||||
let payload = build_payload(
|
||||
|
||||
Reference in New Issue
Block a user