diff --git a/doc/player-state-contract.md b/doc/player-state-contract.md index 1938b67cb..90d53b488 100644 --- a/doc/player-state-contract.md +++ b/doc/player-state-contract.md @@ -167,7 +167,7 @@ ## 10. 从决策到实现 -### 10.1 实现清单(图外执行票,本图不建票、不落代码) +### 10.1 实现清单(已由图外执行票落地) | 编号 | 内容 | |---|---| @@ -178,9 +178,9 @@ | C5 | 播放列表错误路径是否补 `ErrorAction::SkipTrack` 检查(§6.3 登记项) | | C6 | 回调顺序 / 幂等 / 必须上报清单的补强用例(不改既有数值) | -实现路线沿用 #53 第 10 章:`/to-spec` 折叠 linked decisions → `/to-tickets` 拆执行票 + 原生阻塞边 → `/implement-spec` 在 frontier 上并行 implementer 子 agent、integration branch、一次 `/code-review`。**本图收口时 spec 刚产出,尚无可执行的执行票,故本轮不启动 `/implement-spec`。** +实现路线沿用 #53 第 10 章:`/to-spec` 折叠 linked decisions → `/to-tickets` 拆执行票 + 原生阻塞边 → `/implement-spec` 在 integration branch 上实现、一次 `/code-review`。**该链已于 2026-11-08 跑完**:Spec [#85](https://github.com/wt159/SoundBridge/issues/85)、执行票 [#86](https://github.com/wt159/SoundBridge/issues/86)–[#92](https://github.com/wt159/SoundBridge/issues/92)、集成分支 `implement/player-state-contract`;C1–C6 全部落地,逐项落点见 §10.4。 -### 10.2 `player.h` 建议注释文本(交 C3 落地,本图不改代码) +### 10.2 `player.h` 建议注释文本(已由 [T01 / #86](https://github.com/wt159/SoundBridge/issues/86) 落地) ``` // 回调在 SDK 内部单一串行线程上同步发出,不在调用者线程、不并发。 @@ -193,6 +193,22 @@ - fog「公开 API 的兼容 / 演进策略」→ 因不改签名而闭合,删除(§4)。 - fog「能力查询是否独立成 API」→ 裁定不需要,删除(§7)。 -- fog「契约漂移的长期观测口径」→ 不在本图收口,作为后续执行 effort 的输入;若像 #68 的 StreamingBudget 一样只记录不设门槛,须写明「不作为失败门禁」。 +- fog「契约漂移的长期观测口径」→ 本图与后续实现 effort 均未裁决,仍交后续 effort;若像 #68 的 StreamingBudget 一样只记录不设门槛,须写明「不作为失败门禁」。 - **已知代价**:三态无法区分「准备中」与「停止」,调用方只能靠 `trackCount()` / 回调组合判断;error→state 的顺序调整会让播放列表错误路径的 UI 提示相对状态晚一步,属可接受代价(避免 autoSkip 场景下先发一个不存在的状态)。 - 端到端 drift / 切换开销 / 观测指标判据仍是 #42 残留 fog 与 #68 交棒的另一 effort,与本图无关。 + +### 10.4 实现落地记录(2026-11-08) + +| 编号 | 落点 | +|---|---| +| C1 | `sdk/MusicPlayer.cpp::onMusicPlayListError` 先 `onError`、仅在「不跳轨 / 跳轨失败」时发状态;`updatePlayState` 以「最近真正发出去的状态」去重 | +| C2 | `reportPosition` 统一 seek / 消费循环 / 切歌三个位置出口,用 `m_positionFloor` + `m_positionTrackIndex` + `m_positionEpoch` 保证同曲单调、换曲归零、旧会话在途任务失效 | +| C3 | `sdk/include/soundbridge/player.h` 按 §10.2 落地五条注释 | +| C4 | `sdk/test/unit/test_player_integration.cpp` 两条空操作判据(无曲目 `duration()==0` / 起播后超界 seek) | +| C5 | 播放列表错误路径补 `ErrorAction::SkipTrack` 检查;`sdk/test/unit/test_error.cpp` 的 action 表锁住口径输入值 | +| C6 | 曲目三连、重复命令幂等、状态序列无相邻重复的补强用例 | + +- **回归与审查**:本地 `ctest -j4` 14/14 绿(含坏输入四段与覆盖度);一次 `code-review` 提出的 4 条 Major(会话代际漏钳制、状态去重键错误、用例与目标路径错配、裸 sleep / 无声卡 flaky)与 4 条 Minor 已在一个修复提交里全部处理,细节见各票 resolution comment。既有容差数值、坏输入 55 例、14 个 `sdk_*` 目标与 TIMEOUT 全部未动。 +- **务实取舍**:C1–C6 都改同一批文件,且本机 Qt/ffmpeg 依赖与 `build/` 缓存绑定主工作区,多 worktree 独立编译不可行;故 implementer 按拓扑序在同一 integration branch 上逐票提交,task graph、原生阻塞边与一次 `code-review` 均保留。 +- **已知判据缺口**:C1 的 error→state 顺序与 C5 的「设备错误不跳轨」缺端到端判据 —— 现有接缝无法稳定注入「带状态变化的错误」,而新增可注入接缝违反 §8「不新造接缝」。这两条由生产链改动 + 代码审查 + 全量回归锁定;将来若出现可控的错误注入接缝,应补判据。 +- **fog**:契约漂移的长期观测口径本 effort 未裁决,交后续 effort;若只记录不设门槛,须写明「不作为失败门禁」。 diff --git a/sdk/MusicPlayer.cpp b/sdk/MusicPlayer.cpp index 58a3cb21f..049c6d351 100644 --- a/sdk/MusicPlayer.cpp +++ b/sdk/MusicPlayer.cpp @@ -59,6 +59,9 @@ class MusicPlayer::Impl : public MusicPlayListCallback { private: void updatePlayState(MusicPlayerState state); + void reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor, + uint64_t epoch); + void bumpPositionSession(); std::string newTraceId(); void bumpTraceId(const char *reason); bool shouldAutoSkipOnError() const; @@ -89,6 +92,18 @@ class MusicPlayer::Impl : public MusicPlayListCallback { std::atomic m_shuttingDown; // 本次播放(一次 _play)里本次曲目的解码失败只上报一次,避免 10ms 消费循环刷屏 std::atomic m_decodeErrorHandled; + // 契约(规格 §3 / §5 / #88):同曲会话内 onPositionChanged 单调不回退、换曲归零。 + // 六个回调都在同一条 WorkQueue 串行线程发出,这里只钳制二次 asyncRunTask + // 回投 priority_queue 时同刻乱序带来的旧值;position() 查询口径不变。 + std::atomic m_positionFloor; + std::atomic m_positionTrackIndex; + // 会话代际:seek / 换曲 / 从停机重播都会递增,让回投队列里同曲的旧任务整体失效, + // 否则 reset 之前已投递、携带较大值的 position 任务会把新会话的基准重新抬高。 + std::atomic m_positionEpoch; + // 状态通知去重以「最近真正发出去的事件」为键,而不是 m_state 的初值: + // 初值 Stopped 从未对外发过,首播失败 / skip 失败时仍必须通知一次。 + bool m_stateNotified; + MusicPlayerState m_lastReportedState; WorkQueue m_workQueue; WorkQueue::TaskID m_consumerTaskID; }; @@ -105,6 +120,11 @@ MusicPlayer::Impl::Impl(MusicPlayerListener *lister, std::string &logDir) , m_switching(false) , m_shuttingDown(false) , m_decodeErrorHandled(false) + , m_positionFloor(0) + , m_positionTrackIndex(-1) + , m_positionEpoch(0) + , m_stateNotified(false) + , m_lastReportedState(MusicPlayerState::StoppedState) , m_workQueue() , m_consumerTaskID(WorkQueue::TaskID_Error) { @@ -275,6 +295,9 @@ void MusicPlayer::Impl::_play() m_decodeErrorHandled.store(false); if (m_state == MusicPlayerState::StoppedState) { bumpTraceId("play"); + // 一次 _play 从停机起步 = 新的播放会话:递增代际并归零基准, + // 让在途旧任务失效,且重播同曲(不经过 putMusicPlayListCurBuf)不被上一轮 floor 钳住。 + bumpPositionSession(); } MusicPropertiesPtr cur; { @@ -389,7 +412,9 @@ void MusicPlayer::Impl::_setPosition(uint64_t pos) // 消费端不再自己维护字节偏移,避免 seek 目标与播放进度两套口径互相打架。 cur->signalProperties.curPositionMs = pos; task->decoder->seekToMs(pos); - m_listener->onMusicPlayerPositionChanged(pos); + // seek 是合法回退:重置单调基准并立即上报一次(规格 §3 / §7)。 + bumpPositionSession(); + reportPosition(pos, static_cast(cur->index), true, m_positionEpoch.load()); } void MusicPlayer::Impl::_next() @@ -531,9 +556,14 @@ void MusicPlayer::Impl::consumeAudioData() signalProperties.curPositionMs = curPositionMs; if (!isShuttingDown()) { - m_workQueue.asyncRunTask([this, curPositionMs]() { - if (!isShuttingDown()) { - m_listener->onMusicPlayerPositionChanged(curPositionMs); + const int trackIndex = static_cast(cur->index); + const uint64_t epoch = m_positionEpoch.load(); + m_workQueue.asyncRunTask([this, curPositionMs, trackIndex, epoch]() { + // 会话代际已变(seek / 换曲 / 重播)的迟到任务整体丢弃; + // 旧曲的迟到任务也不得写进新曲会话。 + if (!isShuttingDown() && epoch == m_positionEpoch.load() + && trackIndex == m_positionTrackIndex.load()) { + reportPosition(curPositionMs, trackIndex, false, epoch); } }); } @@ -652,7 +682,10 @@ void MusicPlayer::Impl::putMusicPlayListCurBuf(MusicPropertiesPtr property) } m_listener->onMusicPlayerListCurrentIndexChanged(property->index); m_listener->onMusicPlayerDurationChanged(property->signalProperties.durationMs); - m_listener->onMusicPlayerPositionChanged(property->signalProperties.curPositionMs); + // 新曲会话:递增代际、重置单调基准,位置从该曲的 curPositionMs(通常 0)重新起算。 + bumpPositionSession(); + reportPosition(property->signalProperties.curPositionMs, + static_cast(property->index), true, m_positionEpoch.load()); } void MusicPlayer::Impl::updateMusicList(std::vector &list) @@ -697,24 +730,65 @@ void MusicPlayer::Impl::onMusicPlayListError(ErrorCode code, const std::string & m_audioDev->stop(); m_audioDev->close(); } - updatePlayState(MusicPlayerState::StoppedState); + // 契约(规格 §3 / #87):原因先于结果,onError 在状态变化之前。 if (m_listener != nullptr) { m_listener->onMusicPlayerError(code, detail, index, path, m_traceId); } - if (shouldAutoSkipOnError()) { - m_skipFailures.fetch_add(1); - m_workQueue.asyncRunTask([this]() { - if (isShuttingDown()) { - return; - } - if (m_musicList->skipToNextPlayable()) { - _play(); - } else { - LogPrintfWithTrace(SdkLogLevel::Warning, LOG_TAG, m_traceId, - "auto-skip failed: no playable track"); - } - }); + // 规格 §6.3 / #90:与 handleDecodeError 统一,自动跳轨以 SkipTrack 为准; + // 设备错误是 StopPlayback,不该靠跳轨绕过。 + const bool canAutoSkip = shouldAutoSkipOnError() + && GetErrorInfo(code).action == ErrorAction::SkipTrack; + if (!canAutoSkip) { + // 不跳轨:停在当前处置结果上,发且仅发一次状态事件。 + updatePlayState(MusicPlayerState::StoppedState); + return; } + // autoSkip 成功时状态可能根本不变(切歌前后都是 Playing,不闪 Stopped); + // 只有跳轨失败才补那「一次状态事件」,避免先发一个不存在的状态。 + m_skipFailures.fetch_add(1); + m_workQueue.asyncRunTask([this]() { + if (isShuttingDown()) { + return; + } + if (m_musicList->skipToNextPlayable()) { + _play(); + } else { + LogPrintfWithTrace(SdkLogLevel::Warning, LOG_TAG, m_traceId, + "auto-skip failed: no playable track"); + updatePlayState(MusicPlayerState::StoppedState); + } + }); +} + +void MusicPlayer::Impl::bumpPositionSession() +{ + m_positionEpoch.fetch_add(1); + m_positionFloor.store(0); +} + +void MusicPlayer::Impl::reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor, + uint64_t epoch) +{ + if (isShuttingDown() || m_listener == nullptr) { + return; + } + // 会话代际已变(seek / 换曲 / 重播)的迟到任务整体丢弃,不能让它把新基准抬高。 + if (epoch != m_positionEpoch.load()) { + return; + } + // 回调单线程串行(#76 事实基线),普通 load/store 即可;这里只做通知侧的钳制, + // 不回写 signalProperties.curPositionMs,也不影响 position() 的实时查询。 + if (resetFloor || trackIndex != m_positionTrackIndex.load()) { + m_positionTrackIndex.store(trackIndex); + m_positionFloor.store(positionMs); + m_listener->onMusicPlayerPositionChanged(positionMs); + return; + } + if (positionMs < m_positionFloor.load()) { + return; + } + m_positionFloor.store(positionMs); + m_listener->onMusicPlayerPositionChanged(positionMs); } void MusicPlayer::Impl::updatePlayState(MusicPlayerState state) @@ -722,7 +796,15 @@ void MusicPlayer::Impl::updatePlayState(MusicPlayerState state) if (isShuttingDown()) { return; } + // 契约(规格 §3 / §4):重复控制命令不重复发 onStateChanged,状态序列无相邻重复。 + // 去重键是「最近对外发出的状态」而非 m_state:初值 Stopped 从未发过, + // 首播即失败 / skip 失败时仍要通知一次,不能静默停在 Stopped。 + if (m_stateNotified && m_lastReportedState == state) { + return; + } m_state.store(state); + m_lastReportedState = state; + m_stateNotified = true; m_listener->onMusicPlayerStateChanged(state); } diff --git a/sdk/include/soundbridge/player.h b/sdk/include/soundbridge/player.h index 5d9a0803a..6f4b6fb20 100644 --- a/sdk/include/soundbridge/player.h +++ b/sdk/include/soundbridge/player.h @@ -22,6 +22,13 @@ struct PlayerConfig { bool autoSkipOnError = true; }; +// 回调在 SDK 内部单一串行线程上同步发出,不在调用者线程、不并发。 +// 调用方自行 marshal 到自己的线程。回调内禁止调用 Player 的任何控制命令, +// 禁止析构 Player,禁止抛异常。callbacks 由调用方持有,须活过 Player 析构; +// 停机期间不保证也不发送任何回调。 +// +// 完整契约(状态取值、回调顺序、控制命令语义、错误先后)见 +// doc/player-state-contract.md。 class PlayerCallbacks { public: virtual ~PlayerCallbacks() = default; diff --git a/sdk/test/mocks/PlayerMocks.hpp b/sdk/test/mocks/PlayerMocks.hpp index 7deb075b2..42e4ba6f1 100644 --- a/sdk/test/mocks/PlayerMocks.hpp +++ b/sdk/test/mocks/PlayerMocks.hpp @@ -25,6 +25,10 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { std::map track_names; // #72:状态序列要按到达顺序留档,才能断言「相邻状态不重复」。 std::vector state_sequence; + // #86 / T01:六个回调按到达顺序留档,供「曲目三连」「error→state」等契约判据复用。 + std::vector event_sequence; + // #88 / T03:位置值序列,用于断言同曲单调 / 换曲归零 / seek 合法回退。 + std::vector position_values; soundbridge::PlayerState last_state = soundbridge::PlayerState::Stopped; soundbridge::ErrorCode last_error_code = soundbridge::ErrorCode::Unknown; std::string last_error_detail; @@ -44,7 +48,12 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { last_position = 0; last_trace_id.clear(); track_names.clear(); - state_sequence.clear(); + { + std::lock_guard lock(mutex); + state_sequence.clear(); + event_sequence.clear(); + position_values.clear(); + } last_state = soundbridge::PlayerState::Stopped; } @@ -54,6 +63,7 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { last_state = state; std::lock_guard lock(mutex); state_sequence.push_back(state); + event_sequence.push_back("state"); } std::vector stateSequence() const @@ -62,6 +72,40 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { return state_sequence; } + std::vector eventSequence() const + { + std::lock_guard lock(mutex); + return event_sequence; + } + + std::vector positionValues() const + { + std::lock_guard lock(mutex); + return position_values; + } + + // 找不到返回 events.size();调用方可用它断言两个事件的先后。 + static size_t firstEvent(const std::vector &events, const std::string &name) + { + for (size_t i = 0; i < events.size(); ++i) { + if (events[i] == name) { + return i; + } + } + return events.size(); + } + + static size_t countEvents(const std::vector &events, const std::string &name) + { + size_t n = 0; + for (size_t i = 0; i < events.size(); ++i) { + if (events[i] == name) { + ++n; + } + } + return n; + } + std::map playlistSnapshot() const { std::lock_guard lock(mutex); @@ -72,24 +116,32 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { { track_change_count.fetch_add(1); last_track_index.store(index); + std::lock_guard lock(mutex); + event_sequence.push_back("track"); } void onDurationChanged(uint64_t durationMs) override { duration_change_count.fetch_add(1); last_duration.store(durationMs); + std::lock_guard lock(mutex); + event_sequence.push_back("duration"); } void onPositionChanged(uint64_t positionMs) override { position_change_count.fetch_add(1); last_position.store(positionMs); + std::lock_guard lock(mutex); + event_sequence.push_back("position"); + position_values.push_back(positionMs); } void onPlaylistChanged(const std::list &tracks) override { playlist_change_count.fetch_add(1); std::lock_guard lock(mutex); + event_sequence.push_back("playlist"); track_names.clear(); for (const auto &track : tracks) { track_names[track.index] = track.name; @@ -101,6 +153,7 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { { error_count.fetch_add(1); std::lock_guard lock(mutex); + event_sequence.push_back("error"); last_error_code = code; last_error_detail = detail; last_error_track_index = trackIndex; @@ -140,6 +193,8 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { sdk::MusicPlayerState last_state = sdk::MusicPlayerState::StoppedState; sdk::ErrorCode last_error_code = sdk::ErrorCode::Unknown; std::string last_error_detail; + // #86 / T01:内部 listener 也留一份事件序列,供错误路径顺序判据使用。 + std::vector event_sequence; int last_error_index = -1; std::string last_error_path; @@ -155,7 +210,11 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { last_duration = 0; last_position = 0; last_trace_id.clear(); - music_list.clear(); + { + std::lock_guard lock(mutex); + music_list.clear(); + event_sequence.clear(); + } last_state = sdk::MusicPlayerState::StoppedState; } @@ -163,30 +222,39 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { { state_change_count.fetch_add(1); last_state = state; + std::lock_guard lock(mutex); + event_sequence.push_back("state"); } void onMusicPlayerListCurrentIndexChanged(int index) override { index_change_count.fetch_add(1); last_current_index.store(index); + std::lock_guard lock(mutex); + event_sequence.push_back("track"); } void onMusicPlayerDurationChanged(uint64_t durationMs) override { duration_change_count.fetch_add(1); last_duration.store(durationMs); + std::lock_guard lock(mutex); + event_sequence.push_back("duration"); } void onMusicPlayerPositionChanged(uint64_t positionMs) override { position_change_count.fetch_add(1); last_position.store(positionMs); + std::lock_guard lock(mutex); + event_sequence.push_back("position"); } void onMusicPlayerMusicListChanged(std::list tracks) override { list_change_count.fetch_add(1); std::lock_guard lock(mutex); + event_sequence.push_back("playlist"); music_list.clear(); for (const auto &track : tracks) { music_list[track.index] = track.name; @@ -198,6 +266,7 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { { error_count.fetch_add(1); std::lock_guard lock(mutex); + event_sequence.push_back("error"); last_error_code = code; last_error_detail = detail; last_error_index = index; @@ -205,6 +274,33 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { last_trace_id = traceId; } + std::vector eventSequence() const + { + std::lock_guard lock(mutex); + return event_sequence; + } + + static size_t firstEvent(const std::vector &events, const std::string &name) + { + for (size_t i = 0; i < events.size(); ++i) { + if (events[i] == name) { + return i; + } + } + return events.size(); + } + + static size_t countEvents(const std::vector &events, const std::string &name) + { + size_t n = 0; + for (size_t i = 0; i < events.size(); ++i) { + if (events[i] == name) { + ++n; + } + } + return n; + } + bool hasTrack(int index) const { std::lock_guard lock(mutex); diff --git a/sdk/test/unit/test_error.cpp b/sdk/test/unit/test_error.cpp index ed8836f48..3aa56e6cc 100644 --- a/sdk/test/unit/test_error.cpp +++ b/sdk/test/unit/test_error.cpp @@ -139,6 +139,39 @@ TEST_SUITE("sdk::ErrorCode") CHECK(info.action == sdk::ErrorAction::ReportBug); } + // #90 / T05:播放列表错误路径的自动跳轨门槛依赖 ErrorAction(规格 §6.3)。 + // 这张表把「决定口径的输入值」钉成可红可绿判据;生产链据此只对 SkipTrack 跳轨。 + // 有码但不承诺上报的三码(PlaylistEmpty / ResampleInitFailed / ResampleFailed) + // 由 #76 实测确认生产链零上报点,调用方不得等待其 onError(规格 §6); + // 后两者 action 是 SkipTrack,将来若新增上报点会直接走自动跳轨,须先显式重审。 + TEST_CASE("auto-skip gate action table") + { + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::ExtractorUnsupported).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::ExtractorInitFailed).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::DecodeInitFailed).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::DataTruncated).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::DataCorrupted).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::AudioDeviceOpenFailed).action + == sdk::ErrorAction::StopPlayback); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::AudioDeviceStartFailed).action + == sdk::ErrorAction::StopPlayback); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::FileOpenFailed).action + == sdk::ErrorAction::CheckFile); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::PlayerNoCurrent).action + == sdk::ErrorAction::None); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::PlaylistEmpty).action + == sdk::ErrorAction::None); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::ResampleInitFailed).action + == sdk::ErrorAction::SkipTrack); + CHECK(sdk::GetErrorInfo(sdk::ErrorCode::ResampleFailed).action + == sdk::ErrorAction::SkipTrack); + } + TEST_CASE("ToString for ErrorModule returns correct strings") { CHECK(std::string(sdk::ToString(sdk::ErrorModule::File)) == "File"); diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index 6e34af3ec..cf22c44af 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -245,6 +245,28 @@ TEST_SUITE("MusicPlayer Error Handling") CHECK(listener.error_count > 0); } + // #87 / T02:错误不进状态面(规格 §2 / §3)。无曲目时 play() 只发 onError, + // 不产生任何 onStateChanged。注意这条走 _play() 的 cur==nullptr 分支,不经过 + // onMusicPlayListError;C1 的 error→state 顺序由生产链改动与 code-review 锁定, + // 现有接缝无法稳定注入「带状态变化的错误」,不新造接缝(规格 §8)。 + TEST_CASE("play without track reports error without a state event") + { + MockMusicPlayerListener listener; + std::string logDir = "./log"; + MusicPlayer player(&listener, logDir); + + player.play(); + REQUIRE(waitFor([&listener]() { return listener.error_count.load() > 0; }, 3000)); + + const std::vector events = listener.eventSequence(); + CHECK_MESSAGE(MockMusicPlayerListener::countEvents(events, "error") == 1, + "无曲目时 play() 应且仅应上报一次错误"); + CHECK_MESSAGE(MockMusicPlayerListener::countEvents(events, "state") == 0, + "错误不得进状态面:不该收到 onStateChanged"); + REQUIRE_FALSE(events.empty()); + CHECK_MESSAGE(events.front() == "error", "错误必须是这个场景的第一个事件"); + } + TEST_CASE("autoSkipOnError is enabled by default") { MockMusicPlayerListener listener; @@ -1195,6 +1217,319 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK(stopped); } + // 判据沿用既有「回调值序列」接缝,不改任何既有容差数值。 + // 判据沿用既有「回调值序列」接缝,不新造数值、不裸 sleep 兜底。 + TEST_CASE("position callbacks stay monotonic and a legal seek still rewinds") + { + RealMediaFixture fixture; + if (!fixture.exists("music.wav")) { + skipCase("media missing: music.wav"); + return; + } + + TempMediaDir temp("monotonic"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + + MockPlayerCallbacks callbacks; + soundbridge::PlayerConfig config; + config.logDirectory = "./log"; + soundbridge::Player player(&callbacks, config); + player.addMusicDirectory(temp.dir()); + if (!waitFor([&player]() { return player.trackCount() > 0; }, 5000)) { + skipCase("playlist empty"); + return; + } + + player.play(); + if (!waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)) { + skipCase("no usable audio device: play never reached Playing"); + return; + } + + const bool sampled = waitFor( + [&callbacks]() { + return callbacks.positionValues().size() >= 3 + && callbacks.last_position.load() > 500; + }, + 3000); + const std::vector first = callbacks.positionValues(); + bool monotonic = true; + for (size_t i = 1; i < first.size(); ++i) { + if (first[i] < first[i - 1]) { + monotonic = false; + } + } + + CHECK_MESSAGE(sampled, "稳态播放期间没有积累到足够的 position 回调"); + REQUIRE(first.size() >= 3); + CHECK_MESSAGE(monotonic, "同曲会话内 position 回调出现回退"); + + const uint64_t before = first.back(); + const size_t beforeCount = first.size(); + REQUIRE(before > 500); + player.seek(100); + const bool rewound = waitFor( + [&callbacks, beforeCount, before]() { + const std::vector vals = callbacks.positionValues(); + for (size_t i = beforeCount; i < vals.size(); ++i) { + if (vals[i] < before) { + return true; + } + } + return false; + }, + 3000); + const std::vector after = callbacks.positionValues(); + bool afterMonotonic = true; + for (size_t i = beforeCount + 1; i < after.size(); ++i) { + if (after[i] < after[i - 1]) { + afterMonotonic = false; + } + } + player.stop(); + + CHECK_MESSAGE(rewound, "seek 的合法回退被单调钳制吞掉了"); + CHECK_MESSAGE(afterMonotonic, "seek 之后的新位置会话里出现回退"); + } + + // #89 / T04:标称时长为 0 时 seek 是空操作(规格 §7)。INVALID_OPERATION 是内部 + // #89 / T04:标称时长为 0 时 seek 是空操作(规格 §7)。INVALID_OPERATION 是内部 + // 降级码、不对外暴露;对调用方的可见结果是「位置不回退、无新回调、无状态、无错误」。 + // 用例一覆盖「无曲目且 duration()==0」,用一次必然上报的 play() 作队列屏障; + // 用例二先起播让 currentTask 存在,才真正进入 pos > durationMs 的降级 return, + // 与 durationMs==0 && pos>0 同源。 + TEST_CASE("seek with no current track is a silent no-op") + { + MockPlayerCallbacks callbacks; + soundbridge::PlayerConfig config; + config.logDirectory = "./log"; + soundbridge::Player player(&callbacks, config); + + CHECK(player.duration() == 0); + CHECK(player.position() == 0); + player.seek(5000); + // 队列屏障:play() 排在 seek 之后,无曲目时必定上报一次 onError;它到达即说明 + // seek 已被队列取出处理,此时再取否定断言,不用裸 sleep 兜底。 + player.play(); + REQUIRE(waitFor([&callbacks]() { return callbacks.error_count.load() > 0; }, 3000)); + + const std::vector events = callbacks.eventSequence(); + CHECK_MESSAGE(MockPlayerCallbacks::countEvents(events, "state") == 0, + "空播放列表的 seek 不应改变状态"); + CHECK_MESSAGE(MockPlayerCallbacks::countEvents(events, "position") == 0, + "空播放列表的 seek 不应产生位置回调"); + CHECK_MESSAGE(MockPlayerCallbacks::countEvents(events, "error") == 1, + "唯一的错误应来自屏障 play(),seek 自身不报错"); + CHECK(player.position() == 0); + CHECK(player.state() == soundbridge::PlayerState::Stopped); + } + + TEST_CASE("seek beyond nominal duration is a silent no-op") + { + RealMediaFixture fixture; + if (!fixture.exists("music.wav")) { + skipCase("media missing: music.wav"); + return; + } + + TempMediaDir temp("seeknoop"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + + MockPlayerCallbacks callbacks; + soundbridge::PlayerConfig config; + config.logDirectory = "./log"; + soundbridge::Player player(&callbacks, config); + player.addMusicDirectory(temp.dir()); + if (!waitFor( + [&player]() { return player.trackCount() > 0 && player.duration() > 0; }, + 5000)) { + skipCase("playlist or duration unavailable"); + return; + } + + // 必须先起播:只有 currentTask 存在时 _setPosition 才会走到 + // pos > durationMs 的降级 return,否则在「无任务」处就早退了。 + player.play(); + if (!waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)) { + skipCase("no usable audio device: play never reached Playing"); + return; + } + if (!waitFor( + [&callbacks]() { return callbacks.positionValues().size() >= 2; }, 3000)) { + skipCase("playback never produced position callbacks"); + return; + } + + const int errBefore = callbacks.error_count.load(); + const int stateBefore = callbacks.state_change_count.load(); + const uint64_t duration = player.duration(); + const uint64_t target = duration + 1000; + const size_t beforeCount = callbacks.positionValues().size(); + + player.seek(target); + // 用「又来了一个 position 回调」作为队列已推进的确定信号,再在这个窗口里 + // 检查位置没有跳到超界目标、错误与状态都没有变化。 + const bool advanced = waitFor( + [&callbacks, beforeCount]() { + return callbacks.positionValues().size() > beforeCount; + }, + 3000); + const std::vector values = callbacks.positionValues(); + bool jumpedToTarget = false; + for (size_t i = beforeCount; i < values.size(); ++i) { + if (values[i] >= target) { + jumpedToTarget = true; + } + } + + CHECK_MESSAGE(advanced, "seek 后播放没有继续推进,判据窗口无效"); + CHECK_MESSAGE(!jumpedToTarget, "超界 seek 竟然把位置落到了目标"); + CHECK(callbacks.error_count.load() == errBefore); + CHECK(callbacks.state_change_count.load() == stateBefore); + CHECK(player.position() < target); + player.stop(); + } + + // #91 / T06:曲目切换固定三连(规格 §3),duration 每曲一次且与 getter 一致。 + TEST_CASE("track change emits the fixed three-callback sequence") + { + RealMediaFixture fixture; + if (!fixture.exists("music.wav")) { + skipCase("media missing: music.wav"); + return; + } + + TempMediaDir temp("triple"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + + MockPlayerCallbacks callbacks; + soundbridge::PlayerConfig config; + config.logDirectory = "./log"; + soundbridge::Player player(&callbacks, config); + player.addMusicDirectory(temp.dir()); + if (!waitFor( + [&]() { + return player.trackCount() > 0 + && callbacks.duration_change_count.load() > 0; + }, + 5000)) { + skipCase("playlist or duration callback unavailable"); + return; + } + + const std::vector events = callbacks.eventSequence(); + const size_t track = MockPlayerCallbacks::firstEvent(events, "track"); + const size_t duration = MockPlayerCallbacks::firstEvent(events, "duration"); + const size_t position = MockPlayerCallbacks::firstEvent(events, "position"); + CHECK_MESSAGE(track < events.size(), "曲目切换没有发 onTrackChanged"); + CHECK_MESSAGE(track < duration, "onTrackChanged 必须早于 onDurationChanged"); + CHECK_MESSAGE(duration < position, "onDurationChanged 必须早于 onPositionChanged"); + // 只数「首个曲目切换窗口」(首个 position 之前)内的 duration,避免稳态播放 + // 或后续选曲追加的事件让计数断言失真。 + size_t durationInWindow = 0; + for (size_t i = 0; i < position && i < events.size(); ++i) { + if (events[i] == "duration") { + ++durationInWindow; + } + } + CHECK_MESSAGE(durationInWindow == 1, "首个曲目切换窗口内只应报一次 duration"); + CHECK(callbacks.last_duration.load() == player.duration()); + } + + // #91 / T06:重复控制命令不重复发状态(规格 §4),状态序列无相邻重复。 + TEST_CASE("repeated control commands do not duplicate state callbacks") + { + RealMediaFixture fixture; + if (!fixture.exists("music.wav")) { + skipCase("media missing: music.wav"); + return; + } + + TempMediaDir temp("idempotent"); + if (!copyMediaFile(fixture.mediaPath("music.wav"), temp.file("only.wav"))) { + skipCase("cannot synthesize single-track playlist"); + return; + } + + MockPlayerCallbacks callbacks; + soundbridge::PlayerConfig config; + config.logDirectory = "./log"; + soundbridge::Player player(&callbacks, config); + player.addMusicDirectory(temp.dir()); + if (!waitFor([&player]() { return player.trackCount() > 0; }, 5000)) { + skipCase("playlist empty"); + return; + } + + player.play(); + if (!waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)) { + skipCase("no usable audio device: play never reached Playing"); + return; + } + + // 每个重复命令后面接一个「必定改变状态」的哨兵命令作为队列屏障: + // 屏障生效后再取计数,不用裸 sleep 猜命令何时出队。 + const int afterPlay = callbacks.state_change_count.load(); + player.play(); // 重复 + player.pause(); // 屏障:Paused + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Paused; }, + 3000)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterPlay + 1, + "Playing 下重复 play() 发了额外的状态回调"); + + const int afterPause = callbacks.state_change_count.load(); + player.pause(); // 重复 + player.play(); // 屏障:Playing + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterPause + 1, + "Paused 下重复 pause() 发了额外的状态回调"); + + const int afterResume = callbacks.state_change_count.load(); + player.stop(); + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Stopped; }, + 3000)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterResume + 1, + "stop() 应且仅应发一次状态回调"); + + const int afterStop = callbacks.state_change_count.load(); + player.stop(); // 重复 + player.play(); // 屏障:Playing + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterStop + 1, + "Stopped 下重复 stop() 发了额外的状态回调"); + player.stop(); + + const std::vector states = callbacks.stateSequence(); + bool duplicate = false; + for (size_t i = 1; i < states.size(); ++i) { + if (states[i] == states[i - 1]) { + duplicate = true; + } + } + CHECK_MESSAGE(!duplicate, "状态序列里出现了相邻重复项"); + } + // #72 / P2-11 TEST_CASE("state callback sequence has no duplicate adjacent states") {