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/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/TodayFeature+State.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift new file mode 100644 index 00000000..f9bce871 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature+State.swift @@ -0,0 +1,129 @@ +// +// TodayFeature+State.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import DevLogCore +import Foundation + +extension TodayFeature { + static func summaryValue( + for scope: SectionScope, + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions, + now: Date + ) -> Int { + let displayedTodos = displayedTodos( + todos: todos, + displayOptions: displayOptions + ) + + switch scope { + case .all: + return displayedTodos.count + case .focused: + return displayedTodos.filter(\.isPinned).count + case .overdue: + return displayedTodos.filter { isOverdue($0, now: now) }.count + case .dueSoon: + return displayedTodos.filter { isDueSoon($0, now: now) }.count + } + } + + static func displayedTodos( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions + ) -> [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) + } + } + + static func groupedSectionItems( + from items: [TodayTodoItem], + now: Date + ) -> TodayFeature.SectionCollection { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + 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 + } + + 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: now) + } + + 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: now) + 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 + } + + static func makeSection( + category: SectionCategory, + title: String, + items: [TodayTodoItem] + ) -> [SectionContent] { + guard !items.isEmpty else { return [] } + return [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..01ddda06 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -0,0 +1,354 @@ +// +// 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 now = Date() + let items = TodayFeature.groupedSectionItems( + from: TodayFeature.displayedTodos( + todos: todos, + displayOptions: displayOptions + ), + now: now + ) + + switch selectedSectionScope { + case .all: + return + TodayFeature.makeSection( + category: .focused, + title: String(localized: "today_section_focused"), + items: items.focused + ) + + TodayFeature.makeSection( + category: .overdue, + title: String(localized: "today_section_overdue"), + items: items.overdue + ) + + TodayFeature.makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format"), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + + TodayFeature.makeSection( + category: .later, + title: String(localized: "today_section_later"), + items: items.later + ) + + TodayFeature.makeSection( + category: .unscheduled, + title: String(localized: "today_section_unscheduled"), + items: items.unscheduled + ) + case .focused: + return TodayFeature.makeSection( + category: .focused, + title: String(localized: "today_section_focused"), + items: items.focused + ) + case .overdue: + return TodayFeature.makeSection( + category: .overdue, + title: String(localized: "today_section_overdue"), + items: items.overdue + ) + case .dueSoon: + return TodayFeature.makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format"), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + } + } + + var summaryCounts: [SectionScope: Int] { + let now = Date() + return Dictionary( + uniqueKeysWithValues: SectionScope.allCases.map { scope in + ( + scope, + TodayFeature.summaryValue( + for: scope, + todos: todos, + displayOptions: displayOptions, + now: now + ) + ) + } + ) + } + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case refresh + case fetchData + case setSectionScope(SectionScope) + 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() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .binding(\.displayOptions.dueDateVisibility), + .binding(\.displayOptions.focusVisibility), + .binding(\.displayOptions.isFocusedOnly): + return updateDisplayOptionsEffect(state.displayOptions) + case .binding: + break + case .refresh, .fetchData: + return fetchTodosEffect() + case .setSectionScope(let scope): + if state.selectedSectionScope == scope, scope != .all { + state.selectedSectionScope = .all + } else { + state.selectedSectionScope = scope + } + 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) + } +} + +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 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..834894d4 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 { + @Bindable var store: StoreOf let coordinator: TodayViewCoordinator let isCompactLayout: Bool + init( + coordinator: TodayViewCoordinator, + isCompactLayout: Bool + ) { + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + self.store = 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() } } @@ -50,18 +51,21 @@ struct TodayView: View { private var summarySection: some View { Section { ScrollView(.horizontal) { + let summaryCounts = store.summaryCounts + let selectedSectionScope = store.selectedSectionScope + HStack(spacing: 12) { - ForEach(TodayViewModel.SectionScope.allCases, id: \.self) { scope in + ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in Button { - withAnimation(.easeInOut) { - coordinator.viewModel.send(.setSectionScope(scope)) + withAnimation(SwiftUI.Animation.easeInOut) { + _ = store.send(.setSectionScope(scope)) } } label: { SummaryCard( title: scope.title, - value: coordinator.viewModel.summaryValue(for: scope), + value: summaryCounts[scope, default: 0], accentColor: scope.accentColor, - isSelected: coordinator.viewModel.state.selectedSectionScope == scope + isSelected: selectedSectionScope == scope ) } .buttonStyle(.plain) @@ -80,10 +84,7 @@ struct TodayView: View { Menu { Picker( String(localized: "today_due_visibility_label"), - selection: Binding( - get: { coordinator.viewModel.state.displayOptions.dueDateVisibility }, - set: { coordinator.viewModel.send(.setDueDateVisibility($0)) } - ) + selection: $store.displayOptions.dueDateVisibility ) { ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in Text(option.title).tag(option) @@ -92,21 +93,16 @@ struct TodayView: View { Toggle( String(localized: "today_pinned_only"), - isOn: Binding( - get: { coordinator.viewModel.state.displayOptions.focusVisibility == .focusedOnly }, - set: { - coordinator.viewModel.send(.setFocusVisibility($0 ? .focusedOnly : .all)) - } - ) + isOn: $store.displayOptions.isFocusedOnly ) .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 +131,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 +139,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 +172,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 +225,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..7a65193c --- /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(.binding(.set(\.displayOptions.dueDateVisibility, visibility))) + await drainReceivedActions() + } + + func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { + await store.send(.binding(.set(\.displayOptions.focusVisibility, 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..898742aa --- /dev/null +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift @@ -0,0 +1,318 @@ +// +// TodayFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +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() + 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) + } +} + +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))! + ] +}