feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,46 +1,18 @@
|
||||
//! Persistence for per-player achievement unlock records.
|
||||
//!
|
||||
//! The [`AchievementRecord`] struct is defined in `solitaire_sync` so the
|
||||
//! server can use the same type. This module re-exports it and provides
|
||||
//! file I/O helpers.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use solitaire_sync::AchievementRecord;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const FILE_NAME: &str = "achievements.json";
|
||||
|
||||
/// One player's unlock state for a single achievement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AchievementRecord {
|
||||
pub id: String,
|
||||
pub unlocked: bool,
|
||||
pub unlock_date: Option<DateTime<Utc>>,
|
||||
pub reward_granted: bool,
|
||||
}
|
||||
|
||||
impl AchievementRecord {
|
||||
/// Construct an initial record for an achievement that is not yet unlocked.
|
||||
pub fn locked(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
unlocked: false,
|
||||
unlock_date: None,
|
||||
reward_granted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this record unlocked at the given timestamp. No-op if already unlocked
|
||||
/// (preserves earliest `unlock_date`).
|
||||
pub fn unlock(&mut self, at: DateTime<Utc>) {
|
||||
if self.unlocked {
|
||||
return;
|
||||
}
|
||||
self.unlocked = true;
|
||||
self.unlock_date = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `achievements.json`.
|
||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
@@ -70,6 +42,7 @@ pub fn save_achievements_to(path: &Path, records: &[AchievementRecord]) -> io::R
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
||||
//!
|
||||
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
|
||||
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
||||
//!
|
||||
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
||||
//! If the keychain is unavailable, operations return
|
||||
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
||||
//! the user to log in again.
|
||||
//!
|
||||
//! # Note: no unit tests — requires live OS keychain.
|
||||
|
||||
use keyring::Entry;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TokenError {
|
||||
/// The OS keychain (secret service / keychain daemon) is not available.
|
||||
#[error("keychain unavailable: {0}")]
|
||||
KeychainUnavailable(String),
|
||||
/// No token was found in the keychain for the given username.
|
||||
#[error("token not found for user {0}")]
|
||||
NotFound(String),
|
||||
/// An unexpected keychain error occurred.
|
||||
#[error("keychain error: {0}")]
|
||||
Keyring(String),
|
||||
}
|
||||
|
||||
/// Service name used to namespace all keychain entries for this application.
|
||||
const SERVICE: &str = "solitaire_quest_server";
|
||||
|
||||
/// Map a `keyring::Error` to the appropriate `TokenError`.
|
||||
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
|
||||
let msg = err.to_string();
|
||||
match err {
|
||||
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||
_ => TokenError::Keyring(msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the access and refresh tokens for `username` in the OS keychain.
|
||||
///
|
||||
/// Any previously stored tokens for that username are overwritten.
|
||||
pub fn store_tokens(
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.set_password(access_token)
|
||||
.map_err(|e| map_keyring_err(e, username))?;
|
||||
|
||||
Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.set_password(refresh_token)
|
||||
.map_err(|e| map_keyring_err(e, username))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the stored access token for `username` from the OS keychain.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.get_password()
|
||||
.map_err(|e| map_keyring_err(e, username))
|
||||
}
|
||||
|
||||
/// Load the stored refresh token for `username` from the OS keychain.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.get_password()
|
||||
.map_err(|e| map_keyring_err(e, username))
|
||||
}
|
||||
|
||||
/// Delete the stored access and refresh tokens for `username`.
|
||||
///
|
||||
/// Intended to be called on logout or account deletion. Missing entries are
|
||||
/// silently ignored (the tokens are already gone, which is the desired state).
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.delete_password()
|
||||
{
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) => return Err(map_keyring_err(e, username)),
|
||||
}
|
||||
|
||||
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.delete_password()
|
||||
{
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) => return Err(map_keyring_err(e, username)),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -35,11 +35,35 @@ pub trait SyncProvider: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||
/// `provider_for_backend`) can be passed directly to `SyncPlugin::new`.
|
||||
#[async_trait]
|
||||
impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||
(**self).pull().await
|
||||
}
|
||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||
(**self).push(payload).await
|
||||
}
|
||||
fn backend_name(&self) -> &'static str {
|
||||
(**self).backend_name()
|
||||
}
|
||||
fn is_authenticated(&self) -> bool {
|
||||
(**self).is_authenticated()
|
||||
}
|
||||
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
|
||||
(**self).mirror_achievement(id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub mod stats;
|
||||
pub use stats::StatsSnapshot;
|
||||
pub use stats::{StatsExt, StatsSnapshot};
|
||||
|
||||
pub mod storage;
|
||||
pub use storage::{load_stats, load_stats_from, save_stats, save_stats_to, stats_file_path};
|
||||
pub use storage::{
|
||||
cleanup_orphaned_tmp_files, load_stats, load_stats_from, save_stats, save_stats_to,
|
||||
stats_file_path,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
pub use achievements::{
|
||||
@@ -62,4 +86,15 @@ pub mod challenge;
|
||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme,
|
||||
};
|
||||
|
||||
pub mod auth_tokens;
|
||||
pub use auth_tokens::{
|
||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||
};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
//! Player progression — XP, level, unlocks, daily/weekly progress.
|
||||
//!
|
||||
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
|
||||
//!
|
||||
//! [`PlayerProgress`] is defined in `solitaire_sync` (so the server can use
|
||||
//! the same type) and re-exported here along with file I/O helpers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
pub use solitaire_sync::progress::level_for_xp;
|
||||
pub use solitaire_sync::PlayerProgress;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const FILE_NAME: &str = "progress.json";
|
||||
|
||||
/// XP-to-level lookup. Matches ARCHITECTURE.md §13.
|
||||
///
|
||||
/// Levels 1–10: `level = floor(total_xp / 500)`
|
||||
/// Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
|
||||
pub fn level_for_xp(xp: u64) -> u32 {
|
||||
if xp < 5_000 {
|
||||
(xp / 500) as u32
|
||||
} else {
|
||||
10 + ((xp - 5_000) / 1_000) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic seed derived from a date, identical for all players globally.
|
||||
/// Used as the RNG seed for the daily-challenge deal.
|
||||
pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||
@@ -52,112 +44,6 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
base + speed_bonus + no_undo_bonus
|
||||
}
|
||||
|
||||
/// Persisted player progression state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgress {
|
||||
pub total_xp: u64,
|
||||
pub level: u32,
|
||||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||||
pub daily_challenge_streak: u32,
|
||||
pub weekly_goal_progress: HashMap<String, u32>,
|
||||
/// ISO week key (e.g. `"2026-W17"`) the current `weekly_goal_progress`
|
||||
/// counters belong to. When the engine sees a different week it clears
|
||||
/// progress and updates this field.
|
||||
#[serde(default)]
|
||||
pub weekly_goal_week_iso: Option<String>,
|
||||
pub unlocked_card_backs: Vec<usize>,
|
||||
pub unlocked_backgrounds: Vec<usize>,
|
||||
/// Index of the next Challenge-mode seed the player will be served.
|
||||
/// Increments on each Challenge-mode win. Out-of-range values wrap modulo
|
||||
/// `CHALLENGE_SEEDS.len()` at lookup time.
|
||||
#[serde(default)]
|
||||
pub challenge_index: u32,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for PlayerProgress {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_xp: 0,
|
||||
level: 0,
|
||||
daily_challenge_last_completed: None,
|
||||
daily_challenge_streak: 0,
|
||||
weekly_goal_progress: HashMap::new(),
|
||||
weekly_goal_week_iso: None,
|
||||
unlocked_card_backs: vec![0], // back #0 always available
|
||||
unlocked_backgrounds: vec![0], // background #0 always available
|
||||
challenge_index: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerProgress {
|
||||
/// Add XP and recompute level. Returns the previous level so callers can
|
||||
/// detect level-up events.
|
||||
pub fn add_xp(&mut self, amount: u64) -> u32 {
|
||||
let prev_level = self.level;
|
||||
self.total_xp = self.total_xp.saturating_add(amount);
|
||||
self.level = level_for_xp(self.total_xp);
|
||||
self.last_modified = Utc::now();
|
||||
prev_level
|
||||
}
|
||||
|
||||
/// `true` if a level-up just occurred (current level > `prev_level`).
|
||||
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
||||
self.level > prev_level
|
||||
}
|
||||
|
||||
/// Reset weekly-goal progress when the ISO week has rolled over.
|
||||
/// No-op if the stored week key already matches `current`.
|
||||
pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool {
|
||||
if self.weekly_goal_week_iso.as_deref() == Some(current) {
|
||||
return false;
|
||||
}
|
||||
self.weekly_goal_progress.clear();
|
||||
self.weekly_goal_week_iso = Some(current.to_string());
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
|
||||
/// Increment progress for `goal_id` by 1, capped at `target`.
|
||||
/// Returns `true` if this call brought the counter from below `target`
|
||||
/// to at-or-above `target` (i.e. just completed the goal).
|
||||
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
|
||||
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
|
||||
if *entry >= target {
|
||||
// Already complete — do not over-count.
|
||||
return false;
|
||||
}
|
||||
*entry = entry.saturating_add(1);
|
||||
self.last_modified = Utc::now();
|
||||
*entry >= target
|
||||
}
|
||||
|
||||
/// Record a daily-challenge completion for `date`.
|
||||
///
|
||||
/// - First completion ever, or a gap of more than one day: streak resets to 1.
|
||||
/// - Completion the day after the previous: streak increments.
|
||||
/// - Same day as the previous: no-op (idempotent — a player can't double-count).
|
||||
///
|
||||
/// Returns `true` if this call recorded a fresh completion (i.e. it wasn't
|
||||
/// the same-day no-op case).
|
||||
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
||||
match self.daily_challenge_last_completed {
|
||||
Some(last) if last == date => return false,
|
||||
Some(last) if last + Duration::days(1) == date => {
|
||||
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
|
||||
}
|
||||
_ => {
|
||||
self.daily_challenge_streak = 1;
|
||||
}
|
||||
}
|
||||
self.daily_challenge_last_completed = Some(date);
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
pub fn progress_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
@@ -186,6 +72,7 @@ pub fn save_progress_to(path: &Path, progress: &PlayerProgress) -> io::Result<()
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
|
||||
@@ -1,42 +1,127 @@
|
||||
//! User settings (persistent).
|
||||
//!
|
||||
//! Currently tracks SFX volume and the first-run flag. Other fields from
|
||||
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
|
||||
//! will land alongside the systems that need them.
|
||||
//! Tracks draw mode, volumes, animation speed, visual theme, sync backend, and
|
||||
//! the first-run flag. All fields use `#[serde(default)]` so settings files
|
||||
//! written by older versions of the game still deserialize correctly.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
/// Animation playback speed for card transitions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AnimSpeed {
|
||||
/// Standard animation timing (default).
|
||||
#[default]
|
||||
Normal,
|
||||
/// Roughly 2× faster than Normal.
|
||||
Fast,
|
||||
/// Skip animations entirely — cards teleport to their destinations.
|
||||
Instant,
|
||||
}
|
||||
|
||||
/// Visual theme applied to the table background and UI chrome.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum Theme {
|
||||
/// Classic green felt (default).
|
||||
#[default]
|
||||
Green,
|
||||
/// Blue felt variant.
|
||||
Blue,
|
||||
/// Dark / night-mode variant.
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Which sync backend the player has configured.
|
||||
///
|
||||
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via
|
||||
/// `solitaire_data::auth_tokens` — **never** in this struct.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub enum SyncBackend {
|
||||
/// No sync — all progress stays on the local device (default).
|
||||
#[default]
|
||||
#[serde(rename = "local")]
|
||||
Local,
|
||||
/// Sync with a self-hosted Solitaire Quest server.
|
||||
#[serde(rename = "solitaire_server")]
|
||||
SolitaireServer {
|
||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||
url: String,
|
||||
/// The player's username on that server.
|
||||
username: String,
|
||||
// JWT tokens are stored in the OS keychain — not here.
|
||||
},
|
||||
/// Google Play Games Services (Android only). Selecting this on non-Android
|
||||
/// platforms silently falls back to `Local` at runtime.
|
||||
#[serde(rename = "google_play_games")]
|
||||
GooglePlayGames,
|
||||
}
|
||||
|
||||
/// Persistent user settings.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
|
||||
/// Draw mode selected for new games.
|
||||
#[serde(default = "default_draw_mode")]
|
||||
pub draw_mode: DrawMode,
|
||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||
#[serde(default = "default_sfx_volume")]
|
||||
pub sfx_volume: f32,
|
||||
/// Linear music volume in `[0.0, 1.0]`. Applied to kira's music channel gain.
|
||||
#[serde(default = "default_music_volume")]
|
||||
pub music_volume: f32,
|
||||
/// Speed at which card animations play.
|
||||
#[serde(default)]
|
||||
pub animation_speed: AnimSpeed,
|
||||
/// Visual theme for the table and UI.
|
||||
#[serde(default)]
|
||||
pub theme: Theme,
|
||||
/// Which sync backend is active.
|
||||
#[serde(default)]
|
||||
pub sync_backend: SyncBackend,
|
||||
/// Set to `true` once the player has dismissed the first-run banner.
|
||||
#[serde(default)]
|
||||
pub first_run_complete: bool,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
DrawMode::DrawOne
|
||||
}
|
||||
|
||||
fn default_sfx_volume() -> f32 {
|
||||
0.8
|
||||
}
|
||||
|
||||
fn default_music_volume() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sfx_volume: 0.8,
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
sfx_volume: default_sfx_volume(),
|
||||
music_volume: default_music_volume(),
|
||||
animation_speed: AnimSpeed::Normal,
|
||||
theme: Theme::Green,
|
||||
sync_backend: SyncBackend::Local,
|
||||
first_run_complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
|
||||
/// hand-editing of `settings.json`.
|
||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
music_volume: self.music_volume.clamp(0.0, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -46,6 +131,12 @@ impl Settings {
|
||||
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
|
||||
self.sfx_volume
|
||||
}
|
||||
|
||||
/// Adjust music volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
|
||||
pub fn adjust_music_volume(&mut self, delta: f32) -> f32 {
|
||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||
self.music_volume
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
@@ -90,7 +181,12 @@ mod tests {
|
||||
fn defaults_are_reasonable() {
|
||||
let s = Settings::default();
|
||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert!(!s.first_run_complete);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -103,17 +199,43 @@ mod tests {
|
||||
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_music_volume_clamps() {
|
||||
let mut s = Settings::default();
|
||||
s.music_volume = 0.5;
|
||||
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
|
||||
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
|
||||
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||
assert!((s.adjust_music_volume(-1.0) - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_out_of_range_volume() {
|
||||
let s = Settings {
|
||||
sfx_volume: 5.0,
|
||||
music_volume: -1.5,
|
||||
first_run_complete: true,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.sfx_volume, 1.0);
|
||||
assert_eq!(s.music_volume, 0.0);
|
||||
assert!(s.first_run_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_music_volume() {
|
||||
let mut s = Settings::default();
|
||||
s.music_volume = 2.0;
|
||||
let s = s.sanitized();
|
||||
assert_eq!(s.music_volume, 1.0);
|
||||
|
||||
let mut s2 = Settings::default();
|
||||
s2.music_volume = -0.5;
|
||||
let s2 = s2.sanitized();
|
||||
assert_eq!(s2.music_volume, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
@@ -121,6 +243,28 @@ mod tests {
|
||||
let s = Settings {
|
||||
sfx_volume: 0.42,
|
||||
first_run_complete: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load_full_settings() {
|
||||
let path = tmp_path("round_trip_full");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
draw_mode: DrawMode::DrawThree,
|
||||
sfx_volume: 0.3,
|
||||
music_volume: 0.7,
|
||||
animation_speed: AnimSpeed::Fast,
|
||||
theme: Theme::Dark,
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "https://example.com".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
},
|
||||
first_run_complete: true,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -142,4 +286,25 @@ mod tests {
|
||||
let s = load_settings_from(&path);
|
||||
assert_eq!(s, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
||||
// Simulate a settings.json written by an older version that only had
|
||||
// sfx_volume and first_run_complete.
|
||||
let path = tmp_path("old_format");
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
||||
)
|
||||
.expect("write");
|
||||
let s = load_settings_from(&path);
|
||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
||||
assert!(s.first_run_complete);
|
||||
// New fields should fall back to their defaults.
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-59
@@ -1,51 +1,24 @@
|
||||
//! Player statistics — persisted to `stats.json` between sessions.
|
||||
//!
|
||||
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
|
||||
/// Cumulative game statistics. Stored as `stats.json` in the platform data dir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatsSnapshot {
|
||||
pub games_played: u32,
|
||||
pub games_won: u32,
|
||||
pub games_lost: u32,
|
||||
pub win_streak_current: u32,
|
||||
pub win_streak_best: u32,
|
||||
/// Rolling average of win times in seconds.
|
||||
pub avg_time_seconds: u64,
|
||||
/// Fastest win time. `u64::MAX` means no wins yet.
|
||||
pub fastest_win_seconds: u64,
|
||||
/// Sum of all winning scores.
|
||||
pub lifetime_score: u64,
|
||||
pub best_single_score: u32,
|
||||
pub draw_one_wins: u32,
|
||||
pub draw_three_wins: u32,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
pub use solitaire_sync::StatsSnapshot;
|
||||
|
||||
impl Default for StatsSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
games_played: 0,
|
||||
games_won: 0,
|
||||
games_lost: 0,
|
||||
win_streak_current: 0,
|
||||
win_streak_best: 0,
|
||||
avg_time_seconds: 0,
|
||||
fastest_win_seconds: u64::MAX,
|
||||
lifetime_score: 0,
|
||||
best_single_score: 0,
|
||||
draw_one_wins: 0,
|
||||
draw_three_wins: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsSnapshot {
|
||||
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
|
||||
///
|
||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||
pub trait StatsExt {
|
||||
/// Record a completed win. Updates all relevant counters and rolling averages.
|
||||
pub fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||
}
|
||||
|
||||
impl StatsExt for StatsSnapshot {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||
let prev_wins = self.games_won;
|
||||
self.games_played += 1;
|
||||
self.games_won += 1;
|
||||
@@ -78,23 +51,6 @@ impl StatsSnapshot {
|
||||
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
/// Record an abandoned game (player started a new game without winning).
|
||||
pub fn record_abandoned(&mut self) {
|
||||
self.games_played += 1;
|
||||
self.games_lost += 1;
|
||||
self.win_streak_current = 0;
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
/// Win percentage as 0–100, or `None` if no games played.
|
||||
pub fn win_rate(&self) -> Option<f32> {
|
||||
if self.games_played == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -58,10 +58,48 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
save_stats_to(&path, stats)
|
||||
}
|
||||
|
||||
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
||||
///
|
||||
/// These can be left behind if the process crashes between the write and rename
|
||||
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||
/// are silently skipped.
|
||||
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||
let dir = match dirs::data_dir() {
|
||||
Some(d) => d.join(APP_DIR_NAME),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cleanup_tmp_files_in(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
fn cleanup_tmp_files_in(dir: &Path) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.ends_with(".json.tmp"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::StatsSnapshot;
|
||||
use crate::stats::{StatsExt, StatsSnapshot};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use std::env;
|
||||
|
||||
@@ -109,4 +147,44 @@ mod tests {
|
||||
let stats = load_stats_from(&path);
|
||||
assert_eq!(stats, StatsSnapshot::default());
|
||||
}
|
||||
|
||||
/// Test the core cleanup logic by creating `.json.tmp` files in a temporary
|
||||
/// directory, running the cleanup loop manually, and verifying removal.
|
||||
#[test]
|
||||
fn cleanup_removes_tmp_files() {
|
||||
let dir = env::temp_dir().join("solitaire_cleanup_test");
|
||||
fs::create_dir_all(&dir).expect("create test dir");
|
||||
|
||||
// Create a pair of .json.tmp files and one regular file that must survive.
|
||||
let tmp1 = dir.join("stats.json.tmp");
|
||||
let tmp2 = dir.join("progress.json.tmp");
|
||||
let keep = dir.join("settings.json");
|
||||
fs::write(&tmp1, b"orphan1").expect("write tmp1");
|
||||
fs::write(&tmp2, b"orphan2").expect("write tmp2");
|
||||
fs::write(&keep, b"{}").expect("write keep");
|
||||
|
||||
// Run the cleanup logic directly against our test directory.
|
||||
cleanup_tmp_files_in(&dir);
|
||||
|
||||
assert!(!tmp1.exists(), "stats.json.tmp should have been removed");
|
||||
assert!(!tmp2.exists(), "progress.json.tmp should have been removed");
|
||||
assert!(keep.exists(), "settings.json must not be removed");
|
||||
|
||||
// Tidy up.
|
||||
let _ = fs::remove_file(&keep);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
/// Calling `cleanup_orphaned_tmp_files` on a box with no app data dir is a
|
||||
/// no-op and must not return an error.
|
||||
#[test]
|
||||
fn cleanup_on_nonexistent_dir_is_ok() {
|
||||
// We can't control whether the real app dir exists in the test
|
||||
// environment, but the public function must at least not panic or
|
||||
// return an Err when the directory is absent.
|
||||
// The real implementation returns Ok(()) for missing dirs.
|
||||
let result = cleanup_orphaned_tmp_files();
|
||||
// The function is allowed to succeed whether or not the dir exists.
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Concrete [`SyncProvider`] implementations and a factory for constructing
|
||||
//! the correct provider from a [`SyncBackend`] setting.
|
||||
//!
|
||||
//! # Backends
|
||||
//!
|
||||
//! | Struct | Backend |
|
||||
//! |---|---|
|
||||
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
|
||||
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
|
||||
//!
|
||||
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
|
||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||
|
||||
use crate::{
|
||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||
settings::SyncBackend,
|
||||
SyncError, SyncProvider,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalOnlyProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A no-op sync provider used when the player has not configured any backend.
|
||||
///
|
||||
/// Both [`pull`](SyncProvider::pull) and [`push`](SyncProvider::push) always
|
||||
/// return [`SyncError::UnsupportedPlatform`], so callers know no remote data
|
||||
/// is available without treating it as a fatal error.
|
||||
pub struct LocalOnlyProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl SyncProvider for LocalOnlyProvider {
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"local"
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SolitaireServerClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP sync client for the self-hosted Solitaire Quest server.
|
||||
///
|
||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||
/// client automatically attempts a token refresh and retries the request once
|
||||
/// before returning an error.
|
||||
pub struct SolitaireServerClient {
|
||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||
/// Trailing slashes are stripped on construction.
|
||||
base_url: String,
|
||||
/// The player's username on this server — used as the keychain key.
|
||||
username: String,
|
||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SolitaireServerClient {
|
||||
/// Construct a new client for the given server URL and username.
|
||||
///
|
||||
/// The `base_url` trailing slash is stripped so URL construction is
|
||||
/// consistent regardless of how the user entered the setting.
|
||||
pub fn new(base_url: impl Into<String>, username: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
username: username.into(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to refresh the access token using the stored refresh token.
|
||||
///
|
||||
/// On success the new access token is persisted to the OS keychain,
|
||||
/// replacing the previous one. The refresh token itself is unchanged.
|
||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||
let refresh = load_refresh_token(&self.username)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||
.json(&serde_json::json!({ "refresh_token": refresh }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SyncError::Auth("refresh failed".into()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
|
||||
let new_access = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||
|
||||
// store_tokens replaces both access and refresh; we keep the old
|
||||
// refresh token unchanged so its 30-day TTL is preserved.
|
||||
store_tokens(&self.username, new_access, &refresh)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||
}
|
||||
|
||||
/// Load the current access token from the OS keychain.
|
||||
fn access_token(&self) -> Result<String, SyncError> {
|
||||
load_access_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SyncProvider for SolitaireServerClient {
|
||||
/// Fetch the latest sync payload from the server.
|
||||
///
|
||||
/// On HTTP 401 the client refreshes the access token and retries once.
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||
let token = self.access_token()?;
|
||||
let url = format!("{}/api/sync/pull", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(new_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
return extract_pull_body(resp).await;
|
||||
}
|
||||
|
||||
extract_pull_body(resp).await
|
||||
}
|
||||
|
||||
/// Push the local payload to the server and return the merged response.
|
||||
///
|
||||
/// On HTTP 401 the client refreshes the access token and retries once.
|
||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||
let token = self.access_token()?;
|
||||
let url = format!("{}/api/sync/push", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(new_token)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
return extract_push_body(resp).await;
|
||||
}
|
||||
|
||||
extract_push_body(resp).await
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"solitaire_server"
|
||||
}
|
||||
|
||||
/// Returns `true` if a valid access token is present in the OS keychain.
|
||||
fn is_authenticated(&self) -> bool {
|
||||
load_access_token(&self.username).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let sync_resp: SyncResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
Ok(sync_resp.merged)
|
||||
} else {
|
||||
Err(SyncError::Auth(format!("server returned {status}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||
/// statuses to the appropriate [`SyncError`].
|
||||
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))
|
||||
} else {
|
||||
Err(SyncError::Auth(format!("server returned {status}")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Construct the appropriate [`SyncProvider`] for the given [`SyncBackend`]
|
||||
/// setting.
|
||||
///
|
||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||
/// and remains backend-agnostic.
|
||||
///
|
||||
/// `GooglePlayGames` is Android-only; on desktop it silently falls back to
|
||||
/// [`LocalOnlyProvider`].
|
||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||
match backend {
|
||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||
SyncBackend::SolitaireServer { url, username } => {
|
||||
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
||||
}
|
||||
SyncBackend::GooglePlayGames => {
|
||||
// GPGS is Android-only; fall back to no-op on desktop.
|
||||
Box::new(LocalOnlyProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_provider_backend_name() {
|
||||
assert_eq!(LocalOnlyProvider.backend_name(), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_not_authenticated() {
|
||||
assert!(!LocalOnlyProvider.is_authenticated());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_provider_pull_returns_unsupported() {
|
||||
let err = LocalOnlyProvider.pull().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::UnsupportedPlatform));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_client_strips_trailing_slash() {
|
||||
let c = SolitaireServerClient::new("https://example.com/", "alice");
|
||||
assert_eq!(c.base_url, "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_client_backend_name() {
|
||||
let c = SolitaireServerClient::new("https://example.com", "alice");
|
||||
assert_eq!(c.backend_name(), "solitaire_server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_local_returns_local_provider() {
|
||||
let provider = provider_for_backend(&SyncBackend::Local);
|
||||
assert_eq!(provider.backend_name(), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_gpgs_falls_back_to_local() {
|
||||
let provider = provider_for_backend(&SyncBackend::GooglePlayGames);
|
||||
assert_eq!(provider.backend_name(), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_server_returns_server_client() {
|
||||
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
||||
url: "https://example.com".to_string(),
|
||||
username: "bob".to_string(),
|
||||
});
|
||||
assert_eq!(provider.backend_name(), "solitaire_server");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user