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..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() } @@ -57,8 +60,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 `kMRMediaRemoteNowPlayingInfoDidChangeNotification`. +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/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 diff --git a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift index 36d2f889..7a18c990 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,17 +157,23 @@ struct LyricsPresenterDuplicateTests { await waitForLyricsSuccess(presenter) // Set position while playing - positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 1.0)) - try? await Task.sleep(for: .milliseconds(200)) - presenter.updateActiveLineTick() + positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0)) + 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 - positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 0)) - try? await Task.sleep(for: .milliseconds(200)) - presenter.updateActiveLineTick() - - // activeLineIndex should not update when paused + // Pause (rate = 0), send new position — paused guard should keep index at 1 + positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 0)) + 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 3c98b9de..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) - positionSubject.send(PlaybackPosition(elapsed: 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 + // Send a paused playback position (rate = 0). + positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 0)) + + // 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) } } @@ -229,11 +236,97 @@ struct LyricsPresenterTests { await waitForLyricsSuccess(presenter) // Send position at 6s — should highlight Line B (time=5) - positionSubject.send(PlaybackPosition(elapsed: 6, playbackRate: 1.0)) - // Allow Combine to deliver the position update - try? await Task.sleep(for: .milliseconds(50)) + positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 1.0)) + + 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) + } + } + + @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) - presenter.updateActiveLineTick() + // 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)) + + 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) + } + } + + @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)) + + 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 new file mode 100644 index 00000000..12cbf335 --- /dev/null +++ b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift @@ -0,0 +1,191 @@ +@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 + try #require(snapshots.count == 2) + #expect(snapshots[0].rawElapsed == 10) + #expect(snapshots[0].timestamp == t1) + #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 + 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) + } +} 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) + } +}