diff --git a/Sources/Cairn/App/CairnApp.swift b/Sources/Cairn/App/CairnApp.swift index cbc5a22..9a84d32 100644 --- a/Sources/Cairn/App/CairnApp.swift +++ b/Sources/Cairn/App/CairnApp.swift @@ -8,6 +8,15 @@ struct CairnApp: App { var body: some Scene { WindowGroup { ContentView() + .task { + // CairnEnvironment自体の本格的なDI拡張(GitHubClient/AuthenticationState等の保持)は + // Phase5で行う。Phase4時点ではここで都度生成し、起動時1回だけ軽量更新を実行する。 + let scheduler = CacheRefreshScheduler( + gitHubClient: GitHubClient(), + modelContext: environment.modelContainer.mainContext + ) + await scheduler.refreshTopCategoryOnLaunch() + } } .modelContainer(environment.modelContainer) } diff --git a/Sources/Cairn/Cache/CacheRefreshScheduler.swift b/Sources/Cairn/Cache/CacheRefreshScheduler.swift new file mode 100644 index 0000000..a7c9740 --- /dev/null +++ b/Sources/Cairn/Cache/CacheRefreshScheduler.swift @@ -0,0 +1,176 @@ +import Defaults +import Foundation +import SwiftData + +/// 起動時の軽量トップカテゴリ更新と、詳細画面遷移時のTTLベース遅延再検証を担う。 +/// +/// `SearchViewModel`とは責務が異なる: +/// - `SearchViewModel`: ユーザーの検索クエリ入力起因のAPI即応検索 +/// - `CacheRefreshScheduler`: (a)起動時1回の「前回トップカテゴリ」更新、(b)詳細画面遷移時の +/// Releases/READMEのTTLベース遅延再検証 +/// +/// 両者は同じ`GitHubClientProtocol`/`ModelContext`を共有するが呼び出しタイミングが異なるため、 +/// 1つのクラスに寄せず責務を分離する。 +@MainActor +final class CacheRefreshScheduler { + static let releaseTTL: TimeInterval = 60 * 60 + static let readmeTTL: TimeInterval = 60 * 60 * 24 + + private let gitHubClient: GitHubClientProtocol + private let modelContext: ModelContext + private let noiseFilter: NoiseFiltering + private let classifier: CategoryClassifying + private let now: @Sendable () -> Date + /// カテゴリからGitHub検索に使うキーワードを引く。デフォルトは`CategoryKeywords`の + /// 代表キーワード(先頭の1件)を使う。`CategoryClassifier`のスコアリングと同じ辞書を参照するため、 + /// 検索結果が実際にそのカテゴリへ分類されやすいキーワードになる。 + private let keywordProvider: @Sendable (Category) -> String + + init( + gitHubClient: GitHubClientProtocol, + modelContext: ModelContext, + noiseFilter: NoiseFiltering = NoiseFilter(), + classifier: CategoryClassifying = CategoryClassifier(), + now: @escaping @Sendable () -> Date = { Date() }, + keywordProvider: @escaping @Sendable (Category) -> String = { category in + CategoryKeywords.loadBundled().keywordsByCategory[category]?.first ?? category.rawValue + } + ) { + self.gitHubClient = gitHubClient + self.modelContext = modelContext + self.noiseFilter = noiseFilter + self.classifier = classifier + self.now = now + self.keywordProvider = keywordProvider + } + + /// アプリ起動時に1回だけ呼ぶ。前回トップカテゴリをDefaultsから読み出し、 + /// そのカテゴリに該当する検索を1回だけ実行してキャッシュへ反映する。 + /// 未設定(初回起動等)なら何もしない。 + /// + /// 前回トップカテゴリの書き込み(カテゴリ選択時のDefaults更新)はPhase5のDiscovery UIで追加する。 + /// Phase4時点ではこのメソッドは読み出し専用として動作する。 + func refreshTopCategoryOnLaunch() async { + guard let topCategory = Defaults[.lastTopCategory] else { return } + + let query = GitHubSearchQueryBuilder.build(from: keywordProvider(topCategory)) + do { + let response = try await gitHubClient.searchRepositories(query: query, page: 1) + for repository in response.items { + let releases = + (try? await gitHubClient.releases(owner: repository.owner.login, repo: repository.name)) ?? [] + guard noiseFilter.shouldInclude(repository: repository, releases: releases) else { continue } + let classification = classifier.classify(repository: repository, readme: nil) + upsert(repository: repository, releases: releases, classification: classification) + } + try? modelContext.save() + } catch { + // 起動時の軽量更新失敗はサイレントに握りつぶす。キャッシュ済みデータで表示継続できるため。 + } + } + + /// 詳細画面表示時に呼ぶ。Releases/READMEそれぞれのTTLを個別判定し、必要な分だけ取得して + /// キャッシュへ反映する。 + /// + /// `CachedRepository`は`Sendable`でないSwiftDataモデルのため、`async let`による並行実行は + /// せず順に`await`する(どちらもTTL内ならほぼ即時returnするため、体感上の影響は小さい)。 + func revalidateDetailIfNeeded(for repository: CachedRepository) async { + await revalidateReleasesIfNeeded(repository) + await revalidateReadmeIfNeeded(repository) + } + + private func revalidateReleasesIfNeeded(_ repository: CachedRepository) async { + if let last = repository.lastReleaseCheckedAt, now().timeIntervalSince(last) < Self.releaseTTL { + return + } + guard let (owner, repo) = splitOwnerRepo(repository.fullName) else { return } + do { + let releases = try await gitHubClient.releases(owner: owner, repo: repo) + repository.releases = releases.map { + CachedRelease( + tagName: $0.tagName, + assetNames: $0.assets.map(\.name), + assetURLs: $0.assets.map { $0.browserDownloadURL.absoluteString } + ) + } + repository.lastReleaseCheckedAt = now() + try? modelContext.save() + } catch { + // 失敗時はTTLを更新しない(次回訪問時に再取得を試せるようにする)。 + } + } + + private func revalidateReadmeIfNeeded(_ repository: CachedRepository) async { + if let last = repository.lastReadmeFetchedAt, now().timeIntervalSince(last) < Self.readmeTTL { + return + } + guard let (owner, repo) = splitOwnerRepo(repository.fullName) else { return } + do { + let readme = try await gitHubClient.readme(owner: owner, repo: repo) + let classification = classifier.classify( + topics: repository.topics, + name: repo, + readme: readme + ) + repository.category = classification.category.rawValue + repository.subTags = classification.subTags + repository.lastReadmeFetchedAt = now() + try? modelContext.save() + } catch { + // 失敗時はTTLを更新せず、既存分類(topics/名前のみ)を維持する。 + // 更新してしまうと24時間ずっと不正確な分類のまま固定されてしまうため、 + // 次回訪問時に再試行できるようにする。 + } + } + + private func upsert( + repository: Repository, + releases: [Release], + classification: ClassificationResult + ) { + let repositoryID = repository.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.githubId == repositoryID } + ) + let cachedReleases = releases.map { + CachedRelease( + tagName: $0.tagName, + assetNames: $0.assets.map(\.name), + assetURLs: $0.assets.map { $0.browserDownloadURL.absoluteString } + ) + } + + if let existing = try? modelContext.fetch(descriptor).first { + existing.fullName = repository.fullName + existing.topics = repository.topics + existing.starCount = repository.stargazersCount + existing.primaryLanguage = repository.language ?? "" + existing.htmlURL = repository.htmlURL.absoluteString + existing.category = classification.category.rawValue + existing.subTags = classification.subTags + existing.lastFetchedAt = now() + existing.releases = cachedReleases + return + } + + let new = CachedRepository( + githubId: repository.id, + fullName: repository.fullName, + topics: repository.topics, + starCount: repository.stargazersCount, + primaryLanguage: repository.language ?? "", + htmlURL: repository.htmlURL.absoluteString, + category: classification.category.rawValue, + subTags: classification.subTags, + lastFetchedAt: now(), + releases: cachedReleases + ) + modelContext.insert(new) + } + + private func splitOwnerRepo(_ fullName: String) -> (owner: String, repo: String)? { + let parts = fullName.split(separator: "/", maxSplits: 1) + guard parts.count == 2 else { return nil } + return (String(parts[0]), String(parts[1])) + } +} diff --git a/Sources/Cairn/Cache/CachedRepository.swift b/Sources/Cairn/Cache/CachedRepository.swift index 25385bd..eaf08cf 100644 --- a/Sources/Cairn/Cache/CachedRepository.swift +++ b/Sources/Cairn/Cache/CachedRepository.swift @@ -14,6 +14,7 @@ final class CachedRepository { var subTags: [String] var lastFetchedAt: Date var lastReleaseCheckedAt: Date? + var lastReadmeFetchedAt: Date? @Relationship(deleteRule: .cascade, inverse: \CachedRelease.repository) var releases: [CachedRelease] @@ -29,6 +30,7 @@ final class CachedRepository { subTags: [String] = [], lastFetchedAt: Date, lastReleaseCheckedAt: Date? = nil, + lastReadmeFetchedAt: Date? = nil, releases: [CachedRelease] = [] ) { self.githubId = githubId @@ -41,6 +43,7 @@ final class CachedRepository { self.subTags = subTags self.lastFetchedAt = lastFetchedAt self.lastReleaseCheckedAt = lastReleaseCheckedAt + self.lastReadmeFetchedAt = lastReadmeFetchedAt self.releases = releases } } diff --git a/Sources/Cairn/Cache/DefaultsKeys.swift b/Sources/Cairn/Cache/DefaultsKeys.swift new file mode 100644 index 0000000..b8a24da --- /dev/null +++ b/Sources/Cairn/Cache/DefaultsKeys.swift @@ -0,0 +1,7 @@ +import Defaults + +extension Defaults.Keys { + /// 起動時の軽量トップカテゴリ更新(`CacheRefreshScheduler.refreshTopCategoryOnLaunch()`)に使う、 + /// 直近選択されたカテゴリ。書き込み側(カテゴリ選択時の更新)はPhase5のDiscovery UIで追加する。 + static let lastTopCategory = Key("lastTopCategory") +} diff --git a/Sources/Cairn/Classification/Category.swift b/Sources/Cairn/Classification/Category.swift index 38945f5..6bdac10 100644 --- a/Sources/Cairn/Classification/Category.swift +++ b/Sources/Cairn/Classification/Category.swift @@ -1,6 +1,8 @@ +import Defaults + /// アプリのジャンル分類。rawValueは`Resources/CategoryKeywords.json`のキーと一致させる。 /// 表示用の日本語ラベルはUI層(Phase5)で追加するためここでは持たせない。 -enum Category: String, Codable, CaseIterable, Sendable { +enum Category: String, Codable, CaseIterable, Sendable, Defaults.Serializable { case developerTools case productivity case mediaCreation diff --git a/Sources/Cairn/Classification/CategoryClassifier.swift b/Sources/Cairn/Classification/CategoryClassifier.swift index 80da662..1d2432f 100644 --- a/Sources/Cairn/Classification/CategoryClassifier.swift +++ b/Sources/Cairn/Classification/CategoryClassifier.swift @@ -11,6 +11,11 @@ struct ClassificationResult: Equatable, Sendable { /// 将来的にスコアリング方式を差し替えられるようStrategyパターン化する。 protocol CategoryClassifying: Sendable { func classify(repository: Repository, readme: String?) -> ClassificationResult + + /// topics/名前/READMEを直接指定して分類する。`CachedRepository`(SwiftDataモデル)のように + /// `Repository`(APIレスポンス型)を持たない呼び出し元(`CacheRefreshScheduler`の詳細画面 + /// 再検証処理)向けの軽量オーバーロード。 + func classify(topics: [String], name: String, readme: String?) -> ClassificationResult } /// topics完全一致(3点) > リポジトリ名部分一致(2点) > README冒頭一致(1点)の @@ -26,8 +31,12 @@ struct CategoryClassifier: CategoryClassifying { } func classify(repository: Repository, readme: String?) -> ClassificationResult { - let topics = Set(repository.topics.map { $0.lowercased() }) - let nameLowercased = repository.name.lowercased() + classify(topics: repository.topics, name: repository.name, readme: readme) + } + + func classify(topics: [String], name: String, readme: String?) -> ClassificationResult { + let topicsSet = Set(topics.map { $0.lowercased() }) + let nameLowercased = name.lowercased() let readmePrefix = readme.map { String($0.prefix(Self.readmePrefixLength)).lowercased() } var bestCategory: Category? @@ -39,7 +48,7 @@ struct CategoryClassifier: CategoryClassifying { guard let categoryKeywords = keywords.keywordsByCategory[category] else { continue } var score = 0 for keyword in categoryKeywords { - if topics.contains(keyword) { score += 3 } + if topicsSet.contains(keyword) { score += 3 } if nameLowercased.contains(keyword) { score += 2 } if let readmePrefix, readmePrefix.contains(keyword) { score += 1 } } @@ -49,6 +58,6 @@ struct CategoryClassifier: CategoryClassifying { } } - return ClassificationResult(category: bestCategory ?? .other, subTags: repository.topics) + return ClassificationResult(category: bestCategory ?? .other, subTags: topics) } } diff --git a/Sources/Cairn/Features/Search/GitHubSearchQueryBuilder.swift b/Sources/Cairn/Features/Search/GitHubSearchQueryBuilder.swift new file mode 100644 index 0000000..8d4364a --- /dev/null +++ b/Sources/Cairn/Features/Search/GitHubSearchQueryBuilder.swift @@ -0,0 +1,11 @@ +/// GitHub Search API向けクエリ構築の共通ロジック。`SearchViewModel`(ユーザー入力起因の検索)と +/// `CacheRefreshScheduler`(起動時の前回トップカテゴリ検索)の両方から使われるため、 +/// ジェネリックな`SearchViewModel`に紐付けない独立した型として切り出す。 +enum GitHubSearchQueryBuilder { + /// Swift/Objective-C/Objective-C++製リポジトリのみに絞り込む固定条件。 + static let languageFilter = #"language:Swift OR language:"Objective-C" OR language:"Objective-C++""# + + static func build(from userInput: String) -> String { + "\(userInput) \(languageFilter)" + } +} diff --git a/Sources/Cairn/Features/Search/SearchViewModel.swift b/Sources/Cairn/Features/Search/SearchViewModel.swift new file mode 100644 index 0000000..d6d2821 --- /dev/null +++ b/Sources/Cairn/Features/Search/SearchViewModel.swift @@ -0,0 +1,209 @@ +import Foundation +import Observation +import SwiftData + +/// 検索クエリ入力に応じたstale-while-revalidate検索を担う。 +/// +/// `GitHubClientProtocol`/`ModelContext`をinitで直接受け取る疎結合設計。Phase5で本格的な +/// `AppEnvironment`(DIコンテナ)が整備されたら、そちらから注入するだけで差し替えられる。 +/// +/// レート制限自体は`GitHubClient`内部の`GitHubRateLimiter`が別途処理する。ここでの +/// デバウンス・重複クエリ抑制は「無駄なAPI呼び出し回数そのものを減らす」ためのUI層の責務であり、 +/// `GitHubRateLimiter`の待機ロジックとは独立している(二重の待機にはならない)。 +/// +/// `ClockType`をジェネリックにしているのは、300msデバウンス・5秒重複抑制の判定をテストで +/// 実待機なしに検証するため。本番では`ContinuousClock`を使う便利initを用意する。 +@Observable +@MainActor +final class SearchViewModel where ClockType.Duration == Duration { + private static var debounceDuration: Duration { .milliseconds(300) } + private static var duplicateSuppressionWindow: Duration { .seconds(5) } + + var queryText: String = "" { + didSet { + guard queryText != oldValue else { return } + handleQueryTextChanged() + } + } + private(set) var results: [CachedRepository] = [] + private(set) var isLoading = false + private(set) var errorMessage: String? + + private let gitHubClient: GitHubClientProtocol + private let modelContext: ModelContext + private let noiseFilter: NoiseFiltering + private let classifier: CategoryClassifying + private let clock: ClockType + + private var searchTask: Task? + private var lastSentQuery: String? + private var lastSentAt: ClockType.Instant? + + init( + gitHubClient: GitHubClientProtocol, + modelContext: ModelContext, + clock: ClockType, + noiseFilter: NoiseFiltering = NoiseFilter(), + classifier: CategoryClassifying = CategoryClassifier() + ) { + self.gitHubClient = gitHubClient + self.modelContext = modelContext + self.clock = clock + self.noiseFilter = noiseFilter + self.classifier = classifier + } + + private func handleQueryTextChanged() { + searchTask?.cancel() + let query = queryText + searchTask = Task { [weak self] in + await self?.performSearch(for: query) + } + } + + private func performSearch(for query: String) async { + // stale-while-revalidateの"stale"部分: キャッシュを即座に反映する。 + loadFromCacheImmediately(query: query) + + guard !query.isEmpty else { + // 空クエリでの全件API検索は無駄なので、キャッシュ一覧表示のみで完了する。 + return + } + + do { + try await clock.sleep(for: Self.debounceDuration) + } catch { + return // 入力が続いてキャンセルされた + } + if Task.isCancelled { return } + + let isDuplicateWithinWindow = + query == lastSentQuery + && lastSentAt.map { $0.duration(to: clock.now) < Self.duplicateSuppressionWindow } == true + if isDuplicateWithinWindow { + // 直近5秒以内に同一クエリを送信済みなら、APIは叩かずキャッシュ表示のまま据え置く。 + return + } + + await revalidateFromAPI(query: query) + } + + private func loadFromCacheImmediately(query: String) { + // SwiftDataの#Predicateでは配列(topics/subTags)への部分一致表現に限界があるため、 + // 全件取得後にSwift側でフィルタする(想定件数規模であれば許容範囲)。 + guard let all = try? modelContext.fetch(FetchDescriptor()) else { + return + } + guard !query.isEmpty else { + results = all + return + } + results = all.filter { repository in + repository.fullName.localizedCaseInsensitiveContains(query) + || repository.topics.contains { $0.localizedCaseInsensitiveContains(query) } + || repository.subTags.contains { $0.localizedCaseInsensitiveContains(query) } + } + } + + private func revalidateFromAPI(query: String) async { + isLoading = true + defer { isLoading = false } + lastSentQuery = query + lastSentAt = clock.now + + do { + let apiQuery = GitHubSearchQueryBuilder.build(from: query) + let response = try await gitHubClient.searchRepositories(query: apiQuery, page: 1) + if Task.isCancelled { return } + + var reconciled: [CachedRepository] = [] + for repository in response.items { + if Task.isCancelled { return } + // ノイズ除去(dmg/zip資産チェック含む)は一覧段階で適用する。詳細画面遷移時のみ + // 適用するより無駄なAPI呼び出しが増えるトレードオフを承知の上でのユーザー選択。 + // ダウンロードはせず、Releases一覧APIのassets[].nameを見るだけに留める。 + let releases = + (try? await gitHubClient.releases(owner: repository.owner.login, repo: repository.name)) ?? [] + guard noiseFilter.shouldInclude(repository: repository, releases: releases) else { continue } + + // README未取得のため、この段階ではtopics/名前のみでの分類になる + // (README込みの再分類は詳細画面遷移時にCacheRefreshSchedulerが行う)。 + let classification = classifier.classify(repository: repository, readme: nil) + reconciled.append(upsert(repository: repository, releases: releases, classification: classification)) + } + try? modelContext.save() + + errorMessage = nil + if !Task.isCancelled { + results = reconciled + } + } catch { + if !Task.isCancelled { + errorMessage = "検索に失敗しました" + } + } + } + + private func upsert( + repository: Repository, + releases: [Release], + classification: ClassificationResult + ) -> CachedRepository { + let repositoryID = repository.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.githubId == repositoryID } + ) + let cachedReleases = releases.map { + CachedRelease( + tagName: $0.tagName, + assetNames: $0.assets.map(\.name), + assetURLs: $0.assets.map { $0.browserDownloadURL.absoluteString } + ) + } + + if let existing = try? modelContext.fetch(descriptor).first { + existing.fullName = repository.fullName + existing.topics = repository.topics + existing.starCount = repository.stargazersCount + existing.primaryLanguage = repository.language ?? "" + existing.htmlURL = repository.htmlURL.absoluteString + existing.category = classification.category.rawValue + existing.subTags = classification.subTags + existing.lastFetchedAt = Date() + existing.releases = cachedReleases + return existing + } + + let new = CachedRepository( + githubId: repository.id, + fullName: repository.fullName, + topics: repository.topics, + starCount: repository.stargazersCount, + primaryLanguage: repository.language ?? "", + htmlURL: repository.htmlURL.absoluteString, + category: classification.category.rawValue, + subTags: classification.subTags, + lastFetchedAt: Date(), + releases: cachedReleases + ) + modelContext.insert(new) + return new + } +} + +extension SearchViewModel where ClockType == ContinuousClock { + convenience init( + gitHubClient: GitHubClientProtocol, + modelContext: ModelContext, + noiseFilter: NoiseFiltering = NoiseFilter(), + classifier: CategoryClassifying = CategoryClassifier() + ) { + self.init( + gitHubClient: gitHubClient, + modelContext: modelContext, + clock: ContinuousClock(), + noiseFilter: noiseFilter, + classifier: classifier + ) + } +} diff --git a/Tests/CairnTests/Cache/CacheRefreshSchedulerTests.swift b/Tests/CairnTests/Cache/CacheRefreshSchedulerTests.swift new file mode 100644 index 0000000..ae8c632 --- /dev/null +++ b/Tests/CairnTests/Cache/CacheRefreshSchedulerTests.swift @@ -0,0 +1,233 @@ +import Defaults +import Foundation +import SwiftData +import Testing + +@testable import Cairn + +private func makeRepository( + id: Int = 1, + name: String = "repo", + owner: String = "owner", + topics: [String] = ["macos-app"] +) -> Repository { + Repository( + id: id, + name: name, + fullName: "\(owner)/\(name)", + owner: GitHubUser( + id: 1, + login: owner, + avatarURL: URL(string: "https://example.com/avatar.png")!, + htmlURL: URL(string: "https://github.com/\(owner)")!, + type: "User" + ), + htmlURL: URL(string: "https://github.com/\(owner)/\(name)")!, + description: nil, + stargazersCount: 10, + topics: topics, + language: "Swift", + updatedAt: Date(timeIntervalSince1970: 0), + pushedAt: Date(timeIntervalSince1970: 0), + defaultBranch: "main", + archived: false, + fork: false + ) +} + +private func makeRelease(assetName: String? = "App.dmg") -> Release { + Release( + id: 1, + tagName: "v1.0.0", + name: nil, + body: nil, + publishedAt: nil, + draft: false, + prerelease: false, + assets: assetName.map { + [ + ReleaseAsset( + id: 1, + name: $0, + browserDownloadURL: URL(string: "https://example.com/\($0)")!, + size: 100, + contentType: "application/octet-stream" + ) + ] + } ?? [] + ) +} + +@MainActor +@Suite(.serialized) +struct CacheRefreshSchedulerTests { + // CairnEnvironmentを戻り値のModelContextだけでなくインスタンスごと保持し続けないと、 + // スコープを抜けた時点でModelContainerが解放されクラッシュするため、 + // 各テストで環境そのものをローカル変数に束縛する(CachedRepositoryTestsと同様のパターン)。 + private func makeEnvironment() -> CairnEnvironment { + CairnEnvironment(inMemory: true) + } + + /// `Defaults[.lastTopCategory]`は`.standard`スイートを使う唯一のキーであり、他にこのキーへ + /// 触れるテストは無いため、テスト前後で明示的にリセットすることでテスト間の独立性を保つ + /// (専用UserDefaultsスイートへの切り替えは、書き込み側が実装されるPhase5でDefaultsKeys側の + /// 設計と合わせて検討する)。 + private func withLastTopCategory(_ category: Cairn.Category?, _ body: () async throws -> T) async rethrows -> T { + let original = Defaults[.lastTopCategory] + Defaults[.lastTopCategory] = category + defer { Defaults[.lastTopCategory] = original } + return try await body() + } + + @Test + func refreshesUsingLastTopCategoryOnLaunch() async throws { + try await withLastTopCategory(Cairn.Category.developerTools) { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + let repository = makeRepository() + await mockClient.setSearchRepositoriesHandler { _, _ in + SearchRepositoriesResult(totalCount: 1, incompleteResults: false, items: [repository]) + } + await mockClient.setReleasesHandler { _, _ in [makeRelease()] } + + let scheduler = CacheRefreshScheduler( + gitHubClient: mockClient, + modelContext: context, + keywordProvider: { _ in "cli" } + ) + + await scheduler.refreshTopCategoryOnLaunch() + + let queries = await mockClient.recordedQueries + #expect(queries.count == 1) + #expect(queries.first?.hasPrefix("cli ") == true) + + let cached = try context.fetch(FetchDescriptor()) + #expect(cached.map(\.fullName) == ["owner/repo"]) + } + } + + @Test + func doesNothingWhenNoTopCategoryIsSet() async throws { + await withLastTopCategory(nil) { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + let scheduler = CacheRefreshScheduler(gitHubClient: mockClient, modelContext: context) + + await scheduler.refreshTopCategoryOnLaunch() + + #expect(await mockClient.searchCallCount == 0) + } + } + + @Test + func skipsReleasesFetchWhenWithinTTL() async throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + let fixedNow = Date(timeIntervalSince1970: 100_000) + let repository = CachedRepository( + githubId: 1, + fullName: "owner/repo", + starCount: 1, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/repo", + category: "utilities", + lastFetchedAt: fixedNow, + lastReleaseCheckedAt: fixedNow.addingTimeInterval(-100) // TTL(1時間)内 + ) + context.insert(repository) + try context.save() + + let scheduler = CacheRefreshScheduler(gitHubClient: mockClient, modelContext: context, now: { fixedNow }) + await scheduler.revalidateDetailIfNeeded(for: repository) + + #expect(await mockClient.releasesCallCount == 0) + } + + @Test + func fetchesReleasesWhenTTLExpired() async throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + await mockClient.setReleasesHandler { _, _ in [makeRelease()] } + let fixedNow = Date(timeIntervalSince1970: 100_000) + let repository = CachedRepository( + githubId: 1, + fullName: "owner/repo", + starCount: 1, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/repo", + category: "utilities", + lastFetchedAt: fixedNow, + lastReleaseCheckedAt: fixedNow.addingTimeInterval(-CacheRefreshScheduler.releaseTTL - 1) + ) + context.insert(repository) + try context.save() + + let scheduler = CacheRefreshScheduler(gitHubClient: mockClient, modelContext: context, now: { fixedNow }) + await scheduler.revalidateDetailIfNeeded(for: repository) + + #expect(await mockClient.releasesCallCount == 1) + #expect(repository.lastReleaseCheckedAt == fixedNow) + #expect(repository.releases.map(\.tagName) == ["v1.0.0"]) + } + + @Test + func skipsReadmeFetchWhenWithinTTL() async throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + let fixedNow = Date(timeIntervalSince1970: 100_000) + let repository = CachedRepository( + githubId: 1, + fullName: "owner/repo", + starCount: 1, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/repo", + category: "utilities", + lastFetchedAt: fixedNow, + lastReleaseCheckedAt: fixedNow, // ReleasesはTTL内にして今回の検証対象から外す + lastReadmeFetchedAt: fixedNow.addingTimeInterval(-100) // TTL(24時間)内 + ) + context.insert(repository) + try context.save() + + let scheduler = CacheRefreshScheduler(gitHubClient: mockClient, modelContext: context, now: { fixedNow }) + await scheduler.revalidateDetailIfNeeded(for: repository) + + #expect(await mockClient.readmeCallCount == 0) + } + + @Test + func readmeFetchFailureDoesNotUpdateTTLOrCategory() async throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + await mockClient.setReadmeHandler { _, _ in throw GitHubClientError.httpError(statusCode: 500) } + let fixedNow = Date(timeIntervalSince1970: 100_000) + let staleReadmeFetchedAt = fixedNow.addingTimeInterval(-CacheRefreshScheduler.readmeTTL - 1) + let repository = CachedRepository( + githubId: 1, + fullName: "owner/repo", + starCount: 1, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/repo", + category: "utilities", + lastFetchedAt: fixedNow, + lastReleaseCheckedAt: fixedNow, // ReleasesはTTL内にして今回の検証対象から外す + lastReadmeFetchedAt: staleReadmeFetchedAt + ) + context.insert(repository) + try context.save() + + let scheduler = CacheRefreshScheduler(gitHubClient: mockClient, modelContext: context, now: { fixedNow }) + await scheduler.revalidateDetailIfNeeded(for: repository) + + #expect(await mockClient.readmeCallCount == 1) + #expect(repository.category == "utilities") // 既存分類を維持 + #expect(repository.lastReadmeFetchedAt == staleReadmeFetchedAt) // TTLを更新しない + } +} diff --git a/Tests/CairnTests/Cache/CachedRepositoryTests.swift b/Tests/CairnTests/Cache/CachedRepositoryTests.swift index df98f32..d4a8fb5 100644 --- a/Tests/CairnTests/Cache/CachedRepositoryTests.swift +++ b/Tests/CairnTests/Cache/CachedRepositoryTests.swift @@ -38,6 +38,30 @@ struct CachedRepositoryTests { #expect(fetched.first?.fullName == "owner/repo") #expect(fetched.first?.starCount == 42) #expect(fetched.first?.lastReleaseCheckedAt == nil) + #expect(fetched.first?.lastReadmeFetchedAt == nil) + } + + @Test + func lastReadmeFetchedAtIsPersistedAcrossFetch() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let readmeFetchedAt = Date(timeIntervalSince1970: 3000) + let repository = CachedRepository( + githubId: 3, + fullName: "owner/readme-repo", + starCount: 5, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/readme-repo", + category: "utilities", + lastFetchedAt: Date(timeIntervalSince1970: 1000), + lastReadmeFetchedAt: readmeFetchedAt + ) + context.insert(repository) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + + #expect(fetched.first?.lastReadmeFetchedAt == readmeFetchedAt) } @Test diff --git a/Tests/CairnTests/Classification/CategoryClassifierTests.swift b/Tests/CairnTests/Classification/CategoryClassifierTests.swift index 8825dd1..df76937 100644 --- a/Tests/CairnTests/Classification/CategoryClassifierTests.swift +++ b/Tests/CairnTests/Classification/CategoryClassifierTests.swift @@ -117,6 +117,16 @@ struct CategoryClassifierTests { #expect(result.category == .productivity) } + @Test("topics/name/readme直接指定のオーバーロードもRepository経由と同じ結果になる") + func directOverloadMatchesRepositoryBasedClassification() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + + let result = classifier.classify(topics: ["cli"], name: "TodoMaster", readme: nil) + + #expect(result.category == .developerTools) + #expect(result.subTags == ["cli"]) + } + @Test("実運用のCategoryKeywords.jsonがバンドルから正しく読み込める") func loadsBundledKeywordsWithoutCrashing() { let keywords = CategoryKeywords.loadBundled() diff --git a/Tests/CairnTests/Features/Search/ManualClock.swift b/Tests/CairnTests/Features/Search/ManualClock.swift new file mode 100644 index 0000000..d838eb6 --- /dev/null +++ b/Tests/CairnTests/Features/Search/ManualClock.swift @@ -0,0 +1,56 @@ +import Foundation + +@testable import Cairn + +/// デバウンス・重複クエリ抑制のテスト用に、時刻を手動で進められる`Clock`実装。 +/// 実際に300ms/5秒待つ実待機を避けるための注入ポイント。 +final class ManualClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + static func == (lhs: Instant, rhs: Instant) -> Bool { lhs.offset == rhs.offset } + + func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + func duration(to other: Instant) -> Duration { + other.offset - offset + } + } + + private(set) var now = Instant(offset: .zero) + let minimumResolution: Duration = .zero + + private var pendingWaiters: [(deadline: Instant, continuation: CheckedContinuation)] = [] + + func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { + guard deadline > now else { return } + await withCheckedContinuation { continuation in + pendingWaiters.append((deadline, continuation)) + } + } + + /// 時刻を進め、その時点で満了した待機者をすべて再開する。 + func advance(by duration: Duration) async { + now = now.advanced(by: duration) + let ready = pendingWaiters.filter { $0.deadline <= now } + pendingWaiters.removeAll { $0.deadline <= now } + for waiter in ready { + waiter.continuation.resume() + } + // 再開されたTaskが次のawaitへ進む猶予を与える。 + await Task.yield() + } + + /// テスト対象のTaskが`sleep(until:tolerance:)`を呼び出し、待機登録を終えるまで待つ。 + /// 並列テスト実行下ではスケジューリング遅延が生じうるため、固定回数のyieldではなく + /// 実際に登録が完了したことをポーリングで確認してから`advance(by:)`を呼ぶ必要がある。 + func waitForPendingSleep(count: Int = 1, attempts: Int = 1000) async { + for _ in 0..= count { return } + await Task.yield() + } + } +} diff --git a/Tests/CairnTests/Features/Search/MockGitHubClient.swift b/Tests/CairnTests/Features/Search/MockGitHubClient.swift new file mode 100644 index 0000000..cba4539 --- /dev/null +++ b/Tests/CairnTests/Features/Search/MockGitHubClient.swift @@ -0,0 +1,55 @@ +import Foundation + +@testable import Cairn + +/// `SearchViewModel`のテスト用に、呼び出し回数・クエリ文字列を記録する`GitHubClientProtocol`モック。 +/// 可変状態を持つため`actor`にして、`SearchViewModel`(`@MainActor`)からの並行アクセスを安全にする。 +actor MockGitHubClient: GitHubClientProtocol { + var searchRepositoriesHandler: (@Sendable (String, Int) async throws -> SearchRepositoriesResult)? + var releasesHandler: (@Sendable (String, String) async throws -> [Release])? + var readmeHandler: (@Sendable (String, String) async throws -> String?)? + + private(set) var searchCallCount = 0 + private(set) var recordedQueries: [String] = [] + private(set) var releasesCallCount = 0 + private(set) var readmeCallCount = 0 + + func searchRepositories(query: String, page: Int) async throws -> SearchRepositoriesResult { + searchCallCount += 1 + recordedQueries.append(query) + guard let handler = searchRepositoriesHandler else { + return SearchRepositoriesResult(totalCount: 0, incompleteResults: false, items: []) + } + return try await handler(query, page) + } + + func releases(owner: String, repo: String) async throws -> [Release] { + releasesCallCount += 1 + guard let handler = releasesHandler else { return [] } + return try await handler(owner, repo) + } + + func readme(owner: String, repo: String) async throws -> String? { + readmeCallCount += 1 + guard let handler = readmeHandler else { return nil } + return try await handler(owner, repo) + } + + func authenticatedUser() async throws -> GitHubUser { + throw GitHubClientError.unauthenticated + } + + func setSearchRepositoriesHandler( + _ handler: @escaping @Sendable (String, Int) async throws -> SearchRepositoriesResult + ) { + searchRepositoriesHandler = handler + } + + func setReleasesHandler(_ handler: @escaping @Sendable (String, String) async throws -> [Release]) { + releasesHandler = handler + } + + func setReadmeHandler(_ handler: @escaping @Sendable (String, String) async throws -> String?) { + readmeHandler = handler + } +} diff --git a/Tests/CairnTests/Features/Search/SearchViewModelTests.swift b/Tests/CairnTests/Features/Search/SearchViewModelTests.swift new file mode 100644 index 0000000..a4ac2d8 --- /dev/null +++ b/Tests/CairnTests/Features/Search/SearchViewModelTests.swift @@ -0,0 +1,231 @@ +import Foundation +import SwiftData +import Testing + +@testable import Cairn + +private func makeRepository( + id: Int = 1, + name: String = "repo", + owner: String = "owner", + fullName: String? = nil, + topics: [String] = ["macos-app"], + stargazersCount: Int = 10, + language: String? = "Swift" +) -> Repository { + Repository( + id: id, + name: name, + fullName: fullName ?? "\(owner)/\(name)", + owner: GitHubUser( + id: 1, + login: owner, + avatarURL: URL(string: "https://example.com/avatar.png")!, + htmlURL: URL(string: "https://github.com/\(owner)")!, + type: "User" + ), + htmlURL: URL(string: "https://github.com/\(owner)/\(name)")!, + description: nil, + stargazersCount: stargazersCount, + topics: topics, + language: language, + updatedAt: Date(timeIntervalSince1970: 0), + pushedAt: Date(timeIntervalSince1970: 0), + defaultBranch: "main", + archived: false, + fork: false + ) +} + +private func makeRelease(assetName: String? = "App.dmg") -> Release { + Release( + id: 1, + tagName: "v1.0.0", + name: nil, + body: nil, + publishedAt: nil, + draft: false, + prerelease: false, + assets: assetName.map { + [ + ReleaseAsset( + id: 1, + name: $0, + browserDownloadURL: URL(string: "https://example.com/\($0)")!, + size: 100, + contentType: "application/octet-stream" + ) + ] + } ?? [] + ) +} + +@MainActor +@Suite(.serialized) +struct SearchViewModelTests { + // CairnEnvironmentを戻り値のModelContextだけでなくインスタンスごと保持し続けないと、 + // スコープを抜けた時点でModelContainerが解放されクラッシュするため、 + // 各テストで環境そのものをローカル変数に束縛する(CachedRepositoryTestsと同様のパターン)。 + private func makeEnvironment() -> CairnEnvironment { + CairnEnvironment(inMemory: true) + } + + /// 条件が満たされるまで`Task.yield()`を繰り返し、非同期チェーンの完了を待つ。 + /// タイムアウトはテストインフラ側の安全弁であり、検証対象のデバウンス秒数とは無関係。 + private func pollUntil(_ condition: () async -> Bool, attempts: Int = 200) async { + for _ in 0..(predicate: #Predicate { $0.githubId == 2 }) + ) + #expect(cachedExcluded.isEmpty) + } + + @Test + func onlyFinalQueryAmongRapidChangesIsSentToAPI() async throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let mockClient = MockGitHubClient() + await mockClient.setSearchRepositoriesHandler { _, _ in + SearchRepositoriesResult(totalCount: 0, incompleteResults: false, items: []) + } + let clock = ManualClock() + let viewModel = SearchViewModel(gitHubClient: mockClient, modelContext: context, clock: clock) + + viewModel.queryText = "s" + viewModel.queryText = "sw" + viewModel.queryText = "swi" + viewModel.queryText = "swif" + viewModel.queryText = "swift" + await clock.waitForPendingSleep() + + await clock.advance(by: .milliseconds(300)) + await pollUntil { await mockClient.searchCallCount == 1 } + + let queries = await mockClient.recordedQueries + #expect(queries.count == 1) + #expect(queries.first?.hasPrefix("swift ") == true) + } +} diff --git a/docs/progress.md b/docs/progress.md index 37afbbc..892c367 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -91,10 +91,20 @@ 常時のバックグラウンドローリング更新は行わない(認証後はAPIを都度叩けるため必要性が低くなったため)。`CacheRefreshScheduler`は「起動時に前回のトップカテゴリのみ1回軽量更新」「詳細画面の遅延再検証」に縮小する。 -- [ ] `SearchViewModel`実装(デバウンス300ms、直近同一クエリ5秒以内は再送なし、`language:Swift OR language:"Objective-C" OR language:"Objective-C++"`を1クエリにまとめる、stale-while-revalidate) -- [ ] 縮小版`CacheRefreshScheduler`実装(起動時に前回トップカテゴリのみ1回軽量更新、詳細画面の遅延再検証) - -**テスト方針についての注意**: 実装計画には`SearchViewModel`固有のテスト項目が明記されていない。CLAUDE.mdの「実装と同時にテストを追加する」方針に従い、Phase4着手時にデバウンス処理・重複クエリ防止ロジックのテストを自前で設計する必要がある。 +- [x] `SearchViewModel`実装(デバウンス300ms、直近同一クエリ5秒以内は再送なし、`language:Swift OR language:"Objective-C" OR language:"Objective-C++"`を1クエリにまとめる、stale-while-revalidate) +- [x] 縮小版`CacheRefreshScheduler`実装(起動時に前回トップカテゴリのみ1回軽量更新、詳細画面の遅延再検証) + +**実装時に確定した設計判断(着手前にユーザーへ再確認したもの)**: +- `SearchViewModel`/`CacheRefreshScheduler`ともに`GitHubClientProtocol`/`ModelContext`をinitで直接受け取る疎結合設計とし、`CairnEnvironment`自体はPhase4では拡張しない(本格的なDIコンテナ化はPhase5に持ち越す) +- `CachedRepository`に`lastReadmeFetchedAt: Date?`を追加(README取得TTL 24時間の判定用) +- 前回トップカテゴリは`Defaults`パッケージ(`Package.swift`に導入済みだが本フェーズが初使用)で永続化。Phase4は読み出し専用(`refreshTopCategoryOnLaunch()`)に留め、書き込み側(カテゴリ選択時の更新)はPhase5のDiscovery UIで追加する +- デバウンス300ms・重複クエリ抑制5秒の判定は、Swift標準の`Clock`プロトコルを注入可能にして実待機なしにテストする設計にした(`SearchViewModel`は`ClockType: Clock`のジェネリッククラス、本番は`ContinuousClock`、テストは自作の`ManualClock`) +- **NoiseFilter(dmg/zip資産チェック)は検索結果一覧の段階で適用する**(ユーザーの明示選択)。これは「詳細画面遷移時のみ適用」より無駄なAPI呼び出しが増えるトレードオフ——検索結果1ページ(最大30件)ごとにReleases一覧APIを追加で叩く——を承知の上での判断。アセット本体のダウンロードは発生しない +- README取得が失敗した場合、`lastReadmeFetchedAt`のTTLは更新しない(次回訪問時に再取得を試せるようにするため。既存分類はそのまま維持する) +- `CategoryClassifying`に`classify(topics:name:readme:)`オーバーロードを追加し、`CacheRefreshScheduler`が`CachedRepository`(SwiftDataモデル)から`Repository`(APIレスポンス型)を経由せず再分類できるようにした +- 起動時の軽量更新呼び出しは`CairnEnvironment`を拡張せず、`CairnApp.swift`の`.task`から都度`CacheRefreshScheduler`を生成して呼ぶ最小限の配線に留めた + +**テスト方針**: `SearchViewModelTests`(デバウンス・重複抑制・stale-while-revalidateの順序・NoiseFilter除外・Taskキャンセル)、`CacheRefreshSchedulerTests`(前回トップカテゴリ読み出し・未設定時no-op・Releases/README TTL境界・README失敗時のTTL非更新)を追加。時間依存ロジックは`ManualClock`(Clock注入)と固定`now`クロージャで実待機なしに検証している。 ## Phase 5: Discovery UI — 未着手