From 3b3dc2f49a7c1fc01d3ecf240a341af29f57aeb8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 14:31:59 +0900 Subject: [PATCH 1/8] =?UTF-8?q?ui:=20=EC=95=84=EB=AC=B4=EA=B2=83=EB=8F=84?= =?UTF-8?q?=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Goal/Create/GoalCreateView.swift | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 87ca4f58..cef89aa8 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -141,35 +141,34 @@ private struct GoalCreateDescriptionEditor: View { VStack(spacing: 16) { GoalCreateModePicker(store: store, focusedField: _focusedField) - switch store.selectedTab { - case .write: - TextEditor(text: $store.markdownContent) - .focused($focusedField, equals: .content) - .font(.body) - .scrollContentBackground(.hidden) - .padding(12) - .frame(minHeight: 340, alignment: .topLeading) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) - case .preview: - Group { - if store.markdownContent.isEmpty { - ContentUnavailableView( - RecordPresentation.text("development_goal_create_preview_empty_title"), - systemImage: "doc.text.magnifyingglass", - description: Text( - RecordPresentation.text( - "development_goal_create_preview_empty_message" - ) - ) - ) - } else { - MarkdownContentView(content: store.markdownContent) - .padding(.vertical, 16) + Group { + switch store.selectedTab { + case .write: + TextEditor(text: $store.markdownContent) + .focused($focusedField, equals: .content) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + case .preview: + Group { + if store.markdownContent.isEmpty { + ContentUnavailableView { + Text(RecordPresentation.text("development_goal_create_preview_empty_title")) + .bold() + } description: { + Text(RecordPresentation.text("development_goal_create_preview_empty_message")) + } + .frame(maxWidth: .infinity) + .frame(height: 120) + } else { + MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) + } } } - .frame(minHeight: 340) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) } + .frame(minHeight: 120) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) Text(RecordPresentation.text("development_goal_create_markdown_hint")) .font(.caption) From c7befe0bfaf05a6b2b6be4e505d94881448bdc8d Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 14:50:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20Home=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9=ED=91=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 12 +++ .../Home/HomeDevelopmentGoalItem.swift | 15 ++++ .../Home/HomeFeature+Dependencies.swift | 45 ++++++++++ .../Sources/Home/HomeFeature+Effects.swift | 82 +++++++++++++++++++ .../HomeTab/Sources/Home/HomeFeature.swift | 26 +++++- .../Sources/HomeDependencyPreparation.swift | 11 +++ .../Home/HomeFeatureTestAssertions.swift | 47 +++++++++++ .../Tests/Home/HomeFeatureTestSpies.swift | 35 ++++++++ .../Tests/Home/HomeFeatureTestSupport.swift | 10 +++ .../HomeTab/Tests/Home/HomeFeatureTests.swift | 66 +++++++++++++++ 10 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 3195aa5c..fba1d91c 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -128,6 +128,18 @@ private extension AppGraph { } func prepareHomeDependencies(_ dependencies: inout DependencyValues) { + HomePresentationDependencyPreparation.prepareDevelopmentGoal( + &dependencies, + fetchGoalsUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalsUseCase, + fetchRecordsUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordVersionUseCase + ) HomePresentationDependencyPreparation.prepareTodoCategory( &dependencies, updateTodoCategoryPreferencesUseCase: todoGraphSet diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift new file mode 100644 index 00000000..8789beaf --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift @@ -0,0 +1,15 @@ +// +// HomeDevelopmentGoalItem.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import Domain + +struct HomeDevelopmentGoalItem: Equatable, Identifiable { + let goal: DevelopmentGoal + let recentRecord: DevelopmentRecord.Version? + + var id: String { goal.id } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift index f004334b..0aebc592 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift @@ -9,6 +9,21 @@ import PresentationShared import Domain extension DependencyValues { + var homeFetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase { + get { self[HomeFetchDevelopmentGoalsUseCaseKey.self] } + set { self[HomeFetchDevelopmentGoalsUseCaseKey.self] = newValue } + } + + var homeFetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase { + get { self[HomeFetchDevelopmentRecordsUseCaseKey.self] } + set { self[HomeFetchDevelopmentRecordsUseCaseKey.self] = newValue } + } + + var homeFetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase { + get { self[HomeFetchDevelopmentRecordVersionKey.self] } + set { self[HomeFetchDevelopmentRecordVersionKey.self] = newValue } + } + var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase { get { self[HomeUpdatePreferencesUseCaseKey.self] } set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue } @@ -20,6 +35,36 @@ extension DependencyValues { } } +private enum HomeFetchDevelopmentGoalsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentGoalsUseCase { + preconditionFailure("FetchDevelopmentGoalsUseCase must be provided.") + } + + static var testValue: FetchDevelopmentGoalsUseCase { + liveValue + } +} + +private enum HomeFetchDevelopmentRecordsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordsUseCase { + preconditionFailure("FetchDevelopmentRecordsUseCase must be provided.") + } + + static var testValue: FetchDevelopmentRecordsUseCase { + liveValue + } +} + +private enum HomeFetchDevelopmentRecordVersionKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordVersionUseCase { + preconditionFailure("FetchDevelopmentRecordVersionUseCase must be provided.") + } + + static var testValue: FetchDevelopmentRecordVersionUseCase { + liveValue + } +} + private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { static var liveValue: UpdateTodoCategoryPreferencesUseCase { preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift index a7f4a15d..4f760614 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift @@ -38,6 +38,28 @@ extension HomeFeature { } } + func fetchDevelopmentGoalsEffect() -> Effect { + let goalsUseCase = fetchDevelopmentGoalsUseCase + let recordsUseCase = fetchDevelopmentRecordsUseCase + let versionUseCase = fetchDevelopmentRecordVersionUseCase + + return .run { [goalsUseCase, recordsUseCase, versionUseCase] send in + await send(.loading(.begin(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + do { + let goals = try await goalsUseCase.execute(.init(status: .inProgress)) + let items = try await Self.makeDevelopmentGoalItems( + goals, + fetchRecordsUseCase: recordsUseCase, + fetchRecordVersionUseCase: versionUseCase + ) + await send(.store(.developmentGoalsLoaded(items))) + } catch { + await send(.store(.developmentGoalsLoadFailed)) + } + await send(.loading(.end(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + } + } + func trackTodoCreateEffect() -> Effect { .run { [trackAnalyticsEventUseCase] _ in trackAnalyticsEventUseCase.execute(.todoCreate) @@ -105,4 +127,64 @@ extension HomeFeature { } } + static func makeDevelopmentGoalItems( + _ goals: [DevelopmentGoal], + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) async throws -> [HomeDevelopmentGoalItem] { + var items = [HomeDevelopmentGoalItem]() + + try await withThrowingTaskGroup(of: HomeDevelopmentGoalItem.self) { group in + for goal in goals { + group.addTask { + let records = try await fetchRecordsUseCase.execute(goalId: goal.id) + let recentRecord = try await makeRecentRecord( + goalId: goal.id, + records: records, + fetchRecordVersionUseCase: fetchRecordVersionUseCase + ) + return HomeDevelopmentGoalItem(goal: goal, recentRecord: recentRecord) + } + } + + for try await item in group { + items.append(item) + } + } + + return items.sorted { lhs, rhs in + if lhs.goal.createdAt == rhs.goal.createdAt { + return lhs.id < rhs.id + } + return lhs.goal.createdAt < rhs.goal.createdAt + } + } + + static func makeRecentRecord( + goalId: String, + records: [DevelopmentRecord], + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) async throws -> DevelopmentRecord.Version? { + var versions = [DevelopmentRecord.Version]() + + try await withThrowingTaskGroup(of: DevelopmentRecord.Version.self) { group in + for record in records { + guard let currentVersion = record.currentVersion else { continue } + group.addTask { + try await fetchRecordVersionUseCase.execute( + goalId: goalId, + recordId: record.id, + versionId: currentVersion.id + ) + } + } + + for try await version in group { + versions.append(version) + } + } + + return versions.max { $0.confirmedAt < $1.confirmedAt } + } + } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift index 20a646ce..0f4e8700 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift @@ -16,6 +16,9 @@ struct HomeFeature { @Presents var alert: AlertState? @Presents var sheet: SheetState? @Presents var fullScreenCover: FullScreenCoverState? + var developmentGoalItems = [HomeDevelopmentGoalItem]() + var hasDevelopmentGoalsLoaded = false + var hasDevelopmentGoalsLoadFailure = false var preferences = [TodoCategoryItem]() var isTodoCategoryExpanded = false var isNetworkConnected = true @@ -37,6 +40,10 @@ struct HomeFeature { loading.visibleTargets.contains(LoadingTarget.preferences.target) } + var isDevelopmentGoalsLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.developmentGoals.target) + } + } enum Action: BindableAction, Equatable { @@ -62,6 +69,8 @@ struct HomeFeature { case setSheet(SheetState?) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool) + case developmentGoalsLoaded([HomeDevelopmentGoalItem]) + case developmentGoalsLoadFailed case setTodoCategory([TodoCategoryItem]) } } @@ -123,16 +132,22 @@ struct HomeFeature { enum LoadingTarget: Hashable { case preferences + case developmentGoals var target: LoadingFeature.Target { switch self { case .preferences: return LoadingFeature.Target("home.preferences") + case .developmentGoals: + return LoadingFeature.Target("home.developmentGoals") } } } @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.homeFetchDevelopmentGoalsUseCase) var fetchDevelopmentGoalsUseCase + @Dependency(\.homeFetchDevelopmentRecordsUseCase) var fetchDevelopmentRecordsUseCase + @Dependency(\.homeFetchDevelopmentRecordVersionUseCase) var fetchDevelopmentRecordVersionUseCase @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @@ -203,7 +218,10 @@ private extension HomeFeature { case .startObserving: return observeNetworkConnectivityEffect() case .fetchData: - return fetchTodoCategoryPreferencesEffect() + return .merge( + fetchTodoCategoryPreferencesEffect(), + fetchDevelopmentGoalsEffect() + ) case .todoEditorCreated: state.fullScreenCover = nil state.selectedTodoCategory = nil @@ -246,6 +264,12 @@ private extension HomeFeature { Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let isPresented): Self.setAlert(&state, isPresented: isPresented) + case .developmentGoalsLoaded(let items): + state.developmentGoalItems = items + state.hasDevelopmentGoalsLoaded = true + state.hasDevelopmentGoalsLoadFailure = false + case .developmentGoalsLoadFailed: + state.hasDevelopmentGoalsLoadFailure = true case .setTodoCategory(let preferences): state.preferences = preferences } diff --git a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift index b2f571e5..539639a5 100644 --- a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift @@ -9,6 +9,17 @@ import Domain import PresentationShared public enum HomeDependencyPreparation { + public static func prepareDevelopmentGoal( + _ dependencies: inout DependencyValues, + fetchGoalsUseCase: FetchDevelopmentGoalsUseCase, + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) { + dependencies.homeFetchDevelopmentGoalsUseCase = fetchGoalsUseCase + dependencies.homeFetchDevelopmentRecordsUseCase = fetchRecordsUseCase + dependencies.homeFetchDevelopmentRecordVersionUseCase = fetchRecordVersionUseCase + } + public static func prepareTodoCategory( _ dependencies: inout DependencyValues, updateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index 460511cd..1d619f55 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -71,6 +71,53 @@ struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy } +func makeDevelopmentGoal( + id: String, + createdAt: TimeInterval +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: "Goal \(id)", + description: "Description", + status: .inProgress, + createdAt: Date(timeIntervalSinceReferenceDate: createdAt), + updatedAt: Date(timeIntervalSinceReferenceDate: createdAt), + completedAt: nil + ) +} + +func makeDevelopmentRecord( + id: String, + goalId: String, + versionID: String +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: id, + goalId: goalId, + currentVersion: .init(id: versionID, number: 1), + draft: nil, + createdAt: .now + ) +} + +func makeDevelopmentRecordVersion( + id: String, + recordID: String, + title: String, + confirmedAt: TimeInterval +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: id, + recordId: recordID, + number: 1, + title: title, + markdownContent: "", + kind: .initial, + sourceVersionId: nil, + confirmedAt: Date(timeIntervalSinceReferenceDate: confirmedAt) + ) +} + func makeHomeFetchDataContext() -> HomeFetchDataContext { let fetchPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() fetchPreferencesUseCaseSpy.todoCategoryPreferences = [ diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 6304acc2..5d5666fb 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -48,3 +48,38 @@ final class ObserveNetworkConnectivityUseCaseSpy: ObserveNetworkConnectivityUseC currentValueSubject.eraseToAnyPublisher() } } + +final class FetchDevelopmentGoalsUseCaseSpy: FetchDevelopmentGoalsUseCase { + private(set) var queries = [DevelopmentGoal.Query]() + var result: Result<[DevelopmentGoal], Error> = .success([]) + + func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + queries.append(query) + return try result.get() + } +} + +final class FetchDevelopmentRecordsUseCaseSpy: FetchDevelopmentRecordsUseCase { + var resultByGoalID = [String: Result<[DevelopmentRecord], Error>]() + + func execute(goalId: String) async throws -> [DevelopmentRecord] { + try resultByGoalID[goalId, default: .success([])].get() + } +} + +final class FetchDevelopmentRecordVersionUseCaseSpy: FetchDevelopmentRecordVersionUseCase { + var resultByRecordID = [String: Result]() + + func execute( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + try resultByRecordID[recordId, default: .failure(HomeDevelopmentGoalTestError.notFound)].get() + } +} + +enum HomeDevelopmentGoalTestError: Error { + case failed + case notFound +} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift index 007850a7..bc02c952 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -20,6 +20,9 @@ struct HomeStoreTestAdapter { var preferences: [TodoCategoryItem] { store.state.preferences } var isTodoCategoryExpanded: Bool { store.state.isTodoCategoryExpanded } + var developmentGoalItems: [HomeDevelopmentGoalItem] { store.state.developmentGoalItems } + var hasDevelopmentGoalsLoaded: Bool { store.state.hasDevelopmentGoalsLoaded } + var hasDevelopmentGoalsLoadFailure: Bool { store.state.hasDevelopmentGoalsLoadFailure } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } var showCategoryManage: Bool { @@ -31,6 +34,10 @@ struct HomeStoreTestAdapter { fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), + fetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase = FetchDevelopmentGoalsUseCaseSpy(), + fetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase = FetchDevelopmentRecordsUseCaseSpy(), + fetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase = + FetchDevelopmentRecordVersionUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { @@ -42,6 +49,9 @@ struct HomeStoreTestAdapter { $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase $0.homeNetworkConnectivityUseCase = networkConnectivityUseCase + $0.homeFetchDevelopmentGoalsUseCase = fetchDevelopmentGoalsUseCase + $0.homeFetchDevelopmentRecordsUseCase = fetchDevelopmentRecordsUseCase + $0.homeFetchDevelopmentRecordVersionUseCase = fetchDevelopmentRecordVersionUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock configureDependencies?(&$0) diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index fb3188a2..79b80d74 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -22,6 +22,72 @@ struct HomeFeatureTests { try await verifyHomeFetchData(adapter: adapter) } + @Test("HomeFeature fetchData는 진행 중 목표의 최신 확정 기록을 갱신한다") + func HomeFeature_fetchData는_진행_중_목표의_최신_확정_기록을_갱신한다() async throws { + let goal = try makeDevelopmentGoal(id: "goal", createdAt: 1) + let olderRecord = try makeDevelopmentRecord( + id: "older-record", + goalId: goal.id, + versionID: "older-version" + ) + let recentRecord = try makeDevelopmentRecord( + id: "recent-record", + goalId: goal.id, + versionID: "recent-version" + ) + let olderVersion = try makeDevelopmentRecordVersion( + id: "older-version", + recordID: olderRecord.id, + title: "Earlier Record", + confirmedAt: 1 + ) + let recentVersion = try makeDevelopmentRecordVersion( + id: "recent-version", + recordID: recentRecord.id, + title: "Recent Record", + confirmedAt: 2 + ) + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .success([goal]) + let recordsSpy = FetchDevelopmentRecordsUseCaseSpy() + recordsSpy.resultByGoalID[goal.id] = .success([olderRecord, recentRecord]) + let versionsSpy = FetchDevelopmentRecordVersionUseCaseSpy() + versionsSpy.resultByRecordID[olderRecord.id] = .success(olderVersion) + versionsSpy.resultByRecordID[recentRecord.id] = .success(recentVersion) + let adapter = HomeStoreTestAdapter( + fetchDevelopmentGoalsUseCase: goalsSpy, + fetchDevelopmentRecordsUseCase: recordsSpy, + fetchDevelopmentRecordVersionUseCase: versionsSpy + ) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + + #expect(goalsSpy.queries == [.init(status: .inProgress)]) + #expect(adapter.developmentGoalItems.map(\.id) == [goal.id]) + #expect(adapter.developmentGoalItems.first?.recentRecord == recentVersion) + } + + @Test("HomeFeature fetchData 실패 뒤 재시도는 진행 중 목표를 갱신한다") + func HomeFeature_fetchData_실패_뒤_재시도는_진행_중_목표를_갱신한다() async throws { + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .failure(HomeDevelopmentGoalTestError.failed) + let adapter = HomeStoreTestAdapter(fetchDevelopmentGoalsUseCase: goalsSpy) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoadFailure } + #expect(!adapter.hasDevelopmentGoalsLoaded) + + goalsSpy.result = .success([]) + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + #expect(adapter.developmentGoalItems.isEmpty) + #expect(!adapter.hasDevelopmentGoalsLoadFailure) + } + @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") func HomeFeature_tapTodoCategory는_editor를_지연_표시한다() async throws { let adapter = HomeStoreTestAdapter() From 38bfbcdbc6a1f794b10d098ddfa8545bbc7f1ac3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 22:27:15 +0900 Subject: [PATCH 3/8] =?UTF-8?q?ui:=20=EC=99=84=EB=A3=8C=EC=9C=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=B4=88=EC=95=88=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/HomeDevelopmentSummaryCard.swift | 93 +++++++++++++++++++ .../HomeTab/Sources/Home/HomeView.swift | 6 ++ .../Resources/Localizable.xcstrings | 28 ++++++ 3 files changed, 127 insertions(+) create mode 100644 Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift new file mode 100644 index 00000000..60596a1f --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift @@ -0,0 +1,93 @@ +// +// HomeDevelopmentSummaryCard.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import Domain +import SwiftUI +import PresentationShared + +struct HomeDevelopmentSummaryCard: View { + let items: [HomeDevelopmentGoalItem] + let isLoading: Bool + let hasLoaded: Bool + let hasLoadFailure: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("home_development_summary_eyebrow", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textTertiary) + summaryTitle + } + Spacer(minLength: 16) + Image(systemName: "flag.checkered") + .font(.title2.weight(.semibold)) + .foregroundStyle(Color.accent) + .frame(width: 64, height: 64) + .background(Color.accent.opacity(0.1), in: .rect(cornerRadius: 20)) + } + + summaryContent + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 28)) + .accessibilityElement(children: .combine) + } + + private var summaryTitle: some View { + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_summary_title_format", + bundle: PresentationResources.bundle + ), + items.count + ) + ) + .font(.title2.weight(.semibold)) + .foregroundStyle(Color.primary) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var summaryContent: some View { + if !hasLoaded && isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else if !hasLoaded && hasLoadFailure { + Text("common_error_message", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + } else if let recentRecord { + VStack(alignment: .leading, spacing: 8) { + Text("home_development_summary_recent_record", bundle: PresentationResources.bundle) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.textTertiary) + HStack(spacing: 12) { + Text(recentRecord.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.primary) + .lineLimit(1) + Spacer(minLength: 12) + Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + } else { + Text("home_development_summary_empty_message", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + } + } + + private var recentRecord: DevelopmentRecord.Version? { + items.compactMap(\.recentRecord).max { $0.confirmedAt < $1.confirmedAt } + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index cef29b41..bb2579d5 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -47,6 +47,12 @@ public struct HomeView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { + HomeDevelopmentSummaryCard( + items: store.developmentGoalItems, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure + ) todoSection } header: { topBar diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index aede72b2..38142733 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,34 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "home_development_summary_eyebrow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Ongoing Development" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이어가는 개발" } } + } + }, + "home_development_summary_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } + } + }, + "home_development_summary_recent_record" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } + } + }, + "home_development_summary_title_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld goals are in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } + } + }, "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { From b12730148fe36c3d8856fa123d5967676d93a058 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 22:41:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Editor/RecordEditorView.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 5dbf09a6..51cf7c8c 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -297,10 +297,3 @@ private enum Field: Hashable { case title case content } - -#Preview("새 개발 기록") { - RecordEditorView( - goalId: "preview-goal", - goalTitle: "개발 기록의 버전 이력 완성" - ) -} From f122f1b6eb50198cf4f6ed568187dae26b3416d2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 23:01:38 +0900 Subject: [PATCH 5/8] =?UTF-8?q?ui:=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EB=B0=9C=20=EC=98=81=EC=97=AD=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/HomeDevelopmentGoalSection.swift | 192 ++++++++++++++++++ .../HomeTab/Sources/Home/HomeView.swift | 24 ++- .../Resources/Localizable.xcstrings | 42 ++++ Application/Presentation/Project.swift | 2 + 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift new file mode 100644 index 00000000..efb30a35 --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift @@ -0,0 +1,192 @@ +// +// HomeDevelopmentGoalSection.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import PresentationShared + +struct HomeDevelopmentGoalSection: View { + let items: [HomeDevelopmentGoalItem] + let isLoading: Bool + let hasLoaded: Bool + let hasLoadFailure: Bool + let onCreate: () -> Void + let onSelect: (HomeDevelopmentGoalItem) -> Void + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + content + } + .padding(.top, 8) + } + + @ViewBuilder + private var content: some View { + if !hasLoaded && isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if !hasLoaded && hasLoadFailure { + ContentUnavailableView { + Label( + String(localized: "common_error_title", bundle: PresentationResources.bundle), + systemImage: "exclamationmark.triangle" + ) + } description: { + Text(String(localized: "common_error_message", bundle: PresentationResources.bundle)) + } actions: { + Button(String(localized: "development_record_timeline_retry", bundle: PresentationResources.bundle)) { + onRetry() + } + .buttonStyle(.borderedProminent) + } + } else if items.isEmpty { + ContentUnavailableView { + Label( + String( + localized: "home_development_goal_empty_title", + bundle: PresentationResources.bundle + ), + systemImage: "flag.checkered" + ) + } description: { + Text( + String( + localized: "home_development_goal_empty_message", + bundle: PresentationResources.bundle + ) + ) + } + } else { + LazyVStack(spacing: 16) { + ForEach(items) { item in + Button { + onSelect(item) + } label: { + HomeDevelopmentGoalCard(item: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var header: some View { + HStack(spacing: 8) { + Text("home_development_goal_section_title", bundle: PresentationResources.bundle) + .font(.title2) + .foregroundStyle(Color.primary) + if hasLoaded { + Text( + String.localizedStringWithFormat( + String(localized: "home_development_goal_count_format", bundle: PresentationResources.bundle), + items.count + ) + ) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accent.opacity(0.1), in: .capsule) + } + Spacer() + Button(action: onCreate) { + Text("home_development_goal_create", bundle: PresentationResources.bundle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.textSecondary) + } + .accessibilityLabel( + String(localized: "development_goal_create_title", bundle: PresentationResources.bundle) + ) + } + .accessibilityElement(children: .combine) + } +} + +private struct HomeDevelopmentGoalCard: View { + let item: HomeDevelopmentGoalItem + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + HomeDevelopmentGoalStatusBadge() + Spacer(minLength: 12) + if let recentRecord = item.recentRecord { + Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + + Text(item.goal.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + if !item.goal.description.isEmpty { + Text(item.goal.description) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Divider() + + if let recentRecord = item.recentRecord { + VStack(alignment: .leading, spacing: 3) { + Text("home_development_goal_recent_record", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + Text(recentRecord.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.primary) + .lineLimit(1) + } + } else { + Text("development_record_empty_title", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + .padding(18) + .background(Color.surface, in: .rect(cornerRadius: 24)) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } +} + +private struct HomeDevelopmentGoalStatusBadge: View { + var body: some View { + Label { + Text("development_goal_status_in_progress", bundle: PresentationResources.bundle) + } icon: { + Image(systemName: "clock") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accent.opacity(0.1), in: .capsule) + } +} + +enum HomeGoalPresentation: Identifiable { + case create + case detail(String) + + var id: String { + switch self { + case .create: + "create" + case .detail(let goalID): + goalID + } + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index bb2579d5..f67b457f 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import Development import Domain import PresentationShared @@ -18,6 +19,7 @@ public struct HomeView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) @ScaledMetric(relativeTo: .title2) private var categoryIconSize = CGFloat(64) @State private var path = [HomeRoute]() + @State private var goalPresentation: HomeGoalPresentation? @State private var searchStore: StoreOf @State private var store: StoreOf private let isSelected: Bool @@ -57,6 +59,15 @@ public struct HomeView: View { } header: { topBar } + HomeDevelopmentGoalSection( + items: store.developmentGoalItems, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, + onCreate: { goalPresentation = .create }, + onSelect: { goalPresentation = .detail($0.id) }, + onRetry: { store.send(.view(.fetchData)) } + ) } .padding(.horizontal, 16) } @@ -82,6 +93,14 @@ public struct HomeView: View { .activePresentation(when: isSelected), content: sheetContent ) + .sheet(item: $goalPresentation, onDismiss: refreshDevelopmentGoals) { presentation in + switch presentation { + case .create: + GoalCreateView() + case .detail(let goalID): + GoalDetailView(goalId: goalID) + } + } .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) .activePresentation(when: isSelected), @@ -356,7 +375,6 @@ public struct HomeView: View { store.send(.view(.tapTodoCategory(todoCategory))) } } - private func categoryIconBackground(_ item: TodoCategoryItem) -> Color { if colorScheme == .dark { return item.color @@ -364,7 +382,6 @@ public struct HomeView: View { return item.color.opacity(0.12) } - private func categoryIconForeground(_ item: TodoCategoryItem) -> Color { if colorScheme == .dark { return .white @@ -372,6 +389,9 @@ public struct HomeView: View { return item.color } + private func refreshDevelopmentGoals() { + store.send(.view(.fetchData)) + } } public enum HomeRoute: Hashable { diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 38142733..a6693d70 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -452,6 +452,48 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } } }, + "home_development_goal_count_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중 %lld개" } } + } + }, + "home_development_goal_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "New Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표" } } + } + }, + "home_development_goal_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } + } + }, + "home_development_goal_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No goals in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 없어요" } } + } + }, + "home_development_goal_recent_record" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } + } + }, + "home_development_goal_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Goals in Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 개발" } } + } + }, "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index bc587cce..779a86fb 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -151,6 +151,7 @@ let project = Project( dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "Core", path: "../Core"), + .target(name: "Development"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings @@ -170,6 +171,7 @@ let project = Project( ], dependencies: [ .target(name: "HomeTab"), + .target(name: "Development"), .target(name: "PresentationShared"), thirdPartyDependency, ], From 309fb09cc012e6523e8cb48ac964e84acfee3b00 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 23:38:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Home=20=EB=AA=A9=ED=91=9C=20Todo=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EC=9C=A8=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 3 +- .../Home/HomeDevelopmentGoalItem.swift | 32 +++++++++++ .../Home/HomeDevelopmentGoalSection.swift | 55 +++++++++++++++++++ .../Home/HomeDevelopmentSummaryCard.swift | 47 ++++++++++------ .../Sources/Home/HomeFeature+Effects.swift | 53 +++++++++++++++--- .../HomeTab/Sources/Home/HomeFeature.swift | 1 + .../Sources/HomeDependencyPreparation.swift | 15 ++++- .../Home/HomeFeatureTestAssertions.swift | 24 ++++++++ .../Tests/Home/HomeFeatureTestSpies.swift | 10 ++++ .../Tests/Home/HomeFeatureTestSupport.swift | 2 + .../HomeTab/Tests/Home/HomeFeatureTests.swift | 34 ++++++++++++ .../Resources/Localizable.xcstrings | 21 +++++++ 12 files changed, 271 insertions(+), 26 deletions(-) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index fba1d91c..134d97a1 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -138,7 +138,8 @@ private extension AppGraph { .fetchDevelopmentRecordsUseCase, fetchRecordVersionUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph - .fetchDevelopmentRecordVersionUseCase + .fetchDevelopmentRecordVersionUseCase, + fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase ) HomePresentationDependencyPreparation.prepareTodoCategory( &dependencies, diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift index 8789beaf..d511bd21 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift @@ -10,6 +10,38 @@ import Domain struct HomeDevelopmentGoalItem: Equatable, Identifiable { let goal: DevelopmentGoal let recentRecord: DevelopmentRecord.Version? + let todoProgress: HomeDevelopmentGoalTodoProgress var id: String { goal.id } + + init( + goal: DevelopmentGoal, + recentRecord: DevelopmentRecord.Version?, + todoProgress: HomeDevelopmentGoalTodoProgress = .empty + ) { + self.goal = goal + self.recentRecord = recentRecord + self.todoProgress = todoProgress + } +} + +struct HomeDevelopmentGoalTodoProgress: Equatable { + let completedCount: Int + let totalCount: Int + + static let empty = Self(completedCount: 0, totalCount: 0) + + var fraction: Double { + guard totalCount != 0 else { return 0 } + return Double(completedCount) / Double(totalCount) + } + + var percentage: Int { + guard totalCount != 0 else { return 0 } + return (completedCount * 100 + totalCount / 2) / totalCount + } + + var isEmpty: Bool { + totalCount == 0 + } } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift index efb30a35..cdb40e85 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift @@ -137,6 +137,8 @@ private struct HomeDevelopmentGoalCard: View { .frame(maxWidth: .infinity, alignment: .leading) } + HomeDevelopmentTodoProgressView(progress: item.todoProgress) + Divider() if let recentRecord = item.recentRecord { @@ -162,6 +164,59 @@ private struct HomeDevelopmentGoalCard: View { } } +struct HomeDevelopmentTodoProgressView: View { + let progress: HomeDevelopmentGoalTodoProgress + + var body: some View { + if progress.isEmpty { + Text("home_development_goal_todo_empty", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_goal_todo_progress_format", + bundle: PresentationResources.bundle + ), + progress.completedCount, + progress.totalCount + ) + ) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.textSecondary) + Spacer(minLength: 8) + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_goal_todo_progress_percent_format", + bundle: PresentationResources.bundle + ), + progress.percentage + ) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + } + + GeometryReader { proxy in + Capsule() + .fill(Color.border.opacity(0.6)) + .overlay(alignment: .leading) { + Capsule() + .fill(Color.accent) + .frame(width: proxy.size.width * progress.fraction) + } + } + .frame(height: 8) + } + .accessibilityElement(children: .combine) + } + } +} + private struct HomeDevelopmentGoalStatusBadge: View { var body: some View { Label { diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift index 60596a1f..bb1f93f9 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift @@ -64,30 +64,43 @@ struct HomeDevelopmentSummaryCard: View { Text("common_error_message", bundle: PresentationResources.bundle) .font(.subheadline) .foregroundStyle(Color.textSecondary) - } else if let recentRecord { - VStack(alignment: .leading, spacing: 8) { - Text("home_development_summary_recent_record", bundle: PresentationResources.bundle) - .font(.caption.weight(.medium)) - .foregroundStyle(Color.textTertiary) - HStack(spacing: 12) { - Text(recentRecord.title) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Color.primary) - .lineLimit(1) - Spacer(minLength: 12) - Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) - .font(.caption) - .foregroundStyle(Color.textTertiary) - } - } - } else { + } else if items.isEmpty { Text("home_development_summary_empty_message", bundle: PresentationResources.bundle) .font(.subheadline) .foregroundStyle(Color.textSecondary) + } else { + VStack(alignment: .leading, spacing: 8) { + HomeDevelopmentTodoProgressView(progress: todoProgress) + + if let recentRecord { + VStack(alignment: .leading, spacing: 8) { + Text("home_development_summary_recent_record", bundle: PresentationResources.bundle) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.textTertiary) + HStack(spacing: 12) { + Text(recentRecord.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.primary) + .lineLimit(1) + Spacer(minLength: 12) + Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + } + } } } private var recentRecord: DevelopmentRecord.Version? { items.compactMap(\.recentRecord).max { $0.confirmedAt < $1.confirmedAt } } + + private var todoProgress: HomeDevelopmentGoalTodoProgress { + HomeDevelopmentGoalTodoProgress( + completedCount: items.reduce(0) { $0 + $1.todoProgress.completedCount }, + totalCount: items.reduce(0) { $0 + $1.todoProgress.totalCount } + ) + } } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift index 4f760614..e834475b 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift @@ -42,17 +42,39 @@ extension HomeFeature { let goalsUseCase = fetchDevelopmentGoalsUseCase let recordsUseCase = fetchDevelopmentRecordsUseCase let versionUseCase = fetchDevelopmentRecordVersionUseCase + let todosUseCase = fetchTodosUseCase - return .run { [goalsUseCase, recordsUseCase, versionUseCase] send in + return .run { [goalsUseCase, recordsUseCase, versionUseCase, todosUseCase] send in await send(.loading(.begin(target: LoadingTarget.developmentGoals.target, mode: .immediate))) do { let goals = try await goalsUseCase.execute(.init(status: .inProgress)) - let items = try await Self.makeDevelopmentGoalItems( - goals, - fetchRecordsUseCase: recordsUseCase, - fetchRecordVersionUseCase: versionUseCase - ) - await send(.store(.developmentGoalsLoaded(items))) + if goals.isEmpty { + await send(.store(.developmentGoalsLoaded([]))) + } else { + async let todos = todosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + let goalItems = try await Self.makeDevelopmentGoalItems( + goals, + fetchRecordsUseCase: recordsUseCase, + fetchRecordVersionUseCase: versionUseCase + ) + let todoProgressByGoalID = Self.makeTodoProgressByGoalID((try await todos).items) + let items = goalItems.map { item in + HomeDevelopmentGoalItem( + goal: item.goal, + recentRecord: item.recentRecord, + todoProgress: todoProgressByGoalID[item.goal.id] ?? .empty + ) + } + await send(.store(.developmentGoalsLoaded(items))) + } } catch { await send(.store(.developmentGoalsLoadFailed)) } @@ -187,4 +209,21 @@ extension HomeFeature { return versions.max { $0.confirmedAt < $1.confirmedAt } } + static func makeTodoProgressByGoalID( + _ todos: [Todo] + ) -> [String: HomeDevelopmentGoalTodoProgress] { + var progressByGoalID = [String: HomeDevelopmentGoalTodoProgress]() + + for todo in todos { + guard let goalID = todo.goalId else { continue } + let currentProgress = progressByGoalID[goalID] ?? .empty + progressByGoalID[goalID] = HomeDevelopmentGoalTodoProgress( + completedCount: currentProgress.completedCount + (todo.isCompleted ? 1 : 0), + totalCount: currentProgress.totalCount + 1 + ) + } + + return progressByGoalID + } + } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift index 0f4e8700..95ad3e68 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift @@ -148,6 +148,7 @@ struct HomeFeature { @Dependency(\.homeFetchDevelopmentGoalsUseCase) var fetchDevelopmentGoalsUseCase @Dependency(\.homeFetchDevelopmentRecordsUseCase) var fetchDevelopmentRecordsUseCase @Dependency(\.homeFetchDevelopmentRecordVersionUseCase) var fetchDevelopmentRecordVersionUseCase + @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase diff --git a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift index 539639a5..40e57a0a 100644 --- a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift @@ -13,11 +13,13 @@ public enum HomeDependencyPreparation { _ dependencies: inout DependencyValues, fetchGoalsUseCase: FetchDevelopmentGoalsUseCase, fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, - fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase, + fetchTodosUseCase: FetchTodosUseCase ) { dependencies.homeFetchDevelopmentGoalsUseCase = fetchGoalsUseCase dependencies.homeFetchDevelopmentRecordsUseCase = fetchRecordsUseCase dependencies.homeFetchDevelopmentRecordVersionUseCase = fetchRecordVersionUseCase + dependencies.homeFetchTodosUseCase = fetchTodosUseCase } public static func prepareTodoCategory( @@ -47,12 +49,23 @@ public enum HomeDependencyPreparation { } extension DependencyValues { + var homeFetchTodosUseCase: FetchTodosUseCase { + get { self[HomeFetchTodosUseCaseKey.self] } + set { self[HomeFetchTodosUseCaseKey.self] = newValue } + } + var homeFetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase { get { self[HomeFetchRecentSearchQueriesUseCaseKey.self] } set { self[HomeFetchRecentSearchQueriesUseCaseKey.self] = newValue } } } +private enum HomeFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } +} + private enum HomeFetchRecentSearchQueriesUseCaseKey: DependencyKey { static var liveValue: FetchRecentSearchQueriesUseCase { preconditionFailure("FetchRecentSearchQueriesUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index 1d619f55..4486d4c4 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -118,6 +118,30 @@ func makeDevelopmentRecordVersion( ) } +func makeHomeTodo( + id: String, + goalID: String?, + isCompleted: Bool +) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: isCompleted, + isChecked: false, + number: 1, + title: "Todo \(id)", + content: "", + createdAt: .now, + updatedAt: .now, + completedAt: isCompleted ? .now : nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature), + goalId: goalID + ) +} + func makeHomeFetchDataContext() -> HomeFetchDataContext { let fetchPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() fetchPreferencesUseCaseSpy.todoCategoryPreferences = [ diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 5d5666fb..32cccdfe 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -79,6 +79,16 @@ final class FetchDevelopmentRecordVersionUseCaseSpy: FetchDevelopmentRecordVersi } } +final class HomeFetchTodosUseCaseSpy: FetchTodosUseCase { + private(set) var queries = [TodoQuery]() + var page = TodoPage(items: [], nextCursor: nil) + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + return page + } +} + enum HomeDevelopmentGoalTestError: Error { case failed case notFound diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift index bc02c952..83ab1904 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -38,6 +38,7 @@ struct HomeStoreTestAdapter { fetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase = FetchDevelopmentRecordsUseCaseSpy(), fetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = HomeFetchTodosUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { @@ -52,6 +53,7 @@ struct HomeStoreTestAdapter { $0.homeFetchDevelopmentGoalsUseCase = fetchDevelopmentGoalsUseCase $0.homeFetchDevelopmentRecordsUseCase = fetchDevelopmentRecordsUseCase $0.homeFetchDevelopmentRecordVersionUseCase = fetchDevelopmentRecordVersionUseCase + $0.homeFetchTodosUseCase = fetchTodosUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock configureDependencies?(&$0) diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index 79b80d74..870ccd1c 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -69,6 +69,40 @@ struct HomeFeatureTests { #expect(adapter.developmentGoalItems.first?.recentRecord == recentVersion) } + @Test("HomeFeature fetchData는 연결 Todo 완료 수로 목표 진행률을 계산한다") + func HomeFeature_fetchData는_연결_Todo_완료_수로_목표_진행률을_계산한다() async throws { + let goal = try makeDevelopmentGoal(id: "goal", createdAt: 1) + let todosSpy = HomeFetchTodosUseCaseSpy() + todosSpy.page = TodoPage( + items: [ + makeHomeTodo(id: "completed", goalID: goal.id, isCompleted: true), + makeHomeTodo(id: "incomplete", goalID: goal.id, isCompleted: false), + makeHomeTodo(id: "other", goalID: "other-goal", isCompleted: true) + ], + nextCursor: nil + ) + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .success([goal]) + let adapter = HomeStoreTestAdapter( + fetchDevelopmentGoalsUseCase: goalsSpy, + fetchTodosUseCase: todosSpy + ) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + + #expect(adapter.developmentGoalItems.first?.todoProgress == .init(completedCount: 1, totalCount: 2)) + #expect(todosSpy.queries == [ + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ) + ]) + } + @Test("HomeFeature fetchData 실패 뒤 재시도는 진행 중 목표를 갱신한다") func HomeFeature_fetchData_실패_뒤_재시도는_진행_중_목표를_갱신한다() async throws { let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index a6693d70..3d98961c 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -494,6 +494,27 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 개발" } } } }, + "home_development_goal_todo_empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No linked Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo 없음" } } + } + }, + "home_development_goal_todo_progress_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld of %lld Todos complete" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo %lld/%lld 완료" } } + } + }, + "home_development_goal_todo_progress_percent_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } } + } + }, "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { From 86c81151c4196ba3c22ba71b64b8888c27240d29 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 23:38:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20Todo=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A9=ED=91=9C=20=EC=97=B0=EA=B2=B0=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Todo/Editor/TodoEditorFeature.swift | 9 ++++++-- .../Todo/TodoEditorFeatureTestDoubles.swift | 6 +++-- .../Tests/Todo/TodoEditorFeatureTests.swift | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift index 67b3a63f..695eb114 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift @@ -48,6 +48,7 @@ public struct TodoEditorFeature { let number: Int? let createdAt: Date? let deletedAt: Date? + let goalId: String? let originalDraft: TodoDraft? var isValidToSave: Bool { @@ -86,6 +87,7 @@ public struct TodoEditorFeature { self.number = nil self.createdAt = nil self.deletedAt = nil + self.goalId = nil self.originalDraft = nil self.category = TodoCategoryItem(from: category) self.categories = [TodoCategoryItem(from: category)] @@ -97,6 +99,7 @@ public struct TodoEditorFeature { self.number = todo.number self.createdAt = todo.createdAt self.deletedAt = todo.deletedAt + self.goalId = todo.goalId self.originalDraft = TodoDraft(todo: todo) self.isCompleted = todo.isCompleted self.completedAt = todo.completedAt @@ -368,7 +371,8 @@ private extension TodoEditorFeature.State { completedAt: completedAt, dueDate: dueDate, tags: Array(tags), - category: category.category + category: category.category, + goalId: goalId ) } @@ -388,7 +392,8 @@ private extension TodoEditorFeature.State { deletedAt: deletedAt, dueDate: dueDate, tags: Array(tags), - category: category.category + category: category.category, + goalId: goalId ) } } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift index c154b912..59a372cf 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift @@ -330,7 +330,8 @@ func makeTodoEditorTodo( deletedAt: Date? = nil, dueDate: Date? = nil, tags: [String] = [], - category: TodoCategory = .system(.doc) + category: TodoCategory = .system(.doc), + goalId: String? = nil ) -> Todo { Todo( id: id, @@ -346,7 +347,8 @@ func makeTodoEditorTodo( deletedAt: deletedAt, dueDate: dueDate, tags: tags, - category: category + category: category, + goalId: goalId ) } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift index 6a2c8535..c5245f32 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift @@ -288,6 +288,28 @@ struct TodoEditorFeatureTests { #expect(adapter.saveResult == .updated(updated)) } + @Test("연결된 Todo를 완료 처리해도 개발 목표 연결을 유지한다") + func 연결된_Todo를_완료_처리해도_개발_목표_연결을_유지한다() async throws { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.shouldSuspend = true + let todo = makeTodoEditorTodo(goalId: "goal-1") + let adapter = TodoEditorStoreTestAdapter(todo: todo, upsertTodoUseCase: upsertSpy) + + await adapter.setCompleted(true) + await adapter.upsertTodo() + + let updated = try #require(upsertSpy.todos.first) + + #expect(updated.goalId == "goal-1") + #expect(updated.isCompleted) + #expect(updated.completedAt == todoEditorNow) + + upsertSpy.resume() + await adapter.receiveUpdateSucceeded(updated) + await adapter.receiveUpdatedDelegate(updated) + await adapter.drainReceivedActions() + } + @Test("저장 실패는 공통 에러 알림을 표시하고 로딩을 해제한다") func 저장_실패는_공통_에러_알림을_표시하고_로딩을_해제한다() async { let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() From 87d1662f203ccd0f94e1b1234bfd3d330b88eb1e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 21 Sep 2026 00:26:41 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20ci=20=EC=8B=A4=ED=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index 870ccd1c..e01610be 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Core import Domain import PresentationShared @testable import HomeTab