mineral-server 0.5.10

Mineral 后台 server:收纳 audio engine + task scheduler + channels,对外发 ClientHandle
//! session 快照 · save/load 往返 · restore play_mode · 周期保存。

use std::sync::Arc;
use std::time::Duration;

use crate::persistence::ServerStore;
use mineral_model::SongId;
use mineral_protocol::PlayMode;
use mineral_test::song;
use parking_lot::Mutex;
use pretty_assertions::assert_eq;

use super::fixtures::{core_with, core_with_persist};
use super::waiting::drain_spawned;

/// volume_pct(u8 0..=100)→ f64 0.0..=1.0:80 → 0.8。
#[tokio::test]
async fn snapshot_session_converts_volume() -> color_eyre::Result<()> {
    let calls = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let core = core_with(calls)?;
    core.audio().set_volume(80);
    let snap = core.snapshot_session();
    assert!((snap.volume - 0.8).abs() < 1e-9, "80% 应映射到 0.8");
    Ok(())
}

/// 设入队列 + 当前歌 + 模式后,组装的 [`SessionSnapshot`] 落盘再 load 读回内容一致。
///
/// 注:直接 `snapshot_session()` + `session().save()` 落盘(而非依赖 background
/// fire-and-forget 的多次并发 save —— 它们写同一单例行无确定顺序),断言数据正确。
#[tokio::test]
async fn save_then_load_roundtrips_queue_and_current() -> color_eyre::Result<()> {
    let dir = tempfile::tempdir()?;
    let persist = ServerStore::open(&dir.path().join("t.db")).await?;
    let calls = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let core = core_with_persist(calls, persist.clone())?;

    core.cycle_play_mode(mineral_stats::Actor::User); // Sequential → Shuffle
    let queue = vec![song("a"), song("b"), song("c")];
    core.replace_queue(queue, 0, mineral_stats::QueueContext::Unknown)?;
    core.play_song(
        &song("a"),
        mineral_stats::PlayOrigin::Explicit,
        mineral_stats::Actor::User,
    );
    // 组装快照并同步落盘(确定性,不依赖 spawn 顺序)。
    let assembled = core.snapshot_session();
    persist.session().save(&assembled).await?;

    let snap = core
        .load_session()
        .await?
        .ok_or_else(|| color_eyre::eyre::eyre!("应读回会话"))?;
    assert_eq!(snap.queue.len(), 3, "队列长度应为 3");
    assert!(snap.queue.contains(&song("a").id), "队列应含 a");
    assert_eq!(snap.current, Some(song("a").id), "当前歌应为 a");
    assert_eq!(snap.play_mode, "Shuffle", "模式应为 Shuffle");
    Ok(())
}

/// 启动恢复路径:落库的模式名经 `PlayMode::from_name` 解析 + `restore_play_mode`
/// 写回——只动模式标志,不触发洗牌边界(队列空、original_queue 不被置),不回写会话。
#[tokio::test]
async fn restore_play_mode_sets_flag_without_shuffle_side_effects() -> color_eyre::Result<()> {
    let dir = tempfile::tempdir()?;
    let persist = ServerStore::open(&dir.path().join("t.db")).await?;
    let calls = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let core = core_with_persist(calls, persist.clone())?;

    // 模拟上一次会话:Shuffle 模式落盘。
    core.cycle_play_mode(mineral_stats::Actor::User); // Sequential → Shuffle
    persist.session().save(&core.snapshot_session()).await?;

    // 模拟下一次启动:新 core 读回会话,解析模式名并恢复。
    let calls2 = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let fresh = core_with_persist(calls2, persist)?;
    let snap = fresh
        .load_session()
        .await?
        .ok_or_else(|| color_eyre::eyre::eyre!("应读回会话"))?;
    let mode = PlayMode::from_name(&snap.play_mode)
        .ok_or_else(|| color_eyre::eyre::eyre!("落库模式名应可解析: {}", snap.play_mode))?;
    fresh.restore_play_mode(mode);

    let st = fresh.inner.state.lock();
    assert_eq!(st.play_mode, PlayMode::Shuffle, "模式标志应恢复");
    assert!(st.queue.is_empty(), "恢复不带队列");
    assert!(
        st.original_queue.is_none(),
        "restore 不该触发 enter_shuffle 的洗牌/存原序边界"
    );
    Ok(())
}

/// 周期落盘的空态守卫:daemon 空闲(无当前曲、空队列)时跳过,上次会话的队列
/// 不被空快照覆盖——那是将来队列恢复要吃的数据。
#[tokio::test]
async fn periodic_save_skips_empty_state_preserving_last_session() -> color_eyre::Result<()> {
    let dir = tempfile::tempdir()?;
    let persist = ServerStore::open(&dir.path().join("t.db")).await?;
    // 上次会话:真实队列同步落盘。
    let calls = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let core = core_with_persist(calls, persist.clone())?;
    core.replace_queue(
        vec![song("a"), song("b")],
        0,
        mineral_stats::QueueContext::Unknown,
    )?;
    persist.session().save(&core.snapshot_session()).await?;

    // 模拟新启动:空态 core,把节流窗口拨到已过期再触发周期检查。
    let calls2 = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let fresh = core_with_persist(calls2, persist)?;
    if let Some(past) = std::time::Instant::now().checked_sub(Duration::from_secs(60)) {
        *fresh.inner.last_session_save.lock() = past;
    }
    fresh.check_session_save();
    drain_spawned().await;

    let snap = fresh
        .load_session()
        .await?
        .ok_or_else(|| color_eyre::eyre::eyre!("应读回会话"))?;
    assert_eq!(snap.queue.len(), 2, "空态周期落盘应跳过,上次队列应保留");
    Ok(())
}

/// 整组入队保存完整保序队列;容量拒绝与空批次不覆盖已经保存的会话。
#[tokio::test]
async fn batch_enqueue_saves_only_accepted_changes() -> color_eyre::Result<()> {
    let dir = tempfile::tempdir()?;
    let persist = ServerStore::open(&dir.path().join("t.db")).await?;
    let calls = Arc::new(Mutex::new(Vec::<(SongId, bool, u64)>::new()));
    let core = core_with_persist(calls, persist)?;

    assert!(core.queue_append(
        vec![song("a"), song("b"), song("a")],
        mineral_stats::QueueContext::Unknown,
    ));
    drain_spawned().await;

    let saved = core
        .load_session()
        .await?
        .ok_or_else(|| color_eyre::eyre::eyre!("入队后应保存会话"))?;
    assert_eq!(saved.queue, vec![song("a").id, song("b").id, song("a").id]);

    core.with_state(|st| {
        st.queue = (0..crate::queue::QUEUE_CAP - 1)
            .map(|i| song(&i.to_string()))
            .collect();
    });
    for enqueue in [
        crate::player::PlayerCore::queue_append,
        crate::player::PlayerCore::queue_insert_next,
    ] {
        assert!(!enqueue(
            &core,
            vec![song("x"), song("y")],
            mineral_stats::QueueContext::Manual
        ));
        assert!(!enqueue(
            &core,
            Vec::new(),
            mineral_stats::QueueContext::Manual
        ));
    }
    drain_spawned().await;

    let after = core
        .load_session()
        .await?
        .ok_or_else(|| color_eyre::eyre::eyre!("拒绝入队后应保留会话"))?;
    assert_eq!(after.queue, saved.queue, "未实际入队时不得保存当前状态");
    Ok(())
}