Files
Ferrous-Solitaire/solitaire_sync/src/achievements.rs
T
root d0b650e08b test(sync): add unit tests for StatsSnapshot::win_rate and AchievementRecord::unlock
Both public APIs in solitaire_sync had no test coverage:
- win_rate(): None before any game, 100/50/0% cases
- AchievementRecord::locked(), unlock(), idempotency preserving earliest date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:54:49 +00:00

82 lines
2.6 KiB
Rust

//! Shared `AchievementRecord` definition — used by both the game client and
//! the sync server.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// One player's unlock state for a single achievement.
///
/// The achievement *definition* (name, description, condition fn) lives in
/// `solitaire_core`. This record only tracks runtime unlock state and is
/// what gets persisted and synced.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementRecord {
/// Matches the `id` field of the corresponding `AchievementDef` in
/// `solitaire_core`.
pub id: String,
/// Whether the achievement has been unlocked.
pub unlocked: bool,
/// The UTC timestamp at which the achievement was first unlocked.
/// `None` when not yet unlocked.
pub unlock_date: Option<DateTime<Utc>>,
/// Whether the unlock reward (XP, cosmetic, etc.) has been granted.
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 the earliest `unlock_date` so
/// that merging two unlock records always keeps the older timestamp.
pub fn unlock(&mut self, at: DateTime<Utc>) {
if self.unlocked {
return;
}
self.unlocked = true;
self.unlock_date = Some(at);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locked_creates_an_unlocked_record() {
let r = AchievementRecord::locked("first_win");
assert_eq!(r.id, "first_win");
assert!(!r.unlocked);
assert!(r.unlock_date.is_none());
assert!(!r.reward_granted);
}
#[test]
fn unlock_sets_unlocked_and_stores_timestamp() {
let mut r = AchievementRecord::locked("first_win");
let ts = Utc::now();
r.unlock(ts);
assert!(r.unlocked);
assert_eq!(r.unlock_date, Some(ts));
}
#[test]
fn unlock_is_idempotent_and_preserves_earliest_date() {
let mut r = AchievementRecord::locked("first_win");
let early = DateTime::UNIX_EPOCH;
let later = Utc::now();
r.unlock(early);
r.unlock(later); // should be a no-op
assert_eq!(r.unlock_date, Some(early), "earliest unlock date must be preserved");
}
}