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;
#[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(())
}
#[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); 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,
);
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(())
}
#[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())?;
core.cycle_play_mode(mineral_stats::Actor::User); persist.session().save(&core.snapshot_session()).await?;
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(())
}
#[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?;
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(())
}