From 1dc6750a117218545f512243b819e29a8d49421a Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 15 Jun 2026 05:02:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(#57):=20AI=E6=8A=BD=E5=87=BA=E4=B8=AD?= =?UTF-8?q?=E3=81=AE=E3=82=BF=E3=82=A4=E3=83=88=E3=83=AB=E3=83=BB=E3=82=A2?= =?UTF-8?q?=E3=83=BC=E3=83=86=E3=82=A3=E3=82=B9=E3=83=88=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E5=8F=AF=E8=83=BD=E3=81=AA=E9=85=8D=E8=89=B2=E3=81=A7?= =?UTF-8?q?=E3=83=87=E3=82=B3=E3=83=BC=E3=83=89=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLMキャッシュミスかつAIエンドポイント設定時、API往復の間ヘッダーを processing_color(既定: 緑 #4ADE80)でスクランブル表示し、解決後に 通常色へ settle する。キャッシュヒット時は従来通り即時表示で変化なし。 - TrackUpdate に aiResolving フラグを追加 - MetadataRepository/UseCase に isAIMetadataCached を追加(キャッシュ参照のみ) - TrackInteractor: debounce 後、AI設定済み && 未キャッシュ時に aiResolving 更新を送出 - HeaderPresenter: aiResolving で startLoading(無限スクランブル)+ processingColor、 解決更新で通常色へ復帰。HeaderView は実効 titleColor/artistColor を参照 - DecodeEffect(Config) に processingColor(config key: text.decode_effect.processing_color、 solid/gradient 対応)を追加 - バージョンを 2.15.0 に更新、README/CLAUDE.md を更新 --- .claude/CLAUDE.md | 2 + README.md | 1 + .../ConfigRepositoryImpl.swift | 3 +- .../Repository/MetadataRepository.swift | 5 + Sources/Domain/UseCase/MetadataUseCase.swift | 5 + .../Entity/Config/DecodeEffectConfig.swift | 11 +- Sources/Entity/Style/DecodeEffect.swift | 8 +- Sources/Entity/TrackUpdate.swift | 9 +- .../MetadataRepositoryImpl.swift | 4 + .../MetadataUseCase/MetadataUseCaseImpl.swift | 4 + .../Presenters/Track/HeaderPresenter.swift | 53 +++++- .../TrackInteractor/TrackInteractorImpl.swift | 20 +++ Sources/VersionHandler/Resources/version.txt | 2 +- Sources/Views/Header/HeaderView.swift | 4 +- .../ConfigTemplateTests.swift | 5 +- .../ConfigRepositoryTests.swift | 32 +++- .../EntityTests/DecodeEffectConfigTests.swift | 58 +++++++ .../LyricsSearchServiceTests.swift | 2 + .../LyricsServiceTests.swift | 1 + .../MetadataRepositoryTests.swift | 38 +++++ .../MetadataUseCaseTests.swift | 24 +++ .../HeaderPresenterTests.swift | 110 ++++++++++++ .../TrackHandlerImplTests.swift | 1 + .../TrackInteractorAIProcessingTests.swift | 160 ++++++++++++++++++ .../TrackInteractorArtworkTests.swift | 1 + ...TrackInteractorPlaybackPositionTests.swift | 1 + .../TrackInteractorRaceTests.swift | 1 + .../TrackInteractorStyleTests.swift | 1 + 28 files changed, 555 insertions(+), 11 deletions(-) create mode 100644 Tests/EntityTests/DecodeEffectConfigTests.swift create mode 100644 Tests/TrackInteractorTests/TrackInteractorAIProcessingTests.swift diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4ae2bf04..43893b1c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -232,6 +232,8 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via **FetchState\**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275). +**AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`. + **Entity types**: `AppStyle`, `TextLayout`, `TextAppearance`, `ArtworkStyle`, `RippleStyle`, `WallpaperStyle`, `WallpaperItem`, `WallpaperPlaybackMode`, `DecodeEffect`, `AIEndpoint`, `ColorStyle`, `HealthCheckResult`, `ConfigValidationResult`, `MusicBrainzMetadata`, `MediaRemotePollResult`, `LocalWallpaper`, `RemoteWallpaper`, `YouTubeWallpaper`, `TrackUpdate`, `TrackLyricsState`, `WallpaperState`, `ResolvedWallpaperItem`, `ScreenLayout`, `WallpaperConfig`, `WallpaperItemConfig`, `NowPlayingInfo`, `LyricLine`, `LyricsContent`, `RevealPhase`. Config flows through Interactors, not via global `AppStyleKey`. **No AppStyleKey**: `@Dependency(\.appStyle)` was removed. All config access goes through the owning Interactor's computed properties (e.g., `trackInteractor.textLayout`, `wallpaperInteractor.rippleConfig`). This enforces the VIPER dependency rule. diff --git a/README.md b/README.md index 30e856a8..3d5b2615 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Controls the matrix-style text reveal animation. |---|---|---|---| | `duration` | number | `0.8` | Animation duration in seconds | | `charset` | string / array | all | Character sets for scramble: `"latin"`, `"cyrillic"`, `"greek"`, `"symbols"`, `"cjk"`. Single string or array | +| `processing_color` | string / array | `"#4ADE80"` (green) | Title/artist color while the AI extractor is resolving (LLM cache miss). The header scrambles in this color until the API responds, then settles to the resolved text in its normal color. Solid hex or gradient array. Only applies when an `[ai]` endpoint is configured | ### `[artwork]` diff --git a/Sources/ConfigRepository/ConfigRepositoryImpl.swift b/Sources/ConfigRepository/ConfigRepositoryImpl.swift index c2b534ee..79a57076 100644 --- a/Sources/ConfigRepository/ConfigRepositoryImpl.swift +++ b/Sources/ConfigRepository/ConfigRepositoryImpl.swift @@ -22,7 +22,8 @@ extension ConfigRepositoryImpl: ConfigRepository { highlight: config.text.highlight.toTextAppearance(), decodeEffect: DecodeEffect( duration: config.text.decodeEffect.duration.value, - charsets: config.text.decodeEffect.charset + charsets: config.text.decodeEffect.charset, + processingColor: config.text.decodeEffect.processingColor ) ), artwork: ArtworkStyle(size: config.artwork.size.value, opacity: config.artwork.opacity.value), diff --git a/Sources/Domain/Repository/MetadataRepository.swift b/Sources/Domain/Repository/MetadataRepository.swift index 4390ecb8..362452d3 100644 --- a/Sources/Domain/Repository/MetadataRepository.swift +++ b/Sources/Domain/Repository/MetadataRepository.swift @@ -2,6 +2,10 @@ import Dependencies public protocol MetadataRepository: Sendable { func resolve(track: Track) async -> [Track] + /// Whether the AI (LLM) extractor already has a cached result for this raw + /// track. A `false` here with an AI endpoint configured means `resolve` will + /// make a live API call, which presenters surface as a processing indicator (#57). + func isAIMetadataCached(track: Track) async -> Bool } public enum MetadataRepositoryKey: TestDependencyKey { @@ -17,4 +21,5 @@ extension DependencyValues { private struct UnimplementedMetadataRepository: MetadataRepository { func resolve(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { false } } diff --git a/Sources/Domain/UseCase/MetadataUseCase.swift b/Sources/Domain/UseCase/MetadataUseCase.swift index db2832eb..c07efc7c 100644 --- a/Sources/Domain/UseCase/MetadataUseCase.swift +++ b/Sources/Domain/UseCase/MetadataUseCase.swift @@ -3,6 +3,10 @@ import Dependencies public protocol MetadataUseCase: Sendable { func resolve(track: Track) async -> Track? func resolveCandidates(track: Track) async -> [Track] + /// Whether the AI (LLM) extractor already has a cached result for this raw + /// track. Used to decide whether `resolveCandidates` will make a live API + /// call worth showing a processing indicator for (#57). + func isAIMetadataCached(track: Track) async -> Bool } public enum MetadataUseCaseKey: TestDependencyKey { @@ -19,4 +23,5 @@ extension DependencyValues { private struct UnimplementedMetadataUseCase: MetadataUseCase { func resolve(track: Track) async -> Track? { nil } func resolveCandidates(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { false } } diff --git a/Sources/Entity/Config/DecodeEffectConfig.swift b/Sources/Entity/Config/DecodeEffectConfig.swift index 12c7ae89..715e94ad 100644 --- a/Sources/Entity/Config/DecodeEffectConfig.swift +++ b/Sources/Entity/Config/DecodeEffectConfig.swift @@ -1,18 +1,27 @@ public struct DecodeEffectConfig { public let duration: FlexibleDouble public let charset: Set + public let processingColor: ColorStyle } extension DecodeEffectConfig: Sendable {} extension DecodeEffectConfig { - static let defaults = DecodeEffectConfig(duration: 0.8, charset: Set(CharsetName.allCases)) + static let defaults = DecodeEffectConfig( + duration: 0.8, charset: Set(CharsetName.allCases), processingColor: .solid("#4ADE80FF")) } extension DecodeEffectConfig: Codable { + enum CodingKeys: String, CodingKey { + case duration, charset + case processingColor = "processing_color" + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) duration = try container.decodeIfPresent(FlexibleDouble.self, forKey: .duration) ?? Self.defaults.duration + processingColor = + try container.decodeIfPresent(ColorStyle.self, forKey: .processingColor) ?? Self.defaults.processingColor switch ( try? container.decodeIfPresent([CharsetName].self, forKey: .charset), try? container.decodeIfPresent(CharsetName.self, forKey: .charset) ) { diff --git a/Sources/Entity/Style/DecodeEffect.swift b/Sources/Entity/Style/DecodeEffect.swift index 9cb2a03d..725e97b9 100644 --- a/Sources/Entity/Style/DecodeEffect.swift +++ b/Sources/Entity/Style/DecodeEffect.swift @@ -1,13 +1,19 @@ public struct DecodeEffect { public let duration: Double public let charsets: Set + /// Text color used while the AI extractor is resolving title/artist + /// (cache miss): the header scrambles in this color until the API responds, + /// then settles to the resolved text in its normal color (#57). + public let processingColor: ColorStyle public init( duration: Double = 0.8, - charsets: Set = Set(CharsetName.allCases) + charsets: Set = Set(CharsetName.allCases), + processingColor: ColorStyle = .solid("#4ADE80FF") ) { self.duration = duration self.charsets = charsets + self.processingColor = processingColor } } diff --git a/Sources/Entity/TrackUpdate.swift b/Sources/Entity/TrackUpdate.swift index c73e3caa..42877e36 100644 --- a/Sources/Entity/TrackUpdate.swift +++ b/Sources/Entity/TrackUpdate.swift @@ -3,17 +3,24 @@ public struct TrackUpdate { public let artist: String? public let lyrics: LyricsContent? public let lyricsState: TrackLyricsState + /// `true` while the title/artist are being resolved by the AI extractor + /// (LLM cache miss with an AI endpoint configured). Presenters use it to + /// show a "processing" indicator during the API round-trip; it is `false` + /// for the raw, cache-hit, and resolved updates (#57). + public let aiResolving: Bool public init( title: String? = nil, artist: String? = nil, lyrics: LyricsContent? = nil, - lyricsState: TrackLyricsState = .idle + lyricsState: TrackLyricsState = .idle, + aiResolving: Bool = false ) { self.title = title self.artist = artist self.lyrics = lyrics self.lyricsState = lyricsState + self.aiResolving = aiResolving } } diff --git a/Sources/MetadataRepository/MetadataRepositoryImpl.swift b/Sources/MetadataRepository/MetadataRepositoryImpl.swift index 70455f80..40cd08d1 100644 --- a/Sources/MetadataRepository/MetadataRepositoryImpl.swift +++ b/Sources/MetadataRepository/MetadataRepositoryImpl.swift @@ -42,4 +42,8 @@ extension MetadataRepositoryImpl: MetadataRepository { Track(title: $0.title, artist: $0.artist, duration: track.duration) } } + + public func isAIMetadataCached(track: Track) async -> Bool { + await llmDataStore.read(title: track.title, artist: track.artist) != nil + } } diff --git a/Sources/MetadataUseCase/MetadataUseCaseImpl.swift b/Sources/MetadataUseCase/MetadataUseCaseImpl.swift index 70542757..24737689 100644 --- a/Sources/MetadataUseCase/MetadataUseCaseImpl.swift +++ b/Sources/MetadataUseCase/MetadataUseCaseImpl.swift @@ -16,4 +16,8 @@ extension MetadataUseCaseImpl: MetadataUseCase { public func resolveCandidates(track: Track) async -> [Track] { await repository.resolve(track: track) } + + public func isAIMetadataCached(track: Track) async -> Bool { + await repository.isAIMetadataCached(track: track) + } } diff --git a/Sources/Presenters/Track/HeaderPresenter.swift b/Sources/Presenters/Track/HeaderPresenter.swift index 8769d77c..d6dd0073 100644 --- a/Sources/Presenters/Track/HeaderPresenter.swift +++ b/Sources/Presenters/Track/HeaderPresenter.swift @@ -16,6 +16,12 @@ public final class HeaderPresenter: ObservableObject { // state (#275). @Published public private(set) var titlePhase: RevealPhase = .idle @Published public private(set) var artistPhase: RevealPhase = .idle + // Effective foreground colors. They equal the configured title/artist + // colors except while the AI extractor is resolving (cache miss), when both + // switch to `decodeEffect.processingColor` and the header scrambles in it, + // then settle back to the normal color on the resolved text (#57). + @Published public private(set) var titleColor: ColorStyle = .solid("#FFFFFFD9") + @Published public private(set) var artistColor: ColorStyle = .solid("#FFFFFFD9") public private(set) var titleStyle: TextAppearance = .init() public private(set) var artistStyle: TextAppearance = .init() @@ -27,6 +33,7 @@ public final class HeaderPresenter: ObservableObject { private var titleTarget: String? private var artistTarget: String? private var artworkData: Data? + private var processingColor: ColorStyle = .solid("#4ADE80FF") private var cancellables: Set = [] @Dependency(\.trackInteractor) private var interactor @@ -38,6 +45,9 @@ public final class HeaderPresenter: ObservableObject { let style = interactor.textLayout titleStyle = style.title artistStyle = style.artist + titleColor = style.title.color + artistColor = style.artist.color + processingColor = config.processingColor artworkSize = interactor.artworkStyle.size artworkOpacity = interactor.artworkStyle.opacity titleEffect = DecodeEffectState(config: config) @@ -67,8 +77,12 @@ public final class HeaderPresenter: ObservableObject { extension HeaderPresenter { private func receive(_ update: TrackUpdate) { - revealTitle(update.title) - revealArtist(update.artist) + guard update.aiResolving else { + revealTitle(update.title) + revealArtist(update.artist) + return + } + startProcessing(title: update.title, artist: update.artist) } private func receiveArtwork(_ data: Data?) { @@ -78,6 +92,7 @@ extension HeaderPresenter { } private func revealTitle(_ text: String?) { + titleColor = titleStyle.color guard let text else { titleTarget = nil titlePhase = .idle @@ -97,6 +112,7 @@ extension HeaderPresenter { } private func revealArtist(_ text: String?) { + artistColor = artistStyle.color guard let text else { artistTarget = nil artistPhase = .idle @@ -115,3 +131,36 @@ extension HeaderPresenter { } } } + +extension HeaderPresenter { + /// Switches the header into the AI-processing state: both fields scramble + /// indefinitely in `processingColor` until the resolved update arrives and + /// `revealTitle` / `revealArtist` settle them. `*Target` is cleared so the + /// settle is never deduped away even when the AI result equals the raw text. + private func startProcessing(title: String?, artist: String?) { + startProcessingTitle(title) + startProcessingArtist(artist) + } + + private func startProcessingTitle(_ text: String?) { + guard let effect = titleEffect, let text, !text.isEmpty else { return } + titleTarget = nil + titleColor = processingColor + titlePhase = .revealing + effect.onUpdate = { [weak self] displayText in + self?.displayTitle = displayText + } + effect.startLoading(placeholderLength: text.count) + } + + private func startProcessingArtist(_ text: String?) { + guard let effect = artistEffect, let text, !text.isEmpty else { return } + artistTarget = nil + artistColor = processingColor + artistPhase = .revealing + effect.onUpdate = { [weak self] displayText in + self?.displayArtist = displayText + } + effect.startLoading(placeholderLength: text.count) + } +} diff --git a/Sources/TrackInteractor/TrackInteractorImpl.swift b/Sources/TrackInteractor/TrackInteractorImpl.swift index 26cc7a14..2ec777d8 100644 --- a/Sources/TrackInteractor/TrackInteractorImpl.swift +++ b/Sources/TrackInteractor/TrackInteractorImpl.swift @@ -142,6 +142,7 @@ extension TrackInteractorImpl { let metadata = metadataService let lyrics = lyricsService let clock = self.clock + let aiConfigured = configService.appStyle.ai != nil return Just(loading) .append( @@ -159,6 +160,25 @@ extension TrackInteractorImpl { return } + // AI cache miss with an endpoint configured means + // `resolveCandidates` will make a live API call — signal the + // header to show its processing indicator for the round-trip (#57). + if aiConfigured, + await metadata.isAIMetadataCached(track: rawTrack) == false + { + guard !Task.isCancelled else { + unsafeSubject.send(completion: .finished) + return + } + unsafeSubject.send( + TrackUpdate( + title: title, + artist: artist, + lyricsState: .loading, + aiResolving: true + )) + } + let candidates = await metadata.resolveCandidates(track: rawTrack) guard !Task.isCancelled else { unsafeSubject.send(completion: .finished) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 7243b12c..68e69e40 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.14.2 +2.15.0 diff --git a/Sources/Views/Header/HeaderView.swift b/Sources/Views/Header/HeaderView.swift index 1bf5d76c..63785c52 100644 --- a/Sources/Views/Header/HeaderView.swift +++ b/Sources/Views/Header/HeaderView.swift @@ -34,7 +34,7 @@ public struct HeaderView: View { VStack(alignment: .leading, spacing: presenter.titleStyle.spacing) { Text(presenter.displayTitle) .font(resolver.font(from: presenter.titleStyle)) - .foregroundStyle(resolver.shapeStyle(from: presenter.titleStyle.color)) + .foregroundStyle(resolver.shapeStyle(from: presenter.titleColor)) .shadow( color: resolver.solidColor(from: presenter.titleStyle.shadow), radius: 5, x: 0, y: 1 @@ -43,7 +43,7 @@ public struct HeaderView: View { .accessibilityIdentifier("header-title") Text(presenter.displayArtist) .font(resolver.font(from: presenter.artistStyle)) - .foregroundStyle(resolver.shapeStyle(from: presenter.artistStyle.color)) + .foregroundStyle(resolver.shapeStyle(from: presenter.artistColor)) .shadow( color: resolver.solidColor(from: presenter.artistStyle.shadow), radius: 5, x: 0, y: 1 diff --git a/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift b/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift index 050e8d28..a63c14cb 100644 --- a/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift +++ b/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift @@ -80,6 +80,7 @@ spacing = 6.0 [text.decode_effect] charset = \(charsetPlaceholder) duration = 0.8 +processing_color = '#4ADE80' [text.default] color = '#FFFFFFD9' @@ -176,7 +177,8 @@ spacing = 6.0 }, "decode_effect" : { "charset" : "\(charsetPlaceholder)", - "duration" : 0.8 + "duration" : 0.8, + "processing_color" : "#4ADE80" }, "default" : { "color" : "#FFFFFFD9", @@ -242,6 +244,7 @@ spacing = 6.0 decoded.text.highlight.color == .gradient(["#B8942DFF", "#EDCF73FF", "#FFEB99FF", "#CCA64DFF", "#A68038FF"])) #expect(decoded.text.decodeEffect.charset == Set(CharsetName.allCases)) + #expect(decoded.text.decodeEffect.processingColor == .solid("#4ADE80FF")) #expect(decoded.ai == nil) #expect(decoded.wallpaper == nil) } diff --git a/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift b/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift index 8463630d..f7d21f0e 100644 --- a/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift +++ b/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift @@ -99,6 +99,34 @@ struct ConfigRepositoryTests { } } + @Test("decode_effect processing_color flows through to DecodeEffect") + func decodeEffectProcessingColor() { + let config = makeAppConfig(text: ["decode_effect": ["processing_color": "#FF00FF"]]) + let result = ConfigLoadResult(config: config, configDir: "/tmp") + + withDependencies { + $0.configDataSource = StubConfigDataSource(loadResult: result) + } operation: { + let repo = ConfigRepositoryImpl() + let style = repo.loadAppStyle() + #expect(style.text.decodeEffect.processingColor == .solid("#FF00FFFF")) + } + } + + @Test("decode_effect processing_color defaults to green when absent") + func decodeEffectProcessingColorDefault() { + let config = makeAppConfig() + let result = ConfigLoadResult(config: config, configDir: "/tmp") + + withDependencies { + $0.configDataSource = StubConfigDataSource(loadResult: result) + } operation: { + let repo = ConfigRepositoryImpl() + let style = repo.loadAppStyle() + #expect(style.text.decodeEffect.processingColor == .solid("#4ADE80FF")) + } + } + @Test("ripple shape defaults to circle when [ripple] is absent") func rippleShapeDefaultsToCircle() { let config = makeAppConfig() @@ -301,12 +329,14 @@ private enum StubError: Error, LocalizedError { private func makeAppConfig( wallpaper: Any? = nil, ai: AIConfig? = nil, - ripple: [String: Any]? = nil + ripple: [String: Any]? = nil, + text: [String: Any]? = nil ) -> AppConfig { var fields = [String: Any]() wallpaper.map { fields["wallpaper"] = $0 } ai.map { fields["ai"] = ["endpoint": $0.endpoint, "model": $0.model, "api_key": $0.apiKey] } ripple.map { fields["ripple"] = $0 } + text.map { fields["text"] = $0 } let data = try! JSONSerialization.data(withJSONObject: fields) return try! JSONDecoder().decode(AppConfig.self, from: data) } diff --git a/Tests/EntityTests/DecodeEffectConfigTests.swift b/Tests/EntityTests/DecodeEffectConfigTests.swift new file mode 100644 index 00000000..18fadacd --- /dev/null +++ b/Tests/EntityTests/DecodeEffectConfigTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import Entity + +@Suite("DecodeEffectConfig") +struct DecodeEffectConfigTests { + + private func decode(_ json: String) throws -> DecodeEffectConfig { + try JSONDecoder().decode(DecodeEffectConfig.self, from: Data(json.utf8)) + } + + // MARK: - processing_color + + @Test("decodes processing_color as a solid color") + func decodesProcessingColor() throws { + let config = try decode(##"{"processing_color": "#FF00FF"}"##) + #expect(config.processingColor == .solid("#FF00FFFF")) + } + + @Test("decodes processing_color as a gradient") + func decodesProcessingColorGradient() throws { + let config = try decode(##"{"processing_color": ["#FF0000", "#00FF00"]}"##) + #expect(config.processingColor == .gradient(["#FF0000FF", "#00FF00FF"])) + } + + @Test("falls back to the green default when processing_color is absent") + func defaultsProcessingColor() throws { + let config = try decode(#"{"duration": 1.0}"#) + #expect(config.processingColor == DecodeEffectConfig.defaults.processingColor) + #expect(config.processingColor == .solid("#4ADE80FF")) + } + + @Test("default processing_color survives a full empty object decode") + func defaultsOnEmptyObject() throws { + let config = try decode("{}") + #expect(config.processingColor == .solid("#4ADE80FF")) + #expect(config.duration.value == 0.8) + } + + // MARK: - encode round-trip + + @Test("encodes processing_color under the snake_case key") + func encodesProcessingColorKey() throws { + let data = try JSONEncoder().encode(DecodeEffectConfig.defaults) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("processing_color")) + } + + @Test("encode then decode preserves a custom processing_color") + func roundTripProcessingColor() throws { + let original = try decode(##"{"processing_color": "#123456"}"##) + let data = try JSONEncoder().encode(original) + let restored = try JSONDecoder().decode(DecodeEffectConfig.self, from: data) + #expect(restored.processingColor == original.processingColor) + #expect(restored.processingColor == .solid("#123456FF")) + } +} diff --git a/Tests/LyricsUseCaseTests/LyricsSearchServiceTests.swift b/Tests/LyricsUseCaseTests/LyricsSearchServiceTests.swift index 3b4dae27..2e6a80a7 100644 --- a/Tests/LyricsUseCaseTests/LyricsSearchServiceTests.swift +++ b/Tests/LyricsUseCaseTests/LyricsSearchServiceTests.swift @@ -128,6 +128,7 @@ struct LyricsSearchServiceTests { private struct StubMetadataRepository: MetadataRepository { let candidates: [Track] func resolve(track: Track) async -> [Track] { candidates } + func isAIMetadataCached(track: Track) async -> Bool { false } } private struct TrackingMetadataRepository: MetadataRepository { @@ -136,6 +137,7 @@ private struct TrackingMetadataRepository: MetadataRepository { onResolve() return [] } + func isAIMetadataCached(track: Track) async -> Bool { false } } private struct StubLyricsCache: LyricsDataStore { diff --git a/Tests/LyricsUseCaseTests/LyricsServiceTests.swift b/Tests/LyricsUseCaseTests/LyricsServiceTests.swift index 763dc2c6..6eb487d3 100644 --- a/Tests/LyricsUseCaseTests/LyricsServiceTests.swift +++ b/Tests/LyricsUseCaseTests/LyricsServiceTests.swift @@ -75,4 +75,5 @@ private struct MockLyricsRepository: LyricsRepository { private struct MockMetadataRepository: MetadataRepository { let candidates: [Track] func resolve(track: Track) async -> [Track] { candidates } + func isAIMetadataCached(track: Track) async -> Bool { false } } diff --git a/Tests/MetadataRepositoryTests/MetadataRepositoryTests.swift b/Tests/MetadataRepositoryTests/MetadataRepositoryTests.swift index 1bfb3f67..67525509 100644 --- a/Tests/MetadataRepositoryTests/MetadataRepositoryTests.swift +++ b/Tests/MetadataRepositoryTests/MetadataRepositoryTests.swift @@ -218,6 +218,44 @@ struct TypeConversionTests { } } +// MARK: - isAIMetadataCached + +@Suite("isAIMetadataCached") +struct IsAIMetadataCachedTests { + @Test("returns true when the LLM cache holds a value") + func cachedReturnsTrue() async { + await withDependencies { + $0.llmMetadataDataStore = StubMetadataDataStore(result: Track(title: "Cached", artist: "Artist")) + $0.musicBrainzMetadataDataStore = StubMetadataDataStore(result: nil) + $0.llmMetadataDataSource = StubDataSource(candidates: []) + $0.musicBrainzMetadataDataSource = StubDataSource(candidates: []) + $0.regexMetadataDataSource = StubDataSource(candidates: []) + } operation: { + let repo = MetadataRepositoryImpl() + let cached = await repo.isAIMetadataCached(track: Track(title: "raw", artist: "raw")) + #expect(cached) + } + } + + @Test("returns false when the LLM cache is empty — no DataSource is consulted") + func uncachedReturnsFalse() async { + let llmTracker = CallTracker() + await withDependencies { + $0.llmMetadataDataStore = StubMetadataDataStore(result: nil) + $0.musicBrainzMetadataDataStore = StubMetadataDataStore(result: nil) + $0.llmMetadataDataSource = TrackingDataSource(tracker: llmTracker) + $0.musicBrainzMetadataDataSource = StubDataSource(candidates: []) + $0.regexMetadataDataSource = StubDataSource(candidates: []) + } operation: { + let repo = MetadataRepositoryImpl() + let cached = await repo.isAIMetadataCached(track: Track(title: "raw", artist: "raw")) + #expect(!cached) + let llmCalled = await llmTracker.called + #expect(!llmCalled, "isAIMetadataCached must only read the cache, never invoke the DataSource") + } + } +} + // MARK: - Test helpers private struct StubMetadataDataStore: MetadataDataStore { diff --git a/Tests/MetadataUseCaseTests/MetadataUseCaseTests.swift b/Tests/MetadataUseCaseTests/MetadataUseCaseTests.swift index 6d39418b..c7dde4eb 100644 --- a/Tests/MetadataUseCaseTests/MetadataUseCaseTests.swift +++ b/Tests/MetadataUseCaseTests/MetadataUseCaseTests.swift @@ -76,11 +76,35 @@ struct MetadataUseCaseTests { #expect(resolved == allCandidates.first) } } + + @Test("isAIMetadataCached delegates to repository: true") + func isAICachedDelegatesTrue() async { + await withDependencies { + $0.metadataRepository = MockMetadataRepository(candidates: [], aiCached: true) + } operation: { + let useCase = MetadataUseCaseImpl() + let cached = await useCase.isAIMetadataCached(track: Track(title: "raw", artist: "raw")) + #expect(cached) + } + } + + @Test("isAIMetadataCached delegates to repository: false") + func isAICachedDelegatesFalse() async { + await withDependencies { + $0.metadataRepository = MockMetadataRepository(candidates: [], aiCached: false) + } operation: { + let useCase = MetadataUseCaseImpl() + let cached = await useCase.isAIMetadataCached(track: Track(title: "raw", artist: "raw")) + #expect(!cached) + } + } } // MARK: - Mocks private struct MockMetadataRepository: MetadataRepository { let candidates: [Track] + var aiCached: Bool = false func resolve(track: Track) async -> [Track] { candidates } + func isAIMetadataCached(track: Track) async -> Bool { aiCached } } diff --git a/Tests/PresentersTests/HeaderPresenterTests.swift b/Tests/PresentersTests/HeaderPresenterTests.swift index 502b3bee..b9d74369 100644 --- a/Tests/PresentersTests/HeaderPresenterTests.swift +++ b/Tests/PresentersTests/HeaderPresenterTests.swift @@ -31,6 +31,14 @@ private func waitForReveal(_ presenter: HeaderPresenter, timeout: Duration = .se } } +@MainActor +private func waitUntil(timeout: Duration = .seconds(3), _ condition: @MainActor () -> Bool) async { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } +} + // MARK: - Tests @Suite("HeaderPresenter") @@ -140,6 +148,108 @@ struct HeaderPresenterTests { } } + @Suite("AI processing indicator") + struct AIProcessing { + private static let processing: ColorStyle = .solid("#FF00FF") + private static let titleColor: ColorStyle = .solid("#112233") + private static let artistColor: ColorStyle = .solid("#445566") + + private static func makeStub(_ subject: PassthroughSubject) -> StubTrackInteractor { + StubTrackInteractor( + trackChangePublisher: subject.eraseToAnyPublisher(), + decodeEffectConfig: .init(duration: 0, processingColor: processing), + textLayout: TextLayout( + title: TextAppearance(color: titleColor), + artist: TextAppearance(color: artistColor) + ) + ) + } + + @MainActor + @Test("aiResolving update scrambles both fields in processingColor without settling") + func processingState() async { + let subject = PassthroughSubject() + + await withDependencies { + $0.trackInteractor = Self.makeStub(subject) + // A never-advanced TestClock leaves the indefinite scramble loop + // suspended at its first sleep — the synchronous first frame still + // renders, so the sustained-state assertions hold without the loop + // busy-spinning on the unimplemented default clock. + $0.continuousClock = TestClock() + } operation: { + let presenter = HeaderPresenter() + presenter.start() + + subject.send( + TrackUpdate(title: "Song", artist: "Artist", lyricsState: .loading, aiResolving: true)) + await waitUntil { presenter.titleColor == Self.processing } + + #expect(presenter.titleColor == Self.processing) + #expect(presenter.artistColor == Self.processing) + #expect(presenter.titlePhase == .revealing) + #expect(presenter.artistPhase == .revealing) + #expect(presenter.displayTitle != " ") + #expect(presenter.displayArtist != " ") + + // Sustained: the scramble never auto-settles to .revealed on its own. + try? await Task.sleep(for: .milliseconds(120)) + #expect(presenter.titlePhase == .revealing) + #expect(presenter.titleColor == Self.processing) + + presenter.stop() + } + } + + @MainActor + @Test("resolved update after processing settles to the normal color and final text") + func settlesAfterProcessing() async { + let subject = PassthroughSubject() + + await withDependencies { + $0.trackInteractor = Self.makeStub(subject) + $0.continuousClock = TestClock() + } operation: { + let presenter = HeaderPresenter() + presenter.start() + + subject.send( + TrackUpdate(title: "Song", artist: "Artist", lyricsState: .loading, aiResolving: true)) + await waitUntil { presenter.titleColor == Self.processing } + + subject.send(TrackUpdate(title: "AI Song", artist: "AI Artist", lyricsState: .resolved)) + await waitForReveal(presenter) + + #expect(presenter.displayTitle == "AI Song") + #expect(presenter.displayArtist == "AI Artist") + #expect(presenter.titleColor == Self.titleColor) + #expect(presenter.artistColor == Self.artistColor) + #expect(presenter.titlePhase == .revealed) + #expect(presenter.artistPhase == .revealed) + } + } + + @MainActor + @Test("non-AI update reveals in the normal color, never the processing color") + func normalUpdateUsesNormalColor() async { + let subject = PassthroughSubject() + + await withDependencies { + $0.trackInteractor = Self.makeStub(subject) + } operation: { + let presenter = HeaderPresenter() + presenter.start() + + subject.send(TrackUpdate(title: "Song", artist: "Artist", lyricsState: .resolved)) + await waitForReveal(presenter) + + #expect(presenter.titleColor == Self.titleColor) + #expect(presenter.artistColor == Self.artistColor) + #expect(presenter.titleColor != Self.processing) + } + } + } + @Suite("stop") struct Stop { @MainActor diff --git a/Tests/TrackHandlerTests/TrackHandlerImplTests.swift b/Tests/TrackHandlerTests/TrackHandlerImplTests.swift index 0ac698e6..3b8e4c64 100644 --- a/Tests/TrackHandlerTests/TrackHandlerImplTests.swift +++ b/Tests/TrackHandlerTests/TrackHandlerImplTests.swift @@ -216,6 +216,7 @@ private struct StubMetadataUseCase: MetadataUseCase { let handler: @Sendable (Track) async -> [Track] func resolve(track: Track) async -> Track? { await handler(track).first } func resolveCandidates(track: Track) async -> [Track] { await handler(track) } + func isAIMetadataCached(track: Track) async -> Bool { false } } private struct StubLyricsUseCase: LyricsUseCase { diff --git a/Tests/TrackInteractorTests/TrackInteractorAIProcessingTests.swift b/Tests/TrackInteractorTests/TrackInteractorAIProcessingTests.swift new file mode 100644 index 00000000..11070316 --- /dev/null +++ b/Tests/TrackInteractorTests/TrackInteractorAIProcessingTests.swift @@ -0,0 +1,160 @@ +@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 } +} + +/// Metadata stub whose `isAIMetadataCached` answer is configurable so the +/// processing-indicator branch in `resolveTrack` can be exercised both ways. +private struct ConfigurableMetadataUseCase: MetadataUseCase, Sendable { + let aiCached: Bool + let candidates: [Track] + func resolve(track: Track) async -> Track? { candidates.first } + func resolveCandidates(track: Track) async -> [Track] { candidates } + func isAIMetadataCached(track: Track) async -> Bool { aiCached } +} + +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: - Collector + +private final class UpdateCollector: @unchecked Sendable { + private let lock = NSLock() + private var storage: [TrackUpdate] = [] + + func append(_ update: TrackUpdate) { + lock.withLock { storage.append(update) } + } + + var updates: [TrackUpdate] { + lock.withLock { storage } + } + + func contains(where predicate: (TrackUpdate) -> Bool) -> Bool { + lock.withLock { storage.contains(where: predicate) } + } +} + +// MARK: - Helpers + +private let aiEndpoint = AIEndpoint(endpoint: "https://api.example.com", model: "gpt-4", apiKey: "sk-test") + +private func makeInteractor( + playback: StubPlaybackUseCase, + aiConfigured: Bool, + aiCached: Bool +) -> TrackInteractorImpl { + withDependencies { + // ImmediateClock collapses the 300ms debounce so the whole resolveTrack + // pipeline runs to completion without manual clock advancement — these + // tests assert on which updates are emitted, not on their timing. + $0.continuousClock = ImmediateClock() + $0.playbackUseCase = playback + $0.metadataUseCase = ConfigurableMetadataUseCase(aiCached: aiCached, candidates: []) + $0.lyricsUseCase = StubLyricsUseCase() + $0.configUseCase = StubConfigUseCase(style: AppStyle(ai: aiConfigured ? aiEndpoint : nil)) + } operation: { + TrackInteractorImpl() + } +} + +private func sendTrack(_ playback: StubPlaybackUseCase) { + playback.subject.send( + NowPlaying( + title: "Song", artist: "Artist", artworkData: nil, + duration: nil, rawElapsed: nil, playbackRate: 1, timestamp: nil)) +} + +private func waitUntil(timeout: Duration = .seconds(3), _ condition: @Sendable () -> Bool) async { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } +} + +// MARK: - Tests + +@Suite("TrackInteractor AI processing indicator", .serialized) +struct TrackInteractorAIProcessingTests { + + @Test("emits aiResolving update when AI is configured and the LLM cache misses") + func emitsWhenConfiguredAndMissed() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback, aiConfigured: true, aiCached: false) + + let collector = UpdateCollector() + let cancellable = interactor.trackChange.sink { collector.append($0) } + defer { cancellable.cancel() } + + sendTrack(playback) + await waitUntil { collector.contains(where: \.aiResolving) } + + #expect( + collector.contains { $0.aiResolving && $0.title == "Song" && $0.artist == "Artist" }, + "an aiResolving update should be emitted on cache miss with AI configured") + } + + @Test("does not emit aiResolving update when the LLM cache hits") + func noEmitWhenCached() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback, aiConfigured: true, aiCached: true) + + let collector = UpdateCollector() + let cancellable = interactor.trackChange.sink { collector.append($0) } + defer { cancellable.cancel() } + + sendTrack(playback) + await waitUntil { collector.contains { $0.lyricsState == .notFound || $0.lyricsState == .resolved } } + + #expect(!collector.contains(where: \.aiResolving), "cache hit must not show the processing indicator") + } + + @Test("does not emit aiResolving update when no AI endpoint is configured") + func noEmitWhenAIAbsent() async throws { + let playback = StubPlaybackUseCase() + let interactor = makeInteractor(playback: playback, aiConfigured: false, aiCached: false) + + let collector = UpdateCollector() + let cancellable = interactor.trackChange.sink { collector.append($0) } + defer { cancellable.cancel() } + + sendTrack(playback) + await waitUntil { collector.contains { $0.lyricsState == .notFound || $0.lyricsState == .resolved } } + + #expect(!collector.contains(where: \.aiResolving), "no AI endpoint means no processing indicator") + } +} diff --git a/Tests/TrackInteractorTests/TrackInteractorArtworkTests.swift b/Tests/TrackInteractorTests/TrackInteractorArtworkTests.swift index 8c4bd799..bbd9ccea 100644 --- a/Tests/TrackInteractorTests/TrackInteractorArtworkTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorArtworkTests.swift @@ -29,6 +29,7 @@ private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { private struct InstantMetadataUseCase: MetadataUseCase, Sendable { func resolve(track: Track) async -> Track? { nil } func resolveCandidates(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { true } } private struct StubLyricsUseCase: LyricsUseCase, Sendable { diff --git a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift index 12cbf335..7c5967a1 100644 --- a/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorPlaybackPositionTests.swift @@ -29,6 +29,7 @@ private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { private struct InstantMetadataUseCase: MetadataUseCase, Sendable { func resolve(track: Track) async -> Track? { nil } func resolveCandidates(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { true } } private struct StubLyricsUseCase: LyricsUseCase, Sendable { diff --git a/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift b/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift index dfd968e0..1bc6900f 100644 --- a/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorRaceTests.swift @@ -29,6 +29,7 @@ private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { private struct InstantMetadataUseCase: MetadataUseCase, Sendable { func resolve(track: Track) async -> Track? { nil } func resolveCandidates(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { true } } private struct StubLyricsUseCase: LyricsUseCase, Sendable { diff --git a/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift b/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift index eb417f26..2e13645e 100644 --- a/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift +++ b/Tests/TrackInteractorTests/TrackInteractorStyleTests.swift @@ -29,6 +29,7 @@ private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { private struct InstantMetadataUseCase: MetadataUseCase, Sendable { func resolve(track: Track) async -> Track? { nil } func resolveCandidates(track: Track) async -> [Track] { [] } + func isAIMetadataCached(track: Track) async -> Bool { true } } private struct StubLyricsUseCase: LyricsUseCase, Sendable { From baabf7c9252ae05135758fc27091007e7c95b011 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 15 Jun 2026 11:21:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(#57):=20processing=5Fcolor=20=E3=81=AE?= =?UTF-8?q?=E6=97=A2=E5=AE=9A=E5=80=A4=E8=A1=A8=E8=A8=98=E3=82=92=20#4ADE8?= =?UTF-8?q?0FF=20=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README の他の色既定値(highlight グラデーション)が 8 桁 RGBA 形式で、 Entity の既定値も .solid("#4ADE80FF") であるため、表記を統一する。 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d5b2615..5da288e6 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Controls the matrix-style text reveal animation. |---|---|---|---| | `duration` | number | `0.8` | Animation duration in seconds | | `charset` | string / array | all | Character sets for scramble: `"latin"`, `"cyrillic"`, `"greek"`, `"symbols"`, `"cjk"`. Single string or array | -| `processing_color` | string / array | `"#4ADE80"` (green) | Title/artist color while the AI extractor is resolving (LLM cache miss). The header scrambles in this color until the API responds, then settles to the resolved text in its normal color. Solid hex or gradient array. Only applies when an `[ai]` endpoint is configured | +| `processing_color` | string / array | `"#4ADE80FF"` (green) | Title/artist color while the AI extractor is resolving (LLM cache miss). The header scrambles in this color until the API responds, then settles to the resolved text in its normal color. Solid hex or gradient array. Only applies when an `[ai]` endpoint is configured | ### `[artwork]` From 85e690a37c1101161cb626ec1f4002580a52b893 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 15 Jun 2026 11:21:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(#57):=20AI=E5=87=A6=E7=90=86=E4=B8=AD?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B8=E3=82=B1=E3=83=BC=E3=82=BF=E3=81=AE?= =?UTF-8?q?=20sustained=20=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=20TestClock?= =?UTF-8?q?=20=E3=81=A7=E6=B1=BA=E5=AE=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定の Task.sleep(120ms) による実時間待機を廃止し、注入した TestClock を advance してスクランブルループを複数フレーム進めても .revealing のまま settle しないことを決定的に検証する。CI のタイミング非依存になり、 ループの継続描画も網羅する。 --- Tests/PresentersTests/HeaderPresenterTests.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/PresentersTests/HeaderPresenterTests.swift b/Tests/PresentersTests/HeaderPresenterTests.swift index b9d74369..105a5c97 100644 --- a/Tests/PresentersTests/HeaderPresenterTests.swift +++ b/Tests/PresentersTests/HeaderPresenterTests.swift @@ -169,14 +169,14 @@ struct HeaderPresenterTests { @Test("aiResolving update scrambles both fields in processingColor without settling") func processingState() async { let subject = PassthroughSubject() + // An injected TestClock drives the indefinite scramble loop deterministically: + // the synchronous first frame renders immediately, then advancing the clock + // runs further frames without any real-time wait. + let clock = TestClock() await withDependencies { $0.trackInteractor = Self.makeStub(subject) - // A never-advanced TestClock leaves the indefinite scramble loop - // suspended at its first sleep — the synchronous first frame still - // renders, so the sustained-state assertions hold without the loop - // busy-spinning on the unimplemented default clock. - $0.continuousClock = TestClock() + $0.continuousClock = clock } operation: { let presenter = HeaderPresenter() presenter.start() @@ -192,8 +192,9 @@ struct HeaderPresenterTests { #expect(presenter.displayTitle != " ") #expect(presenter.displayArtist != " ") - // Sustained: the scramble never auto-settles to .revealed on its own. - try? await Task.sleep(for: .milliseconds(120)) + // Sustained: advancing the clock through several scramble frames must keep + // the loop scrambling in the processing color — it never auto-settles. + await clock.advance(by: .milliseconds(300)) #expect(presenter.titlePhase == .revealing) #expect(presenter.titleColor == Self.processing)