From 8ce1f44ac68730e3f0f681eb9e5b2055ed81db98 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 16:25:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?perf(#257):=20elapsed=20=E3=82=92=20Present?= =?UTF-8?q?er=20=E5=81=B4=E3=81=A7=E8=A3=9C=E9=96=93=E3=81=97=20helper=20T?= =?UTF-8?q?imer=20=E3=82=92=203=20=E7=A7=92=E3=81=AB=E5=BB=B6=E9=95=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlaybackPosition Entity を rawElapsed + timestamp + playbackRate の純粋スナップショットに変更し、 LyricsPresenter.updateActiveLineTick で `\.date.now` を使って毎フレーム補間する。 これにより helper の polling 間隔を 1s → 3s に延長しても歌詞ハイライトのズレは出ない (DisplayLink tick 毎に最新の補間値が計算されるため)。 #263 で swift-interpret モードに戻して helper のホストが swift-frontend 常駐になった 文脈で、polling 削減の idle CPU 効果が以前より大きい。pause/seek は MediaRemote の notification (kMRMediaRemoteNowPlayingInfoDidChange) で即時反映される。 --- .../AppRouter/AppDependencyBootstrap.swift | 2 +- Sources/Entity/PlaybackPosition.swift | 13 ++- .../Resources/media-remote-helper.swift | 7 +- .../Presenters/Track/LyricsPresenter.swift | 18 ++++- .../TrackInteractor/TrackInteractorImpl.swift | 5 +- .../LyricsPresenterDuplicateTests.swift | 8 +- .../LyricsPresenterTests.swift | 79 ++++++++++++++++++- 7 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Sources/AppRouter/AppDependencyBootstrap.swift b/Sources/AppRouter/AppDependencyBootstrap.swift index 74c9234d..822ac9ef 100644 --- a/Sources/AppRouter/AppDependencyBootstrap.swift +++ b/Sources/AppRouter/AppDependencyBootstrap.swift @@ -78,7 +78,7 @@ import Foundation let decodeEffect = DecodeEffect(duration: 0) trackChange = Just(fixture.trackUpdate).eraseToAnyPublisher() artwork = Just(nil).eraseToAnyPublisher() - playbackPosition = Just(PlaybackPosition(elapsed: nil, playbackRate: 0)).eraseToAnyPublisher() + playbackPosition = Just(PlaybackPosition(rawElapsed: nil, timestamp: nil, playbackRate: 0)).eraseToAnyPublisher() decodeEffectConfig = decodeEffect textLayout = TextLayout(decodeEffect: decodeEffect) artworkStyle = ArtworkStyle(opacity: 0) diff --git a/Sources/Entity/PlaybackPosition.swift b/Sources/Entity/PlaybackPosition.swift index 3894a284..c9c48072 100644 --- a/Sources/Entity/PlaybackPosition.swift +++ b/Sources/Entity/PlaybackPosition.swift @@ -1,13 +1,20 @@ import Foundation public struct PlaybackPosition { - public let elapsed: TimeInterval? + public let rawElapsed: TimeInterval? + public let timestamp: Date? public let playbackRate: Double - public init(elapsed: TimeInterval? = nil, playbackRate: Double = 1.0) { - self.elapsed = elapsed + public init( + rawElapsed: TimeInterval? = nil, + timestamp: Date? = nil, + playbackRate: Double = 1.0 + ) { + self.rawElapsed = rawElapsed + self.timestamp = timestamp self.playbackRate = playbackRate } } extension PlaybackPosition: Sendable {} +extension PlaybackPosition: Equatable {} diff --git a/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift b/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift index dbec8cae..90698bf0 100644 --- a/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift +++ b/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift @@ -57,8 +57,11 @@ for name in [ ) { _ in fetchAndPrint() } } -// Periodic fallback for elapsed time updates (needed for lyric sync) -Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in fetchAndPrint() } +// Periodic fallback for snapshot refresh (rawElapsed/timestamp/playbackRate). +// The client (LyricsPresenter) interpolates elapsed on every DisplayLink tick +// from this snapshot, so 3s polling is sufficient for lyric sync. pause/seek +// is delivered immediately via `kMRMediaRemoteNowPlayingInfoDidChange`. +Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in fetchAndPrint() } // Initial fetch fetchAndPrint() diff --git a/Sources/Presenters/Track/LyricsPresenter.swift b/Sources/Presenters/Track/LyricsPresenter.swift index 4fed0009..bab8e54d 100644 --- a/Sources/Presenters/Track/LyricsPresenter.swift +++ b/Sources/Presenters/Track/LyricsPresenter.swift @@ -14,11 +14,13 @@ public final class LyricsPresenter: ObservableObject { private var lyricEffects: [DecodeEffectState] = [] private var decodeConfig: DecodeEffect? - private var latestElapsed: TimeInterval? + private var latestRawElapsed: TimeInterval? + private var latestTimestamp: Date? private var latestPlaybackRate: Double = 1.0 private var cancellables: Set = [] @Dependency(\.trackInteractor) private var interactor + @Dependency(\.date.now) private var now public init() {} @@ -38,7 +40,8 @@ public final class LyricsPresenter: ObservableObject { interactor.playbackPosition .receive(on: DispatchQueue.main) .sink { [weak self] position in - self?.latestElapsed = position.elapsed + self?.latestRawElapsed = position.rawElapsed + self?.latestTimestamp = position.timestamp self?.latestPlaybackRate = position.playbackRate } .store(in: &cancellables) @@ -101,13 +104,22 @@ public final class LyricsPresenter: ObservableObject { } /// Called from DisplayLink to keep activeLineIndex in sync at frame rate. + /// Interpolates elapsed time on every tick from the latest snapshot + /// (rawElapsed + timestamp + playbackRate), so the helper can poll at + /// a longer interval without introducing lyric-highlight lag. public func updateActiveLineTick() { guard case .success(.timed(let lines)) = lyricsState else { return } guard latestPlaybackRate != 0 else { return } - let index = latestElapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } } + let index = interpolatedElapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } } guard index != activeLineIndex else { return } activeLineIndex = index } + + private var interpolatedElapsed: TimeInterval? { + guard let base = latestRawElapsed else { return nil } + guard let ts = latestTimestamp else { return base } + return base + latestPlaybackRate * now.timeIntervalSince(ts) + } } extension LyricsPresenter { diff --git a/Sources/TrackInteractor/TrackInteractorImpl.swift b/Sources/TrackInteractor/TrackInteractorImpl.swift index 17c430a0..39e4a7a6 100644 --- a/Sources/TrackInteractor/TrackInteractorImpl.swift +++ b/Sources/TrackInteractor/TrackInteractorImpl.swift @@ -62,7 +62,10 @@ public final class TrackInteractorImpl: @unchecked Sendable { public lazy var playbackPosition: AnyPublisher = activeNowPlaying - .map { [playbackService] np in PlaybackPosition(elapsed: playbackService.elapsedTime(for: np), playbackRate: np.playbackRate) } + .map { np in + PlaybackPosition( + rawElapsed: np.rawElapsed, timestamp: np.timestamp, playbackRate: np.playbackRate) + } .eraseToAnyPublisher() public init() { diff --git a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift index 36d2f889..23aae21e 100644 --- a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift @@ -107,7 +107,7 @@ struct LyricsPresenterDuplicateTests { #expect(presenter.lyricsState == .success(content)) // Send playback position at 6 seconds (should highlight "Second") - positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 1.0)) + positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0)) var deadline = ContinuousClock.now + .seconds(3) while ContinuousClock.now < deadline { presenter.updateActiveLineTick() @@ -120,7 +120,7 @@ struct LyricsPresenterDuplicateTests { #expect(presenter.lyricsState == .success(content)) // Advance to 11 seconds (should highlight "Third") - positionSubject.send(PlaybackPosition(elapsed: 11.0, playbackRate: 1.0)) + positionSubject.send(PlaybackPosition(rawElapsed: 11.0, playbackRate: 1.0)) deadline = ContinuousClock.now + .seconds(3) while ContinuousClock.now < deadline { presenter.updateActiveLineTick() @@ -157,13 +157,13 @@ struct LyricsPresenterDuplicateTests { await waitForLyricsSuccess(presenter) // Set position while playing - positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 1.0)) + positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0)) try? await Task.sleep(for: .milliseconds(200)) presenter.updateActiveLineTick() #expect(presenter.activeLineIndex == 1) // Pause (rate = 0), send new position - positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 0)) + positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 0)) try? await Task.sleep(for: .milliseconds(200)) presenter.updateActiveLineTick() diff --git a/Tests/PresentersTests/LyricsPresenterTests.swift b/Tests/PresentersTests/LyricsPresenterTests.swift index 3c98b9de..9b5707ae 100644 --- a/Tests/PresentersTests/LyricsPresenterTests.swift +++ b/Tests/PresentersTests/LyricsPresenterTests.swift @@ -193,7 +193,7 @@ struct LyricsPresenterTests { await waitForLyricsSuccess(presenter) // Send a paused playback position (rate = 0) - positionSubject.send(PlaybackPosition(elapsed: 6, playbackRate: 0)) + positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 0)) // Allow Combine to deliver the position update try? await Task.sleep(for: .milliseconds(50)) @@ -229,7 +229,7 @@ struct LyricsPresenterTests { await waitForLyricsSuccess(presenter) // Send position at 6s — should highlight Line B (time=5) - positionSubject.send(PlaybackPosition(elapsed: 6, playbackRate: 1.0)) + positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 1.0)) // Allow Combine to deliver the position update try? await Task.sleep(for: .milliseconds(50)) @@ -237,6 +237,81 @@ struct LyricsPresenterTests { #expect(presenter.activeLineIndex == 1) } } + + @MainActor + @Test("interpolates elapsed from snapshot (rawElapsed + timestamp + playbackRate)") + func interpolatesFromSnapshot() async throws { + let fixedNow = Date(timeIntervalSinceReferenceDate: 1_000_000) + let trackSubject = PassthroughSubject() + let positionSubject = PassthroughSubject() + let lines: [LyricLine] = [ + .init(time: 0, text: "Line A"), + .init(time: 5, text: "Line B"), + .init(time: 10, text: "Line C"), + ] + let content = LyricsContent.timed(lines) + + await withDependencies { + $0.trackInteractor = StubTrackInteractor( + trackChangePublisher: trackSubject.eraseToAnyPublisher(), + playbackPositionPublisher: positionSubject.eraseToAnyPublisher(), + textLayout: TextLayout(decodeEffect: .init(duration: 0)) + ) + $0.date = .constant(fixedNow) + } operation: { + let presenter = LyricsPresenter() + presenter.start() + + trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved)) + await waitForLyricsSuccess(presenter) + + // Snapshot from 7s ago: rawElapsed=0, rate=1.0 + // Interpolated at fixedNow: 0 + 1.0 * 7.0 = 7.0 → Line B (time=5) + positionSubject.send( + PlaybackPosition( + rawElapsed: 0, + timestamp: fixedNow.addingTimeInterval(-7), + playbackRate: 1.0)) + try? await Task.sleep(for: .milliseconds(50)) + + presenter.updateActiveLineTick() + #expect(presenter.activeLineIndex == 1) + } + } + + @MainActor + @Test("falls back to rawElapsed when timestamp is nil") + func fallsBackWhenTimestampMissing() async throws { + let trackSubject = PassthroughSubject() + let positionSubject = PassthroughSubject() + let lines: [LyricLine] = [ + .init(time: 0, text: "A"), + .init(time: 5, text: "B"), + .init(time: 10, text: "C"), + ] + let content = LyricsContent.timed(lines) + + await withDependencies { + $0.trackInteractor = StubTrackInteractor( + trackChangePublisher: trackSubject.eraseToAnyPublisher(), + playbackPositionPublisher: positionSubject.eraseToAnyPublisher(), + textLayout: TextLayout(decodeEffect: .init(duration: 0)) + ) + } operation: { + let presenter = LyricsPresenter() + presenter.start() + + trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved)) + await waitForLyricsSuccess(presenter) + + // timestamp nil → rawElapsed used verbatim (no interpolation) + positionSubject.send(PlaybackPosition(rawElapsed: 7, timestamp: nil, playbackRate: 1.0)) + try? await Task.sleep(for: .milliseconds(50)) + + presenter.updateActiveLineTick() + #expect(presenter.activeLineIndex == 1) + } + } } @Suite("receive edge cases") From 090761dc7c461c913037394daafb4ce43a4878d6 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 16:25:16 +0900 Subject: [PATCH 2/6] chore: bump version to 2.13.7 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 14239ef1..ea55a03f 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.6 +2.13.7 From 2b7f7d9e741dcd12ca740177f5013d38ffdbc2b4 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 16:35:31 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(#257):=20timestamp=20=E6=AC=A0=E8=90=BD?= =?UTF-8?q?=E6=99=82=E3=81=AE=E8=A3=9C=E9=96=93=E3=81=A8=E6=96=B0=E8=A6=8F?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=20flaky=20sleep=20?= =?UTF-8?q?=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 / Copilot レビュー指摘への対応。 - helper: MediaRemote が timestamp を返さないソース向けに、fetch 時刻 を timestamp として注入。これがないと client 側の補間が base (rawElapsed そのもの) に縮退し、3 秒 polling 間隔のまま歌詞 ハイライトが追従しなくなる。playbackRate=0 (pause) では補間自体を スキップするため副作用なし。 - helper: コメント内の notification 名を kMRMediaRemoteNowPlayingInfoDidChange → kMRMediaRemoteNowPlayingInfoDidChangeNotification に修正 (Apple 公式の正式名にあわせる)。 - tests: 新規追加した 2 件 (interpolatesFromSnapshot / fallsBackWhenTimestampMissing) で使っていた固定 Task.sleep(50ms) を deadline polling (3s) に置換。CLAUDE.md "Async test timing" の ガイドラインに準拠し、CI 負荷で flaky 化するリスクを除去。 --- .../Resources/media-remote-helper.swift | 11 +++++++---- Tests/PresentersTests/LyricsPresenterTests.swift | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift b/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift index 90698bf0..dc586cd3 100644 --- a/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift +++ b/Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift @@ -29,9 +29,12 @@ let register = unsafeBitCast(regSym, to: RegisterFn.self) r["duration"] = d["kMRMediaRemoteNowPlayingInfoDuration"] r["elapsed"] = d["kMRMediaRemoteNowPlayingInfoElapsedTime"] r["rate"] = d["kMRMediaRemoteNowPlayingInfoPlaybackRate"] - if let ts = d["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date { - r["timestamp"] = ts.timeIntervalSinceReferenceDate - } + // MediaRemote sometimes omits the timestamp field; synthesize it from + // the current fetch moment so the client can always interpolate elapsed + // between polls (otherwise lyric highlighting would only advance once + // per polling interval for timestamp-less sources). + let ts = (d["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date) ?? Date() + r["timestamp"] = ts.timeIntervalSinceReferenceDate if let art = d["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data { r["artwork_base64"] = art.base64EncodedString() } @@ -60,7 +63,7 @@ for name in [ // Periodic fallback for snapshot refresh (rawElapsed/timestamp/playbackRate). // The client (LyricsPresenter) interpolates elapsed on every DisplayLink tick // from this snapshot, so 3s polling is sufficient for lyric sync. pause/seek -// is delivered immediately via `kMRMediaRemoteNowPlayingInfoDidChange`. +// is delivered immediately via `kMRMediaRemoteNowPlayingInfoDidChangeNotification`. Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in fetchAndPrint() } // Initial fetch diff --git a/Tests/PresentersTests/LyricsPresenterTests.swift b/Tests/PresentersTests/LyricsPresenterTests.swift index 9b5707ae..519fb96f 100644 --- a/Tests/PresentersTests/LyricsPresenterTests.swift +++ b/Tests/PresentersTests/LyricsPresenterTests.swift @@ -272,9 +272,13 @@ struct LyricsPresenterTests { rawElapsed: 0, timestamp: fixedNow.addingTimeInterval(-7), playbackRate: 1.0)) - try? await Task.sleep(for: .milliseconds(50)) - presenter.updateActiveLineTick() + let deadline = ContinuousClock.now + .seconds(3) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex == 1 { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == 1) } } @@ -306,9 +310,13 @@ struct LyricsPresenterTests { // timestamp nil → rawElapsed used verbatim (no interpolation) positionSubject.send(PlaybackPosition(rawElapsed: 7, timestamp: nil, playbackRate: 1.0)) - try? await Task.sleep(for: .milliseconds(50)) - presenter.updateActiveLineTick() + let deadline = ContinuousClock.now + .seconds(3) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex == 1 { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == 1) } } From fa8a6637e74968d7a6133d57e09362ec75b56846 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 17:29:42 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test(#257):=20TrackInteractor=20playbackPos?= =?UTF-8?q?ition=20publisher=20=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR で変更した TrackInteractorImpl.playbackPosition publisher (lines 64-69) がテストでカバーされておらず Codecov patch coverage が 失敗していたため、専用テスト suite を追加。 検証内容: - rawElapsed / timestamp / playbackRate を NowPlaying からそのまま PlaybackPosition に passthrough すること - nil rawElapsed / nil timestamp / rate=0 (pause) でも例外なく流れること - NowPlaying が連続して変化した際に複数 snapshot を emit すること 既存の TrackInteractorArtworkTests と同じパターン (Stub + Collector + makeInteractor helper) に揃え、suite は .serialized で安定性を担保。 --- ...TrackInteractorPlaybackPositionTests.swift | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift diff --git a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift new file mode 100644 index 00000000..757d1663 --- /dev/null +++ b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift @@ -0,0 +1,155 @@ +@preconcurrency import Combine +import Dependencies +import Domain +import Foundation +import Testing + +@testable import TrackInteractor + +// MARK: - Stubs + +private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { + let subject = CurrentValueSubject(nil) + + func fetchNowPlaying() async -> NowPlaying? { nil } + + func observeNowPlaying() -> AsyncStream { + AsyncStream { continuation in + let cancellable = subject.sink( + receiveCompletion: { _ in continuation.finish() }, + receiveValue: { continuation.yield($0) } + ) + continuation.onTermination = { _ in cancellable.cancel() } + } + } + + func elapsedTime(for np: NowPlaying) -> TimeInterval? { np.rawElapsed } +} + +private struct InstantMetadataUseCase: MetadataUseCase, Sendable { + func resolve(track: Track) async -> Track? { nil } + func resolveCandidates(track: Track) async -> [Track] { [] } +} + +private struct StubLyricsUseCase: LyricsUseCase, Sendable { + func fetchLyrics(track: Track) async -> LyricsResult { LyricsResult() } + func fetchLyrics(candidates: [Track]) async -> LyricsResult { LyricsResult() } + func parseLyricsContent(from result: LyricsResult?) -> LyricsContent? { nil } +} + +private struct StubConfigUseCase: ConfigUseCase, Sendable { + var appStyle: AppStyle { .init() } + func template(format: ConfigFormat) -> String? { nil } + func writeTemplate(format: ConfigFormat, force: Bool) throws -> String { "" } + var existingConfigPath: String? { nil } +} + +// MARK: - Helpers + +private final class PositionCollector: @unchecked Sendable { + private let lock = NSLock() + private var emissions: [PlaybackPosition] = [] + + var snapshot: [PlaybackPosition] { lock.withLock { emissions } } + + func append(_ pos: PlaybackPosition) { + lock.withLock { emissions.append(pos) } + } + + func waitForCount(_ target: Int, timeout: Duration = .seconds(2)) async { + let deadline = ContinuousClock.now + timeout + while snapshot.count < target, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } +} + +private func makeInteractor(playback: StubPlaybackUseCase) -> TrackInteractorImpl { + withDependencies { + $0.continuousClock = ImmediateClock() + $0.playbackUseCase = playback + $0.metadataUseCase = InstantMetadataUseCase() + $0.lyricsUseCase = StubLyricsUseCase() + $0.configUseCase = StubConfigUseCase() + } operation: { + TrackInteractorImpl() + } +} + +// MARK: - Tests + +@Suite("TrackInteractor playback position stream", .serialized) +struct TrackInteractorPlaybackPositionTests { + + @Test("playbackPosition emits snapshot (rawElapsed / timestamp / playbackRate) verbatim from NowPlaying") + func emitsSnapshotFields() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback) + let collector = PositionCollector() + let cancellable = interactor.playbackPosition.sink { collector.append($0) } + defer { cancellable.cancel() } + + let ts = Date(timeIntervalSinceReferenceDate: 1_000_000) + playback.subject.send( + NowPlaying( + title: "Song", artist: "Artist", artworkData: nil, + duration: 200, rawElapsed: 42, playbackRate: 1.5, timestamp: ts)) + + await collector.waitForCount(1) + let first = try #require(collector.snapshot.first) + #expect(first.rawElapsed == 42) + #expect(first.timestamp == ts) + #expect(first.playbackRate == 1.5) + } + + @Test("playbackPosition propagates nil rawElapsed / timestamp and rate 0 (paused) gracefully") + func emitsNilSnapshotFields() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback) + let collector = PositionCollector() + let cancellable = interactor.playbackPosition.sink { collector.append($0) } + defer { cancellable.cancel() } + + playback.subject.send( + NowPlaying( + title: "Song", artist: "Artist", artworkData: nil, + duration: nil, rawElapsed: nil, playbackRate: 0, timestamp: nil)) + + await collector.waitForCount(1) + let first = try #require(collector.snapshot.first) + #expect(first.rawElapsed == nil) + #expect(first.timestamp == nil) + #expect(first.playbackRate == 0) + } + + @Test("playbackPosition emits multiple snapshots when NowPlaying changes") + func emitsMultipleSnapshots() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback) + let collector = PositionCollector() + let cancellable = interactor.playbackPosition.sink { collector.append($0) } + defer { cancellable.cancel() } + + let t1 = Date(timeIntervalSinceReferenceDate: 1_000_000) + let t2 = Date(timeIntervalSinceReferenceDate: 1_000_010) + + playback.subject.send( + NowPlaying( + title: "Song A", artist: "Artist", artworkData: nil, + duration: 200, rawElapsed: 10, playbackRate: 1.0, timestamp: t1)) + await collector.waitForCount(1) + + playback.subject.send( + NowPlaying( + title: "Song B", artist: "Artist", artworkData: nil, + duration: 200, rawElapsed: 0, playbackRate: 1.0, timestamp: t2)) + await collector.waitForCount(2) + + let snapshots = collector.snapshot + #expect(snapshots.count == 2) + #expect(snapshots[0].rawElapsed == 10) + #expect(snapshots[0].timestamp == t1) + #expect(snapshots[1].rawElapsed == 0) + #expect(snapshots[1].timestamp == t2) + } +} From 1250bdf00670fa9949baa2832d5f9dd405e02941 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 17:39:54 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test(#257):=20TrackInteractor=20=E6=AE=8B?= =?UTF-8?q?=E3=82=AB=E3=83=90=E3=83=AC=E3=83=83=E3=82=B8=E3=82=92=2092.94%?= =?UTF-8?q?=20=E2=86=92=2097.49%=20=E3=81=AB=E5=BC=95=E3=81=8D=E4=B8=8A?= =?UTF-8?q?=E3=81=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR の主目的 (line 64-69 のカバー) を超えるが、関連する周辺コードの 未カバー領域もまとめて潰す。残り 4 lines は cancellation race (`Task.isCancelled`) の経路で時序的に強制できないため対象外。 追加内容: - volume-mute pattern (line 30) TrackInteractorPlaybackPositionTests に test 追加。 「同一 title で artist が消失 → 抑制」を 3 つの emission 列で検証。 - computed style properties (lines 84-94) TrackInteractorStyleTests を新規追加。 decodeEffectConfig / textLayout / artworkStyle の 3 つを 非デフォルト・デフォルト両方で検証。 - nil title/artist guard (line 123) TrackInteractorRaceTests に test 追加。 resolveTrack の guard で Just(loading) に短絡することと、 以後 resolved/notFound が emit されないことを検証。 結果: - lines coverage 92.94% → 97.49% (+4.55pt) - function coverage 71.05% → 81.58% - region coverage 66.67% → 75.76% --- ...TrackInteractorPlaybackPositionTests.swift | 36 ++++++ .../TrackInteractorRaceTests.swift | 36 ++++++ .../TrackInteractorStyleTests.swift | 104 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 Tests/TrackInteractorTests/TrackInteractorStyleTests.swift diff --git a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift index 757d1663..ccdafb02 100644 --- a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift @@ -152,4 +152,40 @@ struct TrackInteractorPlaybackPositionTests { #expect(snapshots[1].rawElapsed == 0) #expect(snapshots[1].timestamp == t2) } + + @Test("playbackPosition suppresses 'volume mute' transition (same title, artist disappears)") + func volumeMutePatternSuppressed() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback) + let collector = PositionCollector() + let cancellable = interactor.playbackPosition.sink { collector.append($0) } + defer { cancellable.cancel() } + + // First: title with non-empty artist -> emits + playback.subject.send( + NowPlaying( + title: "Song", artist: "Artist", artworkData: nil, + duration: nil, rawElapsed: 1, playbackRate: 1, timestamp: nil)) + await collector.waitForCount(1) + + // Second: same title, artist degrades to empty (system mute / lookup hiccup). + // activeNowPlaying.compactMap returns nil for this pattern (line 30) — suppressed. + playback.subject.send( + NowPlaying( + title: "Song", artist: "", artworkData: nil, + duration: nil, rawElapsed: 2, playbackRate: 1, timestamp: nil)) + + // Third: artist returns -> emits again. Awaiting count==2 here proves + // the second send did NOT emit (otherwise count would already be >=2). + playback.subject.send( + NowPlaying( + title: "Song", artist: "Artist", artworkData: nil, + duration: nil, rawElapsed: 3, playbackRate: 1, timestamp: nil)) + await collector.waitForCount(2) + + let snapshots = collector.snapshot + #expect(snapshots.count == 2, "second (volume-mute) emission should be filtered out by activeNowPlaying") + #expect(snapshots[0].rawElapsed == 1) + #expect(snapshots[1].rawElapsed == 3) + } } diff --git a/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift b/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift index 86ee3ead..dfd968e0 100644 --- a/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift @@ -273,4 +273,40 @@ struct TrackInteractorRaceTests { #expect(!isDuplicate(normal, sameTitleDiffArtist), "Different non-empty artist should not match") #expect(isDuplicate(muted, muted), "Both empty artist, same title should match") } + + // MARK: - resolveTrack guards + + @Test("trackChange emits loading state when NowPlaying has nil title/artist (no resolution attempted)") + func nilTitleArtistEmitsLoadingOnly() async throws { + let playback = StubPlaybackUseCase() + let clock = TestClock() + let interactor = makeInteractor(playback: playback, clock: clock) + + let collector = UpdateCollector() + let cancellable = interactor.trackChange.sink { collector.append($0) } + defer { cancellable.cancel() } + + // Nil title/artist hits the early `guard let title, let artist` + // branch in resolveTrack and short-circuits to Just(loading) without + // calling metadata/lyrics services. + playback.subject.send( + NowPlaying( + title: nil, artist: nil, artworkData: nil, + duration: nil, rawElapsed: nil, playbackRate: 1, timestamp: nil)) + + let loading = await collector.waitFor { update in + update.title == nil && update.lyricsState == .loading + } + #expect(loading.lyricsState == .loading) + #expect(loading.title == nil) + #expect(loading.artist == nil) + + // No resolved/notFound should ever follow — the guard returns Just(loading) + // without scheduling any async work. + await clock.advance(by: .milliseconds(500)) + let resolved = collector.updates.filter { + $0.lyricsState == .resolved || $0.lyricsState == .notFound + } + #expect(resolved.isEmpty, "no resolution should be attempted for nil title/artist") + } } diff --git a/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift b/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift new file mode 100644 index 00000000..eb417f26 --- /dev/null +++ b/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift @@ -0,0 +1,104 @@ +@preconcurrency import Combine +import Dependencies +import Domain +import Foundation +import Testing + +@testable import TrackInteractor + +// MARK: - Stubs + +private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { + let subject = CurrentValueSubject(nil) + + func fetchNowPlaying() async -> NowPlaying? { nil } + + func observeNowPlaying() -> AsyncStream { + AsyncStream { continuation in + let cancellable = subject.sink( + receiveCompletion: { _ in continuation.finish() }, + receiveValue: { continuation.yield($0) } + ) + continuation.onTermination = { _ in cancellable.cancel() } + } + } + + func elapsedTime(for np: NowPlaying) -> TimeInterval? { np.rawElapsed } +} + +private struct InstantMetadataUseCase: MetadataUseCase, Sendable { + func resolve(track: Track) async -> Track? { nil } + func resolveCandidates(track: Track) async -> [Track] { [] } +} + +private struct StubLyricsUseCase: LyricsUseCase, Sendable { + func fetchLyrics(track: Track) async -> LyricsResult { LyricsResult() } + func fetchLyrics(candidates: [Track]) async -> LyricsResult { LyricsResult() } + func parseLyricsContent(from result: LyricsResult?) -> LyricsContent? { nil } +} + +private struct StubConfigUseCase: ConfigUseCase, Sendable { + let style: AppStyle + var appStyle: AppStyle { style } + func template(format: ConfigFormat) -> String? { nil } + func writeTemplate(format: ConfigFormat, force: Bool) throws -> String { "" } + var existingConfigPath: String? { nil } +} + +// MARK: - Helpers + +private func makeInteractor(config: any ConfigUseCase = StubConfigUseCase(style: AppStyle())) -> TrackInteractorImpl { + withDependencies { + $0.continuousClock = ImmediateClock() + $0.playbackUseCase = StubPlaybackUseCase() + $0.metadataUseCase = InstantMetadataUseCase() + $0.lyricsUseCase = StubLyricsUseCase() + $0.configUseCase = config + } operation: { + TrackInteractorImpl() + } +} + +// MARK: - Tests + +@Suite("TrackInteractor style computed properties", .serialized) +struct TrackInteractorStyleTests { + + @Test("decodeEffectConfig exposes AppStyle.text.decodeEffect") + func decodeEffectConfigReturnsConfigValue() { + let decode = DecodeEffect(duration: 1.7) + let style = AppStyle(text: TextLayout(decodeEffect: decode)) + let interactor = makeInteractor(config: StubConfigUseCase(style: style)) + + #expect(interactor.decodeEffectConfig.duration == 1.7) + } + + @Test("textLayout exposes AppStyle.text") + func textLayoutReturnsConfigValue() { + let decode = DecodeEffect(duration: 2.3) + let style = AppStyle(text: TextLayout(decodeEffect: decode)) + let interactor = makeInteractor(config: StubConfigUseCase(style: style)) + + #expect(interactor.textLayout.decodeEffect.duration == 2.3) + } + + @Test("artworkStyle exposes AppStyle.artwork") + func artworkStyleReturnsConfigValue() { + let artwork = ArtworkStyle(size: 128, opacity: 0.6) + let style = AppStyle(artwork: artwork) + let interactor = makeInteractor(config: StubConfigUseCase(style: style)) + + #expect(interactor.artworkStyle.size == 128) + #expect(interactor.artworkStyle.opacity == 0.6) + } + + @Test("default AppStyle values flow through to computed properties") + func defaultsFlowThrough() { + let interactor = makeInteractor() + + #expect(interactor.decodeEffectConfig.duration == 0.8) + #expect(interactor.textLayout.decodeEffect.duration == 0.8) + #expect(interactor.artworkStyle.size == 96) + #expect(interactor.artworkStyle.opacity == 1.0) + } +} From 78acf8b008a66f806bce58ac5852baf203ccd2ff Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 1 Jun 2026 19:09:33 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test(#257):=20=E5=9B=BA=E5=AE=9A=20Task.sle?= =?UTF-8?q?ep=20=E3=82=92=20deadline=20polling=20=E3=81=AB=E7=BD=AE?= =?UTF-8?q?=E6=8F=9B=E3=80=81count=20=E6=A4=9C=E8=A8=BC=E3=82=92=20try=20#?= =?UTF-8?q?require=20=E3=81=AB=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit / Copilot レビュー指摘への対応。 ### Copilot: 固定 Task.sleep 撤廃 (CLAUDE.md "Async test timing" 準拠) 3 箇所で残っていた固定 sleep を deadline polling に置換: - LyricsPresenterTests `skips update when playback rate is 0 (paused)` Task.sleep(50ms) → 1s deadline で activeLineIndex==nil を polling 観測 - LyricsPresenterTests `updates active line index for timed lyrics` Task.sleep(50ms) → 3s deadline で activeLineIndex==1 になるまで polling - LyricsPresenterDuplicateTests `pausedPlaybackKeepsIndex` Task.sleep(200ms) × 2 → それぞれ 3s / 1s deadline polling に。 rate=1.0 配信は activeLineIndex==1 になるまで、rate=0 後は逸脱したら early break する形で「paused-guard 破れ」を高速検知できるように。 ### CodeRabbit: 配列 index アクセス前に #require で固める snapshots[0] / snapshots[1] アクセスは #expect(count == N) だけでは non-fatal で素通りし、count が想定より小さいと out-of-bounds で test runner ごと落ちる可能性。 try #require に強化: - TrackInteractorPlaybackPositionTests.emitsMultipleSnapshots (count==2) - TrackInteractorPlaybackPositionTests.volumeMutePatternSuppressed (count==2) --- .../LyricsPresenterDuplicateTests.swift | 20 +++++++++----- .../LyricsPresenterTests.swift | 26 +++++++++++++------ ...TrackInteractorPlaybackPositionTests.swift | 4 +-- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift index 23aae21e..7a18c990 100644 --- a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift @@ -158,16 +158,22 @@ struct LyricsPresenterDuplicateTests { // Set position while playing positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0)) - try? await Task.sleep(for: .milliseconds(200)) - presenter.updateActiveLineTick() + var deadline = ContinuousClock.now + .seconds(3) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex == 1 { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == 1) - // Pause (rate = 0), send new position + // Pause (rate = 0), send new position — paused guard should keep index at 1 positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 0)) - try? await Task.sleep(for: .milliseconds(200)) - presenter.updateActiveLineTick() - - // activeLineIndex should not update when paused + deadline = ContinuousClock.now + .seconds(1) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex != 1 { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == 1) } } diff --git a/Tests/PresentersTests/LyricsPresenterTests.swift b/Tests/PresentersTests/LyricsPresenterTests.swift index 519fb96f..c6824139 100644 --- a/Tests/PresentersTests/LyricsPresenterTests.swift +++ b/Tests/PresentersTests/LyricsPresenterTests.swift @@ -192,13 +192,20 @@ struct LyricsPresenterTests { trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved)) await waitForLyricsSuccess(presenter) - // Send a paused playback position (rate = 0) + // Send a paused playback position (rate = 0). positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 0)) - // Allow Combine to deliver the position update - try? await Task.sleep(for: .milliseconds(50)) - presenter.updateActiveLineTick() - // activeLineIndex should remain nil because playback is paused + // activeLineIndex must remain nil — both before sink delivery + // (latestRawElapsed still nil → interpolatedElapsed nil → no index + // change) and after delivery (latestPlaybackRate == 0 → early + // return). Poll the tick to confirm it never flips during a + // short observation window. + let deadline = ContinuousClock.now + .seconds(1) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex != nil { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == nil) } } @@ -230,10 +237,13 @@ struct LyricsPresenterTests { // Send position at 6s — should highlight Line B (time=5) positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 1.0)) - // Allow Combine to deliver the position update - try? await Task.sleep(for: .milliseconds(50)) - presenter.updateActiveLineTick() + let deadline = ContinuousClock.now + .seconds(3) + while ContinuousClock.now < deadline { + presenter.updateActiveLineTick() + if presenter.activeLineIndex == 1 { break } + try? await Task.sleep(for: .milliseconds(10)) + } #expect(presenter.activeLineIndex == 1) } } diff --git a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift index ccdafb02..12cbf335 100644 --- a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift @@ -146,7 +146,7 @@ struct TrackInteractorPlaybackPositionTests { await collector.waitForCount(2) let snapshots = collector.snapshot - #expect(snapshots.count == 2) + try #require(snapshots.count == 2) #expect(snapshots[0].rawElapsed == 10) #expect(snapshots[0].timestamp == t1) #expect(snapshots[1].rawElapsed == 0) @@ -184,7 +184,7 @@ struct TrackInteractorPlaybackPositionTests { await collector.waitForCount(2) let snapshots = collector.snapshot - #expect(snapshots.count == 2, "second (volume-mute) emission should be filtered out by activeNowPlaying") + try #require(snapshots.count == 2, "second (volume-mute) emission should be filtered out by activeNowPlaying") #expect(snapshots[0].rawElapsed == 1) #expect(snapshots[1].rawElapsed == 3) }