neser 0.3.1

NESER - NES Emulator in Rust. Desktop (SDL) and WebAssembly frontends.
Documentation
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};

use crate::platform::config::Config;

pub const TOAST_LIFETIME_SECS: u64 = 4;
pub const MAX_VISIBLE_TOASTS: usize = 3;

pub type SharedAppContext = Rc<RefCell<AppContext>>;

pub trait IntoSharedAppContext {
    fn into_shared(self) -> SharedAppContext;
}

#[derive(Debug, Clone)]
pub struct AppContext {
    toast_manager: ToastManager,
    config: Config,
}

impl Default for AppContext {
    fn default() -> Self {
        Self {
            toast_manager: ToastManager::new(),
            config: Config::default(),
        }
    }
}

impl IntoSharedAppContext for AppContext {
    fn into_shared(self) -> SharedAppContext {
        Rc::new(RefCell::new(self))
    }
}

impl IntoSharedAppContext for &AppContext {
    fn into_shared(self) -> SharedAppContext {
        Rc::new(RefCell::new(self.clone()))
    }
}

impl IntoSharedAppContext for SharedAppContext {
    fn into_shared(self) -> SharedAppContext {
        self
    }
}

impl AppContext {
    #[allow(dead_code)]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn new_with_config(config: Config) -> Self {
        Self {
            config,
            ..Self::default()
        }
    }

    pub fn config(&self) -> &Config {
        &self.config
    }

    pub fn config_mut(&mut self) -> &mut Config {
        &mut self.config
    }

    pub fn add_toast(&mut self, text: impl Into<String>) {
        self.toast_manager.push(text.into(), Instant::now());
    }

    pub fn visible_toasts(&mut self, now: Instant) -> Vec<String> {
        self.toast_manager
            .visible_toasts(now)
            .into_iter()
            .map(|toast| toast.text.clone())
            .collect()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct Toast {
    text: String,
    created_at: Instant,
}

#[derive(Debug, Clone, Default)]
struct ToastManager {
    toasts: Vec<Toast>,
}

impl ToastManager {
    fn new() -> Self {
        Self::default()
    }

    fn push(&mut self, text: String, now: Instant) {
        self.toasts.push(Toast {
            text,
            created_at: now,
        });
    }

    fn prune_expired(&mut self, now: Instant) {
        let lifetime = Duration::from_secs(TOAST_LIFETIME_SECS);
        self.toasts
            .retain(|toast| now.saturating_duration_since(toast.created_at) <= lifetime);
    }

    fn visible_toasts(&mut self, now: Instant) -> Vec<&Toast> {
        self.prune_expired(now);
        let start = self.toasts.len().saturating_sub(MAX_VISIBLE_TOASTS);
        self.toasts[start..].iter().collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_toast_manager_expires_toast_after_lifetime() {
        let mut manager = ToastManager::new();
        let now = Instant::now();
        manager.push("Saved state".to_string(), now);

        let visible = manager.visible_toasts(now + Duration::from_secs(TOAST_LIFETIME_SECS));
        assert_eq!(visible.len(), 1);

        let visible = manager.visible_toasts(now + Duration::from_secs(TOAST_LIFETIME_SECS + 1));
        assert!(visible.is_empty());
    }

    #[test]
    fn test_toast_manager_expires_without_extra_truncated_second() {
        let mut manager = ToastManager::new();
        let now = Instant::now();
        manager.push("Saved state".to_string(), now);

        let visible = manager.visible_toasts(
            now + Duration::from_secs(TOAST_LIFETIME_SECS) + Duration::from_millis(999),
        );
        assert!(
            visible.is_empty(),
            "toast should expire once lifetime is exceeded, even within the next second"
        );
    }

    #[test]
    fn test_toast_manager_limits_visible_to_three() {
        let mut manager = ToastManager::new();
        let now = Instant::now();

        manager.push("One".to_string(), now);
        manager.push("Two".to_string(), now + Duration::from_millis(1));
        manager.push("Three".to_string(), now + Duration::from_millis(2));
        manager.push("Four".to_string(), now + Duration::from_millis(3));

        let visible = manager.visible_toasts(now + Duration::from_millis(3));
        assert_eq!(visible.len(), MAX_VISIBLE_TOASTS);
        assert_eq!(visible[0].text, "Two");
        assert_eq!(visible[1].text, "Three");
        assert_eq!(visible[2].text, "Four");
    }

    #[test]
    fn test_toast_manager_returns_oldest_to_newest_for_stacking() {
        let mut manager = ToastManager::new();
        let now = Instant::now();

        manager.push("Oldest".to_string(), now);
        manager.push("Middle".to_string(), now + Duration::from_millis(1));
        manager.push("Newest".to_string(), now + Duration::from_millis(2));

        let visible = manager.visible_toasts(now + Duration::from_millis(2));
        assert_eq!(visible.len(), 3);
        assert_eq!(visible[0].text, "Oldest");
        assert_eq!(visible[1].text, "Middle");
        assert_eq!(visible[2].text, "Newest");
    }

    #[test]
    fn test_app_context_exposes_toast_visibility() {
        let mut context = AppContext::new();
        context.add_toast("Saved state");

        let visible = context.visible_toasts(Instant::now());
        assert_eq!(visible, vec!["Saved state".to_string()]);
    }
}