crates\nfarms-core\src\music.rs¶
源码路径:
crates\nfarms-core\src\music.rs· 708 行
//! 音乐播放 —— C# `Music.cs` 的等价移植。
//!
//! 原版用的是 **Windows 自带 MCI(winmm.dll)**:零第三方依赖,支持
//! mp3 / wav / wma(装了相应解码器还能更多)。这里照抄这个选择 ——
//! 直接 `#[link(name = "winmm")]` 调 `mciSendStringW`,
//! 和 `time.rs` 调 `GetLocalTime` 是同一套做法:不引 rodio / cpal 那一大串。
//!
//! MCI 有三个必须记住的怪癖:
//!
//! 1. **返回码 0 只代表成功,信息在缓冲区里**。比如 `open` 成功时返回码是 0,
//! 但缓冲区会带回一个设备号(`"1"`)。所以每个命令都要同时看返回码和缓冲区。
//! 2. **错误信息也在缓冲区里**。失败时 `mciGetErrorStringW` 拿到的才是人话,
//! 只看返回码只能得到「MCI 错误码 263」这种没用的东西。
//! 3. **设备类型不一定猜得出来**。不带 `type` 的 `open` 让 MCI 自己按注册表挑
//! 设备,挑不出来就得显式写 `type waveaudio` / `type mpegvideo` ——
//! 原版 `Music.cs:58-71` 就是靠这三级回退把 `.wma` 这类文件放出来的,
//! 这里照抄(见 `Music::open`)。
//!
//! 还有一条纪律:**命令只有 `Music::cmd()` 一个出口**。生产路径它直连
//! `imp::send`,测试构建里换成注入的假实现 —— MCI 是进程外的真设备,不换出口
//! 就没法断言"到底发了哪几条命令",而三级回退、无条件 `close`、`seek` 夹取
//! 这些逻辑恰恰全在"发了什么命令"这一层。
use std::cell::Cell;
#[cfg(test)]
use std::cell::RefCell;
use std::path::Path;
/// 设备别名。固定一个就够 —— 同时只放一首。
const ALIAS: &str = "nfmusic";
/// 本模块**保证**能放的扩展名(`.wav` 走 waveaudio,`.mp3` / `.wma` 走 DirectShow)。
///
/// 播放列表那侧允许的比这里宽 —— `lib.rs` 的 `EXT`、`background.rs` 的
/// `AUDIO_FILTER` 还带 m4a / aac / flac / ogg。那几种能不能放取决于目标机装了
/// 哪些解码器,所以**不做前置拒绝**(拒绝会让本来放得了的机器也放不了),
/// 只用它决定三级回退全失败时要不要补一句"这是解码器的事",见 `codec_hint()`。
const NATIVE_EXT: [&str; 3] = ["mp3", "wav", "wma"];
/// 播放状态。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlayState {
Stopped,
Playing,
Paused,
}
/// 测试用的命令出口。抽别名的理由同 `context::ProvideHook`:
/// `RefCell<Box<dyn FnMut(&str) -> Result<String, String>>>` 写全了
/// 读不出"它是个可替换的命令出口"。
#[cfg(test)]
type SendFn = Box<dyn FnMut(&str) -> Result<String, String>>;
pub struct Music {
opened: bool,
/// 播放状态。**这是给界面读的,外部不要写。**
///
/// 它只是 `Music` 对 MCI 真实状态的记账,直接赋值不会去动 MCI:下一次
/// `poll_state()` / `play()` 就会把它改回来,中间这段时间界面显示的
/// (比如"播放中")与实际出声的不一致,而且看不出是谁改的。
/// 之所以还是 `pub` 而不是私有 + getter:界面两处(`core_pages.rs:1826`、
/// `lib.rs:1472`)直接读字段,改成私有要连着改那两个文件。
pub state: PlayState,
/// 当前打开的文件(界面上显示用)
pub path: Option<String>,
/// 音量 0..=1000(MCI 的刻度)
pub volume: u32,
/// `length_ms()` 的缓存。
///
/// MCI 的长度查一次就定了,而界面是**每帧**都问一遍
/// (`core_pages.rs:1827`,不受"是否在播"影响):不缓存的话每帧一次同步
/// `mciSendStringW`,拖着窗口时能看出顿。
/// 用 `Cell` 是为了保住 `length_ms(&self)` 的只读签名 —— 界面那边是借着
/// 一个 `&` 调它的。写入/清空只有 `length_ms()` 自己和 `close()` 两处。
length_cache: Cell<Option<i64>>,
/// 命令出口。生产路径恒为 `imp::send`;测试里换成假的(MCI 是进程外的真设备,
/// 没有可注入的桩)。见 `cmd()`。
#[cfg(test)]
send: RefCell<SendFn>,
}
impl Default for Music {
fn default() -> Self {
Self {
opened: false,
state: PlayState::Stopped,
path: None,
// 和 C# 原版 `Music.SetVolume` 的刻度一致(0..=1000);真正该用的是
// `settings.music_volume * 10`,接线在界面侧(`core_pages.rs`),
// 这里只保证没读到设置时也能出声。
volume: 700,
length_cache: Cell::new(None),
#[cfg(test)]
send: RefCell::new(Box::new(imp::send)),
}
}
}
impl Music {
pub fn is_opened(&self) -> bool {
self.opened
}
/// 所有命令的唯一出口:生产路径直连 `imp::send`。
#[cfg(not(test))]
fn cmd(&self, cmd: &str) -> Result<String, String> {
imp::send(cmd)
}
/// 测试构建的出口:走 `Default` 里接上的真 `imp::send`,或者被测试换掉的假实现。
#[cfg(test)]
fn cmd(&self, cmd: &str) -> Result<String, String> {
let mut f = self.send.borrow_mut();
(*f)(cmd)
}
/// 查询命令:只取值,不关心错误文案(界面每帧都在问,失败没什么好报的)。
///
/// 走 `cmd()` 而不是另开一条 `imp::query()`:两者底层是同一件事 ——
/// 一次 `mciSendStringW` 加一次缓冲区解码,分成两份只会改一处漏一处。
fn query(&self, cmd: &str) -> Option<String> {
self.cmd(cmd).ok()
}
/// 打开一个音频文件。已经开着别的会先关掉。
///
/// 三级回退照抄 C# 原版 `Music.cs:58-71`:先让 MCI 按注册表自己挑设备,
/// 挑不出来再显式上 `type waveaudio`,最后 `type mpegvideo`。第三级是
/// `.wma` 这类只能走 DirectShow 的格式唯一的出路 —— 少了它,那种文件会在
/// 第一级就失败,用户只看到一句"打开失败",而其实换个 type 就能放。
pub fn open(&mut self, path: &Path) -> Result<(), String> {
if !path.is_file() {
return Err(format!("文件不存在: {}", path.display()));
}
self.close();
// 路径里的引号会破坏 MCI 语法(它没有转义机制),直接拒绝而不是拼一个坏命令
let p = path.display().to_string();
if p.contains('"') {
return Err("路径里有引号,MCI 处理不了".to_string());
}
let attempts = [
("auto", format!("open \"{p}\" alias {ALIAS}")),
(
"waveaudio",
format!("open \"{p}\" type waveaudio alias {ALIAS}"),
),
(
"mpegvideo",
format!("open \"{p}\" type mpegvideo alias {ALIAS}"),
),
];
let mut errs: Vec<(&str, String)> = Vec::with_capacity(attempts.len());
let mut ok = false;
for (kind, cmd) in &attempts {
match self.cmd(cmd) {
Ok(_) => {
ok = true;
break;
}
Err(e) => {
errs.push((kind, e));
// 失败的那次 open 可能在 MCI 里留下半个设备别名;不关掉,
// 下一次 open 会以"别名已被使用"失败,把真正的原因盖住。
// 原版每次回退前都先 close(Music.cs:61 / 65 / 69)。
let _ = self.cmd(&format!("close {ALIAS}"));
}
}
}
if !ok {
// 三次都失败才报错,并且三条一起带出去(原版 `Music.cs:71` 同款格式)。
// 只报第一条会把"设备类型没猜对"误导成"文件坏了",而三条并排
// 一眼就能看出是哪一层的问题。
let mut msg = errs
.iter()
.map(|(kind, e)| format!("{kind}={e}"))
.collect::<Vec<_>>()
.join(" | ");
if let Some(hint) = codec_hint(path) {
msg.push(' ');
msg.push_str(&hint);
}
return Err(msg);
}
self.opened = true;
self.path = Some(p);
self.state = PlayState::Stopped;
// 新设备起来后音量回到默认值,必须重下一次。这里用 `?` 而不是
// `set_volume()`:set_volume 是给界面滑块拖的那条路径(一次拖动几十条,
// 失败无处可报),而 open 这里的失败意味着"打开了但可能是个哑巴",
// 必须让调用方看见 —— 原来就是被 `let _ =` 吞掉的。
//
// **失败也不回滚**:设备确实已经打开、能播,为一条音量命令把刚打开的
// 文件关掉,用户会看到的是一句"打不开",比"音量没设上"难查得多。
self.apply_volume()
.map_err(|e| format!("已打开,但音量没设上:{e}"))?;
Ok(())
}
pub fn play(&mut self) -> Result<(), String> {
if !self.opened {
return Err("还没有打开音频".to_string());
}
// 从头播还是接着播由 MCI 自己决定;要重头播用 seek_to_start
self.cmd(&format!("play {ALIAS}"))?;
self.state = PlayState::Playing;
Ok(())
}
pub fn pause(&mut self) -> Result<(), String> {
if !self.opened {
return Err("还没有打开音频".to_string());
}
self.cmd(&format!("pause {ALIAS}"))?;
self.state = PlayState::Paused;
Ok(())
}
pub fn stop(&mut self) -> Result<(), String> {
if !self.opened {
return Err("还没有打开音频".to_string());
}
self.cmd(&format!("stop {ALIAS}"))?;
self.cmd(&format!("seek {ALIAS} to start"))?;
self.state = PlayState::Stopped;
Ok(())
}
pub fn seek_to_start(&mut self) -> Result<(), String> {
if !self.opened {
return Err("还没有打开音频".to_string());
}
// send 会带回缓冲区内容(可能非空),这里只要成败
self.cmd(&format!("seek {ALIAS} to start")).map(|_| ())
}
/// 跳到指定毫秒(MCI 的 `seek ... to <ms>`)。
///
/// 先把 `ms` 夹到 `0..=length_ms()`:越界值原样塞进命令串是"看驱动心情" ——
/// 有的驱动报错,有的夹到末尾,还有的会跳到别处。拿不到时长(`len <= 0`,
/// 通常是设备还没把时长解析出来)时什么都不做:那种时候 seek 的结果本来就
/// 不可预期,不如等下一帧。
pub fn seek_ms(&mut self, ms: i64) -> Result<(), String> {
if !self.opened {
return Err("还没有打开音频".to_string());
}
let len = self.length_ms();
if len <= 0 {
return Ok(());
}
let ms = ms.clamp(0, len);
self.cmd(&format!("seek {ALIAS} to {ms}")).map(|_| ())
}
/// 关掉当前音频并复位记账。
///
/// **不判断 `opened`**(和 C# `Music.cs:93-99` 一致):MCI 的 `close` 只对
/// 不存在的别名报错,没有副作用;可一旦状态不同步,漏发一次 `close` 就会
/// 让下一次 `open` 报"别名已被使用" —— 那种错比多发两条命令难查得多。
pub fn close(&mut self) {
let _ = self.cmd(&format!("stop {ALIAS}"));
let _ = self.cmd(&format!("close {ALIAS}"));
self.opened = false;
self.state = PlayState::Stopped;
self.path = None;
// 换/关文件必须让时长缓存失效,否则下一首会顶着上一首的时长
self.length_cache.set(None);
}
/// 总时长(毫秒)。拿不到返回 0。
///
/// 结果缓存:界面**每帧**都问一遍(`core_pages.rs:1827`),而后端是同步的
/// `mciSendStringW`,不缓存拖着窗口就能看出顿。缓存的生命周期由
/// `open()` / `close()` 管(两者都会清),这里只负责填。
pub fn length_ms(&self) -> i64 {
if !self.opened {
return 0;
}
if let Some(v) = self.length_cache.get() {
return v;
}
let v = self
.query(&format!("status {ALIAS} length"))
.and_then(|s| s.trim().parse::<i64>().ok())
.unwrap_or(0);
// 只缓存正数:刚 open 完 MCI 有时还没把时长解析出来,那一刻的 0 是
// "还没算好"而不是"这首歌没有长度"。把 0 也缓存住,进度条就会永远
// 停在 `--:--` 且再也不会重问。
if v > 0 {
self.length_cache.set(Some(v));
}
v
}
/// 当前位置(毫秒)。**不缓存** —— 它问的就是"现在播到哪"。
pub fn position_ms(&self) -> i64 {
if !self.opened {
return 0;
}
self.query(&format!("status {ALIAS} position"))
.and_then(|s| s.trim().parse::<i64>().ok())
.unwrap_or(0)
}
/// 是否还在播(MCI 的 `mode` 会给出 playing / paused / stopped)。
pub fn poll_state(&mut self) -> PlayState {
if !self.opened {
return PlayState::Stopped;
}
if let Some(m) = self.query(&format!("status {ALIAS} mode")) {
let m = m.trim().to_ascii_lowercase();
self.state = if m.starts_with("play") {
PlayState::Playing
} else if m.starts_with("pause") {
PlayState::Paused
} else {
PlayState::Stopped
};
}
self.state
}
/// 音量 0..=1000。给界面滑块用。
///
/// 失败刻意吞掉:滑块一次拖动会发几十条 `setaudio`,每条都往
/// `media.message` 里塞一句错误只会把提示刷满,而且用户松手前本来就会再发
/// 一条。`open()` 里那次下发是另一回事 —— 那里走 `apply_volume()`,`?`
/// 上抛,因为"打开成功但没音量"必须被看见。
pub fn set_volume(&mut self, v: u32) {
self.volume = v.min(1000);
let _ = self.apply_volume();
}
/// 把当前 `volume` 下发给 MCI 设备。
fn apply_volume(&self) -> Result<(), String> {
if !self.opened {
// 没设备时 MCI 必然报错,不必发;这不算失败,不报给调用方。
return Ok(());
}
self.cmd(&format!("setaudio {ALIAS} volume to {}", self.volume))
.map(|_| ())
}
/// `mm:ss` 形式的时长,界面上显示用。
pub fn fmt_ms(ms: i64) -> String {
if ms <= 0 {
return "--:--".to_string();
}
let total = ms / 1000;
format!("{:02}:{:02}", total / 60, total % 60)
}
}
impl Drop for Music {
fn drop(&mut self) {
self.close();
}
}
/// 三次打开都失败时,给"允许面比承诺面宽"的那些格式补一句说明。
///
/// 播放列表允许的扩展名(`lib.rs` 的 `EXT`、`background.rs` 的 `AUDIO_FILTER`)
/// 比本模块承诺的 mp3 / wav / wma 宽:m4a / aac / flac / ogg 全看目标机挂没挂
/// 对应解码器。与其把允许面收窄(会让本来放得了的机器也放不了),不如在失败时
/// 把原因说清楚 —— 否则用户拿到的是一句设备错误,完全猜不到是"这台机器没有
/// 这个格式的解码器"。
fn codec_hint(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if NATIVE_EXT.contains(&ext.as_str()) {
return None;
}
Some(format!(
"(提示:.{ext} 不是 MCI 原生支持的格式,放不了通常是这台机器没装对应解码器)"
))
}
#[cfg(windows)]
mod imp {
use std::os::raw::c_void;
#[link(name = "winmm")]
unsafe extern "system" {
fn mciSendStringW(
cmd: *const u16,
ret: *mut u16,
ret_len: u32,
hwnd: *mut c_void,
) -> u32;
fn mciGetErrorStringW(code: u32, buf: *mut u16, len: u32) -> i32;
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// 发一条 MCI 命令。成功返回缓冲区内容(可能为空),失败返回人话错误。
///
/// 查询命令也从这里走(`Music::query()` 取 `.ok()`):查询与命令的差别只有
/// "读不读错误文案",底层是同一件事,没必要各抄一份 `mciSendStringW` + 解码。
pub fn send(cmd: &str) -> Result<String, String> {
let c = wide(cmd);
let mut buf = vec![0u16; 512];
// 安全性:两个指针都指向我们自己持有的、生命周期覆盖整个调用的缓冲区
let rc = unsafe {
mciSendStringW(
c.as_ptr(),
buf.as_mut_ptr(),
buf.len() as u32,
std::ptr::null_mut(),
)
};
let text = String::from_utf16_lossy(
&buf[..buf.iter().position(|&x| x == 0).unwrap_or(buf.len())],
);
if rc == 0 {
Ok(text)
} else {
Err(error_text(rc))
}
}
/// MCI 错误码 → 人话。只看返回码只能得到「错误码 263」这种没用的东西。
fn error_text(code: u32) -> String {
let mut buf = vec![0u16; 512];
let ok = unsafe { mciGetErrorStringW(code, buf.as_mut_ptr(), buf.len() as u32) };
if ok != 0 {
let msg = String::from_utf16_lossy(
&buf[..buf.iter().position(|&x| x == 0).unwrap_or(buf.len())],
);
if !msg.trim().is_empty() {
return format!("MCI 错误码 {code}:{}", msg.trim());
}
}
format!("MCI 错误码 {code}")
}
}
#[cfg(not(windows))]
mod imp {
pub fn send(_cmd: &str) -> Result<String, String> {
Err("MCI 只有 Windows 有;其它平台要另接音频后端".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
/// 换掉 `Music` 的命令出口,返回一个按顺序记录所有命令的账本。
///
/// 为什么要它:三级回退、无条件 `close`、`seek` 夹取全都是**"发了什么命令"**
/// 层面的行为,只看返回值断言不出来;而真 MCI 是进程外的设备,桩不进去。
fn fake(
m: &mut Music,
reply: impl Fn(&str) -> Result<String, String> + 'static,
) -> Rc<RefCell<Vec<String>>> {
let cmds = Rc::new(RefCell::new(Vec::new()));
let sink = Rc::clone(&cmds);
m.send = RefCell::new(Box::new(move |cmd: &str| {
sink.borrow_mut().push(cmd.to_string());
reply(cmd)
}));
cmds
}
fn sent(cmds: &Rc<RefCell<Vec<String>>>) -> Vec<String> {
cmds.borrow().clone()
}
/// 造一个真文件,好过 `open()` 里 `is_file()` 那道门。
fn touch(name: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
"nfarms-music-test-{}-{name}",
std::process::id()
));
std::fs::write(&p, b"x").expect("写临时文件");
p
}
fn cleanup(p: &std::path::Path) {
let _ = std::fs::remove_file(p);
}
#[test]
fn duration_formatting() {
assert_eq!(Music::fmt_ms(0), "--:--");
assert_eq!(Music::fmt_ms(-5), "--:--");
assert_eq!(Music::fmt_ms(65_000), "01:05");
assert_eq!(Music::fmt_ms(3_600_000), "60:00");
}
#[test]
fn open_on_missing_file_is_a_clean_error() {
let mut m = Music::default();
let e = m.open(Path::new(r"C:\no\such\song.mp3")).unwrap_err();
assert!(e.contains("文件不存在"));
assert!(!m.is_opened());
}
#[test]
fn commands_without_open_do_not_panic() {
let mut m = Music::default();
// 没打开就操作:必须是可读的错误,不是 panic
assert!(m.play().is_err());
assert!(m.pause().is_err());
assert!(m.stop().is_err());
assert_eq!(m.length_ms(), 0);
assert_eq!(m.position_ms(), 0);
assert_eq!(m.poll_state(), PlayState::Stopped);
// 没设备时 seek 不再往 MCI 发越界命令
assert!(m.seek_ms(1000).is_err());
}
#[test]
fn volume_is_clamped() {
let mut m = Music::default();
m.set_volume(99999);
assert_eq!(m.volume, 1000);
}
/// 回归点:曾经只发第一条 `open`,失败即返回 —— C# 原版的第二、三级回退
/// 被丢掉了,`.wma` 这类只能走 DirectShow 的文件就此打不开。
#[test]
fn open_falls_back_through_three_device_types() {
let p = touch("fallback.wma");
let mut m = Music::default();
let cmds = fake(&mut m, |cmd| {
if cmd.starts_with("open") && !cmd.contains("type mpegvideo") {
Err("MCI 错误码 259:无法识别设备类型".to_string())
} else {
Ok(String::new())
}
});
assert!(m.open(&p).is_ok(), "应该靠第三级回退成功");
let c = sent(&cmds);
let opens: Vec<&String> = c.iter().filter(|s| s.starts_with("open")).collect();
assert_eq!(opens.len(), 3, "三级都要试过:{c:?}");
assert!(
opens[0].contains(&format!("alias {ALIAS}")) && !opens[0].contains("type "),
"第一级必须是不带 type 的裸 open:{}",
opens[0]
);
assert!(opens[1].contains("type waveaudio"), "{}", opens[1]);
assert!(opens[2].contains("type mpegvideo"), "{}", opens[2]);
assert!(m.is_opened());
assert_eq!(m.path.as_deref(), Some(p.display().to_string().as_str()));
cleanup(&p);
}
/// 三级全失败时要把三条错误一起带出去(原版 `Music.cs:71` 的格式),
/// 否则"设备类型没猜对"会被读成"文件坏了"。
#[test]
fn open_reports_all_three_failures() {
let p = touch("all-fail.flac");
let mut m = Music::default();
fake(&mut m, |cmd| {
if cmd.starts_with("open") {
Err(format!("失败:{cmd}"))
} else {
Ok(String::new())
}
});
let e = m.open(&p).unwrap_err();
assert!(e.contains("auto="), "{e}");
assert!(e.contains("waveaudio="), "{e}");
assert!(e.contains("mpegvideo="), "{e}");
assert!(!m.is_opened());
// `.flac` 在播放列表的允许面内、不在本模块的承诺面内:得点明是解码器的事
assert!(e.contains("解码器"), "{e}");
cleanup(&p);
}
/// 回归点:`close()` 曾经套着 `if self.opened` —— 状态一旦不同步,残留别名
/// 就清不掉,下一次 `open` 会以"别名已被使用"失败(原版 `Music.cs:93-99`)。
#[test]
fn close_clears_alias_even_when_not_opened() {
let mut m = Music::default();
assert!(!m.is_opened());
let cmds = fake(&mut m, |_| Ok(String::new()));
m.close();
let c = sent(&cmds);
assert!(c.contains(&format!("stop {ALIAS}")), "{c:?}");
assert!(c.contains(&format!("close {ALIAS}")), "{c:?}");
}
/// 回归点:`open()` 末尾重下音量的失败曾经被 `set_volume` 里的 `let _ =` 吞掉,
/// 界面上"打开了但没声音"没有任何解释。
#[test]
fn open_surfaces_volume_failure() {
let p = touch("vol.mp3");
let mut m = Music::default();
fake(&mut m, |cmd| {
if cmd.starts_with("setaudio") {
Err("MCI 错误码 289:设备不支持该命令".to_string())
} else {
Ok(String::new())
}
});
let e = m.open(&p).unwrap_err();
assert!(e.contains("音量"), "{e}");
// 刻意不回滚:设备是真打开了的,关掉会让用户以为"这文件打不开"
assert!(m.is_opened());
cleanup(&p);
}
/// 回归点:`length_ms()` 曾经每次调用都是一次同步 `mciSendStringW`,而界面
/// 每帧都问 —— 缓存住,并且换文件时必须失效。
#[test]
fn length_is_queried_once_then_cached() {
let p = touch("len.mp3");
let mut m = Music::default();
let cmds = fake(&mut m, |cmd| {
if cmd.ends_with("length") {
Ok("12345".to_string())
} else {
Ok(String::new())
}
});
assert!(m.open(&p).is_ok());
assert_eq!(m.length_ms(), 12_345);
assert_eq!(m.length_ms(), 12_345);
let n = sent(&cmds).iter().filter(|c| c.ends_with("length")).count();
assert_eq!(n, 1, "第二次应该命中缓存");
// 重新 open 会让缓存失效,否则新歌顶着上一首的时长
assert!(m.open(&p).is_ok());
assert_eq!(m.length_ms(), 12_345);
let n = sent(&cmds).iter().filter(|c| c.ends_with("length")).count();
assert_eq!(n, 2, "重新打开后必须重新问一次");
cleanup(&p);
}
/// "还没算好"的那个 0 不能被当成定论缓存住,否则进度条永远停在 `--:--`。
#[test]
fn length_zero_is_not_cached() {
let p = touch("len0.mp3");
let mut m = Music::default();
let calls = Rc::new(Cell::new(0));
let counter = Rc::clone(&calls);
fake(&mut m, move |cmd| {
if cmd.ends_with("length") {
counter.set(counter.get() + 1);
return Ok(if counter.get() == 1 { "0" } else { "8000" }.to_string());
}
Ok(String::new())
});
assert!(m.open(&p).is_ok());
assert_eq!(m.length_ms(), 0);
assert_eq!(m.length_ms(), 8_000, "0 不该被缓存");
cleanup(&p);
}
/// 回归点:`seek_ms()` 只把负数夹到 0,越界值原样进命令串 —— 驱动是报错
/// 还是跳到别处全看运气。
#[test]
fn seek_is_clamped_to_track_length() {
let p = touch("seek.mp3");
let mut m = Music::default();
let cmds = fake(&mut m, |cmd| {
if cmd.ends_with("length") {
Ok("5000".to_string())
} else {
Ok(String::new())
}
});
assert!(m.open(&p).is_ok());
assert!(m.seek_ms(999_999).is_ok());
assert!(m.seek_ms(-42).is_ok());
let seeks: Vec<String> = sent(&cmds)
.into_iter()
.filter(|c| c.starts_with("seek"))
.collect();
assert_eq!(
seeks,
vec![
format!("seek {ALIAS} to 5000"),
format!("seek {ALIAS} to 0")
]
);
cleanup(&p);
}
/// 查询和命令必须走同一个出口 —— 曾经 `send` / `query` 各抄一份
/// `mciSendStringW`,改一处漏一处。这条测试同时是"假出口能截住两者"的证据。
#[test]
fn queries_and_commands_share_one_exit() {
let p = touch("exit.mp3");
let mut m = Music::default();
let cmds = fake(&mut m, |cmd| {
if cmd.ends_with("length") {
Ok("1000".to_string())
} else {
Ok(String::new())
}
});
assert!(m.open(&p).is_ok());
assert_eq!(m.length_ms(), 1000); // 查询
assert!(m.play().is_ok()); // 命令
let c = sent(&cmds);
assert!(c.iter().any(|s| s.ends_with("length")), "查询没走公共出口:{c:?}");
assert!(
c.iter().any(|s| s == &format!("play {ALIAS}")),
"命令没走公共出口:{c:?}"
);
cleanup(&p);
}
}