use mineral_model::SongId;
use mineral_protocol::{PlayCursor, QueueAnchor, QueueEditOutcome, QueueOp, QueuePos};
use crate::state::{QueueSnapshot, State};
pub(crate) fn apply(st: &mut State, op: &QueueOp) -> QueueEditOutcome {
if matches!(op, QueueOp::Undo) {
return undo(st);
}
let Some(order) = plan(st, op) else {
return QueueEditOutcome::Stale;
};
commit(st, &order)
}
fn plan(st: &State, op: &QueueOp) -> Option<Vec<usize>> {
let len = st.queue.len();
let identity = || (0..len).collect::<Vec<_>>();
match op {
QueueOp::Remove(at) => {
let target = resolve(st, at)?;
Some((0..len).filter(|&i| i != target).collect())
}
QueueOp::Move { at, to } => {
let target = resolve(st, at)?;
let mut order = identity();
let dest = destination(st, target, *to)?;
order.remove(target);
order.insert(dest.min(order.len()), target);
Some(order)
}
QueueOp::ClearAbove(at) => {
let target = resolve(st, at)?;
Some((target..len).collect())
}
QueueOp::ClearBelow(at) => {
let target = resolve(st, at)?;
Some((0..=target).collect())
}
QueueOp::ApplyTransform { .. } | QueueOp::Undo => Some(identity()),
}
}
fn resolve(st: &State, at: &QueueAnchor) -> Option<usize> {
st.queue
.get(at.index)
.filter(|s| s.id == at.song_id)
.map(|_| at.index)
}
fn destination(st: &State, target: usize, to: QueuePos) -> Option<usize> {
let last = st.queue.len().checked_sub(1)?;
Some(match to {
QueuePos::Up => target.saturating_sub(1),
QueuePos::Down => (target + 1).min(last),
QueuePos::Top => 0,
QueuePos::Bottom => last,
QueuePos::AfterCurrent => {
let abs = super::nav::after_current_pos(st);
if target < abs { abs - 1 } else { abs }
}
})
}
fn commit(st: &mut State, order: &[usize]) -> QueueEditOutcome {
if order.len() == st.queue.len() && order.iter().enumerate().all(|(at, &i)| at == i) {
return QueueEditOutcome::NoOp;
}
let before = snapshot(st);
let rebuilt = order
.iter()
.filter_map(|&i| st.queue.get(i).cloned())
.collect::<Vec<_>>();
st.cursor = follow_cursor(st.cursor, order, rebuilt.len());
st.queue = rebuilt;
sync_original(st);
st.queue_undo = Some(before);
st.bump_queue();
QueueEditOutcome::Applied
}
fn follow_cursor(cursor: PlayCursor, order: &[usize], new_len: usize) -> PlayCursor {
let survivor_after = |from: usize| {
order
.iter()
.enumerate()
.filter(|&(_, &original)| original >= from)
.min_by_key(|&(_, &original)| original)
.map_or(new_len, |(at, _)| at)
};
match cursor {
PlayCursor::InQueue(cur) => order.iter().position(|&i| i == cur).map_or(
PlayCursor::Detached {
resume_at: survivor_after(cur + 1),
},
PlayCursor::InQueue,
),
PlayCursor::Detached { resume_at } => PlayCursor::Detached {
resume_at: survivor_after(resume_at),
},
}
}
fn sync_original(st: &mut State) {
let Some(orig) = st.original_queue.as_mut() else {
return;
};
let live = st
.queue
.iter()
.map(|s| s.id.clone())
.collect::<rustc_hash::FxHashSet<_>>();
orig.retain(|s| live.contains(&s.id));
}
fn snapshot(st: &State) -> QueueSnapshot {
QueueSnapshot {
queue: st.queue.clone(),
original_queue: st.original_queue.clone(),
cursor: st.cursor,
}
}
fn undo(st: &mut State) -> QueueEditOutcome {
let Some(before) = st.queue_undo.take() else {
return QueueEditOutcome::NoOp;
};
st.queue = before.queue;
st.original_queue = before.original_queue;
st.cursor = before.cursor;
st.bump_queue();
QueueEditOutcome::Applied
}
pub(crate) fn apply_order(st: &mut State, ids: &[SongId]) -> QueueEditOutcome {
let mut order = Vec::with_capacity(ids.len());
for id in ids {
let Some(at) = st.queue.iter().position(|s| s.id == *id) else {
mineral_log::warn!(
target: "player",
id = id.qualified(),
"queue transform returned an unknown song, rejecting the whole order"
);
return QueueEditOutcome::Stale;
};
order.push(at);
}
commit(st, &order)
}
#[cfg(test)]
mod tests {
use mineral_protocol::{
PlayCursor, PlayMode, QueueAnchor, QueueEditOutcome, QueueOp, QueuePos,
};
use mineral_test::song;
use pretty_assertions::assert_eq;
use super::{apply, apply_order};
use crate::state::State;
fn state(sel: usize) -> State {
let mut st = State::empty();
st.queue = vec![song("a"), song("b"), song("c"), song("d")];
st.cursor = PlayCursor::InQueue(sel);
st.current_song = st.queue.get(sel).cloned();
st
}
fn ids(st: &State) -> Vec<&str> {
st.queue.iter().map(|s| s.id.as_str()).collect()
}
fn anchor(st: &State, index: usize) -> QueueAnchor {
let id = st
.queue
.get(index)
.map(|s| s.id.clone())
.unwrap_or_else(|| song("missing").id);
QueueAnchor::new(index, id)
}
#[test]
fn remove_non_current_keeps_cursor_on_same_song() {
let mut st = state(2); let at = anchor(&st, 0); assert_eq!(
apply(&mut st, &QueueOp::Remove(at)),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["b", "c", "d"]);
assert_eq!(st.cursor, PlayCursor::InQueue(1), "c 前移到下标 1");
}
#[test]
fn remove_current_detaches_cursor_to_successor() {
let mut st = state(1); let at = anchor(&st, 1);
assert_eq!(
apply(&mut st, &QueueOp::Remove(at)),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "c", "d"]);
assert_eq!(
st.cursor,
PlayCursor::Detached { resume_at: 1 },
"接续点应指向原下一首 c(新下标 1)"
);
}
#[test]
fn remove_current_at_tail_stops_after_finish() {
let mut st = state(3); let at = anchor(&st, 3);
assert_eq!(
apply(&mut st, &QueueOp::Remove(at)),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "b", "c"]);
assert_eq!(st.cursor, PlayCursor::Detached { resume_at: 3 });
}
#[test]
fn stale_anchor_is_rejected() {
let mut st = state(0);
let bogus = QueueAnchor::new(1, song("zzz").id);
assert_eq!(
apply(&mut st, &QueueOp::Remove(bogus)),
QueueEditOutcome::Stale
);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"], "拒绝后队列不变");
}
#[test]
fn move_up_and_down_follows_the_song() {
let mut st = state(2); let at = anchor(&st, 2);
assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::Up
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "c", "b", "d"]);
assert_eq!(st.cursor, PlayCursor::InQueue(1), "游标跟着 c 上移");
let at = anchor(&st, 1);
assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::Down
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
assert_eq!(st.cursor, PlayCursor::InQueue(2));
}
#[test]
fn move_at_edges_does_not_wrap() {
let mut st = state(0);
let first = anchor(&st, 0);
assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at: first,
to: QueuePos::Up
}
),
QueueEditOutcome::NoOp
);
let last = anchor(&st, 3);
assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at: last,
to: QueuePos::Down
}
),
QueueEditOutcome::NoOp
);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
}
#[test]
fn move_to_top_and_bottom() {
let mut st = state(0);
let at = anchor(&st, 2); assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::Top
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["c", "a", "b", "d"]);
assert_eq!(st.cursor, PlayCursor::InQueue(1), "当前曲 a 被 c 挤后一位");
let at = anchor(&st, 0); assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::Bottom
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "b", "d", "c"]);
}
#[test]
fn move_after_current_relocates_without_copying() {
let mut st = state(0); let at = anchor(&st, 3); assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::AfterCurrent
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "d", "b", "c"], "长度不变,只是换位");
assert_eq!(st.cursor, PlayCursor::InQueue(0));
}
#[test]
fn move_after_current_from_before_lands_snug() {
let mut st = state(2); let at = anchor(&st, 0); assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::AfterCurrent
}
),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["b", "c", "a", "d"], "a 紧挨当前曲 c 之后");
assert_eq!(
st.cursor,
PlayCursor::InQueue(1),
"当前曲 c 前移一位(a 被摘走)"
);
}
#[test]
fn clear_above_keeps_the_anchor() {
let mut st = state(0); let at = anchor(&st, 2); assert_eq!(
apply(&mut st, &QueueOp::ClearAbove(at)),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["c", "d"]);
assert_eq!(
st.cursor,
PlayCursor::Detached { resume_at: 0 },
"在播的 a 被清掉 → 悬空,播完接 c"
);
}
#[test]
fn clear_below_keeps_the_anchor() {
let mut st = state(0); let at = anchor(&st, 1); assert_eq!(
apply(&mut st, &QueueOp::ClearBelow(at)),
QueueEditOutcome::Applied
);
assert_eq!(ids(&st), vec!["a", "b"]);
assert_eq!(st.cursor, PlayCursor::InQueue(0));
}
#[test]
fn clear_above_first_is_noop() {
let mut st = state(0);
let at = anchor(&st, 0);
assert_eq!(
apply(&mut st, &QueueOp::ClearAbove(at)),
QueueEditOutcome::NoOp
);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
}
#[test]
fn undo_restores_queue_and_cursor() {
let mut st = state(1); let at = anchor(&st, 1);
assert_eq!(
apply(&mut st, &QueueOp::Remove(at)),
QueueEditOutcome::Applied
);
assert_eq!(st.cursor, PlayCursor::Detached { resume_at: 1 });
assert_eq!(apply(&mut st, &QueueOp::Undo), QueueEditOutcome::Applied);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
assert_eq!(st.cursor, PlayCursor::InQueue(1), "游标从悬空变回附着");
assert_eq!(
apply(&mut st, &QueueOp::Undo),
QueueEditOutcome::NoOp,
"单级撤销:没有第二份快照"
);
}
#[test]
fn rejected_edit_leaves_no_undo_snapshot() {
let mut st = state(0);
let bogus = QueueAnchor::new(1, song("zzz").id);
assert_eq!(
apply(&mut st, &QueueOp::Remove(bogus)),
QueueEditOutcome::Stale
);
assert_eq!(apply(&mut st, &QueueOp::Undo), QueueEditOutcome::NoOp);
}
#[test]
fn removal_syncs_original_queue() {
let mut st = state(0);
st.play_mode = PlayMode::Shuffle;
st.queue = vec![song("c"), song("a"), song("d"), song("b")];
st.cursor = PlayCursor::InQueue(0);
st.current_song = Some(song("c"));
st.original_queue = Some(vec![song("a"), song("b"), song("c"), song("d")]);
let at = anchor(&st, 3); assert_eq!(
apply(&mut st, &QueueOp::Remove(at)),
QueueEditOutcome::Applied
);
assert_eq!(
st.original_queue
.as_ref()
.map(|q| q.iter().map(|s| s.id.as_str()).collect::<Vec<_>>()),
Some(vec!["a", "c", "d"]),
"b 应一并从原序里摘掉"
);
}
#[test]
fn reorder_does_not_pollute_original_queue() {
let mut st = state(0);
st.play_mode = PlayMode::Shuffle;
st.queue = vec![song("c"), song("a"), song("d"), song("b")];
st.cursor = PlayCursor::InQueue(0);
st.current_song = Some(song("c"));
st.original_queue = Some(vec![song("a"), song("b"), song("c"), song("d")]);
let at = anchor(&st, 3);
assert_eq!(
apply(
&mut st,
&QueueOp::Move {
at,
to: QueuePos::Top
}
),
QueueEditOutcome::Applied
);
assert_eq!(
st.original_queue
.as_ref()
.map(|q| q.iter().map(|s| s.id.as_str()).collect::<Vec<_>>()),
Some(vec!["a", "b", "c", "d"]),
"原序不受洗牌视图内重排影响"
);
}
#[test]
fn apply_order_accepts_permutation_and_subset() {
let mut st = state(1); let order = vec![song("d").id, song("b").id];
assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Applied);
assert_eq!(ids(&st), vec!["d", "b"]);
assert_eq!(st.cursor, PlayCursor::InQueue(1), "当前曲跟到新位置");
}
#[test]
fn apply_order_allows_duplicates_beyond_original_count() {
let mut st = state(0);
let order = vec![song("a").id, song("a").id, song("a").id];
assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Applied);
assert_eq!(ids(&st), vec!["a", "a", "a"]);
}
#[test]
fn apply_order_rejects_unknown_id() {
let mut st = state(0);
let order = vec![song("a").id, song("zzz").id];
assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Stale);
assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
}
#[test]
fn apply_order_identity_is_noop() {
let mut st = state(0);
let order: Vec<_> = st.queue.iter().map(|s| s.id.clone()).collect();
assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::NoOp);
}
}