From d8c440bfca219ca4b60845a7e74b656981f54a1e Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:43:18 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E5=A4=B4?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E5=A5=91=E7=BA=A6=E6=B3=A8=E9=87=8A=E4=B8=8E?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E5=BA=8F=E5=88=97=E8=AE=B0=E5=BD=95=E5=99=A8?= =?UTF-8?q?=EF=BC=88#86=20/=20T01=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk/include/soundbridge/player.h 按规格 §10.2 落地五条契约注释:单线程串行、marshal 归调用方、回调内禁发命令/禁析构/禁抛异常、callbacks 所有权与存活期、停机期静默。 - PlayerMocks.hpp 两个 mock 增加事件序列(六个回调按到达顺序留档)与 firstEvent/countEvents 辅助,供曲目三连、error→state、幂等等判据复用。 - 不改任何生产链行为与公开签名;既有断言数值一字不动。 --- sdk/include/soundbridge/player.h | 7 +++ sdk/test/mocks/PlayerMocks.hpp | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) 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..c2f96627f 100644 --- a/sdk/test/mocks/PlayerMocks.hpp +++ b/sdk/test/mocks/PlayerMocks.hpp @@ -25,6 +25,8 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { std::map track_names; // #72:状态序列要按到达顺序留档,才能断言「相邻状态不重复」。 std::vector state_sequence; + // #86 / T01:六个回调按到达顺序留档,供「曲目三连」「error→state」等契约判据复用。 + std::vector event_sequence; soundbridge::PlayerState last_state = soundbridge::PlayerState::Stopped; soundbridge::ErrorCode last_error_code = soundbridge::ErrorCode::Unknown; std::string last_error_detail; @@ -45,6 +47,7 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { last_trace_id.clear(); track_names.clear(); state_sequence.clear(); + event_sequence.clear(); last_state = soundbridge::PlayerState::Stopped; } @@ -54,6 +57,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 +66,34 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { return state_sequence; } + std::vector eventSequence() const + { + std::lock_guard lock(mutex); + return event_sequence; + } + + // 找不到返回 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 +104,31 @@ 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"); } 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 +140,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 +180,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; @@ -156,6 +198,7 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { last_position = 0; last_trace_id.clear(); music_list.clear(); + event_sequence.clear(); last_state = sdk::MusicPlayerState::StoppedState; } @@ -163,30 +206,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 +250,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 +258,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); From 9b607d7137c3905fcb804a665a1e69c10106b29b Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:45:12 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E9=94=99=E8=AF=AF=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20error=E2=86=92state=EF=BC=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=80=9A=E7=9F=A5=E5=8E=BB=E9=87=8D=EF=BC=88#87=20/?= =?UTF-8?q?=20T02=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onMusicPlayListError 把 onError 提到 updatePlayState 之前,与解码错误路径一致;原因是错误、结果是状态。 - updatePlayState 相同值只跳过通知、不改查询值:落实「重复控制命令不重复发 onStateChanged / 状态序列无相邻重复」契约。 - skip 失败不再补发第二次状态(同步那次即为规格要求的「一次状态事件」)。 - 新增可判定用例:无曲目 play() 只发一次 onError、零状态回调,错误不进状态面。 --- sdk/MusicPlayer.cpp | 10 +++++++++- sdk/test/unit/test_player_integration.cpp | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sdk/MusicPlayer.cpp b/sdk/MusicPlayer.cpp index 58a3cb21f..205e56ca4 100644 --- a/sdk/MusicPlayer.cpp +++ b/sdk/MusicPlayer.cpp @@ -697,10 +697,13 @@ void MusicPlayer::Impl::onMusicPlayListError(ErrorCode code, const std::string & m_audioDev->stop(); m_audioDev->close(); } - updatePlayState(MusicPlayerState::StoppedState); + // 契约(doc/player-state-contract.md §3 / #87):原因先于结果,onError 在状态变化之前。 + // autoSkip 跳轨成功时状态可能根本不变;失败时这里同步置 Stopped 就是那「一次状态事件」, + // 不再补发(updatePlayState 去重保证状态序列无相邻重复)。 if (m_listener != nullptr) { m_listener->onMusicPlayerError(code, detail, index, path, m_traceId); } + updatePlayState(MusicPlayerState::StoppedState); if (shouldAutoSkipOnError()) { m_skipFailures.fetch_add(1); m_workQueue.asyncRunTask([this]() { @@ -722,6 +725,11 @@ void MusicPlayer::Impl::updatePlayState(MusicPlayerState state) if (isShuttingDown()) { return; } + // 契约(规格 §3 / §4):重复控制命令不重复发 onStateChanged,状态序列无相邻重复。 + // m_state 仍是唯一权威;相同值只跳过通知,不改变查询值。 + if (m_state.load() == state) { + return; + } m_state.store(state); m_listener->onMusicPlayerStateChanged(state); } diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index 6e34af3ec..b6a2d9291 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -245,6 +245,26 @@ TEST_SUITE("MusicPlayer Error Handling") CHECK(listener.error_count > 0); } + // #87 / T02:错误不进状态面(规格 §2 / §3)。无曲目时 play() 只发 onError, + // 不产生任何 onStateChanged —— autoSkip 跳轨成功时「状态可能根本不变」的极端情形。 + 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; From dbdbfb3dfd61660239616b06bc908c774301373d Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:47:51 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20position=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E5=90=8C=E6=9B=B2=E5=8D=95=E8=B0=83=E4=B8=8D=E5=9B=9E=E9=80=80?= =?UTF-8?q?=EF=BC=88#88=20/=20T03=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 reportPosition 统一三个位置出口:seek / 换曲重置单调基准(合法回退),消费循环的二次 asyncRunTask 按曲目会话钳制乱序旧值;旧曲迟到任务在新曲会话里直接丢弃。 - 钳制只作用于事件通知,不回写 signalProperties.curPositionMs、不改 position() 查询口径,seek 精度判据不动。 - PlayerMocks.hpp 增加位置值序列;新增可判定用例:稳态单调、seek 合法回退不被吞掉、回退后新会话仍单调。 --- sdk/MusicPlayer.cpp | 43 ++++++++++-- sdk/test/mocks/PlayerMocks.hpp | 10 +++ sdk/test/unit/test_player_integration.cpp | 80 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/sdk/MusicPlayer.cpp b/sdk/MusicPlayer.cpp index 205e56ca4..222612bbf 100644 --- a/sdk/MusicPlayer.cpp +++ b/sdk/MusicPlayer.cpp @@ -59,6 +59,7 @@ class MusicPlayer::Impl : public MusicPlayListCallback { private: void updatePlayState(MusicPlayerState state); + void reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor); std::string newTraceId(); void bumpTraceId(const char *reason); bool shouldAutoSkipOnError() const; @@ -89,6 +90,11 @@ 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; WorkQueue m_workQueue; WorkQueue::TaskID m_consumerTaskID; }; @@ -105,6 +111,8 @@ MusicPlayer::Impl::Impl(MusicPlayerListener *lister, std::string &logDir) , m_switching(false) , m_shuttingDown(false) , m_decodeErrorHandled(false) + , m_positionFloor(0) + , m_positionTrackIndex(-1) , m_workQueue() , m_consumerTaskID(WorkQueue::TaskID_Error) { @@ -389,7 +397,8 @@ void MusicPlayer::Impl::_setPosition(uint64_t pos) // 消费端不再自己维护字节偏移,避免 seek 目标与播放进度两套口径互相打架。 cur->signalProperties.curPositionMs = pos; task->decoder->seekToMs(pos); - m_listener->onMusicPlayerPositionChanged(pos); + // seek 是合法回退:重置单调基准并立即上报一次(规格 §3 / §7)。 + reportPosition(pos, static_cast(cur->index), true); } void MusicPlayer::Impl::_next() @@ -531,9 +540,11 @@ 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); + m_workQueue.asyncRunTask([this, curPositionMs, trackIndex]() { + // 旧曲的迟到任务不得写进新曲会话(换曲归零后基准已重置)。 + if (!isShuttingDown() && trackIndex == m_positionTrackIndex.load()) { + reportPosition(curPositionMs, trackIndex, false); } }); } @@ -652,7 +663,9 @@ 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)重新起算。 + reportPosition(property->signalProperties.curPositionMs, + static_cast(property->index), true); } void MusicPlayer::Impl::updateMusicList(std::vector &list) @@ -720,6 +733,26 @@ void MusicPlayer::Impl::onMusicPlayListError(ErrorCode code, const std::string & } } +void MusicPlayer::Impl::reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor) +{ + if (isShuttingDown() || m_listener == nullptr) { + 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) { if (isShuttingDown()) { diff --git a/sdk/test/mocks/PlayerMocks.hpp b/sdk/test/mocks/PlayerMocks.hpp index c2f96627f..ec9b211b6 100644 --- a/sdk/test/mocks/PlayerMocks.hpp +++ b/sdk/test/mocks/PlayerMocks.hpp @@ -27,6 +27,8 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { 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; @@ -48,6 +50,7 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { track_names.clear(); state_sequence.clear(); event_sequence.clear(); + position_values.clear(); last_state = soundbridge::PlayerState::Stopped; } @@ -72,6 +75,12 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { 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) { @@ -122,6 +131,7 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { 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 diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index b6a2d9291..d7a94a765 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -1215,6 +1215,86 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK(stopped); } + // #88 / T03:同曲会话内 position 回调单调不回退;seek 是合法回退,不能被单调钳制吞掉。 + // 判据沿用既有「回调值序列」接缝,不新造数值、不裸 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 之后的新位置会话里出现回退"); + } + // #72 / P2-11 TEST_CASE("state callback sequence has no duplicate adjacent states") { From eddc92de98851380abf7c0f0c7b753b4f5884d68 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:48:35 +0800 Subject: [PATCH 4/9] =?UTF-8?q?test:=20=E6=A0=87=E7=A7=B0=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E4=B8=BA=200=20/=20=E8=B6=85=E7=95=8C=E6=97=B6=20seek?= =?UTF-8?q?=20=E7=9A=84=E7=A9=BA=E6=93=8D=E4=BD=9C=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=EF=BC=88#89=20/=20T04=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生产链行为已符合规格 §7,本票不改代码,只把「四条无变化」升级为可红可绿判据。 - 用例一:无曲目且 duration()==0 时 seek 不产生任何回调、位置保持 0、状态 Stopped、无 onError。 - 用例二:曲目已加载但目标超界(与 durationMs==0 && pos>0 同一条降级 return)同样为空操作;pos==0 例外不产生错误或状态变化。 - 不新增公开 API、错误码或 onError 上报;沿用既有交互测试分组与观察窗口惯例。 --- sdk/test/unit/test_player_integration.cpp | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index d7a94a765..97881f703 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -1295,6 +1295,78 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK_MESSAGE(afterMonotonic, "seek 之后的新位置会话里出现回退"); } + // #89 / T04:标称时长为 0 时 seek 是空操作(规格 §7)。INVALID_OPERATION 是内部 + // 降级码、不对外暴露;对调用方的可见结果就是「位置不回退、无新回调、无状态、无错误」。 + // 下面两例分别覆盖「无曲目、duration()==0」与「有曲目但目标超界」,后者与 + // durationMs==0 && pos>0 走的是 _setPosition 里同一条降级 return。 + 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); + // 否定断言的观察窗口:命令异步排队,给队列一次完整轮询的机会(不是时序兜底)。 + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + + CHECK_MESSAGE(callbacks.eventSequence().empty(), + "空播放列表的 seek 不应产生任何回调"); + CHECK(player.position() == 0); + CHECK(player.state() == soundbridge::PlayerState::Stopped); + CHECK(callbacks.error_count.load() == 0); + } + + 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; + } + + const int posBefore = callbacks.position_change_count.load(); + const int errBefore = callbacks.error_count.load(); + const int stateBefore = callbacks.state_change_count.load(); + const uint64_t positionBefore = player.position(); + const uint64_t duration = player.duration(); + + player.seek(duration + 1000); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + + CHECK(player.position() == positionBefore); + CHECK(callbacks.position_change_count.load() == posBefore); + CHECK(callbacks.error_count.load() == errBefore); + CHECK(callbacks.state_change_count.load() == stateBefore); + CHECK(player.state() == soundbridge::PlayerState::Stopped); + + // pos == 0 例外保留;归零不得变成错误或状态变化。 + player.seek(0); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + CHECK(callbacks.error_count.load() == errBefore); + CHECK(callbacks.state_change_count.load() == stateBefore); + } + // #72 / P2-11 TEST_CASE("state callback sequence has no duplicate adjacent states") { From 8da41a22302a0878b8d1cb6adac45207b73deb0b Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:49:24 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E9=87=8D=E6=92=AD=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E4=BD=8D=E7=BD=AE=E5=8D=95=E8=B0=83=E5=9F=BA?= =?UTF-8?q?=E5=87=86=EF=BC=88#88=20/=20T03=20=E5=9B=9E=E5=BD=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _play() 从 Stopped 起步时把 m_positionFloor 归零:stop 后重播同一曲目不经过 putMusicPlayListCurBuf,上一轮的基准会把新会话的 0 附近位置全部当成乱序旧值丢弃。 - 既有「stop 后重播从头起算」判据因此恢复;暂停后恢复不重置(位置继续增长,不受影响)。 --- sdk/MusicPlayer.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/MusicPlayer.cpp b/sdk/MusicPlayer.cpp index 222612bbf..1cccb9682 100644 --- a/sdk/MusicPlayer.cpp +++ b/sdk/MusicPlayer.cpp @@ -283,6 +283,9 @@ void MusicPlayer::Impl::_play() m_decodeErrorHandled.store(false); if (m_state == MusicPlayerState::StoppedState) { bumpTraceId("play"); + // 一次 _play 从停机起步 = 新的播放会话:位置单调基准必须跟着归零, + // 否则 stop 后重播同曲(不经过 putMusicPlayListCurBuf)会被上一轮 floor 钳住。 + m_positionFloor.store(0); } MusicPropertiesPtr cur; { From 6918cf3da26c657def15549a1865beb71e620830 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:52:00 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=B7=B3=E8=BD=A8=E5=8F=A3=E5=BE=84=EF=BC=88#90=20/?= =?UTF-8?q?=20T05=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onMusicPlayListError 与 handleDecodeError 一样要求 ErrorAction::SkipTrack 才自动跳轨;设备错误(StopPlayback)不再靠跳轨绕过。 - 不改 shouldAutoSkipOnError() 的 count<=1 / failuresonMusicPlayerError(code, detail, index, path, m_traceId); } updatePlayState(MusicPlayerState::StoppedState); - if (shouldAutoSkipOnError()) { + // 规格 §6.3 / #90:与解码错误路径的 handleDecodeError 统一,自动跳轨以 + // ErrorAction::SkipTrack 为准。设备错误是 StopPlayback,不该靠跳轨绕过。 + const bool canAutoSkip = shouldAutoSkipOnError() + && GetErrorInfo(code).action == ErrorAction::SkipTrack; + if (canAutoSkip) { m_skipFailures.fetch_add(1); m_workQueue.asyncRunTask([this]() { if (isShuttingDown()) { diff --git a/sdk/test/unit/test_error.cpp b/sdk/test/unit/test_error.cpp index ed8836f48..fd6195b40 100644 --- a/sdk/test/unit/test_error.cpp +++ b/sdk/test/unit/test_error.cpp @@ -139,6 +139,34 @@ TEST_SUITE("sdk::ErrorCode") CHECK(info.action == sdk::ErrorAction::ReportBug); } + // #90 / T05:播放列表错误路径的自动跳轨门槛依赖 ErrorAction(规格 §6.3)。 + // 这张表把「决定口径的输入值」钉成可红可绿判据;生产链据此只对 SkipTrack 跳轨。 + // 有码但不承诺上报的三码(PlaylistEmpty / ResampleInitFailed / ResampleFailed) + // 由 #76 实测确认生产链零上报点,调用方不得等待其 onError(规格 §6)。 + 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); + } + TEST_CASE("ToString for ErrorModule returns correct strings") { CHECK(std::string(sdk::ToString(sdk::ErrorModule::File)) == "File"); From 21ae69e47897e28867bab14381e1af8851641c29 Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 10:52:01 +0800 Subject: [PATCH 7/9] =?UTF-8?q?test:=20=E5=9B=9E=E8=B0=83=E4=B8=89?= =?UTF-8?q?=E8=BF=9E=E4=B8=8E=E9=87=8D=E5=A4=8D=E5=91=BD=E4=BB=A4=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E7=9A=84=E8=A1=A5=E5=BC=BA=E5=88=A4=E6=8D=AE=EF=BC=88?= =?UTF-8?q?#91=20/=20T06=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 曲目切换固定 onTrackChanged → onDurationChanged → onPositionChanged,duration 每曲一次且与 getter 一致。 - 重复 play / pause / stop 不额外发状态回调,状态序列无相邻重复。 - 只扩既有分组与记录器,不改任何既有断言数值;重入 / 所有权 / ABI 类仍只写注释与规格。 --- sdk/test/unit/test_player_integration.cpp | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index 97881f703..f8fa36d41 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -1367,6 +1367,113 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK(callbacks.state_change_count.load() == stateBefore); } + // #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"); + CHECK_MESSAGE(MockPlayerCallbacks::countEvents(events, "duration") == 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(); + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, + 3000)); + const int afterPlay = callbacks.state_change_count.load(); + player.play(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterPlay, + "Playing 下重复 play() 发了额外的状态回调"); + + player.pause(); + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Paused; }, + 3000)); + const int afterPause = callbacks.state_change_count.load(); + player.pause(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterPause, + "Paused 下重复 pause() 发了额外的状态回调"); + + player.stop(); + REQUIRE(waitFor( + [&player]() { return player.state() == soundbridge::PlayerState::Stopped; }, + 3000)); + const int afterStop = callbacks.state_change_count.load(); + player.stop(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + CHECK_MESSAGE(callbacks.state_change_count.load() == afterStop, + "Stopped 下重复 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") { From 800e7467c95a63061d3d7005f2e0efa81b6848cf Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 11:15:12 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=B8=80=E6=AC=A1=E4=BF=AE=E5=AE=8C?= =?UTF-8?q?=20code-review=20=E6=8F=90=E5=87=BA=E7=9A=84=204=20Major=20+=20?= =?UTF-8?q?4=20Minor=EF=BC=88#92=20/=20T07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1 位置会话代际:新增 m_positionEpoch,seek / 换曲 / 停机重播递增,回投队列的旧任务整体失效;单靠 trackIndex 无法区分同曲的两次会话,旧值会把新基准重新抬高。 - M2 状态通知:去重键改用「最近真正发出去的事件」而非 m_state 初值(首播失败 / skip 失败仍通知一次);onMusicPlayListError 不再无条件先置 Stopped,只在「不跳轨 / 跳轨失败」时发,autoSkip 成功不闪 Stopped。 - M3/M4 判据:T04 超界用例先起播让 currentTask 存在,真正进入 pos > durationMs 分支;否定断言改用「必然上报的命令」作队列屏障,去掉裸 sleep;无声卡场景加 skipCase。 - m1 mock reset() 对 vector 的 clear 加锁;m2 三连用例把 duration 计数限定在首个 position 之前的窗口;m3 补齐 Resample* 两码的 action 断言并修正注释;m4 修正「不新造数值」措辞。 - 全量 ctest -j4 14/14 绿;既有容差数值、坏输入 55 例、14 个目标与 TIMEOUT 全部未动。 --- sdk/MusicPlayer.cpp | 100 ++++++++++------ sdk/test/mocks/PlayerMocks.hpp | 16 ++- sdk/test/unit/test_error.cpp | 7 +- sdk/test/unit/test_player_integration.cpp | 138 +++++++++++++++------- 4 files changed, 181 insertions(+), 80 deletions(-) diff --git a/sdk/MusicPlayer.cpp b/sdk/MusicPlayer.cpp index a81cf50b5..049c6d351 100644 --- a/sdk/MusicPlayer.cpp +++ b/sdk/MusicPlayer.cpp @@ -59,7 +59,9 @@ class MusicPlayer::Impl : public MusicPlayListCallback { private: void updatePlayState(MusicPlayerState state); - void reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor); + 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; @@ -95,6 +97,13 @@ class MusicPlayer::Impl : public MusicPlayListCallback { // 回投 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; }; @@ -113,6 +122,9 @@ MusicPlayer::Impl::Impl(MusicPlayerListener *lister, std::string &logDir) , 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) { @@ -283,9 +295,9 @@ void MusicPlayer::Impl::_play() m_decodeErrorHandled.store(false); if (m_state == MusicPlayerState::StoppedState) { bumpTraceId("play"); - // 一次 _play 从停机起步 = 新的播放会话:位置单调基准必须跟着归零, - // 否则 stop 后重播同曲(不经过 putMusicPlayListCurBuf)会被上一轮 floor 钳住。 - m_positionFloor.store(0); + // 一次 _play 从停机起步 = 新的播放会话:递增代际并归零基准, + // 让在途旧任务失效,且重播同曲(不经过 putMusicPlayListCurBuf)不被上一轮 floor 钳住。 + bumpPositionSession(); } MusicPropertiesPtr cur; { @@ -401,7 +413,8 @@ void MusicPlayer::Impl::_setPosition(uint64_t pos) cur->signalProperties.curPositionMs = pos; task->decoder->seekToMs(pos); // seek 是合法回退:重置单调基准并立即上报一次(规格 §3 / §7)。 - reportPosition(pos, static_cast(cur->index), true); + bumpPositionSession(); + reportPosition(pos, static_cast(cur->index), true, m_positionEpoch.load()); } void MusicPlayer::Impl::_next() @@ -544,10 +557,13 @@ void MusicPlayer::Impl::consumeAudioData() if (!isShuttingDown()) { const int trackIndex = static_cast(cur->index); - m_workQueue.asyncRunTask([this, curPositionMs, trackIndex]() { - // 旧曲的迟到任务不得写进新曲会话(换曲归零后基准已重置)。 - if (!isShuttingDown() && trackIndex == m_positionTrackIndex.load()) { - reportPosition(curPositionMs, trackIndex, false); + 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); } }); } @@ -666,9 +682,10 @@ void MusicPlayer::Impl::putMusicPlayListCurBuf(MusicPropertiesPtr property) } m_listener->onMusicPlayerListCurrentIndexChanged(property->index); m_listener->onMusicPlayerDurationChanged(property->signalProperties.durationMs); - // 新曲会话:重置单调基准,位置从该曲的 curPositionMs(通常 0)重新起算。 + // 新曲会话:递增代际、重置单调基准,位置从该曲的 curPositionMs(通常 0)重新起算。 + bumpPositionSession(); reportPosition(property->signalProperties.curPositionMs, - static_cast(property->index), true); + static_cast(property->index), true, m_positionEpoch.load()); } void MusicPlayer::Impl::updateMusicList(std::vector &list) @@ -713,38 +730,52 @@ void MusicPlayer::Impl::onMusicPlayListError(ErrorCode code, const std::string & m_audioDev->stop(); m_audioDev->close(); } - // 契约(doc/player-state-contract.md §3 / #87):原因先于结果,onError 在状态变化之前。 - // autoSkip 跳轨成功时状态可能根本不变;失败时这里同步置 Stopped 就是那「一次状态事件」, - // 不再补发(updatePlayState 去重保证状态序列无相邻重复)。 + // 契约(规格 §3 / #87):原因先于结果,onError 在状态变化之前。 if (m_listener != nullptr) { m_listener->onMusicPlayerError(code, detail, index, path, m_traceId); } - updatePlayState(MusicPlayerState::StoppedState); - // 规格 §6.3 / #90:与解码错误路径的 handleDecodeError 统一,自动跳轨以 - // ErrorAction::SkipTrack 为准。设备错误是 StopPlayback,不该靠跳轨绕过。 + // 规格 §6.3 / #90:与 handleDecodeError 统一,自动跳轨以 SkipTrack 为准; + // 设备错误是 StopPlayback,不该靠跳轨绕过。 const bool canAutoSkip = shouldAutoSkipOnError() && GetErrorInfo(code).action == ErrorAction::SkipTrack; - if (canAutoSkip) { - 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"); - } - }); + 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::reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor) +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()) { @@ -766,11 +797,14 @@ void MusicPlayer::Impl::updatePlayState(MusicPlayerState state) return; } // 契约(规格 §3 / §4):重复控制命令不重复发 onStateChanged,状态序列无相邻重复。 - // m_state 仍是唯一权威;相同值只跳过通知,不改变查询值。 - if (m_state.load() == state) { + // 去重键是「最近对外发出的状态」而非 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/test/mocks/PlayerMocks.hpp b/sdk/test/mocks/PlayerMocks.hpp index ec9b211b6..42e4ba6f1 100644 --- a/sdk/test/mocks/PlayerMocks.hpp +++ b/sdk/test/mocks/PlayerMocks.hpp @@ -48,9 +48,12 @@ class MockPlayerCallbacks : public soundbridge::PlayerCallbacks { last_position = 0; last_trace_id.clear(); track_names.clear(); - state_sequence.clear(); - event_sequence.clear(); - position_values.clear(); + { + std::lock_guard lock(mutex); + state_sequence.clear(); + event_sequence.clear(); + position_values.clear(); + } last_state = soundbridge::PlayerState::Stopped; } @@ -207,8 +210,11 @@ class MockMusicPlayerListener : public sdk::MusicPlayerListener { last_duration = 0; last_position = 0; last_trace_id.clear(); - music_list.clear(); - event_sequence.clear(); + { + std::lock_guard lock(mutex); + music_list.clear(); + event_sequence.clear(); + } last_state = sdk::MusicPlayerState::StoppedState; } diff --git a/sdk/test/unit/test_error.cpp b/sdk/test/unit/test_error.cpp index fd6195b40..3aa56e6cc 100644 --- a/sdk/test/unit/test_error.cpp +++ b/sdk/test/unit/test_error.cpp @@ -142,7 +142,8 @@ TEST_SUITE("sdk::ErrorCode") // #90 / T05:播放列表错误路径的自动跳轨门槛依赖 ErrorAction(规格 §6.3)。 // 这张表把「决定口径的输入值」钉成可红可绿判据;生产链据此只对 SkipTrack 跳轨。 // 有码但不承诺上报的三码(PlaylistEmpty / ResampleInitFailed / ResampleFailed) - // 由 #76 实测确认生产链零上报点,调用方不得等待其 onError(规格 §6)。 + // 由 #76 实测确认生产链零上报点,调用方不得等待其 onError(规格 §6); + // 后两者 action 是 SkipTrack,将来若新增上报点会直接走自动跳轨,须先显式重审。 TEST_CASE("auto-skip gate action table") { CHECK(sdk::GetErrorInfo(sdk::ErrorCode::ExtractorUnsupported).action @@ -165,6 +166,10 @@ TEST_SUITE("sdk::ErrorCode") == 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") diff --git a/sdk/test/unit/test_player_integration.cpp b/sdk/test/unit/test_player_integration.cpp index f8fa36d41..cf22c44af 100644 --- a/sdk/test/unit/test_player_integration.cpp +++ b/sdk/test/unit/test_player_integration.cpp @@ -246,7 +246,9 @@ TEST_SUITE("MusicPlayer Error Handling") } // #87 / T02:错误不进状态面(规格 §2 / §3)。无曲目时 play() 只发 onError, - // 不产生任何 onStateChanged —— autoSkip 跳轨成功时「状态可能根本不变」的极端情形。 + // 不产生任何 onStateChanged。注意这条走 _play() 的 cur==nullptr 分支,不经过 + // onMusicPlayListError;C1 的 error→state 顺序由生产链改动与 code-review 锁定, + // 现有接缝无法稳定注入「带状态变化的错误」,不新造接缝(规格 §8)。 TEST_CASE("play without track reports error without a state event") { MockMusicPlayerListener listener; @@ -1215,7 +1217,7 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK(stopped); } - // #88 / T03:同曲会话内 position 回调单调不回退;seek 是合法回退,不能被单调钳制吞掉。 + // 判据沿用既有「回调值序列」接缝,不改任何既有容差数值。 // 判据沿用既有「回调值序列」接缝,不新造数值、不裸 sleep 兜底。 TEST_CASE("position callbacks stay monotonic and a legal seek still rewinds") { @@ -1296,9 +1298,11 @@ TEST_SUITE("PublicPlayer Interactive Paths") } // #89 / T04:标称时长为 0 时 seek 是空操作(规格 §7)。INVALID_OPERATION 是内部 - // 降级码、不对外暴露;对调用方的可见结果就是「位置不回退、无新回调、无状态、无错误」。 - // 下面两例分别覆盖「无曲目、duration()==0」与「有曲目但目标超界」,后者与 - // durationMs==0 && pos>0 走的是 _setPosition 里同一条降级 return。 + // #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; @@ -1309,14 +1313,20 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK(player.duration() == 0); CHECK(player.position() == 0); player.seek(5000); - // 否定断言的观察窗口:命令异步排队,给队列一次完整轮询的机会(不是时序兜底)。 - std::this_thread::sleep_for(std::chrono::milliseconds(250)); + // 队列屏障:play() 排在 seek 之后,无曲目时必定上报一次 onError;它到达即说明 + // seek 已被队列取出处理,此时再取否定断言,不用裸 sleep 兜底。 + player.play(); + REQUIRE(waitFor([&callbacks]() { return callbacks.error_count.load() > 0; }, 3000)); - CHECK_MESSAGE(callbacks.eventSequence().empty(), - "空播放列表的 seek 不应产生任何回调"); + 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); - CHECK(callbacks.error_count.load() == 0); } TEST_CASE("seek beyond nominal duration is a silent no-op") @@ -1345,26 +1355,49 @@ TEST_SUITE("PublicPlayer Interactive Paths") return; } - const int posBefore = callbacks.position_change_count.load(); - const int errBefore = callbacks.error_count.load(); - const int stateBefore = callbacks.state_change_count.load(); - const uint64_t positionBefore = player.position(); - const uint64_t duration = player.duration(); + // 必须先起播:只有 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; + } - player.seek(duration + 1000); - std::this_thread::sleep_for(std::chrono::milliseconds(250)); + 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(); - CHECK(player.position() == positionBefore); - CHECK(callbacks.position_change_count.load() == posBefore); - CHECK(callbacks.error_count.load() == errBefore); - CHECK(callbacks.state_change_count.load() == stateBefore); - CHECK(player.state() == soundbridge::PlayerState::Stopped); + 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; + } + } - // pos == 0 例外保留;归零不得变成错误或状态变化。 - player.seek(0); - std::this_thread::sleep_for(std::chrono::milliseconds(250)); + 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 一致。 @@ -1404,8 +1437,15 @@ TEST_SUITE("PublicPlayer Interactive Paths") CHECK_MESSAGE(track < events.size(), "曲目切换没有发 onTrackChanged"); CHECK_MESSAGE(track < duration, "onTrackChanged 必须早于 onDurationChanged"); CHECK_MESSAGE(duration < position, "onDurationChanged 必须早于 onPositionChanged"); - CHECK_MESSAGE(MockPlayerCallbacks::countEvents(events, "duration") == 1, - "每曲只应报一次 duration"); + // 只数「首个曲目切换窗口」(首个 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()); } @@ -1435,34 +1475,50 @@ TEST_SUITE("PublicPlayer Interactive Paths") } 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::Playing; }, + [&player]() { return player.state() == soundbridge::PlayerState::Paused; }, 3000)); - const int afterPlay = callbacks.state_change_count.load(); - player.play(); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - CHECK_MESSAGE(callbacks.state_change_count.load() == afterPlay, + CHECK_MESSAGE(callbacks.state_change_count.load() == afterPlay + 1, "Playing 下重复 play() 发了额外的状态回调"); - player.pause(); + const int afterPause = callbacks.state_change_count.load(); + player.pause(); // 重复 + player.play(); // 屏障:Playing REQUIRE(waitFor( - [&player]() { return player.state() == soundbridge::PlayerState::Paused; }, + [&player]() { return player.state() == soundbridge::PlayerState::Playing; }, 3000)); - const int afterPause = callbacks.state_change_count.load(); - player.pause(); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - CHECK_MESSAGE(callbacks.state_change_count.load() == afterPause, + 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(); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - CHECK_MESSAGE(callbacks.state_change_count.load() == afterStop, + 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; From ee4b6ae4df38188749841e8b167bedca7876b64a Mon Sep 17 00:00:00 2001 From: wtp <15908282659@163.com> Date: Sun, 8 Nov 2026 11:15:43 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E5=9B=9E=E5=A1=AB=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=B8=85=E5=8D=95=E8=90=BD=E5=9C=B0=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E6=9F=A5=E7=BB=93=E6=9E=9C=EF=BC=88#92=20/?= =?UTF-8?q?=20T07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §10.1 标注 /to-tickets → /implement-spec 链已于 2026-11-08 跑完(#85–#92、integration branch)。 - 新增 §10.4:C1–C6 逐项落点、code-review 4 Major + 4 Minor 的修复、务实取舍(同批文件 + 本机构建缓存,逐票串行提交)、C1/C5 端到端判据缺口的原因与后续触发条件。 - §10.2 标题改为已落地;§10.3 明确契约漂移观测口径仍未裁决且若记录须标「不作为失败门禁」。 --- doc/player-state-contract.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) 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;若只记录不设门槛,须写明「不作为失败门禁」。