跳转至

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);
    }
}