diff --git a/.claude/agents/ios-code-reviewer.md b/.claude/agents/ios-code-reviewer.md new file mode 100644 index 0000000..25ea692 --- /dev/null +++ b/.claude/agents/ios-code-reviewer.md @@ -0,0 +1,120 @@ +--- +name: ios-code-reviewer +description: "Swiftコードのコードレビューエージェント。機能実装、リファクタリング、バグ修正、テスト作成後に使用。以下のタイミングで起動:\n\n- 新機能実装後(View、Store、Client、Service、Domainモデル)\n- 既存コードのリファクタリング後\n- バグ修正後\n- アーキテクチャ変更後\n- テスト作成・更新後\n\n**使用例:**\n\n\nContext: ユーザーが新しいSwiftUI ViewとStoreを実装した\nuser: \"新しいProfileEditViewとProfileEditStoreを実装しました\"\nassistant: \"実装が完了したようですので、ios-code-reviewerエージェントを使用してコードレビューを実行します\"\n\n重要なコード実装が完了したため、Taskツールでios-code-reviewerエージェントを起動してレビューする。\n\nassistant: \"では、TaskツールでiOS Code Reviewerエージェントを起動して、実装したコードをレビューします\"\n\n\n\nContext: ユーザーが複数ファイルでDIパターンをリファクタリングした\nuser: \"DIパターンをリファクタリングして、3つのClientを更新しました\"\nassistant: \"リファクタリングが完了しましたね。ios-code-reviewerエージェントでコードレビューを行いましょう\"\n\nリファクタリングが実施されたため、Taskツールでios-code-reviewerエージェントを起動してベストプラクティスに従っているか確認する。\n\nassistant: \"TaskツールでiOS Code Reviewerエージェントを起動して、リファクタリング内容をレビューします\"\n\n\n\nContext: ユーザーが新機能のユニットテストを作成した\nuser: \"HouseworkBoardStoreのユニットテストを追加しました\"\nassistant: \"テストコードが書けましたね。ios-code-reviewerエージェントでレビューを実施します\"\n\nテストコードが作成されたため、Taskツールでios-code-reviewerエージェントを起動してテスト実装の品質を確認する。\n\nassistant: \"TaskツールでiOS Code Reviewerエージェントを起動して、テストコードの品質を確認します\"\n" +tools: Bash, Glob, Grep, Read +model: opus +color: cyan +--- + +あなたはSwift 6、SwiftUI、モダンなiOS開発プラクティスに特化したエリートiOSコードレビュアーです。homete iOSプロジェクト専用に、クリーンアーキテクチャ、strict concurrency、Firebase統合パターンの専門知識を有しています。 + +## 役割と責務 + +プロンプトで渡されたSwiftコードの差分を分析し、徹底的なコードレビューを実施します。レビューは建設的で教育的であり、プロジェクトの確立されたパターンとベストプラクティスに沿ったものである必要があります。 + +**注意:** このエージェントは「思考・判断」に特化しています。SwiftLintの実行やgit diffの取得などの実行タスクは、呼び出し側(code-reviewスキル)が担当します。 + +## 考慮すべきプロジェクトコンテキスト + +### アーキテクチャパターン +- **カスタムDIを用いたクリーンアーキテクチャ**: Views → Stores (@Observable) → Clients (プロトコル) → Services (actors) → Domain Models +- **Dependency Injection**: 全てのクライアントは`DependencyClient`に準拠し、`.liveValue`と`.previewValue`を持つ +- **ViewからServiceへの直接アクセス禁止**: 必ずStoreとClientを経由する +- **状態管理**: `@Observable`マクロを使用(CombineやObservableObjectは使用しない) +- **並行性**: Swift 6 strict concurrency有効 - アクター分離と`@Sendable`を強制 + +### 重要な技術要件 +- **async/awaitのみ**: completion handlerは使用しない +- **アクターベースのサービス**: Firestoreなどのサービスはスレッドセーフのためactorである必要がある +- **プロトコルベースのDI**: 全てのサービスに対応するClientプロトコルが必要 +- **SwiftUIベストプラクティス**: モダンなSwiftUIパターンを活用 +- **Firebase統合**: Firestore、Auth、Cloud Messagingの適切な使用 + +### ファイル整理基準 +- Views: `homete/Views/` 機能別に整理 +- Domain Models: `homete/Model/Domain/` ドメイン領域別のサブディレクトリ +- Clients: `homete/Model/Dependencies/` プロトコル定義 +- Services: `homete/Model/Service/` インフラコード +- Stores: `homete/Model/Store/` @Observableクラス +- Tests: `hometeTests/` メインアプリ構造をミラー + +## レビュープロセス + +コードレビュー時は、以下の体系的なアプローチに従います: + +### 1. アーキテクチャ準拠性 +- CLAUDE.mdを参照してプロジェクトのアーキテクチャに準拠しているか確認 +- ViewがServiceに直接アクセスしていないか確認 +- 新機能がDIパターンを正しく使用しているか確認 +- Storeが@Observableで、初期化時にAppDependenciesを受け取っているか確認 + +### 2. Swift 6 Strict Concurrency +- 共有可変状態に対する適切なアクター分離を検証 +- 並行性境界を越える全ての型が`@Sendable`であるか確認 +- async/awaitが正しく使用されているか確認(completion handlerなし) +- 共有状態を管理する際、Serviceがactorであるか検証 +- 潜在的なデータ競合や並行性違反を探す + +### 3. コード品質とベストプラクティス +- コードの可読性と保守性を評価 +- 適切なエラーハンドリングパターンを確認 +- Swift言語機能(guard、if let、optional chaining)の適切な使用を検証 +- 強制アンラップを避けているか確認(失敗が不可能な場合を除く) +- 命名規則(明確、説明的、Swift規約に従う)を確認 +- 抽出可能なコード重複がないか確認 + +### 4. SwiftUIパターン +- Viewが構成可能でプレゼンテーションに焦点を当てているか検証 +- @Observable、@State、@Binding、@Environmentの適切な使用を確認 +- View modifierが適切に適用されているか確認 +- ナビゲーションパターンがプロジェクト構造と一致しているか検証 +- Previewプロバイダーの完全性を確認 + +### 5. テストの考慮事項 +- コードのテスト可能性を評価 +- 適切なテストが存在するか、追加が必要か確認 +- クリティカルパスのテストカバレッジを検証 +- DIのためのモック/プレビューが適切に実装されているか確認 +- 必要に応じてUIコンポーネントのスナップショットテストを検証 + +### 6. Firebase統合 +- FirestoreServiceパターンの適切な使用を検証 +- コレクションパスがCollectionPath.swiftで定義されているか確認 +- リアルタイムリスナーにAsyncStreamが使用されているか確認 +- Firebase操作の適切なエラーハンドリングを検証 +- セキュリティとデータ検証を確認 + +### 7. パフォーマンスと効率性 +- 潜在的なパフォーマンスボトルネックを特定 +- 不要な計算や再レンダリングがないか確認 +- Firebaseクエリの効率的な使用を検証 +- メモリリークの可能性を探す +- 非同期操作の効率性を評価 + +**レビュー方針**: +- 差分(追加・変更・削除された行)を中心にレビュー +- 新規ファイルの場合は全体の構造も確認 +- 必要に応じて、文脈を理解するために関連ファイル全体をReadツールで読む +- 差分が大きすぎる場合は、主要な変更箇所を優先してレビュー + +## レビュー原則 + +1. **建設的であること**: フィードバックを前向きに組み立て、提案の背後にある理由を説明する +2. **具体的であること**: 改善を提案する際は、具体的な例とコードスニペットを提供する +3. **教育的であること**: このプロジェクトで特定のパターンが好まれる理由を説明する +4. **優先順位付け**: 重大な問題、重要な改善点、あると良い項目を明確に区別する +5. **良い仕事を認める**: 常に良く実装されたパターンと良いプラクティスを認識する +6. **文脈を考慮**: 最近作成/変更されたコードに焦点を当て、明示的に求められない限り全体のコードベースには触れない +7. **プロジェクトと整合**: 全てのフィードバックがCLAUDE.mdのプロジェクトの確立されたパターンと整合していることを確認 + +## 自己検証ステップ + +レビューを確定する前に: +1. 全ての重要なアーキテクチャパターンを確認しましたか? +2. Swift 6並行性準拠を検証しましたか? +3. 提案は具体的で実行可能ですか? +4. 役立つ場合にコード例を提供しましたか? +5. 批評と良い仕事の認識のバランスを取りましたか? +6. フィードバックはプロジェクトの確立されたプラクティスと整合していますか? + +あなたは徹底的で知識豊富であり、開発者がhometeプロジェクトの高い基準に沿った優れたSwiftコードを書けるよう支援することに専念しています。 diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 51b5159..6287352 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -9,23 +9,22 @@ SwiftLintの自動チェックと包括的なコード品質分析を備えた ## ワークフロー -以下の手順を順番に実行する: +このスキルは以下の手順でコードレビューを実行します: ### 1. SwiftLintの実行 -コードスタイルと品質をチェックするためSwiftLintを実行: +まず、SwiftLintを実行してコードスタイルと品質をチェックします: ```bash swift run --package-path ProjectTools swiftlint lint --config .swiftlint.yml ``` -### 2. SwiftLint結果の報告 - +**SwiftLint結果の報告:** - 警告やエラーが存在する場合、ファイルパスと行番号を明確にリスト表示 - 重要度でグループ化(エラーを最初に、次に警告) - エラーがある場合はコードレビューに進まず、ユーザーが修正する必要があることを伝える -### 3. 変更差分の取得 +### 2. 変更差分の取得 現在のブランチの派生元(分岐点)からの変更差分を取得: @@ -39,93 +38,52 @@ git diff $(git merge-base main HEAD)..HEAD -- '*.swift' git diff $(git merge-base origin/main HEAD)..HEAD -- '*.swift' ``` -### 4. 変更コードのレビュー - -取得した差分を分析し、以下の基準でコードレビューを実施: - -**レビュー方針**: -- 差分(追加・変更・削除された行)を中心にレビュー -- 新規ファイルの場合は全体の構造も確認 -- 必要に応じて、文脈を理解するために関連ファイル全体をReadツールで読むこともある -- 差分が大きすぎる場合は、主要な変更箇所を優先してレビュー - -#### 主要なレビュー基準 - -**Dependency Injection違反** (最優先): -- ViewはServiceに直接アクセスしてはいけない - Clientを使用すべき -- Storeは初期化時に`AppDependencies`を受け取り、`.liveValue`または`.previewValue`を使用 -- 全てのクライアントは`DependencyClient`プロトコルを実装 -- パターン: View → Store(AppDependencies) → Client.liveValue → Service - -**過度なエンジニアリング** (最優先): -- 一度しか使わない処理のために不要な抽象化や汎用的なソリューションを避ける -- 要求された内容以外の機能を追加しない -- 発生し得ないシナリオのエラーハンドリングを追加しない -- 単一の操作のためにヘルパー/ユーティリティを作成しない -- 類似した3行のコード > 早すぎる抽象化 - -**テストコードの品質** (最優先): -- 新機能には対応するユニットテストが必要 -- UIコンポーネントにはスナップショットテスト(swift-snapshot-testing使用)が必要 -- テストは日本語ロケール(`ja_JP`)と東京タイムゾーンを使用 -- テストファイルは`hometeTests/`でメインアプリの構造をミラー - -#### 副次的なレビュー基準 - -**Swift 6 Strict Concurrency**: -- UI関連コードに適切な`@MainActor`アノテーション -- アクター分離の準拠 -- 並行性境界を越える型の`Sendable`準拠 -- async/awaitを使用、completion handlerは使用しない - -**セキュリティ問題**: -- ハードコードされたシークレットや認証情報がないこと -- システム境界での適切な入力検証 -- クラッシュの可能性がある強制アンラップ(! 演算子)がないこと -- OWASP Top 10の一般的な脆弱性をチェック - -**アーキテクチャ準拠**: -- クリーンアーキテクチャのレイヤーに従う(Views → Stores → Clients → Services → Domain) -- ドメインモデルは`homete/Model/Domain/`に配置 -- サービスは`homete/Model/Service/`に配置 -- Storeは`homete/Model/Store/`に配置 - -**コード品質**: -- ロジックが自明でない場合のみコメントを追加 -- 変更していないコードにdocstringを追加しない -- 使用されていないコードに対する後方互換性のハックを避ける -- 使用されていないコードは完全に削除、コメントアウトしない - -### 5. レビュー結果の出力 - -簡潔なサマリー形式を使用: - -```markdown -## SwiftLint結果 -[✓] エラーや警告なし -または -[⚠️] X個の警告、Y個のエラーを検出 -- 各問題をファイル:行番号でリスト表示 - -## コードレビューサマリー -N個のファイル、M個の変更をレビュー - -### 重大な問題 -[DI違反、過度なエンジニアリング、テスト不足などをリスト表示] - -### 提案 -[軽微な改善点や懸念事項をリスト表示] - -### 承認済み -[問題のないファイルをリスト表示] +**差分取得のポイント:** +- 差分が大きすぎる場合(10000行以上)は、主要な変更箇所を優先してレビュー +- 差分がない場合は、ユーザーに変更がないことを伝える + +### 3. ios-code-reviewerエージェントを起動 + +Taskツールを使用して`ios-code-reviewer`エージェントを起動し、取得した差分をレビューさせます: + ``` +Task tool with: +- subagent_type: "ios-code-reviewer" +- description: "Swiftコードの差分をレビュー" +- prompt: "以下のSwift差分をレビューしてください:\n\n[差分内容を貼り付け]" +``` + +**重要:** エージェントには具体的な差分内容を渡すこと。「現在のブランチのSwiftコードの差分をレビューしてください」のような抽象的な指示ではなく、実際の差分テキストを含めてください。 + +### 4. レビュー結果のユーザーへの報告 -## このスキルを使用しない場合 +エージェントから返されたレビュー結果を、以下の形式でユーザーに報告します: +- SwiftLintの結果(エラー・警告のサマリー) +- レビューの総合評価 +- 主要なフィードバック(アーキテクチャ、並行性、コード品質) +- 改善提案と良い点 +- 優先度の高い修正項目 + +## 使用タイミング + +### このスキルを使用する場合 + +- ユーザーが `/code-review` コマンドを実行した時 +- 機能実装、リファクタリング、バグ修正が完了した時 +- ユーザーが「コードレビューをお願いします」と依頼した時 + +### このスキルを使用しない場合 - コード構造に関する一般的な質問(代わりに探索を使用) - Swiftファイルに変更がない場合 - 初期のコード探索や理解フェーズ中 +## 注意事項 + +- このスキルは**エントリーポイント**として機能します +- 実際のレビュー処理は `ios-code-reviewer` エージェントが実行します +- スキル自身でSwiftLintやgit diffを直接実行しないでください + ## リファレンス プロジェクトの詳細なアーキテクチャと規約については、以下を参照: diff --git a/CLAUDE.md b/CLAUDE.md index 49aa2cd..41e8f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,15 +16,6 @@ hometeは同居人(ルームメイト/家族)間で家事を管理するた ### iOS開発 ```bash -# Xcodeでプロジェクトを開く -open homete.xcodeproj - -# テスト実行(ユニット + スナップショット) -xcodebuild test -project homete.xcodeproj -scheme homete -testPlan CI.xctestplan -destination 'platform=iOS Simulator,name=iPhone 16' - -# スナップショットテストのみ実行 -xcodebuild test -project homete.xcodeproj -scheme homete -testPlan snapshotTesting.xctestplan -destination 'platform=iOS Simulator,name=iPhone 16' - # SwiftLint(CIでDanger経由で実行、スタンドアロンでは実行しない) swift run --package-path ProjectTools swiftlint lint --config .swiftlint.yml diff --git a/homete/Model/Dependencies/HouseworkClient.swift b/homete/Model/Dependencies/HouseworkClient.swift index fe20a3c..12384a1 100644 --- a/homete/Model/Dependencies/HouseworkClient.swift +++ b/homete/Model/Dependencies/HouseworkClient.swift @@ -96,13 +96,13 @@ private extension HouseworkClient { let base = calendar.startOfDay(for: anchorDate) guard offsetDays >= 0 else { - return [HouseworkIndexedDate(base, locale: locale).mapValue] + return [["value": HouseworkIndexedDate(base, locale: locale).value]] } // -offset ... +offset の範囲を列挙 return (-offsetDays...offsetDays).compactMap { delta in guard let date = calendar.date(byAdding: .day, value: delta, to: base) else { return nil } - return HouseworkIndexedDate(date, locale: locale).mapValue + return ["value": HouseworkIndexedDate(base, locale: locale).value] } } } diff --git a/homete/Model/Domain/Cohabitant/CohabitantMemberList.swift b/homete/Model/Domain/Cohabitant/CohabitantMemberList.swift index 8a2cbef..6f1591b 100644 --- a/homete/Model/Domain/Cohabitant/CohabitantMemberList.swift +++ b/homete/Model/Domain/Cohabitant/CohabitantMemberList.swift @@ -17,7 +17,7 @@ struct CohabitantMemberList { /// 与えられたユーザーID配列の中から、まだvalueに存在しないユーザーIDのみを返します。 /// - Parameter userIds: 追加するユーザーIDの候補の配列 /// - Returns: 追加が必要なユーザーIDの配列 - func missingMemberIds(from userIds: [String]) -> [String] { + func missingMemberIds(from userIds: Set) -> Set { let existingIds = value.map(\.id) return userIds.filter { !existingIds.contains($0) } diff --git a/homete/Model/Domain/Cohabitant/CohabitantStore.swift b/homete/Model/Domain/Cohabitant/CohabitantStore.swift index b89da43..08d56c7 100644 --- a/homete/Model/Domain/Cohabitant/CohabitantStore.swift +++ b/homete/Model/Domain/Cohabitant/CohabitantStore.swift @@ -44,9 +44,9 @@ final class CohabitantStore { for await cohabitantDataList in stream { - guard let cohabitantData = cohabitantDataList.first else { return } + guard let cohabitantData = cohabitantDataList.first else { continue } - for member in self.members.missingMemberIds(from: cohabitantData.members) { + for member in self.members.missingMemberIds(from: .init(cohabitantData.members)) { do { @@ -61,8 +61,9 @@ final class CohabitantStore { print("error occurred: \(error)") } } - print("finish listening cohabitant snapshot.") } + + print("finish listening cohabitant snapshot.") } } diff --git a/homete/Model/Domain/Cohabitant/Housework/HouseworkIndexedDate.swift b/homete/Model/Domain/Cohabitant/Housework/HouseworkIndexedDate.swift index 17c90bd..d2f66ad 100644 --- a/homete/Model/Domain/Cohabitant/Housework/HouseworkIndexedDate.swift +++ b/homete/Model/Domain/Cohabitant/Housework/HouseworkIndexedDate.swift @@ -9,11 +9,6 @@ import Foundation struct HouseworkIndexedDate: Equatable, Codable, Hashable { let value: String - - var mapValue: [String: String] { - - return ["value": value] - } } extension HouseworkIndexedDate { diff --git a/homete/Model/Domain/Cohabitant/Housework/HouseworkItem.swift b/homete/Model/Domain/Cohabitant/Housework/HouseworkItem.swift index 274a78f..e87c644 100644 --- a/homete/Model/Domain/Cohabitant/Housework/HouseworkItem.swift +++ b/homete/Model/Domain/Cohabitant/Housework/HouseworkItem.swift @@ -23,16 +23,30 @@ struct HouseworkItem: Identifiable, Equatable, Sendable, Hashable, Codable { return indexedDate.value } - func updateState(_ nextState: HouseworkState, at now: Date, changer: String) -> Self { + func updatePendingApproval(at now: Date, changer: String) -> Self { return .init( id: id, indexedDate: indexedDate, title: title, point: point, - state: nextState, - executorId: nextState == .pendingApproval ? changer : executorId, - executedAt: nextState == .pendingApproval ? now : executedAt, + state: .pendingApproval, + executorId: changer, + executedAt: now, + expiredAt: expiredAt + ) + } + + func updateIncomplete() -> Self { + + return .init( + id: id, + indexedDate: indexedDate, + title: title, + point: point, + state: .incomplete, + executorId: nil, + executedAt: nil, expiredAt: expiredAt ) } diff --git a/homete/Model/Domain/Cohabitant/Housework/HouseworkListStore.swift b/homete/Model/Domain/Cohabitant/Housework/HouseworkListStore.swift index 66f7b43..193c457 100644 --- a/homete/Model/Domain/Cohabitant/Housework/HouseworkListStore.swift +++ b/homete/Model/Domain/Cohabitant/Housework/HouseworkListStore.swift @@ -87,7 +87,7 @@ final class HouseworkListStore { preconditionFailure("Not found target item(id: \(targetId), indexedDate: \(targetIndexedDate))") } - let updatedItem = targetItem.updateState(.pendingApproval, at: now, changer: executor) + let updatedItem = targetItem.updatePendingApproval(at: now, changer: executor) try await houseworkClient.insertOrUpdateItem(updatedItem, cohabitantId) Task.detached { @@ -100,6 +100,20 @@ final class HouseworkListStore { } } + func returnToIncomplete(target: HouseworkItem, now: Date) async throws { + + let targetIndexedDate = target.indexedDate + let targetId = target.id + + guard let targetItem = items.item(targetId, targetIndexedDate) else { + + preconditionFailure("Not found target item(id: \(targetId), indexedDate: \(targetIndexedDate))") + } + + let updatedItem = targetItem.updateIncomplete() + try await houseworkClient.insertOrUpdateItem(updatedItem, cohabitantId) + } + func remove(_ target: HouseworkItem) async throws { try await houseworkClient.removeItem(target, cohabitantId) diff --git a/homete/Views/HouseworkDetailView/SubViews/HouseworkDetailActionContent.swift b/homete/Views/HouseworkDetailView/SubViews/HouseworkDetailActionContent.swift index e5bbea2..d6dd0d1 100644 --- a/homete/Views/HouseworkDetailView/SubViews/HouseworkDetailActionContent.swift +++ b/homete/Views/HouseworkDetailView/SubViews/HouseworkDetailActionContent.swift @@ -33,6 +33,7 @@ struct HouseworkDetailActionContent: View { undoChangeStateButton() } } + .disabled(isLoading) .fullScreenCover(isPresented: $isPresentedApprovalView) { HouseworkApprovalView(item: item) } @@ -43,8 +44,10 @@ private extension HouseworkDetailActionContent { func requestReviewButton() -> some View { Button { + isLoading = true Task { await tappedRequestConfirmButton() + isLoading = false } } label: { Label("確認してもらう", systemImage: "paperplane.fill") @@ -55,7 +58,11 @@ private extension HouseworkDetailActionContent { func undoChangeStateButton() -> some View { Button { - // TODO: 未完了に戻す + isLoading = true + Task { + await tappedUndoStateButton() + isLoading = false + } } label: { Label("未完了に戻す", systemImage: "arrow.uturn.backward") .frame(maxWidth: .infinity) @@ -80,8 +87,6 @@ private extension HouseworkDetailActionContent { func tappedRequestConfirmButton() async { - isLoading = true - do { try await houseworkListStore.requestReview( target: item, @@ -92,8 +97,17 @@ private extension HouseworkDetailActionContent { catch { commonErrorContent = .init(error: error) } + } + + func tappedUndoStateButton() async { - isLoading = false + do { + + try await houseworkListStore.returnToIncomplete(target: item, now: .now) + } catch { + + commonErrorContent = .init(error: error) + } } } diff --git "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16-Pro-Max.1.png" "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16-Pro-Max.1.png" index 3566ff5..367fe31 100644 Binary files "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16-Pro-Max.1.png" and "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16-Pro-Max.1.png" differ diff --git "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16.1.png" "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16.1.png" index 39f12db..0a596df 100644 Binary files "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16.1.png" and "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-16.1.png" differ diff --git "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-SE-2nd-generation.1.png" "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-SE-2nd-generation.1.png" index fcd3115..4c09547 100644 Binary files "a/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-SE-2nd-generation.1.png" and "b/hometeSnapshotTests/__Snapshots__/PreviewTests.generated/HouseworkDetailView_\351\200\232\344\277\241\344\270\255-iPhone-SE-2nd-generation.1.png" differ diff --git a/hometeTests/Domain/CohabitantStoreTest.swift b/hometeTests/Domain/CohabitantStoreTest.swift new file mode 100644 index 0000000..a94bea9 --- /dev/null +++ b/hometeTests/Domain/CohabitantStoreTest.swift @@ -0,0 +1,81 @@ +// +// CohabitantStoreTest.swift +// hometeTests +// +// Created by Taichi Sato on 2026/01/12. +// + +import Testing +import Observation +@testable import homete + +@MainActor +struct CohabitantStoreTest { + + private let inputCohabitantId = "testCohabitantId" + private let inputListenerId = "cohabitantListenerKey" + + @Test("パートナーの監視中に、まだキャッシュしていないメンバーの場合はパートナーのリストにキャッシュとして追加する") + func addSnapshotListenerIfNeeded_add_member_case() async throws { + + // Arrange + + let newMemberId = "newMemberId" + let newMemberUserName = "新しいメンバー" + let expectedAccount = Account( + id: newMemberId, + userName: newMemberUserName, + fcmToken: nil, + cohabitantId: inputCohabitantId + ) + let inputCohabitantData = CohabitantData( + id: inputCohabitantId, + members: [newMemberId] + ) + + let (stream, continuation) = AsyncStream<[CohabitantData]>.makeStream() + + let store = CohabitantStore( + appDependencies: .init( + accountInfoClient: .init(fetch: { userId in + + // Assert + + #expect(userId == newMemberId) + return expectedAccount + }), + cohabitantClient: .init( + addSnapshotListener: { listenerId, cohabitantId in + + #expect(listenerId == inputListenerId) + #expect(cohabitantId == inputCohabitantId) + return stream + } + ) + ) + ) + + // Act + + await store.addSnapshotListenerIfNeeded(inputCohabitantId) + + // Assert + + let waiterForUpdateMembers = Task { + await withCheckedContinuation { continuation in + ObservationHelper.continuousObservationTracking { + store.members + } onChange: { + continuation.resume(returning: ()) + } + } + } + + continuation.yield([inputCohabitantData]) + await waiterForUpdateMembers.value + continuation.finish() + + #expect(store.members.value.count == 1) + #expect(store.members.value.contains(.init(id: newMemberId, userName: newMemberUserName))) + } +} diff --git a/hometeTests/Domain/Housework/HouseworkListStoreTest.swift b/hometeTests/Domain/Housework/HouseworkListStoreTest.swift index 4e260db..6c0699a 100644 --- a/hometeTests/Domain/Housework/HouseworkListStoreTest.swift +++ b/hometeTests/Domain/Housework/HouseworkListStoreTest.swift @@ -46,9 +46,9 @@ struct HouseworkListStoreTest { // Assert - var waiterForUpdateItems = Task { + let waiterForUpdateItems = Task { await withCheckedContinuation { continuation in - continuousObservationTracking { + ObservationHelper.continuousObservationTracking { store.items } onChange: { continuation.resume(returning: ()) @@ -56,7 +56,7 @@ struct HouseworkListStoreTest { } } - var inputHouseworkList: [HouseworkItem] = [ + let inputHouseworkList: [HouseworkItem] = [ .makeForTest(id: 1, indexedDate: now, expiredAt: now) ] continuation.yield(inputHouseworkList) @@ -176,6 +176,54 @@ struct HouseworkListStoreTest { } } + @Test("実施者、実施日をクリアして家事のステータスを未完了に戻す") + func returnToIncomplete() async throws { + + // Arrange + + let inputHouseworkItem = HouseworkItem.makeForTest( + id: 1, + state: .pendingApproval, + executorId: "dummyExecutor", + executedAt: .distantPast + ) + let requestedAt = Date() + let updatedHouseworkItem = inputHouseworkItem.updateProperties( + state: .incomplete, + executorId: nil, + executedAt: nil + ) + + try await confirmation(expectedCount: 1) { confirmation in + + let store = HouseworkListStore( + houseworkClient: .init( + insertOrUpdateItemHandler: { item, cohabitantId in + + // Assert + + #expect(item == updatedHouseworkItem) + #expect(cohabitantId == inputCohabitantId) + confirmation() + } + ), + cohabitantPushNotificationClient: .init { _, _ in + + Issue.record() + }, + items: [.makeForTest(items: [inputHouseworkItem])], + cohabitantId: inputCohabitantId + ) + + // Act + + try await store.returnToIncomplete( + target: inputHouseworkItem, + now: requestedAt + ) + } + } + @Test("家事削除時は家事を削除するAPIを実行する") func remove() async throws { @@ -205,18 +253,3 @@ struct HouseworkListStoreTest { } } } - -private extension HouseworkListStoreTest { - - nonisolated func continuousObservationTracking( - _ apply: @escaping () -> T, - onChange: @escaping (@Sendable () -> Void) - ) { - - _ = withObservationTracking(apply) { - - onChange() - continuousObservationTracking(apply, onChange: onChange) - } - } -} diff --git a/hometeTests/TestHelper/HouseworkItemHelper.swift b/hometeTests/TestHelper/HouseworkItemHelper.swift index 4e04492..b09f13b 100644 --- a/hometeTests/TestHelper/HouseworkItemHelper.swift +++ b/hometeTests/TestHelper/HouseworkItemHelper.swift @@ -47,8 +47,8 @@ extension HouseworkItem { let inputTitle = title ?? self.title let inputPoint = point ?? self.point let inputState = state ?? self.state - let inputExecutorId = executorId ?? self.executorId - let inputExecutedAt = executedAt ?? self.executedAt + let inputExecutorId = executorId + let inputExecutedAt = executedAt let inputExpiredAt = expiredAt ?? self.expiredAt return .init( diff --git a/hometeTests/TestHelper/ObservationHelper.swift b/hometeTests/TestHelper/ObservationHelper.swift new file mode 100644 index 0000000..086e4c1 --- /dev/null +++ b/hometeTests/TestHelper/ObservationHelper.swift @@ -0,0 +1,27 @@ +// +// ObservationHelper.swift +// homete +// +// Created by Taichi Sato on 2026/01/12. +// + +import Observation + +enum ObservationHelper { + + /// Observableなオブジェクトのプロパティが変更を検知する + /// - Parameters: + /// - apply: 変更を検知したいプロパティを返す + /// - onChange: 変更検知時に発火するクロージャ + static func continuousObservationTracking( + _ apply: @escaping () -> T, + onChange: @escaping (@Sendable () -> Void) + ) { + + _ = withObservationTracking(apply) { + + onChange() + continuousObservationTracking(apply, onChange: onChange) + } + } +}