//! Evaluates achievements on `GameWonEvent`, persists unlocks, and fires //! `AchievementUnlockedEvent` for each newly unlocked achievement. //! //! The persistence path is configurable via `AchievementPlugin::storage_path`. //! `AchievementPlugin::default()` uses the platform data dir; //! `AchievementPlugin::headless()` disables I/O entirely (for tests). use std::path::PathBuf; use bevy::prelude::*; use chrono::{Local, Timelike, Utc}; use solitaire_core::achievement::{ achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward, ALL_ACHIEVEMENTS, }; use solitaire_data::{ achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, save_progress_to, }; use crate::events::{ AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, }; use crate::ui_theme::{ ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL, }; use crate::ui_tooltip::Tooltip; /// Marker on the achievements overlay root node. #[derive(Component, Debug)] pub struct AchievementsScreen; /// Marker on each per-achievement row inside the Achievements modal. Used by /// hover-tooltip plumbing and tests so a row can be identified independently /// of its visible text. #[derive(Component, Debug)] pub struct AchievementRow; /// All per-player achievement records (one per known achievement). #[derive(Resource, Debug, Clone)] pub struct AchievementsResource(pub Vec); /// Persistence path for `AchievementsResource`. `None` disables I/O. #[derive(Resource, Debug, Clone)] pub struct AchievementsStoragePath(pub Option); pub struct AchievementPlugin { pub storage_path: Option, } impl Default for AchievementPlugin { fn default() -> Self { Self { storage_path: achievements_file_path(), } } } impl AchievementPlugin { /// Plugin configured with no persistence. pub fn headless() -> Self { Self { storage_path: None } } } impl Plugin for AchievementPlugin { fn build(&self, app: &mut App) { let mut records = match &self.storage_path { Some(path) => load_achievements_from(path), None => Vec::new(), }; // Ensure every known achievement has a record. Keeps file forward-compatible // when new achievements are added in future releases. for def in ALL_ACHIEVEMENTS { if !records.iter().any(|r| r.id == def.id) { records.push(AchievementRecord::locked(def.id)); } } app.insert_resource(AchievementsResource(records)) .insert_resource(AchievementsStoragePath(self.storage_path.clone())) .add_message::() .add_message::() .add_message::() .add_message::() // Run after GameMutation (so GameWonEvent is available), after // StatsUpdate (so stats reflect this win), and after ProgressUpdate // (so daily_challenge_streak is up to date for daily_devotee). .add_systems( Update, evaluate_on_win .after(GameMutation) .after(StatsUpdate) .after(ProgressUpdate), ) .add_systems(Update, toggle_achievements_screen) .add_systems(Update, handle_achievements_close_button); } } #[allow(clippy::too_many_arguments)] fn evaluate_on_win( mut wins: MessageReader, mut unlocks: MessageWriter, mut levelups: MessageWriter, mut xp_awarded: MessageWriter, game: Res, stats: Res, path: Res, progress_path: Res, mut achievements: ResMut, mut progress: ResMut, ) { let Some(ev) = wins.read().last() else { return; }; let ctx = AchievementContext { games_played: stats.0.games_played, games_won: stats.0.games_won, win_streak_current: stats.0.win_streak_current, best_single_score: stats.0.best_single_score, lifetime_score: stats.0.lifetime_score, draw_three_wins: stats.0.draw_three_wins, daily_challenge_streak: progress.0.daily_challenge_streak, last_win_score: ev.score, last_win_time_seconds: ev.time_seconds, last_win_used_undo: game.0.undo_count > 0, wall_clock_hour: Some(Local::now().hour()), last_win_recycle_count: game.0.recycle_count, last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen, }; let hits = check_achievements(&ctx); if hits.is_empty() { return; } let now = Utc::now(); let mut achievements_changed = false; let mut progress_changed = false; for def in hits { let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else { continue; }; if record.unlocked { continue; } record.unlock(now); achievements_changed = true; // Grant the reward on first unlock. if !record.reward_granted { if let Some(reward) = def.reward { match reward { Reward::CardBack(idx) => { if !progress.0.unlocked_card_backs.contains(&idx) { progress.0.unlocked_card_backs.push(idx); progress_changed = true; } } Reward::Background(idx) => { if !progress.0.unlocked_backgrounds.contains(&idx) { progress.0.unlocked_backgrounds.push(idx); progress_changed = true; } } Reward::BonusXp(amount) => { xp_awarded.write(XpAwardedEvent { amount }); let prev_level = progress.0.add_xp(amount); if progress.0.leveled_up_from(prev_level) { levelups.write(LevelUpEvent { previous_level: prev_level, new_level: progress.0.level, total_xp: progress.0.total_xp, }); } progress_changed = true; } Reward::Badge => {} } } record.reward_granted = true; } unlocks.write(AchievementUnlockedEvent(record.clone())); } if achievements_changed && let Some(target) = &path.0 && let Err(e) = save_achievements_to(target, &achievements.0) { warn!("failed to save achievements: {e}"); } if progress_changed && let Some(target) = &progress_path.0 && let Err(e) = save_progress_to(target, &progress.0) { warn!("failed to save progress after reward: {e}"); } } /// Convenience: resolve an achievement ID to its human-readable name. /// Used by the toast renderer in `animation_plugin`. pub fn display_name_for(id: &str) -> String { achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string()) } /// Marker on the "Done" button inside the Achievements modal. #[derive(Component, Debug)] pub struct AchievementsCloseButton; /// Toggle the achievements overlay — `A` keyboard accelerator or /// `ToggleAchievementsRequestEvent` from the HUD Menu popover. fn toggle_achievements_screen( mut commands: Commands, keys: Res>, mut requests: MessageReader, achievements: Res, font_res: Option>, screens: Query>, ) { let button_clicked = requests.read().count() > 0; if !keys.just_pressed(KeyCode::KeyA) && !button_clicked { return; } if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else { spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref()); } } /// Click handler for the modal's "Done" button — despawns the overlay /// the same way the `A` accelerator does. fn handle_achievements_close_button( mut commands: Commands, close_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, ) { if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { return; } for entity in &screens { commands.entity(entity).despawn(); } } fn spawn_achievements_screen( commands: &mut Commands, records: &[AchievementRecord], font_res: Option<&FontResource>, ) { let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect(); let total = ALL_ACHIEVEMENTS.len(); let header = format!("Achievements ({}/{})", unlocked.len(), total); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_name = TextFont { font: font_handle.clone(), font_size: TYPE_BODY_LG, ..default() }; let font_desc = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() }; let font_meta = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, header, font_res); // Achievement rows — unlocked first, then locked alphabetical. let mut sorted: Vec<_> = records.iter().collect(); sorted.sort_by_key(|r| (!r.unlocked, r.id.clone())); for record in &sorted { let def = achievement_by_id(&record.id); let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description)); // Hide secret locked achievements so they remain a surprise. let is_secret = def.is_some_and(|d| d.secret); if is_secret && !record.unlocked { continue; } let (name_color, desc_color, prefix) = if record.unlocked { (ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") } else { (TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") }; let tooltip_text = tooltip_for_row(record.unlocked, def); card.spawn(( Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, ..default() }, AchievementRow, Tooltip::new(tooltip_text), )) .with_children(|row| { row.spawn(( Text::new(format!("{prefix}{name}")), font_name.clone(), TextColor(name_color), )); if !description.is_empty() { row.spawn(( Text::new(format!(" {description}")), font_desc.clone(), TextColor(desc_color), )); } if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) { row.spawn(( Text::new(format!(" Reward: {reward_str}")), font_meta.clone(), TextColor(STATE_SUCCESS), )); } if let Some(date) = record.unlock_date { row.spawn(( Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))), font_meta.clone(), TextColor(TEXT_SECONDARY), )); } }); // Subtle row separator — keeps the long list scannable. card.spawn(( Node { height: Val::Px(1.0), ..default() }, BackgroundColor(BORDER_SUBTLE), )); } spawn_modal_actions(card, |actions| { spawn_modal_button( actions, AchievementsCloseButton, "Done", Some("A"), ButtonVariant::Primary, font_res, ); }); }); } fn format_reward(reward: Reward) -> String { match reward { Reward::CardBack(idx) => format!("Card Back #{idx}"), Reward::Background(idx) => format!("Background #{idx}"), Reward::BonusXp(xp) => format!("+{xp} XP"), Reward::Badge => "Badge".to_string(), } } /// Compose the per-row hover-tooltip string. Surfaces information that the /// row itself does not always make obvious: /// /// * Unlocked + reward → "Reward: ." — celebrates the prize. /// * Unlocked, no reward → "Earned!". /// * Locked, non-secret → "How to unlock: ." plus the reward /// when one is defined; the visible row already shows the same lines, but /// gathering them in one tooltip keeps the long list scannable on hover. /// * Locked, secret rows are filtered out before they reach this helper — /// they get no tooltip so the unlock condition stays a surprise. /// /// Defs are looked up at the call site; `None` means the record refers to an /// achievement no longer present in `ALL_ACHIEVEMENTS` (forward-compat) and /// gets a generic fallback. fn tooltip_for_row(unlocked: bool, def: Option<&AchievementDef>) -> String { if unlocked { match def.and_then(|d| d.reward).map(format_reward) { Some(reward) => format!("Reward: {reward}."), None => "Earned!".to_string(), } } else { let description = def.map_or("", |d| d.description); let how = if description.is_empty() { "How to unlock: keep playing.".to_string() } else { format!("How to unlock: {description}.") }; match def.and_then(|d| d.reward).map(format_reward) { Some(reward) => format!("{how} Reward: {reward}."), None => how, } } } #[cfg(test)] mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::stats_plugin::StatsPlugin; use crate::table_plugin::TablePlugin; fn headless_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(StatsPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(AchievementPlugin::headless()); // StatsPlugin's UI toggle system reads ButtonInput; under // MinimalPlugins it isn't auto-registered. app.init_resource::>(); app.update(); app } #[test] fn resource_is_populated_with_all_known_ids() { let app = headless_app(); let records = &app.world().resource::().0; assert_eq!(records.len(), ALL_ACHIEVEMENTS.len()); for def in ALL_ACHIEVEMENTS { assert!(records.iter().any(|r| r.id == def.id && !r.unlocked)); } } #[test] fn win_unlocks_first_win_and_fires_event() { let mut app = headless_app(); // StatsPlugin runs update_stats_on_win first (after GameMutation); that // bumps games_won to 1 before evaluate_on_win reads StatsResource. app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let unlocked_first_win = app .world() .resource::() .0 .iter() .find(|r| r.id == "first_win") .map(|r| r.unlocked) .unwrap_or(false); assert!(unlocked_first_win); // Verify the event was emitted. let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!(fired.contains(&"first_win".to_string())); } #[test] fn repeated_win_does_not_refire_already_unlocked_achievement() { let mut app = headless_app(); app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); // Clear events from first win. app.world_mut() .resource_mut::>() .clear(); app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!( !fired.contains(&"first_win".to_string()), "first_win must not re-fire on subsequent wins" ); } #[test] fn display_name_resolves_known_and_unknown_ids() { assert_eq!(display_name_for("first_win"), "First Win"); assert_eq!(display_name_for("bogus"), "bogus"); } #[test] fn bonus_xp_reward_fires_xp_awarded_event() { let mut app = headless_app(); // "no_undo" achievement awards BonusXp(25). Trigger it by sending a // GameWonEvent with undo_count == 0 (default) and enough stats to match. app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let xp_events: Vec = cursor.read(events).map(|e| e.amount).collect(); // The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent. assert!( xp_events.contains(&25), "BonusXp(25) must fire XpAwardedEvent; got {xp_events:?}" ); } #[test] fn no_undo_achievement_does_not_fire_when_undo_was_used() { let mut app = headless_app(); // Simulate a win where the player used undo at least once. app.world_mut() .resource_mut::() .0 .undo_count = 1; app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); // "no_undo" awards BonusXp(25). If undo was used it must NOT fire. let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let xp_events: Vec = cursor.read(events).map(|e| e.amount).collect(); assert!( !xp_events.contains(&25), "BonusXp(25) must not fire when undo_count > 0; got {xp_events:?}" ); } // ----------------------------------------------------------------------- // draw_three_master integration // ----------------------------------------------------------------------- #[test] fn draw_three_master_fires_on_tenth_draw_three_win() { let mut app = headless_app(); // Pre-seed nine prior Draw-Three wins. The pending GameWonEvent will // trigger update_stats_on_win first (StatsUpdate runs before // evaluate_on_win), bumping draw_three_wins to 10 — the unlock // threshold for the draw_three_master achievement. app.world_mut().resource_mut::().0.draw_three_wins = 9; // The current game must be in DrawThree mode so update_on_win // increments draw_three_wins (and not draw_one_wins). app.world_mut() .resource_mut::() .0 .draw_mode = solitaire_core::game_state::DrawMode::DrawThree; app.world_mut().write_message(GameWonEvent { score: 500, time_seconds: 240, }); app.update(); // Sanity-check that the win was actually attributed to Draw-Three so // the achievement reads the correct counter. let stats = &app.world().resource::().0; assert_eq!(stats.draw_three_wins, 10); let unlocked = app .world() .resource::() .0 .iter() .find(|r| r.id == "draw_three_master") .map(|r| r.unlocked) .unwrap_or(false); assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win"); // Verify the AchievementUnlockedEvent fired for this id. let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!( fired.contains(&"draw_three_master".to_string()), "AchievementUnlockedEvent for draw_three_master must fire; got {fired:?}" ); } #[test] fn draw_three_master_does_not_fire_at_nine_wins() { let mut app = headless_app(); // Pre-seed eight prior Draw-Three wins. The pending GameWonEvent // brings draw_three_wins to 9 — one short of the threshold. app.world_mut().resource_mut::().0.draw_three_wins = 8; app.world_mut() .resource_mut::() .0 .draw_mode = solitaire_core::game_state::DrawMode::DrawThree; app.world_mut().write_message(GameWonEvent { score: 500, time_seconds: 240, }); app.update(); let stats = &app.world().resource::().0; assert_eq!(stats.draw_three_wins, 9); let unlocked = app .world() .resource::() .0 .iter() .find(|r| r.id == "draw_three_master") .map(|r| r.unlocked) .unwrap_or(false); assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins"); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!( !fired.contains(&"draw_three_master".to_string()), "draw_three_master must not fire below threshold; got {fired:?}" ); } // ----------------------------------------------------------------------- // zen_winner integration // ----------------------------------------------------------------------- #[test] fn zen_winner_fires_on_zen_mode_win() { let mut app = headless_app(); // Put the active game in Zen mode. evaluate_on_win reads // GameStateResource.mode directly to populate last_win_is_zen. app.world_mut() .resource_mut::() .0 .mode = solitaire_core::game_state::GameMode::Zen; app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 600, }); app.update(); let unlocked = app .world() .resource::() .0 .iter() .find(|r| r.id == "zen_winner") .map(|r| r.unlocked) .unwrap_or(false); assert!(unlocked, "zen_winner must unlock when the game mode is Zen"); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!( fired.contains(&"zen_winner".to_string()), "AchievementUnlockedEvent for zen_winner must fire; got {fired:?}" ); } #[test] fn zen_winner_does_not_fire_for_classic_win() { let mut app = headless_app(); // Default GameMode is Classic; assert and rely on it. assert_eq!( app.world().resource::().0.mode, solitaire_core::game_state::GameMode::Classic ); app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let unlocked = app .world() .resource::() .0 .iter() .find(|r| r.id == "zen_winner") .map(|r| r.unlocked) .unwrap_or(false); assert!(!unlocked, "zen_winner must remain locked outside Zen mode"); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = cursor.read(events).map(|e| e.0.id.clone()).collect(); assert!( !fired.contains(&"zen_winner".to_string()), "zen_winner must not fire on a Classic-mode win; got {fired:?}" ); } fn press(app: &mut App, key: KeyCode) { let mut input = app.world_mut().resource_mut::>(); input.release(key); input.clear(); input.press(key); } #[test] fn pressing_a_spawns_achievements_screen() { let mut app = headless_app(); press(&mut app, KeyCode::KeyA); app.update(); let count = app .world_mut() .query::<&AchievementsScreen>() .iter(app.world()) .count(); assert_eq!(count, 1); } #[test] fn pressing_a_twice_dismisses_screen() { let mut app = headless_app(); press(&mut app, KeyCode::KeyA); app.update(); press(&mut app, KeyCode::KeyA); app.update(); let count = app .world_mut() .query::<&AchievementsScreen>() .iter(app.world()) .count(); assert_eq!(count, 0); } // ----------------------------------------------------------------------- // format_reward // ----------------------------------------------------------------------- #[test] fn format_reward_card_back() { assert_eq!(format_reward(Reward::CardBack(2)), "Card Back #2"); } #[test] fn format_reward_background() { assert_eq!(format_reward(Reward::Background(3)), "Background #3"); } #[test] fn format_reward_bonus_xp() { assert_eq!(format_reward(Reward::BonusXp(25)), "+25 XP"); } #[test] fn format_reward_badge() { assert_eq!(format_reward(Reward::Badge), "Badge"); } // ----------------------------------------------------------------------- // Per-row tooltips // ----------------------------------------------------------------------- /// Collects every `Tooltip` string attached to an `AchievementRow` in the /// current world. Order is unspecified — callers should search for a /// substring rather than rely on positions. fn collect_row_tooltips(app: &mut App) -> Vec { let mut q = app .world_mut() .query_filtered::<&Tooltip, With>(); q.iter(app.world()) .map(|t| t.0.clone().into_owned()) .collect() } /// `on_a_roll` is unlocked and has `Reward::CardBack(1)`. Its row's /// tooltip must surface that reward — the row UI already lists it, but /// the tooltip exists so the value is never just below the fold on /// long lists. #[test] fn unlocked_achievement_row_carries_tooltip_with_reward() { let mut app = headless_app(); // Pre-unlock on_a_roll directly on the resource so the row renders // in the "unlocked" branch when the screen spawns. { let mut achievements = app.world_mut().resource_mut::(); let record = achievements .0 .iter_mut() .find(|r| r.id == "on_a_roll") .expect("on_a_roll record must be seeded by AchievementPlugin"); record.unlock(Utc::now()); record.reward_granted = true; } press(&mut app, KeyCode::KeyA); app.update(); let tips = collect_row_tooltips(&mut app); assert!( !tips.is_empty(), "spawning the achievements screen must attach Tooltips to rows" ); // The reward for on_a_roll is `Card Back #1`. Find a tooltip // mentioning "Card back" (case-insensitive on "Back" → match the // exact format_reward output). let has_card_back_reward = tips.iter().any(|t| t.contains("Card Back")); assert!( has_card_back_reward, "expected an unlocked-row tooltip to mention the Card Back reward; got: {tips:?}" ); } /// Locked secret achievements are filtered out of the row list, so the /// screen must not contain a row tooltip carrying the secret /// achievement's reward (`Card Back #4` for `speed_and_skill`) — the /// only fingerprint that would betray the row's identity even though /// the canonical description is already cryptic. #[test] fn locked_secret_achievement_does_not_reveal_condition() { let mut app = headless_app(); // `speed_and_skill` starts locked under headless_app(); confirm. let locked = app .world() .resource::() .0 .iter() .find(|r| r.id == "speed_and_skill") .map(|r| !r.unlocked) .unwrap_or(false); assert!( locked, "precondition: speed_and_skill must be locked in a fresh headless app" ); press(&mut app, KeyCode::KeyA); app.update(); let tips = collect_row_tooltips(&mut app); // No row may carry the secret reward — that's the only way the // secret row's identity could leak through the tooltip surface. for t in &tips { assert!( !t.contains("Card Back #4"), "tooltip leaks the secret reward: {t:?}" ); } // No row may quote the verbatim secret-condition vocabulary. The // canonical secret description in `solitaire_core` is already // generic ("A secret achievement"); these checks guard against a // future leak where someone replaces it with the literal predicate. let leaked_predicate = tips.iter().any(|t| { t.contains("90") && t.to_lowercase().contains("without undo") }); assert!( !leaked_predicate, "no tooltip may state the speed_and_skill predicate: {tips:?}" ); // Sanity: the screen actually rendered some rows. If the spawn // path were broken there'd be nothing to leak in the first place. assert!(!tips.is_empty(), "screen must have rendered rows"); } // ----------------------------------------------------------------------- // tooltip_for_row policy // ----------------------------------------------------------------------- #[test] fn tooltip_for_row_unlocked_with_reward_mentions_reward() { let def = achievement_by_id("on_a_roll").expect("on_a_roll exists"); let s = tooltip_for_row(true, Some(def)); assert!(s.contains("Card Back"), "got {s:?}"); } #[test] fn tooltip_for_row_unlocked_without_reward_says_earned() { let def = achievement_by_id("first_win").expect("first_win exists"); assert_eq!(tooltip_for_row(true, Some(def)), "Earned!"); } #[test] fn tooltip_for_row_locked_includes_description_and_reward() { let def = achievement_by_id("lightning").expect("lightning exists"); let s = tooltip_for_row(false, Some(def)); assert!(s.contains("How to unlock")); assert!(s.contains("under 90 seconds")); assert!(s.contains("Card Back #2")); } #[test] fn tooltip_for_row_locked_no_reward_omits_reward() { let def = achievement_by_id("first_win").expect("first_win exists"); let s = tooltip_for_row(false, Some(def)); assert!(s.contains("How to unlock")); assert!(!s.contains("Reward"), "got {s:?}"); } }