From de34bc088b5951fca626a3ba49e1c67559402e93 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:15:21 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Store=201=EC=B0=A8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Today/TodayFeature+State.swift | 114 ++++++ .../Sources/Today/TodayFeature.swift | 345 ++++++++++++++++++ .../Sources/Today/TodayView.swift | 101 ++--- .../Sources/Today/TodayViewCoordinator.swift | 27 +- .../Today/TodayFeatureTestAssertions.swift | 213 +++++++++++ .../Tests/Today/TodayFeatureTestDoubles.swift | 316 ++++++++++++++++ .../Tests/Today/TodayFeatureTestSpies.swift | 155 ++++++++ .../Tests/Today/TodayFeatureTests.swift | 236 ++++++++++++ 8 files changed, 1452 insertions(+), 55 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift create mode 100644 Application/DevLogPresentation/Sources/Today/TodayFeature.swift create mode 100644 Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift create mode 100644 Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift create mode 100644 Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift create mode 100644 Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift new file mode 100644 index 00000000..d0ef1db6 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift @@ -0,0 +1,114 @@ +// +// TodayFeature+State.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import Foundation + +extension TodayFeature.State { + func summaryValue(for scope: TodayFeature.SectionScope) -> Int { + switch scope { + case .all: + return displayedTodos.count + case .focused: + return displayedTodos.filter(\.isPinned).count + case .overdue: + return displayedTodos.filter(isOverdue).count + case .dueSoon: + return displayedTodos.filter(isDueSoon).count + } + } + + var displayedTodos: [TodayTodoItem] { + let dueDateFilteredTodos: [TodayTodoItem] + switch displayOptions.dueDateVisibility { + case .all: + dueDateFilteredTodos = todos + case .withDueDateOnly: + dueDateFilteredTodos = todos.filter { $0.dueDate != nil } + case .withoutDueDateOnly: + dueDateFilteredTodos = todos.filter { $0.dueDate == nil } + } + + switch displayOptions.focusVisibility { + case .all: + return dueDateFilteredTodos + case .focusedOnly: + return dueDateFilteredTodos.filter(\.isPinned) + } + } + + func groupedSectionItems( + from items: [TodayTodoItem] + ) -> TodayFeature.SectionCollection { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date( + byAdding: .day, + value: TodayFeature.upcomingWindowDays, + to: startOfToday + ) else { + return TodayFeature.SectionCollection( + focused: items.filter(\.isPinned), + unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } + ) + } + + var collection = TodayFeature.SectionCollection() + + for item in items { + if item.isPinned { + collection.focused.append(item) + continue + } + + guard let dueDate = item.dueDate else { + collection.unscheduled.append(item) + continue + } + + let dueDay = calendar.startOfDay(for: dueDate) + if dueDay < startOfToday { + collection.overdue.append(item) + } else if dueDay <= windowEnd { + collection.dueSoon.append(item) + } else { + collection.later.append(item) + } + } + + return collection + } + + func isOverdue(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + let calendar = Calendar.current + return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) + } + + func isDueSoon(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date( + byAdding: .day, + value: TodayFeature.upcomingWindowDays, + to: startOfToday + ) else { + return false + } + let dueDay = calendar.startOfDay(for: dueDate) + return startOfToday <= dueDay && dueDay <= windowEnd + } + + func makeSection( + category: TodayFeature.SectionCategory, + title: String, + items: [TodayTodoItem] + ) -> [TodayFeature.SectionContent] { + guard !items.isEmpty else { return [] } + return [TodayFeature.SectionContent(category: category, title: title, items: items)] + } +} diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift new file mode 100644 index 00000000..536416bc --- /dev/null +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -0,0 +1,345 @@ +// +// TodayFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +@Reducer +struct TodayFeature { + enum SectionScope: Hashable, CaseIterable { + case all + case focused + case overdue + case dueSoon + } + + enum SectionCategory: Hashable { + case later + case unscheduled + case focused + case overdue + case dueSoon + } + + struct SectionContent: Identifiable, Equatable { + var id: SectionCategory { category } + let category: SectionCategory + let title: String + let items: [TodayTodoItem] + } + + struct SectionCollection { + var focused: [TodayTodoItem] = [] + var overdue: [TodayTodoItem] = [] + var dueSoon: [TodayTodoItem] = [] + var later: [TodayTodoItem] = [] + var unscheduled: [TodayTodoItem] = [] + } + + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var todos: [TodayTodoItem] = [] + var selectedSectionScope: SectionScope = .all + var displayOptions: TodayDisplayOptions + var loading = LoadingFeature.State() + + init(displayOptions: TodayDisplayOptions = .default) { + self.displayOptions = displayOptions + } + + var isLoading: Bool { + loading.isLoading + } + + var sections: [SectionContent] { + let items = groupedSectionItems(from: displayedTodos) + + switch selectedSectionScope { + case .all: + return + makeSection( + category: .focused, + title: String(localized: "today_section_focused"), + items: items.focused + ) + + makeSection( + category: .overdue, + title: String(localized: "today_section_overdue"), + items: items.overdue + ) + + makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format"), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + + makeSection( + category: .later, + title: String(localized: "today_section_later"), + items: items.later + ) + + makeSection( + category: .unscheduled, + title: String(localized: "today_section_unscheduled"), + items: items.unscheduled + ) + case .focused: + return makeSection( + category: .focused, + title: String(localized: "today_section_focused"), + items: items.focused + ) + case .overdue: + return makeSection( + category: .overdue, + title: String(localized: "today_section_overdue"), + items: items.overdue + ) + case .dueSoon: + return makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format"), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + } + } + + var summaryCounts: [SectionScope: Int] { + Dictionary( + uniqueKeysWithValues: SectionScope.allCases.map { scope in + (scope, summaryValue(for: scope)) + } + ) + } + } + + enum Action: Equatable { + case alert(PresentationAction) + case refresh + case fetchData + case setSectionScope(SectionScope) + case setDueDateVisibility(TodayDisplayOptions.DueDateVisibility) + case setFocusVisibility(TodayDisplayOptions.FocusVisibility) + case resetDisplayOptions + case completeTodo(TodayTodoItem) + case togglePinned(TodayTodoItem) + case store(StoreAction) + case loading(LoadingFeature.Action) + + enum StoreAction: Equatable { + case setAlert + case setTodos([TodayTodoItem]) + case updateTodo(TodayTodoItem) + case removeTodo(String) + } + } + + @Dependency(\.todayFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase + @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase + @Dependency(\.updateTodayDisplayOptionsUseCase) var updateTodayDisplayOptionsUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + + static let pageSize = 20 + static let upcomingWindowDays = 7 + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + reduce(action, state: &state) + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var todayFetchTodosUseCase: FetchTodosUseCase { + get { self[TodayFetchTodosUseCaseKey.self] } + set { self[TodayFetchTodosUseCaseKey.self] = newValue } + } + + var updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase { + get { self[UpdateTodayDisplayOptionsUseCaseKey.self] } + set { self[UpdateTodayDisplayOptionsUseCaseKey.self] = newValue } + } +} + +private enum TodayFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum UpdateTodayDisplayOptionsUseCaseKey: DependencyKey { + static var liveValue: UpdateTodayDisplayOptionsUseCase { + preconditionFailure("UpdateTodayDisplayOptionsUseCase must be provided.") + } + + static var testValue: UpdateTodayDisplayOptionsUseCase { + liveValue + } +} + +private extension TodayFeature { + func reduce( + _ action: Action, + state: inout State + ) -> Effect { + switch action { + case .alert: + break + case .refresh, .fetchData: + return fetchTodosEffect() + case .setSectionScope(let scope): + if state.selectedSectionScope == scope, scope != .all { + state.selectedSectionScope = .all + } else { + state.selectedSectionScope = scope + } + case .setDueDateVisibility(let visibility): + state.displayOptions.dueDateVisibility = visibility + return updateDisplayOptionsEffect(state.displayOptions) + case .setFocusVisibility(let visibility): + state.displayOptions.focusVisibility = visibility + return updateDisplayOptionsEffect(state.displayOptions) + case .resetDisplayOptions: + state.displayOptions = .default + return updateDisplayOptionsEffect(state.displayOptions) + case .completeTodo(let item): + return completeTodoEffect(item) + case .togglePinned(let item): + return togglePinnedEffect(item) + case .store(.setAlert): + state.alert = Self.alertState() + case .store(.setTodos(let todos)): + state.todos = todos + case .store(.updateTodo(let item)): + if let index = state.todos.firstIndex(where: { $0.id == item.id }) { + state.todos[index] = item + } else { + state.todos.append(item) + } + case .store(.removeTodo(let todoId)): + state.todos.removeAll { $0.id == todoId } + case .loading: + break + } + + return .none + } + + func fetchTodosEffect() -> Effect { + .run { [fetchTodosUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + async let todosWithDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: Self.pageSize, + fetchAllPages: true + ), + cursor: nil + ) + async let todosWithoutDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withoutDueDate, + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: Self.pageSize, + fetchAllPages: true + ), + cursor: nil + ) + let todosWithDueDate = try await todosWithDueDatePage.items.compactMap(TodayTodoItem.init(from:)) + let todosWithoutDueDate = try await todosWithoutDueDatePage.items.compactMap(TodayTodoItem.init(from:)) + await send(.store(.setTodos(todosWithDueDate + todosWithoutDueDate))) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.store(.setAlert)) + } + } + } + + func updateDisplayOptionsEffect(_ options: TodayDisplayOptions) -> Effect { + .run { [updateTodayDisplayOptionsUseCase] _ in + updateTodayDisplayOptionsUseCase.execute(options) + } + } + + func completeTodoEffect(_ item: TodayTodoItem) -> Effect { + .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + var todo = try await fetchTodoByIdUseCase.execute(item.id) + let now = Date() + todo.isCompleted = true + todo.completedAt = now + todo.updatedAt = now + try await upsertTodoUseCase.execute(todo) + trackAnalyticsEventUseCase?.execute(.todoComplete) + await send(.store(.removeTodo(todo.id))) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.store(.setAlert)) + } + } + } + + func togglePinnedEffect(_ item: TodayTodoItem) -> Effect { + .run { [fetchTodoByIdUseCase, upsertTodoUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + var todo = try await fetchTodoByIdUseCase.execute(item.id) + todo.isPinned.toggle() + todo.updatedAt = Date() + try await upsertTodoUseCase.execute(todo) + guard let todayTodoItem = TodayTodoItem(from: todo) else { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.store(.setAlert)) + return + } + await send(.store(.updateTodo(todayTodoItem))) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.store(.setAlert)) + } + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } + } +} diff --git a/Application/DevLogPresentation/Sources/Today/TodayView.swift b/Application/DevLogPresentation/Sources/Today/TodayView.swift index 7db59ef9..44651f8d 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayView.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayView.swift @@ -6,20 +6,31 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain struct TodayView: View { + @State private var store: StoreOf let coordinator: TodayViewCoordinator let isCompactLayout: Bool + init( + coordinator: TodayViewCoordinator, + isCompactLayout: Bool + ) { + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + self._store = State(initialValue: coordinator.store) + } + var body: some View { List { summarySection - if coordinator.viewModel.sections.isEmpty, !coordinator.viewModel.state.isLoading { + if store.sections.isEmpty, !store.isLoading { emptySection } else { - ForEach(coordinator.viewModel.sections) { section in + ForEach(store.sections) { section in todoSection(section.title, items: section.items) } } @@ -28,20 +39,10 @@ struct TodayView: View { .navigationTitle(String(localized: "nav_today")) .toolbar { toolbarContent } .background(NavigationBarConfigurator()) - .refreshable { coordinator.viewModel.send(.refresh) } - .alert( - coordinator.viewModel.state.alertTitle, - isPresented: Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(coordinator.viewModel.state.alertMessage) - } + .refreshable { store.send(.refresh) } + .alert($store.scope(state: \.alert, action: \.alert)) .overlay { - if coordinator.viewModel.state.isLoading { + if store.isLoading { LoadingView() } } @@ -49,29 +50,39 @@ struct TodayView: View { private var summarySection: some View { Section { - ScrollView(.horizontal) { - HStack(spacing: 12) { - ForEach(TodayViewModel.SectionScope.allCases, id: \.self) { scope in - Button { - withAnimation(.easeInOut) { - coordinator.viewModel.send(.setSectionScope(scope)) - } - } label: { - SummaryCard( - title: scope.title, - value: coordinator.viewModel.summaryValue(for: scope), - accentColor: scope.accentColor, - isSelected: coordinator.viewModel.state.selectedSectionScope == scope - ) - } - .buttonStyle(.plain) + summaryScrollView + } + .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) + } + + private var summaryScrollView: some View { + SwiftUI.ScrollView(.horizontal, showsIndicators: false) { + summaryCards + } + .contentMargins(.horizontal, 16) + } + + private var summaryCards: some View { + let summaryCounts = store.summaryCounts + let selectedSectionScope = store.selectedSectionScope + + return HStack(spacing: 12) { + ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in + Button { + withAnimation(SwiftUI.Animation.easeInOut) { + _ = store.send(.setSectionScope(scope)) } + } label: { + SummaryCard( + title: scope.title, + value: summaryCounts[scope, default: 0], + accentColor: scope.accentColor, + isSelected: selectedSectionScope == scope + ) } + .buttonStyle(.plain) } - .scrollIndicators(.never) - .contentMargins(.horizontal, 16) } - .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) } @ToolbarContentBuilder @@ -81,8 +92,8 @@ struct TodayView: View { Picker( String(localized: "today_due_visibility_label"), selection: Binding( - get: { coordinator.viewModel.state.displayOptions.dueDateVisibility }, - set: { coordinator.viewModel.send(.setDueDateVisibility($0)) } + get: { store.displayOptions.dueDateVisibility }, + set: { store.send(.setDueDateVisibility($0)) } ) ) { ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in @@ -93,20 +104,20 @@ struct TodayView: View { Toggle( String(localized: "today_pinned_only"), isOn: Binding( - get: { coordinator.viewModel.state.displayOptions.focusVisibility == .focusedOnly }, + get: { store.displayOptions.focusVisibility == .focusedOnly }, set: { - coordinator.viewModel.send(.setFocusVisibility($0 ? .focusedOnly : .all)) + store.send(.setFocusVisibility($0 ? .focusedOnly : .all)) } ) ) .tint(.orange) - if coordinator.viewModel.state.displayOptions.focusVisibility == .focusedOnly { + if store.displayOptions.focusVisibility == .focusedOnly { Text(String(localized: "today_pinned_only_description")) .font(.caption) } } label: { - let options = coordinator.viewModel.state.displayOptions + let options = store.displayOptions Image(systemName: "line.3.horizontal.decrease.circle\(options == .default ? "" : ".fill")") } } @@ -135,7 +146,7 @@ struct TodayView: View { todoRow(item) .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { - coordinator.viewModel.send(.togglePinned(item)) + store.send(.togglePinned(item)) } label: { Image(systemName: item.isPinned ? "star.slash" : "star.fill") } @@ -143,7 +154,7 @@ struct TodayView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button { - coordinator.viewModel.send(.completeTodo(item)) + store.send(.completeTodo(item)) } label: { Label(String(localized: "today_complete_action"), systemImage: "checkmark") } @@ -176,9 +187,9 @@ struct TodayView: View { } private var emptyStateContent: EmptyStateContent { - switch coordinator.viewModel.state.selectedSectionScope { + switch store.selectedSectionScope { case .all: - if coordinator.viewModel.state.todos.isEmpty { + if store.todos.isEmpty { return EmptyStateContent( title: String(localized: "today_empty_all_title"), message: String(localized: "today_empty_all_message") @@ -229,7 +240,7 @@ private extension TodayDisplayOptions.DueDateVisibility { } } -private extension TodayViewModel.SectionScope { +private extension TodayFeature.SectionScope { var title: String { switch self { case .all: diff --git a/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift b/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift index 22b052bf..c2c870dc 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift @@ -6,27 +6,34 @@ // import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @MainActor @Observable final class TodayViewCoordinator { - let viewModel: TodayViewModel + let store: StoreOf let router = NavigationRouter() init(container: DIContainer) { - self.viewModel = TodayViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), - updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) - ) + let fetchDisplayOptionsUseCase = container.resolve(FetchTodayDisplayOptionsUseCase.self) + self.store = Store( + initialState: TodayFeature.State( + displayOptions: fetchDisplayOptionsUseCase.execute() + ) + ) { + TodayFeature() + } withDependencies: { + $0.todayFetchTodosUseCase = container.resolve(FetchTodosUseCase.self) + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.updateTodayDisplayOptionsUseCase = container.resolve(UpdateTodayDisplayOptionsUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + } } func fetchData() { - viewModel.send(.fetchData) + store.send(.fetchData) } } diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift new file mode 100644 index 00000000..b84534a3 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift @@ -0,0 +1,213 @@ +// +// TodayFeatureTestAssertions.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +private func waitUntilTodayMainActor( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(20), + _ condition: @escaping @MainActor () -> Bool +) async { + let continuousClock = ContinuousClock() + let deadline = continuousClock.now + timeout + + while !condition() && continuousClock.now < deadline { + try? await Task.sleep(for: pollInterval) + } +} + +@MainActor +func verifyTodayFetchData( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy +) async throws { + await adapter.fetchData() + + await waitUntilTodayMainActor { + adapter.todos.count == 5 + } + + #expect(fetchUseCaseSpy.queries.map(\.dueDateFilter) == [.withDueDate, .withoutDueDate]) + #expect(fetchUseCaseSpy.queries.map(\.completionFilter) == [.incomplete, .incomplete]) + #expect(fetchUseCaseSpy.queries.map(\.sortTarget) == [.dueDate, .updatedAt]) + #expect(fetchUseCaseSpy.queries.map(\.sortOrder) == [.oldest, .latest]) + #expect(fetchUseCaseSpy.queries.map(\.pageSize) == [20, 20]) + #expect(fetchUseCaseSpy.queries.map(\.fetchAllPages) == [true, true]) + #expect(fetchUseCaseSpy.cursors.allSatisfy { $0 == nil }) + #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) + #expect(adapter.summaryCounts == [ + .all: 5, + .focused: 1, + .overdue: 1, + .dueSoon: 2 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .focused, itemIds: ["focused"]), + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .dueSoon, itemIds: ["due-soon"]), + TodayDisplayedSection(category: .later, itemIds: ["later"]), + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) +} + +@MainActor +func verifyTodaySectionScopeToggle( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + await adapter.setSectionScope(.focused) + + #expect(adapter.selectedSectionScope == .focused) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .focused, itemIds: ["focused"]) + ]) + + await adapter.setSectionScope(.focused) + + #expect(adapter.selectedSectionScope == .all) + #expect(adapter.displayedSections.count == 5) + + await adapter.setSectionScope(.overdue) + + #expect(adapter.selectedSectionScope == .overdue) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]) + ]) +} + +@MainActor +func verifyTodayDisplayOptions( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, + updateDisplayOptionsUseCaseSpy: TodayUpdateDisplayOptionsUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + await adapter.setDueDateVisibility(.withoutDueDateOnly) + + #expect(adapter.displayOptions.dueDateVisibility == .withoutDueDateOnly) + #expect(adapter.summaryCounts == [ + .all: 1, + .focused: 0, + .overdue: 0, + .dueSoon: 0 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) + + await adapter.setDueDateVisibility(.all) + await adapter.setFocusVisibility(.focusedOnly) + + #expect(adapter.displayOptions.focusVisibility == .focusedOnly) + #expect(adapter.summaryCounts == [ + .all: 1, + .focused: 1, + .overdue: 0, + .dueSoon: 1 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .focused, itemIds: ["focused"]) + ]) + + await adapter.resetDisplayOptions() + + #expect(adapter.displayOptions == .default) + #expect(updateDisplayOptionsUseCaseSpy.options == [ + TodayDisplayOptions(dueDateVisibility: .withoutDueDateOnly, focusVisibility: .all), + TodayDisplayOptions(dueDateVisibility: .all, focusVisibility: .all), + TodayDisplayOptions(dueDateVisibility: .all, focusVisibility: .focusedOnly), + .default + ]) +} + +@MainActor +func verifyTodayTogglePinned( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, + fetchTodoByIdUseCaseSpy: TodayFetchTodoByIdUseCaseSpy, + upsertTodoUseCaseSpy: TodayUpsertTodoUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + let item = try #require(adapter.todos.first { $0.id == "later" }) + await adapter.togglePinned(item) + + await waitUntilTodayMainActor { + adapter.todos.first { $0.id == "later" }?.isPinned == true + } + + #expect(fetchTodoByIdUseCaseSpy.todoIds == ["later"]) + #expect(upsertTodoUseCaseSpy.todos.last?.id == "later") + #expect(upsertTodoUseCaseSpy.todos.last?.isPinned == true) + #expect(adapter.summaryCounts == [ + .all: 5, + .focused: 2, + .overdue: 1, + .dueSoon: 2 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .focused, itemIds: ["focused", "later"]), + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .dueSoon, itemIds: ["due-soon"]), + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) +} + +@MainActor +func verifyTodayCompleteTodo( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, + fetchTodoByIdUseCaseSpy: TodayFetchTodoByIdUseCaseSpy, + upsertTodoUseCaseSpy: TodayUpsertTodoUseCaseSpy, + trackAnalyticsEventUseCaseSpy: TodayTrackAnalyticsEventUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + let item = try #require(adapter.todos.first { $0.id == "due-soon" }) + await adapter.completeTodo(item) + + await waitUntilTodayMainActor { + !adapter.todos.map(\.id).contains("due-soon") + } + + #expect(fetchTodoByIdUseCaseSpy.todoIds == ["due-soon"]) + #expect(upsertTodoUseCaseSpy.todos.last?.id == "due-soon") + #expect(upsertTodoUseCaseSpy.todos.last?.isCompleted == true) + #expect(trackAnalyticsEventUseCaseSpy.hasTrackedTodoComplete) + #expect(adapter.summaryCounts == [ + .all: 4, + .focused: 1, + .overdue: 1, + .dueSoon: 1 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .focused, itemIds: ["focused"]), + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .later, itemIds: ["later"]), + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) +} + +@MainActor +func verifyTodayFetchFailureShowsAlert( + adapter: Adapter +) async { + await adapter.fetchData() + + await waitUntilTodayMainActor { + adapter.showAlert + } + + #expect(adapter.showAlert) +} diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift new file mode 100644 index 00000000..1855ea9e --- /dev/null +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift @@ -0,0 +1,316 @@ +// +// TodayFeatureTestDoubles.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Foundation +import ComposableArchitecture +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +enum TodayTestError: Error { + case failure +} + +enum TodayTestSectionScope: Hashable, CaseIterable { + case all + case focused + case overdue + case dueSoon +} + +enum TodayTestSectionCategory: Hashable { + case later + case unscheduled + case focused + case overdue + case dueSoon +} + +struct TodayDisplayedSection: Equatable { + let category: TodayTestSectionCategory + let itemIds: [String] +} + +@MainActor +protocol TodayStateDriving { + var todos: [TodayTodoItem] { get } + var selectedSectionScope: TodayTestSectionScope { get } + var displayOptions: TodayDisplayOptions { get } + var showAlert: Bool { get } + var isLoading: Bool { get } + var displayedSections: [TodayDisplayedSection] { get } + var summaryCounts: [TodayTestSectionScope: Int] { get } + + func fetchData() async + func setSectionScope(_ scope: TodayTestSectionScope) async + func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async + func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async + func resetDisplayOptions() async + func completeTodo(_ item: TodayTodoItem) async + func togglePinned(_ item: TodayTodoItem) async +} + +@MainActor +struct TodayViewModelTestAdapter: TodayStateDriving { + private let viewModel: TodayViewModel + + var todos: [TodayTodoItem] { viewModel.state.todos } + var selectedSectionScope: TodayTestSectionScope { viewModel.state.selectedSectionScope.testValue } + var displayOptions: TodayDisplayOptions { viewModel.state.displayOptions } + var showAlert: Bool { viewModel.state.showAlert } + var isLoading: Bool { viewModel.state.isLoading } + var displayedSections: [TodayDisplayedSection] { viewModel.sections.map(\.testValue) } + var summaryCounts: [TodayTestSectionScope: Int] { + Dictionary( + uniqueKeysWithValues: TodayTestSectionScope.allCases.map { scope in + (scope, viewModel.summaryValue(for: scope.viewModelValue)) + } + ) + } + + init( + fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), + fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), + upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), + fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), + updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy() + ) { + viewModel = TodayViewModel( + fetchTodosUseCase: fetchUseCase, + fetchTodoByIdUseCase: fetchTodoByIdUseCase, + upsertTodoUseCase: upsertUseCase, + fetchTodayDisplayOptionsUseCase: fetchDisplayOptionsUseCase, + updateTodayDisplayOptionsUseCase: updateDisplayOptionsUseCase, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCase + ) + } + + func fetchData() async { + viewModel.send(.fetchData) + } + + func setSectionScope(_ scope: TodayTestSectionScope) async { + viewModel.send(.setSectionScope(scope.viewModelValue)) + } + + func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async { + viewModel.send(.setDueDateVisibility(visibility)) + } + + func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { + viewModel.send(.setFocusVisibility(visibility)) + } + + func resetDisplayOptions() async { + viewModel.send(.resetDisplayOptions) + } + + func completeTodo(_ item: TodayTodoItem) async { + viewModel.send(.completeTodo(item)) + } + + func togglePinned(_ item: TodayTodoItem) async { + viewModel.send(.togglePinned(item)) + } +} + +@MainActor +struct TodayStoreTestAdapter: TodayStateDriving { + private let store: TestStoreOf + + var todos: [TodayTodoItem] { store.state.todos } + var selectedSectionScope: TodayTestSectionScope { store.state.selectedSectionScope.testValue } + var displayOptions: TodayDisplayOptions { store.state.displayOptions } + var showAlert: Bool { store.state.alert != nil } + var isLoading: Bool { store.state.isLoading } + var displayedSections: [TodayDisplayedSection] { store.state.sections.map(\.testValue) } + var summaryCounts: [TodayTestSectionScope: Int] { + Dictionary( + uniqueKeysWithValues: store.state.summaryCounts.map { key, value in + (key.testValue, value) + } + ) + } + + init( + fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), + fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), + upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), + fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), + updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + store = TestStore( + initialState: TodayFeature.State( + displayOptions: fetchDisplayOptionsUseCase.execute() + ) + ) { + TodayFeature() + } withDependencies: { + $0.todayFetchTodosUseCase = fetchUseCase + $0.fetchTodoByIdUseCase = fetchTodoByIdUseCase + $0.upsertTodoUseCase = upsertUseCase + $0.updateTodayDisplayOptionsUseCase = updateDisplayOptionsUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func fetchData() async { + await store.send(.fetchData) + await drainReceivedActions() + } + + func setSectionScope(_ scope: TodayTestSectionScope) async { + await store.send(.setSectionScope(scope.featureValue)) + } + + func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async { + await store.send(.setDueDateVisibility(visibility)) + await drainReceivedActions() + } + + func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { + await store.send(.setFocusVisibility(visibility)) + await drainReceivedActions() + } + + func resetDisplayOptions() async { + await store.send(.resetDisplayOptions) + await drainReceivedActions() + } + + func completeTodo(_ item: TodayTodoItem) async { + await store.send(.completeTodo(item)) + await drainReceivedActions() + } + + func togglePinned(_ item: TodayTodoItem) async { + await store.send(.togglePinned(item)) + await drainReceivedActions() + } + + private func drainReceivedActions() async { + for _ in 0..<10 { + await store.skipReceivedActions(strict: false) + } + } +} + +private extension TodayViewModel.SectionScope { + var testValue: TodayTestSectionScope { + switch self { + case .all: + return .all + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } +} + +private extension TodayTestSectionScope { + var viewModelValue: TodayViewModel.SectionScope { + switch self { + case .all: + return .all + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } + + var featureValue: TodayFeature.SectionScope { + switch self { + case .all: + return .all + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } +} + +private extension TodayFeature.SectionScope { + var testValue: TodayTestSectionScope { + switch self { + case .all: + return .all + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } +} + +private extension TodayViewModel.SectionCategory { + var testValue: TodayTestSectionCategory { + switch self { + case .later: + return .later + case .unscheduled: + return .unscheduled + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } +} + +private extension TodayFeature.SectionCategory { + var testValue: TodayTestSectionCategory { + switch self { + case .later: + return .later + case .unscheduled: + return .unscheduled + case .focused: + return .focused + case .overdue: + return .overdue + case .dueSoon: + return .dueSoon + } + } +} + +private extension TodayViewModel.SectionContent { + var testValue: TodayDisplayedSection { + TodayDisplayedSection( + category: category.testValue, + itemIds: items.map(\.id) + ) + } +} + +private extension TodayFeature.SectionContent { + var testValue: TodayDisplayedSection { + TodayDisplayedSection( + category: category.testValue, + itemIds: items.map(\.id) + ) + } +} diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift new file mode 100644 index 00000000..5c2326cc --- /dev/null +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift @@ -0,0 +1,155 @@ +// +// TodayFeatureTestSpies.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Foundation +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { + var pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] + var error: Error? + private(set) var queries = [TodoQuery]() + private(set) var cursors = [TodoCursor?]() + + init( + pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] = [ + .withDueDate: TodoPage(items: [], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ] + ) { + self.pagesByFilter = pagesByFilter + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + cursors.append(cursor) + + if let error { + throw error + } + + return pagesByFilter[query.dueDateFilter] ?? TodoPage(items: [], nextCursor: nil) + } +} + +final class TodayFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { + var todos: [Todo] + var error: Error? + private(set) var todoIds = [String]() + + init(todos: [Todo] = []) { + self.todos = todos + } + + func execute(_ todoId: String) async throws -> Todo { + todoIds.append(todoId) + + if let error { + throw error + } + + return todos.first { $0.id == todoId } ?? makeTodayTodo(id: todoId) + } +} + +final class TodayUpsertTodoUseCaseSpy: UpsertTodoUseCase { + var error: Error? + private(set) var todos = [Todo]() + private(set) var todoDrafts = [TodoDraft]() + + func execute(_ todo: Todo) async throws { + todos.append(todo) + + if let error { + throw error + } + } + + func execute(_ todoDraft: TodoDraft) async throws { + todoDrafts.append(todoDraft) + + if let error { + throw error + } + } +} + +struct TodayFetchDisplayOptionsUseCaseSpy: FetchTodayDisplayOptionsUseCase { + var options: TodayDisplayOptions = .default + + func execute() -> TodayDisplayOptions { + options + } +} + +final class TodayUpdateDisplayOptionsUseCaseSpy: UpdateTodayDisplayOptionsUseCase { + private(set) var options = [TodayDisplayOptions]() + + func execute(_ options: TodayDisplayOptions) { + self.options.append(options) + } +} + +final class TodayTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events = [AnalyticsEvent]() + + var hasTrackedTodoComplete: Bool { + events.contains { + guard case .todoComplete = $0 else { return false } + return true + } + } + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +func makeTodayTodo( + id: String = "todo-1", + isPinned: Bool = false, + isCompleted: Bool = false, + number: Int = 1, + title: String = "Todo", + dueDate: Date? = nil +) -> Todo { + let now = Date(timeIntervalSince1970: 0) + return Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: false, + number: number, + title: title, + content: "content", + createdAt: now, + updatedAt: now, + completedAt: isCompleted ? now : nil, + deletedAt: nil, + dueDate: dueDate, + tags: [], + category: .system(.feature) + ) +} + +func makeTodaySectionTodos() -> [Todo] { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + + func dueDate(_ dayOffset: Int) -> Date { + calendar.date(byAdding: .day, value: dayOffset, to: startOfToday) ?? startOfToday + } + + return [ + makeTodayTodo(id: "focused", isPinned: true, number: 1, dueDate: dueDate(1)), + makeTodayTodo(id: "overdue", number: 2, dueDate: dueDate(-1)), + makeTodayTodo(id: "due-soon", number: 3, dueDate: dueDate(2)), + makeTodayTodo(id: "later", number: 4, dueDate: dueDate(10)), + makeTodayTodo(id: "unscheduled", number: 5, dueDate: nil) + ] +} diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift new file mode 100644 index 00000000..7b544bfd --- /dev/null +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift @@ -0,0 +1,236 @@ +// +// TodayFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +@testable import DevLogPresentation + +@MainActor +struct TodayFeatureTests { + @Test("현재 TodayViewModel fetchData는 요약과 섹션 상태를 갱신한다") + func 현재_TodayViewModel_fetchData는_요약과_섹션_상태를_갱신한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) + + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("TodayFeature fetchData는 현재 TodayViewModel과 같은 요약과 섹션 상태를 만든다") + func TodayFeature_fetchData는_현재_TodayViewModel과_같은_요약과_섹션_상태를_만든다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("현재 TodayViewModel setSectionScope는 동일 탭 재선택 시 all로 되돌린다") + func 현재_TodayViewModel_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) + + try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("TodayFeature setSectionScope는 현재 TodayViewModel과 같은 토글 동작을 유지한다") + func TodayFeature_setSectionScope는_현재_TodayViewModel과_같은_토글_동작을_유지한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + + try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("현재 TodayViewModel displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") + func 현재_TodayViewModel_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() + let adapter = TodayViewModelTestAdapter( + fetchUseCase: fetchSpy, + updateDisplayOptionsUseCase: updateSpy + ) + + try await verifyTodayDisplayOptions( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + updateDisplayOptionsUseCaseSpy: updateSpy + ) + } + + @Test("TodayFeature displayOptions 변경은 현재 TodayViewModel과 같은 필터링 결과를 유지한다") + func TodayFeature_displayOptions_변경은_현재_TodayViewModel과_같은_필터링_결과를_유지한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + updateDisplayOptionsUseCase: updateSpy + ) + + try await verifyTodayDisplayOptions( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + updateDisplayOptionsUseCaseSpy: updateSpy + ) + } + + @Test("현재 TodayViewModel togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") + func 현재_TodayViewModel_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let adapter = TodayViewModelTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy + ) + + try await verifyTodayTogglePinned( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy + ) + } + + @Test("TodayFeature togglePinned는 현재 TodayViewModel과 같은 섹션 재계산을 유지한다") + func TodayFeature_togglePinned는_현재_TodayViewModel과_같은_섹션_재계산을_유지한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy + ) + + try await verifyTodayTogglePinned( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy + ) + } + + @Test("현재 TodayViewModel completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") + func 현재_TodayViewModel_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let trackSpy = TodayTrackAnalyticsEventUseCaseSpy() + let adapter = TodayViewModelTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy, + trackAnalyticsEventUseCase: trackSpy + ) + + try await verifyTodayCompleteTodo( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy, + trackAnalyticsEventUseCaseSpy: trackSpy + ) + } + + @Test("TodayFeature completeTodo는 현재 TodayViewModel과 같은 제거와 완료 추적을 유지한다") + func TodayFeature_completeTodo는_현재_TodayViewModel과_같은_제거와_완료_추적을_유지한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let trackSpy = TodayTrackAnalyticsEventUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy, + trackAnalyticsEventUseCase: trackSpy + ) + + try await verifyTodayCompleteTodo( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy, + trackAnalyticsEventUseCaseSpy: trackSpy + ) + } + + @Test("현재 TodayViewModel fetchData 실패는 에러 표시 상태를 만든다") + func 현재_TodayViewModel_fetchData_실패는_에러_표시_상태를_만든다() async { + let fetchSpy = TodayFetchTodosUseCaseSpy() + fetchSpy.error = TodayTestError.failure + let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) + + await verifyTodayFetchFailureShowsAlert(adapter: adapter) + } + + @Test("TodayFeature fetchData 실패는 현재 TodayViewModel과 같은 에러 표시 상태를 만든다") + func TodayFeature_fetchData_실패는_현재_TodayViewModel과_같은_에러_표시_상태를_만든다() async { + let fetchSpy = TodayFetchTodosUseCaseSpy() + fetchSpy.error = TodayTestError.failure + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + + await verifyTodayFetchFailureShowsAlert(adapter: adapter) + } +} From 36abe2d180b53aa2c6f4c0cb6707dcb5d670a803 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:25:06 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=ED=97=AC=ED=8D=BC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A5=BC=20=ED=94=BC=EC=B3=90=20=EC=AA=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Today/TodayFeature+State.swift | 33 +++++++++++++------ .../Sources/Today/TodayFeature.swift | 32 ++++++++++++------ 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift index d0ef1db6..433759ff 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift @@ -5,10 +5,20 @@ // Created by opfic on 6/14/26. // +import DevLogCore import Foundation -extension TodayFeature.State { - func summaryValue(for scope: TodayFeature.SectionScope) -> Int { +extension TodayFeature { + static func summaryValue( + for scope: SectionScope, + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions + ) -> Int { + let displayedTodos = displayedTodos( + todos: todos, + displayOptions: displayOptions + ) + switch scope { case .all: return displayedTodos.count @@ -21,7 +31,10 @@ extension TodayFeature.State { } } - var displayedTodos: [TodayTodoItem] { + static func displayedTodos( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions + ) -> [TodayTodoItem] { let dueDateFilteredTodos: [TodayTodoItem] switch displayOptions.dueDateVisibility { case .all: @@ -40,7 +53,7 @@ extension TodayFeature.State { } } - func groupedSectionItems( + static func groupedSectionItems( from items: [TodayTodoItem] ) -> TodayFeature.SectionCollection { let calendar = Calendar.current @@ -82,13 +95,13 @@ extension TodayFeature.State { return collection } - func isOverdue(_ item: TodayTodoItem) -> Bool { + static func isOverdue(_ item: TodayTodoItem) -> Bool { guard let dueDate = item.dueDate else { return false } let calendar = Calendar.current return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) } - func isDueSoon(_ item: TodayTodoItem) -> Bool { + static func isDueSoon(_ item: TodayTodoItem) -> Bool { guard let dueDate = item.dueDate else { return false } let calendar = Calendar.current let startOfToday = calendar.startOfDay(for: Date()) @@ -103,12 +116,12 @@ extension TodayFeature.State { return startOfToday <= dueDay && dueDay <= windowEnd } - func makeSection( - category: TodayFeature.SectionCategory, + static func makeSection( + category: SectionCategory, title: String, items: [TodayTodoItem] - ) -> [TodayFeature.SectionContent] { + ) -> [SectionContent] { guard !items.isEmpty else { return [] } - return [TodayFeature.SectionContent(category: category, title: title, items: items)] + return [SectionContent(category: category, title: title, items: items)] } } diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 536416bc..922772ed 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -59,22 +59,27 @@ struct TodayFeature { } var sections: [SectionContent] { - let items = groupedSectionItems(from: displayedTodos) + let items = TodayFeature.groupedSectionItems( + from: TodayFeature.displayedTodos( + todos: todos, + displayOptions: displayOptions + ) + ) switch selectedSectionScope { case .all: return - makeSection( + TodayFeature.makeSection( category: .focused, title: String(localized: "today_section_focused"), items: items.focused ) - + makeSection( + + TodayFeature.makeSection( category: .overdue, title: String(localized: "today_section_overdue"), items: items.overdue ) - + makeSection( + + TodayFeature.makeSection( category: .dueSoon, title: String.localizedStringWithFormat( String(localized: "today_section_due_soon_format"), @@ -82,30 +87,30 @@ struct TodayFeature { ), items: items.dueSoon ) - + makeSection( + + TodayFeature.makeSection( category: .later, title: String(localized: "today_section_later"), items: items.later ) - + makeSection( + + TodayFeature.makeSection( category: .unscheduled, title: String(localized: "today_section_unscheduled"), items: items.unscheduled ) case .focused: - return makeSection( + return TodayFeature.makeSection( category: .focused, title: String(localized: "today_section_focused"), items: items.focused ) case .overdue: - return makeSection( + return TodayFeature.makeSection( category: .overdue, title: String(localized: "today_section_overdue"), items: items.overdue ) case .dueSoon: - return makeSection( + return TodayFeature.makeSection( category: .dueSoon, title: String.localizedStringWithFormat( String(localized: "today_section_due_soon_format"), @@ -119,7 +124,14 @@ struct TodayFeature { var summaryCounts: [SectionScope: Int] { Dictionary( uniqueKeysWithValues: SectionScope.allCases.map { scope in - (scope, summaryValue(for: scope)) + ( + scope, + TodayFeature.summaryValue( + for: scope, + todos: todos, + displayOptions: displayOptions + ) + ) } ) } From 546a321d56ee803fd65a2e10947289df2f5b7ce2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:37:37 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=A6=AC=EB=93=80=EC=84=9C=20=EB=B6=84=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Today/TodayFeature.swift | 89 +++++++++---------- 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 922772ed..4c3999ac 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -172,7 +172,47 @@ struct TodayFeature { LoadingFeature() } Reduce { state, action in - reduce(action, state: &state) + switch action { + case .alert: + break + case .refresh, .fetchData: + return fetchTodosEffect() + case .setSectionScope(let scope): + if state.selectedSectionScope == scope, scope != .all { + state.selectedSectionScope = .all + } else { + state.selectedSectionScope = scope + } + case .setDueDateVisibility(let visibility): + state.displayOptions.dueDateVisibility = visibility + return updateDisplayOptionsEffect(state.displayOptions) + case .setFocusVisibility(let visibility): + state.displayOptions.focusVisibility = visibility + return updateDisplayOptionsEffect(state.displayOptions) + case .resetDisplayOptions: + state.displayOptions = .default + return updateDisplayOptionsEffect(state.displayOptions) + case .completeTodo(let item): + return completeTodoEffect(item) + case .togglePinned(let item): + return togglePinnedEffect(item) + case .store(.setAlert): + state.alert = Self.alertState() + case .store(.setTodos(let todos)): + state.todos = todos + case .store(.updateTodo(let item)): + if let index = state.todos.firstIndex(where: { $0.id == item.id }) { + state.todos[index] = item + } else { + state.todos.append(item) + } + case .store(.removeTodo(let todoId)): + state.todos.removeAll { $0.id == todoId } + case .loading: + break + } + + return .none } .ifLet(\.$alert, action: \.alert) } @@ -211,53 +251,6 @@ private enum UpdateTodayDisplayOptionsUseCaseKey: DependencyKey { } private extension TodayFeature { - func reduce( - _ action: Action, - state: inout State - ) -> Effect { - switch action { - case .alert: - break - case .refresh, .fetchData: - return fetchTodosEffect() - case .setSectionScope(let scope): - if state.selectedSectionScope == scope, scope != .all { - state.selectedSectionScope = .all - } else { - state.selectedSectionScope = scope - } - case .setDueDateVisibility(let visibility): - state.displayOptions.dueDateVisibility = visibility - return updateDisplayOptionsEffect(state.displayOptions) - case .setFocusVisibility(let visibility): - state.displayOptions.focusVisibility = visibility - return updateDisplayOptionsEffect(state.displayOptions) - case .resetDisplayOptions: - state.displayOptions = .default - return updateDisplayOptionsEffect(state.displayOptions) - case .completeTodo(let item): - return completeTodoEffect(item) - case .togglePinned(let item): - return togglePinnedEffect(item) - case .store(.setAlert): - state.alert = Self.alertState() - case .store(.setTodos(let todos)): - state.todos = todos - case .store(.updateTodo(let item)): - if let index = state.todos.firstIndex(where: { $0.id == item.id }) { - state.todos[index] = item - } else { - state.todos.append(item) - } - case .store(.removeTodo(let todoId)): - state.todos.removeAll { $0.id == todoId } - case .loading: - break - } - - return .none - } - func fetchTodosEffect() -> Effect { .run { [fetchTodosUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) From a0f755acec463e07d59d4976f9d690f82efd74fc Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:49:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=EC=BD=94=EB=94=94=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=ED=95=98=EB=8A=94=20Store=EB=93=A4=EC=9D=80=20Bindable=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/PushNotification/PushNotificationListView.swift | 5 +++-- Application/DevLogPresentation/Sources/Today/TodayView.swift | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 01ecb8bb..27f39efc 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -15,16 +15,17 @@ struct PushNotificationListView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false - @State private var store: StoreOf + @Bindable var store: StoreOf let coordinator: PushNotificationListViewCoordinator let isCompactLayout: Bool + init( coordinator: PushNotificationListViewCoordinator, isCompactLayout: Bool ) { self.coordinator = coordinator self.isCompactLayout = isCompactLayout - self._store = State(initialValue: coordinator.store) + self.store = coordinator.store } var body: some View { diff --git a/Application/DevLogPresentation/Sources/Today/TodayView.swift b/Application/DevLogPresentation/Sources/Today/TodayView.swift index 44651f8d..7f72bd79 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayView.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayView.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain struct TodayView: View { - @State private var store: StoreOf + @Bindable var store: StoreOf let coordinator: TodayViewCoordinator let isCompactLayout: Bool @@ -21,7 +21,7 @@ struct TodayView: View { ) { self.coordinator = coordinator self.isCompactLayout = isCompactLayout - self._store = State(initialValue: coordinator.store) + self.store = coordinator.store } var body: some View { From 668e80af22ab0b88b9b55d9649a864bad46c1354 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:55:26 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Today/TodayView.swift | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayView.swift b/Application/DevLogPresentation/Sources/Today/TodayView.swift index 7f72bd79..3bc080bc 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayView.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayView.swift @@ -50,39 +50,32 @@ struct TodayView: View { private var summarySection: some View { Section { - summaryScrollView - } - .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) - } - - private var summaryScrollView: some View { - SwiftUI.ScrollView(.horizontal, showsIndicators: false) { - summaryCards - } - .contentMargins(.horizontal, 16) - } + ScrollView(.horizontal) { + let summaryCounts = store.summaryCounts + let selectedSectionScope = store.selectedSectionScope - private var summaryCards: some View { - let summaryCounts = store.summaryCounts - let selectedSectionScope = store.selectedSectionScope - - return HStack(spacing: 12) { - ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in - Button { - withAnimation(SwiftUI.Animation.easeInOut) { - _ = store.send(.setSectionScope(scope)) + HStack(spacing: 12) { + ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in + Button { + withAnimation(SwiftUI.Animation.easeInOut) { + _ = store.send(.setSectionScope(scope)) + } + } label: { + SummaryCard( + title: scope.title, + value: summaryCounts[scope, default: 0], + accentColor: scope.accentColor, + isSelected: selectedSectionScope == scope + ) + } + .buttonStyle(.plain) } - } label: { - SummaryCard( - title: scope.title, - value: summaryCounts[scope, default: 0], - accentColor: scope.accentColor, - isSelected: selectedSectionScope == scope - ) } - .buttonStyle(.plain) } + .scrollIndicators(.never) + .contentMargins(.horizontal, 16) } + .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) } @ToolbarContentBuilder From 6fddced80544f55427ba932191d270bca8060a45 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:12:15 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20Picker=EC=97=90=20BindingAction?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Today/TodayFeature.swift | 9 ++++++++- .../DevLogPresentation/Sources/Today/TodayView.swift | 5 +---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 4c3999ac..a1b89db1 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -137,8 +137,9 @@ struct TodayFeature { } } - enum Action: Equatable { + enum Action: BindableAction, Equatable { case alert(PresentationAction) + case binding(BindingAction) case refresh case fetchData case setSectionScope(SectionScope) @@ -171,10 +172,16 @@ struct TodayFeature { Scope(state: \.loading, action: \.loading) { LoadingFeature() } + BindingReducer() Reduce { state, action in switch action { case .alert: break + case .binding(\.displayOptions.dueDateVisibility), + .binding(\.displayOptions.focusVisibility): + return updateDisplayOptionsEffect(state.displayOptions) + case .binding: + break case .refresh, .fetchData: return fetchTodosEffect() case .setSectionScope(let scope): diff --git a/Application/DevLogPresentation/Sources/Today/TodayView.swift b/Application/DevLogPresentation/Sources/Today/TodayView.swift index 3bc080bc..e18d451e 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayView.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayView.swift @@ -84,10 +84,7 @@ struct TodayView: View { Menu { Picker( String(localized: "today_due_visibility_label"), - selection: Binding( - get: { store.displayOptions.dueDateVisibility }, - set: { store.send(.setDueDateVisibility($0)) } - ) + selection: $store.displayOptions.dueDateVisibility ) { ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in Text(option.title).tag(option) From d3d61bc3e9aeac7728b42545cb96834be27e41cd Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:48:20 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20Toggle=EC=97=90=20BindingAction?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogCore/Sources/TodayDisplayOptions.swift | 4 ++++ .../DevLogPresentation/Sources/Today/TodayFeature.swift | 3 ++- .../DevLogPresentation/Sources/Today/TodayView.swift | 7 +------ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Application/DevLogCore/Sources/TodayDisplayOptions.swift b/Application/DevLogCore/Sources/TodayDisplayOptions.swift index a27ceb39..7a227be7 100644 --- a/Application/DevLogCore/Sources/TodayDisplayOptions.swift +++ b/Application/DevLogCore/Sources/TodayDisplayOptions.swift @@ -21,6 +21,10 @@ public struct TodayDisplayOptions: Equatable { public var dueDateVisibility: DueDateVisibility public var focusVisibility: FocusVisibility + public var isFocusedOnly: Bool { + get { focusVisibility == .focusedOnly } + set { focusVisibility = newValue ? .focusedOnly : .all } + } public init( dueDateVisibility: DueDateVisibility, diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index a1b89db1..95a1e744 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -178,7 +178,8 @@ struct TodayFeature { case .alert: break case .binding(\.displayOptions.dueDateVisibility), - .binding(\.displayOptions.focusVisibility): + .binding(\.displayOptions.focusVisibility), + .binding(\.displayOptions.isFocusedOnly): return updateDisplayOptionsEffect(state.displayOptions) case .binding: break diff --git a/Application/DevLogPresentation/Sources/Today/TodayView.swift b/Application/DevLogPresentation/Sources/Today/TodayView.swift index e18d451e..834894d4 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayView.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayView.swift @@ -93,12 +93,7 @@ struct TodayView: View { Toggle( String(localized: "today_pinned_only"), - isOn: Binding( - get: { store.displayOptions.focusVisibility == .focusedOnly }, - set: { - store.send(.setFocusVisibility($0 ? .focusedOnly : .all)) - } - ) + isOn: $store.displayOptions.isFocusedOnly ) .tint(.orange) From 3b152036c136c8a965e94037b1ee47ae48345834 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:11:51 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20BindingAction=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=9C=20=EA=B8=B0=EC=A1=B4=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Today/TodayFeature.swift | 8 -------- .../Tests/Today/TodayFeatureTestDoubles.swift | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 95a1e744..2542e4b5 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -143,8 +143,6 @@ struct TodayFeature { case refresh case fetchData case setSectionScope(SectionScope) - case setDueDateVisibility(TodayDisplayOptions.DueDateVisibility) - case setFocusVisibility(TodayDisplayOptions.FocusVisibility) case resetDisplayOptions case completeTodo(TodayTodoItem) case togglePinned(TodayTodoItem) @@ -191,12 +189,6 @@ struct TodayFeature { } else { state.selectedSectionScope = scope } - case .setDueDateVisibility(let visibility): - state.displayOptions.dueDateVisibility = visibility - return updateDisplayOptionsEffect(state.displayOptions) - case .setFocusVisibility(let visibility): - state.displayOptions.focusVisibility = visibility - return updateDisplayOptionsEffect(state.displayOptions) case .resetDisplayOptions: state.displayOptions = .default return updateDisplayOptionsEffect(state.displayOptions) diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift index 1855ea9e..7a65193c 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift @@ -174,12 +174,12 @@ struct TodayStoreTestAdapter: TodayStateDriving { } func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async { - await store.send(.setDueDateVisibility(visibility)) + await store.send(.binding(.set(\.displayOptions.dueDateVisibility, visibility))) await drainReceivedActions() } func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { - await store.send(.setFocusVisibility(visibility)) + await store.send(.binding(.set(\.displayOptions.focusVisibility, visibility))) await drainReceivedActions() } From 09b5009c6e33eb4604e4596dd0bab06e35ff3254 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:23:51 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20now=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=EC=9D=84=20=EC=B5=9C=EB=8C=80=ED=95=9C=20=EC=83=81=EC=9C=84=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20=EC=9D=BC=EC=B0=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Today/TodayFeature+State.swift | 20 +++-- .../Sources/Today/TodayFeature.swift | 10 ++- .../Tests/Today/TodayFeatureTests.swift | 82 +++++++++++++++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift index 433759ff..f9bce871 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift @@ -12,7 +12,8 @@ extension TodayFeature { static func summaryValue( for scope: SectionScope, todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions + displayOptions: TodayDisplayOptions, + now: Date ) -> Int { let displayedTodos = displayedTodos( todos: todos, @@ -25,9 +26,9 @@ extension TodayFeature { case .focused: return displayedTodos.filter(\.isPinned).count case .overdue: - return displayedTodos.filter(isOverdue).count + return displayedTodos.filter { isOverdue($0, now: now) }.count case .dueSoon: - return displayedTodos.filter(isDueSoon).count + return displayedTodos.filter { isDueSoon($0, now: now) }.count } } @@ -54,10 +55,11 @@ extension TodayFeature { } static func groupedSectionItems( - from items: [TodayTodoItem] + from items: [TodayTodoItem], + now: Date ) -> TodayFeature.SectionCollection { let calendar = Calendar.current - let startOfToday = calendar.startOfDay(for: Date()) + let startOfToday = calendar.startOfDay(for: now) guard let windowEnd = calendar.date( byAdding: .day, value: TodayFeature.upcomingWindowDays, @@ -95,16 +97,16 @@ extension TodayFeature { return collection } - static func isOverdue(_ item: TodayTodoItem) -> Bool { + static func isOverdue(_ item: TodayTodoItem, now: Date) -> Bool { guard let dueDate = item.dueDate else { return false } let calendar = Calendar.current - return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) + return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: now) } - static func isDueSoon(_ item: TodayTodoItem) -> Bool { + static func isDueSoon(_ item: TodayTodoItem, now: Date) -> Bool { guard let dueDate = item.dueDate else { return false } let calendar = Calendar.current - let startOfToday = calendar.startOfDay(for: Date()) + let startOfToday = calendar.startOfDay(for: now) guard let windowEnd = calendar.date( byAdding: .day, value: TodayFeature.upcomingWindowDays, diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 2542e4b5..01ddda06 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -59,11 +59,13 @@ struct TodayFeature { } var sections: [SectionContent] { + let now = Date() let items = TodayFeature.groupedSectionItems( from: TodayFeature.displayedTodos( todos: todos, displayOptions: displayOptions - ) + ), + now: now ) switch selectedSectionScope { @@ -122,14 +124,16 @@ struct TodayFeature { } var summaryCounts: [SectionScope: Int] { - Dictionary( + let now = Date() + return Dictionary( uniqueKeysWithValues: SectionScope.allCases.map { scope in ( scope, TodayFeature.summaryValue( for: scope, todos: todos, - displayOptions: displayOptions + displayOptions: displayOptions, + now: now ) ) } diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift index 7b544bfd..898742aa 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift @@ -6,10 +6,65 @@ // import Testing +import Foundation +import DevLogCore @testable import DevLogPresentation @MainActor struct TodayFeatureTests { + @Test("TodayFeature groupedSectionItems는 주입된 now 기준으로 섹션을 분류한다") + func TodayFeature_groupedSectionItems는_주입된_now_기준으로_섹션을_분류한다() throws { + let now = try #require(makeFixedTodayNow()) + let items = makeFixedTodayTodoItems(now: now) + + let sections = TodayFeature.groupedSectionItems(from: items, now: now) + + #expect(sections.focused.map(\.id) == ["focused"]) + #expect(sections.overdue.map(\.id) == ["overdue"]) + #expect(sections.dueSoon.map(\.id) == ["due-soon"]) + #expect(sections.later.map(\.id) == ["later"]) + #expect(sections.unscheduled.map(\.id) == ["unscheduled"]) + } + + @Test("TodayFeature summaryValue는 주입된 now 기준으로 요약 값을 계산한다") + func TodayFeature_summaryValue는_주입된_now_기준으로_요약_값을_계산한다() throws { + let now = try #require(makeFixedTodayNow()) + let todos = makeFixedTodayTodoItems(now: now) + + #expect( + TodayFeature.summaryValue( + for: .all, + todos: todos, + displayOptions: .default, + now: now + ) == 5 + ) + #expect( + TodayFeature.summaryValue( + for: .focused, + todos: todos, + displayOptions: .default, + now: now + ) == 1 + ) + #expect( + TodayFeature.summaryValue( + for: .overdue, + todos: todos, + displayOptions: .default, + now: now + ) == 1 + ) + #expect( + TodayFeature.summaryValue( + for: .dueSoon, + todos: todos, + displayOptions: .default, + now: now + ) == 2 + ) + } + @Test("현재 TodayViewModel fetchData는 요약과 섹션 상태를 갱신한다") func 현재_TodayViewModel_fetchData는_요약과_섹션_상태를_갱신한다() async throws { let todos = makeTodaySectionTodos() @@ -234,3 +289,30 @@ struct TodayFeatureTests { await verifyTodayFetchFailureShowsAlert(adapter: adapter) } } + +private func makeFixedTodayNow() -> Date? { + Calendar.current.date( + from: DateComponents( + year: 2026, + month: 6, + day: 14, + hour: 12 + ) + ) +} + +private func makeFixedTodayTodoItems(now: Date) -> [TodayTodoItem] { + let calendar = Calendar.current + + func dueDate(_ dayOffset: Int) -> Date { + calendar.date(byAdding: .day, value: dayOffset, to: now) ?? now + } + + return [ + TodayTodoItem(from: makeTodayTodo(id: "focused", isPinned: true, dueDate: dueDate(1)))!, + TodayTodoItem(from: makeTodayTodo(id: "overdue", dueDate: dueDate(-1)))!, + TodayTodoItem(from: makeTodayTodo(id: "due-soon", dueDate: dueDate(2)))!, + TodayTodoItem(from: makeTodayTodo(id: "later", dueDate: dueDate(10)))!, + TodayTodoItem(from: makeTodayTodo(id: "unscheduled", dueDate: nil))! + ] +}