diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 66156443..57fd4a8c 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -14,7 +14,7 @@ struct TodoDetailView: View { @Environment(\.diContainer) private var container: DIContainer @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { ZStack { @@ -88,16 +88,19 @@ struct TodoDetailView: View { case .editor: if let todo = store.todo { TodoEditorView( - viewModel: TodoEditorViewModel( - todo: todo, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - onUpdateSuccess: { todo in - store.send(.setFullScreenCover(nil)) - store.send(.setTodo(todo)) - } - ) + store: Store(initialState: TodoEditorFeature.State(todo: todo)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + }, + onUpdateSuccess: { todo in + store.send(.setFullScreenCover(nil)) + store.send(.setTodo(todo)) + } ) } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift new file mode 100644 index 00000000..ce6c4f78 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -0,0 +1,369 @@ +// +// TodoEditorFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation +import OrderedCollections + +@Reducer +struct TodoEditorFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var sheet: SheetState? + var isCompleted: Bool = false + var completedAt: Date? + var isPinned: Bool = false + var title: String = "" + var content: String = "" + var referenceItems: [Int: TodoReferenceItem] = [:] + var dueDate: Date? + var loading = LoadingFeature.State() + var tags: OrderedSet = [] + var tagText: String = "" + var tabViewTag: EditorTab = .editor + var categories: [TodoCategoryItem] = [] + var category = TodoCategoryItem(from: .system(.etc)) + var saveResult: SaveResult? + let id: String + let isChecked: Bool + let number: Int? + let createdAt: Date? + let deletedAt: Date? + let originalDraft: TodoDraft? + + var isValidToSave: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + var isLoading: Bool { + loading.isLoading + } + var navigationTitle: String { + if originalDraft == nil { + return String.localizedStringWithFormat( + String(localized: "todo_editor_new_format"), + category.localizedName + ) + } + + return String(localized: "todo_edit") + } + var hasChanges: Bool { + guard let originalDraft else { return true } + return originalDraft != makeTodoDraft(now: Date()) + } + var isReadyToSubmit: Bool { + isValidToSave && hasChanges + } + + init(category: TodoCategory, id: String = UUID().uuidString) { + self.id = id + self.isChecked = false + self.number = nil + self.createdAt = nil + self.deletedAt = nil + self.originalDraft = nil + self.category = TodoCategoryItem(from: category) + self.categories = [TodoCategoryItem(from: category)] + } + + init(todo: Todo) { + self.id = todo.id + self.isChecked = todo.isChecked + self.number = todo.number + self.createdAt = todo.createdAt + self.deletedAt = todo.deletedAt + self.originalDraft = TodoDraft(todo: todo) + self.isCompleted = todo.isCompleted + self.completedAt = todo.completedAt + self.isPinned = todo.isPinned + self.title = todo.title + self.content = todo.content + self.dueDate = todo.dueDate + self.tags = OrderedSet(todo.tags) + self.category = TodoCategoryItem(from: todo.category) + } + } + + @ObservableState + @CasePathable + enum SheetState: Equatable { + case info + case todo(TodoIdItem) + } + + enum EditorTab: Equatable { + case editor + case preview + } + + enum SaveResult: Equatable { + case created + case updated(Todo) + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case sheet(PresentationAction) + case binding(BindingAction) + case onAppear + case addTag(String) + case removeTag(String) + case setCompleted(Bool) + case setSheet(SheetState?) + case upsertTodo + case createSucceeded + case saveFailed + case updateSucceeded(Todo) + case loading(LoadingFeature.Action) + + enum Sheet: Equatable { + case tapCloseButton + } + } + + private enum CancelID: Hashable { + case resolveMarkdown + } + + @Dependency(\.date.now) var now + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase + @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .sheet(.dismiss): + state.sheet = nil + case .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break + case .binding(\.content): + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .binding(\.dueDate): + if let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), + let dueDate = state.dueDate { + state.dueDate = max(dueDate, tomorrowDate) + } else { + state.dueDate = nil + } + case .binding(\.tabViewTag): + if state.tabViewTag == .preview { + return resolveMarkdownEffect(content: state.content) + } + case .binding: + break + case .onAppear: + return fetchCategoriesEffect() + case .addTag(let tag): + if !tag.isEmpty { + state.tags.append(tag) + } + case .removeTag(let tagText): + state.tags.removeAll { $0 == tagText } + case .setCompleted(let isCompleted): + if state.isCompleted != isCompleted { + state.completedAt = isCompleted ? now : nil + } + state.isCompleted = isCompleted + case .setSheet(let sheet): + state.sheet = sheet + case .upsertTodo: + state.saveResult = nil + if state.originalDraft == nil { + return createTodoEffect(state.makeTodoDraft(now: now)) + } else if let todo = state.makeTodo(now: now) { + return updateTodoEffect(todo) + } + case .createSucceeded: + state.saveResult = .created + case .saveFailed: + state.alert = Self.alertState() + case .updateSucceeded(let todo): + state.saveResult = .updated(todo) + case .loading: + break + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + TodoEditorSheetFeature() + } + } +} + +private struct TodoEditorSheetFeature: Reducer { + typealias State = TodoEditorFeature.SheetState + typealias Action = TodoEditorFeature.Action.Sheet + + var body: some ReducerOf { + EmptyReducer() + } +} + +extension DependencyValues { + var fetchTodoCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase { + get { self[FetchTodoCategoryPreferencesUseCaseKey.self] } + set { self[FetchTodoCategoryPreferencesUseCaseKey.self] = newValue } + } + + var upsertTodoUseCase: UpsertTodoUseCase { + get { self[UpsertTodoUseCaseKey.self] } + set { self[UpsertTodoUseCaseKey.self] = newValue } + } + + var trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? { + get { self[TrackAnalyticsEventUseCaseKey.self] } + set { self[TrackAnalyticsEventUseCaseKey.self] = newValue } + } +} + +private enum FetchTodoCategoryPreferencesUseCaseKey: DependencyKey { + static var liveValue: FetchTodoCategoryPreferencesUseCase { + preconditionFailure("FetchTodoCategoryPreferencesUseCase must be provided.") + } + + static var testValue: FetchTodoCategoryPreferencesUseCase { + liveValue + } +} + +private enum UpsertTodoUseCaseKey: DependencyKey { + static var liveValue: UpsertTodoUseCase { + preconditionFailure("UpsertTodoUseCase must be provided.") + } + + static var testValue: UpsertTodoUseCase { + liveValue + } +} + +private enum TrackAnalyticsEventUseCaseKey: DependencyKey { + static let liveValue: TrackAnalyticsEventUseCase? = nil +} + +private extension TodoEditorFeature { + func fetchCategoriesEffect() -> Effect { + .run { [fetchPreferencesUseCase] send in + do { + let preferences = try await fetchPreferencesUseCase.execute() + await send(.binding(.set(\.categories, preferences.map(TodoCategoryItem.init(from:))))) + } catch { } + } + } + + func resolveMarkdownEffect(content: String) -> Effect { + .run { [fetchReferenceItemsUseCase] send in + let numbers = content.todoReferenceNumbers + var referenceItems = [Int: TodoReferenceItem]() + + if !numbers.isEmpty { + do { + referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) + .mapValues(TodoReferenceItem.init(from:)) + } catch { + referenceItems = [:] + } + } + + await send(.binding(.set(\.referenceItems, referenceItems))) + } + .cancellable(id: CancelID.resolveMarkdown, cancelInFlight: true) + } + + func createTodoEffect(_ draft: TodoDraft) -> Effect { + .run { [trackAnalyticsEventUseCase, upsertTodoUseCase] send in + await send(.loading(.begin(target: .default, mode: .immediate))) + do { + try await upsertTodoUseCase.execute(draft) + trackAnalyticsEventUseCase?.execute(.todoCreate) + await send(.createSucceeded) + } catch { + await send(.saveFailed) + } + await send(.loading(.end(target: .default, mode: .immediate))) + } + } + + func updateTodoEffect(_ todo: Todo) -> Effect { + .run { [upsertTodoUseCase] send in + await send(.loading(.begin(target: .default, mode: .immediate))) + do { + try await upsertTodoUseCase.execute(todo) + await send(.updateSucceeded(todo)) + } catch { + await send(.saveFailed) + } + await send(.loading(.end(target: .default, mode: .immediate))) + } + } + + 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")) + } + } +} + +private extension TodoEditorFeature.State { + func makeTodoDraft(now: Date) -> TodoDraft { + TodoDraft( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + title: title, + content: content, + createdAt: now, + updatedAt: now, + completedAt: completedAt, + dueDate: dueDate, + tags: Array(tags), + category: category.category + ) + } + + func makeTodo(now: Date) -> Todo? { + guard let number, let createdAt else { return nil } + return Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + number: number, + title: title, + content: content, + createdAt: createdAt, + updatedAt: now, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: Array(tags), + category: category.category + ) + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 7a757615..766f5014 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -6,19 +6,20 @@ // import MarkdownUI -import OrderedCollections import SwiftUI import ComposableArchitecture import DevLogCore import DevLogDomain struct TodoEditorView: View { - @State var viewModel: TodoEditorViewModel @Environment(\.diContainer) private var container: DIContainer @Environment(\.dismiss) private var dismiss @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + @State var store: StoreOf @FocusState private var field: Field? private let calendar = Calendar.current + var onCreateSuccess: (() -> Void)? + var onUpdateSuccess: ((Todo) -> Void)? var onClose: (() -> Void)? var body: some View { @@ -45,39 +46,15 @@ struct TodoEditorView: View { .onTapGesture { field = .content } - .onAppear { viewModel.send(.onAppear) } - .navigationTitle(viewModel.navigationTitle) + .onAppear { store.send(.onAppear) } + .onChange(of: store.saveResult) { _, result in + handleSaveResult(result) + } + .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) - .sheet(isPresented: Binding( - get: { viewModel.state.showInfo }, - set: { viewModel.send(.setShowInfo($0)) } - )) { - TodoEditorInfoSheetView(viewModel: viewModel) { - viewModel.send(.setShowInfo(false)) - } - } - .sheet(item: Binding( - get: { viewModel.state.selectedTodoId }, - set: { viewModel.send(.setSelectedTodoId($0)) } - )) { item in - NavigationStack { - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) - ) { - TodoDetailFeature() - } withDependencies: { - $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) - $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) - }) - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setSelectedTodoId(nil)) - } - } - } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + sheetContent(sheetStore) } .toolbar { if !isiOSAppOnMac { @@ -85,7 +62,7 @@ struct TodoEditorView: View { } ToolbarItem(placement: .topBarTrailing) { Button { - viewModel.send(.setShowInfo(true)) + store.send(.setSheet(.info)) } label: { Image(systemName: "info.circle") } @@ -93,19 +70,9 @@ struct TodoEditorView: View { ToolbarTrailingButton { submit() } - .disabled(!viewModel.isReadyToSubmit || viewModel.state.isLoading) - } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + .disabled(!store.isReadyToSubmit || store.isLoading) } + .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -128,10 +95,7 @@ struct TodoEditorView: View { private var titleField: some View { TextField( "", - text: Binding( - get: { viewModel.state.title }, - set: { viewModel.send(.setTitle($0)) } - ), + text: $store.title, prompt: Text(String(localized: "todo_editor_title_required")).foregroundColor(Color.secondary), ) .font(.title2) @@ -143,10 +107,10 @@ struct TodoEditorView: View { Picker( "", selection: Binding( - get: { viewModel.state.tabViewTag }, + get: { store.tabViewTag }, set: { tag in if tag == .editor { - viewModel.send(.setTabViewTag(.editor)) + store.send(.binding(.set(\.tabViewTag, .editor))) field = .content } else { transitionToPreview() @@ -155,35 +119,32 @@ struct TodoEditorView: View { ) ) { Text(String(localized: "todo_write")) - .tag(TodoEditorViewModel.Tag.editor) + .tag(TodoEditorFeature.EditorTab.editor) Text(String(localized: "todo_preview")) - .tag(TodoEditorViewModel.Tag.preview) + .tag(TodoEditorFeature.EditorTab.preview) } .pickerStyle(.segmented) } private var tabView: some View { Group { - if viewModel.state.tabViewTag == .editor { + if store.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { markdownHint UIKitTextEditor( - text: Binding( - get: { viewModel.state.content }, - set: { viewModel.send(.setContent($0)) } - ), + text: $store.content, placeholder: String(localized: "todo_editor_description_optional") ) .focused($field, equals: .content) } } else { - if viewModel.state.content.isEmpty { + if store.content.isEmpty { previewPlaceholder } else { TodoMarkdownContentView( - content: viewModel.state.content, - referenceItems: viewModel.state.referenceItems, - onOpenTodoID: { viewModel.send(.setSelectedTodoId(TodoIdItem(id: $0))) } + content: store.content, + referenceItems: store.referenceItems, + onOpenTodoID: { store.send(.setSheet(.todo(TodoIdItem(id: $0)))) } ) } } @@ -211,7 +172,7 @@ struct TodoEditorView: View { } private func submit() { - viewModel.send(.upsertTodo) + store.send(.upsertTodo) } private func close() { @@ -226,7 +187,48 @@ struct TodoEditorView: View { field = nil DispatchQueue.main.async { - viewModel.send(.setTabViewTag(.preview)) + store.send(.binding(.set(\.tabViewTag, .preview))) + } + } + + private func handleSaveResult(_ result: TodoEditorFeature.SaveResult?) { + switch result { + case .created: + onCreateSuccess?() + case .updated(let todo): + onUpdateSuccess?(todo) + case .none: + break + } + } + + @ViewBuilder + private func sheetContent( + _ sheetStore: Store + ) -> some View { + switch sheetStore.state { + case .info: + TodoEditorInfoSheetView(store: store) { + sheetStore.send(.tapCloseButton) + } + case .todo(let item): + NavigationStack { + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) + .toolbar { + ToolbarLeadingButton { + sheetStore.send(.tapCloseButton) + } + } + } + .background(Color(.systemGroupedBackground)) + .presentationDragIndicator(.visible) } } @@ -236,7 +238,7 @@ struct TodoEditorView: View { } private struct TodoEditorInfoSheetView: View { - @Bindable var viewModel: TodoEditorViewModel + @Bindable var store: StoreOf let onClose: () -> Void @FocusState private var isTagFieldFocused: Bool private let calendar = Calendar.current @@ -248,19 +250,19 @@ private struct TodoEditorInfoSheetView: View { Picker( String(localized: "todo_category"), selection: Binding( - get: { viewModel.state.category.id }, + get: { store.category.id }, set: { categoryId in - guard let item = viewModel.state.categories.first(where: { + guard let item = store.categories.first(where: { $0.id == categoryId }) else { return } - viewModel.send(.setCategory(item)) + store.send(.binding(.set(\.category, item))) } ) ) { - ForEach(viewModel.state.categories, id: \.id) { item in + ForEach(store.categories, id: \.id) { item in Text(item.localizedName) .tag(item.id) } @@ -269,8 +271,8 @@ private struct TodoEditorInfoSheetView: View { Toggle( String(localized: "todo_completed"), isOn: Binding( - get: { viewModel.state.isCompleted }, - set: { viewModel.send(.setCompleted($0)) } + get: { store.isCompleted }, + set: { store.send(.setCompleted($0)) } ) ) .tint(.blue) @@ -278,8 +280,8 @@ private struct TodoEditorInfoSheetView: View { Toggle( String(localized: "todo_pinned"), isOn: Binding( - get: { viewModel.state.isPinned }, - set: { viewModel.send(.setPinned($0)) } + get: { store.isPinned }, + set: { store.send(.binding(.set(\.isPinned, $0))) } ) ) .tint(.blue) @@ -291,10 +293,7 @@ private struct TodoEditorInfoSheetView: View { HStack(spacing: 12) { TextField( String(localized: "todo_add"), - text: Binding( - get: { viewModel.state.tagText }, - set: { viewModel.send(.setTagText($0)) } - ) + text: $store.tagText ) .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) .textInputAutocapitalization(.never) @@ -315,15 +314,15 @@ private struct TodoEditorInfoSheetView: View { } } - if viewModel.state.tags.isEmpty { + if store.tags.isEmpty { Text(String(localized: "todo_no_tags")) .foregroundStyle(.secondary) .padding(.vertical, 4) } else { TagList( - viewModel.state.tags, + store.tags, isEditing: isTagFieldFocused, - action: { viewModel.send(.removeTag($0)) } + action: { store.send(.removeTag($0)) } ) } } @@ -340,16 +339,16 @@ private struct TodoEditorInfoSheetView: View { private var dueDateControl: some View { DueDatePicker(selection: Binding( - get: { viewModel.state.dueDate ?? Date() }, - set: { viewModel.send(.setDueDate($0)) } + get: { store.dueDate ?? Date() }, + set: { store.send(.binding(.set(\.dueDate, $0))) } )) { HStack { Text(String(localized: "todo_due_date")) .foregroundStyle(.primary) Spacer() - if let dueDate = viewModel.state.dueDate { + if let dueDate = store.dueDate { Tag(dueDateText(for: dueDate), isEditing: true) { - viewModel.send(.setDueDate(nil)) + store.send(.binding(.set(\.dueDate, nil))) } .padding(.vertical, -4) } else { @@ -364,16 +363,16 @@ private struct TodoEditorInfoSheetView: View { guard canSubmitTag else { return } let tagText = normalizedTagText - viewModel.send(.addTag(tagText)) - viewModel.send(.setTagText("")) + store.send(.addTag(tagText)) + store.send(.binding(.set(\.tagText, ""))) } private var normalizedTagText: String { - viewModel.state.tagText.trimmingCharacters(in: .whitespacesAndNewlines) + store.tagText.trimmingCharacters(in: .whitespacesAndNewlines) } private var canSubmitTag: Bool { - !normalizedTagText.isEmpty && !viewModel.state.tags.contains(normalizedTagText) + !normalizedTagText.isEmpty && !store.tags.contains(normalizedTagText) } private func dueDateText(for dueDate: Date) -> String { diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift deleted file mode 100644 index 0919804f..00000000 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorViewModel.swift +++ /dev/null @@ -1,343 +0,0 @@ -// -// TodoEditorViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/24/25. -// - -import Foundation -import OrderedCollections -import DevLogDomain - -@Observable -final class TodoEditorViewModel: StorePattern { - struct State: Equatable { - var isCompleted: Bool = false - var completedAt: Date? - var isPinned: Bool = false - var selectedTodoId: TodoIdItem? - var title: String = "" - var content: String = "" - var referenceItems: [Int: TodoReferenceItem] = [:] - var dueDate: Date? - var showInfo: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var isLoading: Bool = false - var tags: OrderedSet = [] - var tagText: String = "" - var focusOnEditor: Bool = false - var tabViewTag: Tag = .editor - var categories: [TodoCategoryItem] = [] - var category = TodoCategoryItem(from: .system(.etc)) - var isValidToSave: Bool { - !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - } - - enum Tag { - case editor, preview - } - - enum Action { - case onAppear - case addTag(String) - case removeTag(String) - case setContent(String) - case setCompleted(Bool) - case setDueDate(Date?) - case setCategory(TodoCategoryItem) - case setAlert(Bool) - case setLoading(Bool) - case setPinned(Bool) - case setShowInfo(Bool) - case setSelectedTodoId(TodoIdItem?) - case setTabViewTag(Tag) - case setTagText(String) - case setTitle(String) - case setCategories([TodoCategoryItem]) - case setReferenceItems([Int: TodoReferenceItem]) - case upsertTodo - } - - enum SideEffect { - case fetchCategories - case resolveMarkdown(String) - case createTodo(TodoDraft) - case updateTodo(Todo) - } - - private(set) var state = State() - private let calendar = Calendar.current - private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase - private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase - private let upsertTodoUseCase: UpsertTodoUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? - private let onCreateSuccess: (() -> Void)? - private let onUpdateSuccess: ((Todo) -> Void)? - private let id: String - private let isChecked: Bool - private let number: Int? - private let createdAt: Date? - private let deletedAt: Date? - private let originalDraft: TodoDraft? - - var navigationTitle: String { - if originalDraft == nil { - return String.localizedStringWithFormat( - String(localized: "todo_editor_new_format"), - state.category.localizedName - ) - } - - return String(localized: "todo_edit") - } - - var hasChanges: Bool { - guard let originalDraft else { return true } - return originalDraft != makeTodoDraft() - } - - var isReadyToSubmit: Bool { - state.isValidToSave && hasChanges - } - - // 새로운 Todo 생성용 생성자 - init( - category: TodoCategory, - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil, - onCreateSuccess: (() -> Void)? = nil - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.onCreateSuccess = onCreateSuccess - self.onUpdateSuccess = nil - self.id = UUID().uuidString - self.isChecked = false - self.number = nil - self.createdAt = nil - self.deletedAt = nil - self.originalDraft = nil - state.category = TodoCategoryItem(from: category) - state.categories = [TodoCategoryItem(from: category)] - } - - // 기존 Todo 편집용 생성자 - init( - todo: Todo, - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil, - onUpdateSuccess: ((Todo) -> Void)? = nil - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.onCreateSuccess = nil - self.onUpdateSuccess = onUpdateSuccess - self.id = todo.id - self.isChecked = todo.isChecked - self.number = todo.number - self.createdAt = todo.createdAt - self.deletedAt = todo.deletedAt - self.originalDraft = TodoDraft(todo: todo) - state.isCompleted = todo.isCompleted - state.completedAt = todo.completedAt - state.isPinned = todo.isPinned - state.title = todo.title - state.content = todo.content - state.dueDate = todo.dueDate - state.tags = OrderedSet(todo.tags) - state.category = TodoCategoryItem(from: todo.category) - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .onAppear: - effects = [.fetchCategories] - case .addTag(let tag): - if !tag.isEmpty { - state.tags.append(tag) - } - case .removeTag(let tagText): - state.tags.removeAll { $0 == tagText } - case .setContent(let stringValue), - .setTagText(let stringValue), - .setTitle(let stringValue): - handleStringAction(action, stringValue: stringValue, state: &state) - if state.tabViewTag == .preview { - effects = [.resolveMarkdown(state.content)] - } - case .setDueDate(let dueDate): - if let tomorrowDate = calendar.date(byAdding: .day, value: 1, to: Date()), let dueDate { - state.dueDate = max(dueDate, tomorrowDate) - } else { - state.dueDate = nil - } - case .setCompleted(let isCompleted): - if state.isCompleted != isCompleted { - state.completedAt = isCompleted ? Date() : nil - } - state.isCompleted = isCompleted - case .setCategory(let todoCategoryItem): - state.category = todoCategoryItem - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setLoading(let value): - state.isLoading = value - case .setPinned(let isPinned): - state.isPinned = isPinned - case .setShowInfo(let isPresented): - state.showInfo = isPresented - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId - case .setTabViewTag(let tag): - state.tabViewTag = tag - if tag == .preview { - effects = [.resolveMarkdown(state.content)] - } - case .setCategories(let categories): - state.categories = categories - case .setReferenceItems(let items): - state.referenceItems = items - case .upsertTodo: - if originalDraft == nil { - effects = [.createTodo(makeTodoDraft())] - } else if let todo = makeTodo() { - effects = [.updateTodo(todo)] - } - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchCategories: - Task { - do { - let preferences = try await fetchPreferencesUseCase.execute() - send(.setCategories(preferences.map(TodoCategoryItem.init(from:)))) - } catch { } - } - case .resolveMarkdown(let content): - Task { - let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReferenceItem]() - - if !numbers.isEmpty { - do { - referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) - .mapValues(TodoReferenceItem.init(from:)) - } catch { - referenceItems = [:] - } - } - - send(.setReferenceItems(referenceItems)) - } - case .createTodo(let todoDraft): - send(.setLoading(true)) - Task { - do { - defer { send(.setLoading(false)) } - try await upsertTodoUseCase.execute(todoDraft) - trackAnalyticsEventUseCase?.execute(.todoCreate) - onCreateSuccess?() - } catch { - send(.setAlert(true)) - } - } - case .updateTodo(let todo): - send(.setLoading(true)) - Task { - do { - defer { send(.setLoading(false)) } - try await upsertTodoUseCase.execute(todo) - onUpdateSuccess?(todo) - } catch { - send(.setAlert(true)) - } - } - } - } -} - -extension TodoEditorViewModel { - private func handleStringAction( - _ action: Action, - stringValue: String, - state: inout State - ) { - switch action { - case .setContent: - state.content = stringValue - case .setTagText: - state.tagText = stringValue - case .setTitle: - state.title = stringValue - default: - break - } - } - - private func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - private func makeTodoDraft() -> TodoDraft { - let date = Date() - return TodoDraft( - id: self.id, - isPinned: state.isPinned, - isCompleted: state.isCompleted, - isChecked: self.isChecked, - title: state.title, - content: state.content, - createdAt: date, - updatedAt: date, - completedAt: state.completedAt, - dueDate: state.dueDate, - tags: Array(state.tags), - category: state.category.category - ) - } - - private func makeTodo() -> Todo? { - guard let number, let createdAt else { return nil } - let date = Date() - return Todo( - id: self.id, - isPinned: state.isPinned, - isCompleted: state.isCompleted, - isChecked: self.isChecked, - number: number, - title: state.title, - content: state.content, - createdAt: createdAt, - updatedAt: date, - completedAt: state.completedAt, - deletedAt: self.deletedAt, - dueDate: state.dueDate, - tags: Array(state.tags), - category: state.category.category - ) - } -} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index c3a073a9..82242d23 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -50,7 +50,11 @@ struct HomeView: View { )) { if let selectedCategory = coordinator.viewModel.state.selectedTodoCategory { TodoEditorView( - viewModel: coordinator.makeTodoEditorViewModel(category: selectedCategory) + store: coordinator.makeTodoEditorStore(category: selectedCategory), + onCreateSuccess: { + coordinator.viewModel.send(.setPresentation(.todoEditor, false)) + coordinator.viewModel.send(.fetchData) + } ) } } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 1bd6c929..19e5ba68 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -77,18 +77,15 @@ final class HomeViewCoordinator { .store(in: &cancellables) } - func makeTodoEditorViewModel(category: TodoCategory) -> TodoEditorViewModel { - TodoEditorViewModel( - category: category, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: { [weak self] in - self?.viewModel.send(.setPresentation(.todoEditor, false)) - self?.viewModel.send(.fetchData) - } - ) + func makeTodoEditorStore(category: TodoCategory) -> StoreOf { + Store(initialState: TodoEditorFeature.State(category: category)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = self.container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.fetchReferenceItemsUseCase = self.container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = self.container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = self.container.resolve(TrackAnalyticsEventUseCase.self) + } } func makeSearchStore() -> StoreOf { diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 809677ed..f5759749 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -71,17 +72,18 @@ struct TodoListView: View { set: { viewModel.send(.setShowEditor($0)) } )) { TodoEditorView( - viewModel: TodoEditorViewModel( - category: viewModel.category, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: { - viewModel.send(.setShowEditor(false)) - viewModel.send(.refresh) - } - ) + store: Store(initialState: TodoEditorFeature.State(category: viewModel.category)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }, + onCreateSuccess: { + viewModel.send(.setShowEditor(false)) + viewModel.send(.refresh) + } ) } .toolbar { diff --git a/Application/DevLogPresentation/Sources/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index 32e112dc..80e4a637 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchView.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchView.swift @@ -14,7 +14,7 @@ struct SearchView: View { @Environment(\.dismiss) private var dismiss @Environment(\.diContainer) private var container: DIContainer @State private var router = NavigationRouter() - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { NavigationStack(path: $router.path) { diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index ac9efede..28c664de 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -10,7 +10,7 @@ import ComposableArchitecture import DevLogDomain struct AccountView: View { - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { List { diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index d7847c98..88d7a481 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI import ComposableArchitecture struct PushNotificationSettingsView: View { - @Bindable var store: StoreOf + @State var store: StoreOf var body: some View { List { diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift index 1f489fab..93bb5d4a 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoEditorWindowView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -28,25 +29,31 @@ public struct TodoEditorWindowView: View { switch value { case .create(let windowCategory, _): TodoEditorView( - viewModel: TodoEditorViewModel( - category: windowCategory.todoCategory, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - onCreateSuccess: create - ), + store: Store(initialState: TodoEditorFeature.State(category: windowCategory.todoCategory)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }, + onCreateSuccess: create, onClose: closeWindow ) case .edit(let windowTodo): TodoEditorView( - viewModel: TodoEditorViewModel( - todo: windowTodo.todo, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - onUpdateSuccess: update - ), + store: Store(initialState: TodoEditorFeature.State(todo: windowTodo.todo)) { + TodoEditorFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve( + FetchTodoCategoryPreferencesUseCase.self + ) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + $0.upsertTodoUseCase = container.resolve(UpsertTodoUseCase.self) + }, + onUpdateSuccess: update, onClose: closeWindow ) } diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift new file mode 100644 index 00000000..4ba816c4 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -0,0 +1,356 @@ +// +// TodoEditorFeatureTestDoubles.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +let todoEditorNow = Date(timeIntervalSince1970: 1_000) + +@MainActor +final class TodoEditorStoreTestAdapter { + private let store: TestStoreOf + private let now: Date + + var isCompleted: Bool { store.state.isCompleted } + var completedAt: Date? { store.state.completedAt } + var isPinned: Bool { store.state.isPinned } + var title: String { store.state.title } + var content: String { store.state.content } + var referenceItems: [Int: TodoReferenceItem] { store.state.referenceItems } + var dueDate: Date? { store.state.dueDate } + var sheet: TodoEditorFeature.SheetState? { store.state.sheet } + var isLoading: Bool { store.state.isLoading } + var tags: [String] { Array(store.state.tags) } + var categories: [TodoCategoryItem] { store.state.categories } + var category: TodoCategoryItem { store.state.category } + var hasChanges: Bool { store.state.hasChanges } + var isReadyToSubmit: Bool { store.state.isReadyToSubmit } + var saveResult: TodoEditorFeature.SaveResult? { store.state.saveResult } + var hasErrorAlert: Bool { store.state.alert == expectedTodoEditorErrorAlert() } + + init( + category: TodoCategory, + now: Date = todoEditorNow, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = TodoEditorFetchPreferencesUseCaseSpy(), + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase = TodoEditorFetchReferenceItemsUseCaseSpy(), + upsertTodoUseCase: UpsertTodoUseCase = TodoEditorUpsertTodoUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil + ) { + self.now = now + store = TestStore( + initialState: TodoEditorFeature.State(category: category, id: "todo-draft-id") + ) { + TodoEditorFeature() + } withDependencies: { + $0.date.now = now + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + $0.upsertTodoUseCase = upsertTodoUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + init( + todo: Todo, + now: Date = todoEditorNow, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = TodoEditorFetchPreferencesUseCaseSpy(), + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase = TodoEditorFetchReferenceItemsUseCaseSpy(), + upsertTodoUseCase: UpsertTodoUseCase = TodoEditorUpsertTodoUseCaseSpy() + ) { + self.now = now + store = TestStore(initialState: TodoEditorFeature.State(todo: todo)) { + TodoEditorFeature() + } withDependencies: { + $0.date.now = now + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + $0.upsertTodoUseCase = upsertTodoUseCase + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func onAppear() async { + await store.send(.onAppear) + await drainReceivedActions() + } + + func addTag(_ tag: String) async { + await store.send(.addTag(tag)) { + if !tag.isEmpty { + $0.tags.append(tag) + } + } + } + + func removeTag(_ tag: String) async { + await store.send(.removeTag(tag)) { + $0.tags.removeAll { $0 == tag } + } + } + + func setContent(_ content: String) async { + await store.send(.binding(.set(\.content, content))) { + $0.content = content + } + await drainReceivedActions() + } + + func setCompleted(_ isCompleted: Bool) async { + let shouldUpdateDate = store.state.isCompleted != isCompleted + await store.send(.setCompleted(isCompleted)) { + if shouldUpdateDate { + $0.completedAt = isCompleted ? self.now : nil + } + $0.isCompleted = isCompleted + } + } + + func setSheet(_ sheet: TodoEditorFeature.SheetState?) async { + await store.send(.setSheet(sheet)) { + $0.sheet = sheet + } + } + + func dismissSheet() async { + await store.send(.sheet(.dismiss)) { + $0.sheet = nil + } + } + + func tapSheetCloseButton() async { + await store.send(.sheet(.presented(.tapCloseButton))) { + $0.sheet = nil + } + } + + func setDueDate(_ dueDate: Date?) async { + let expectedDueDate = expectedDueDate(for: dueDate) + await store.send(.binding(.set(\.dueDate, dueDate))) { + $0.dueDate = expectedDueDate + } + } + + func setPinned(_ isPinned: Bool) async { + await store.send(.binding(.set(\.isPinned, isPinned))) { + $0.isPinned = isPinned + } + } + + func setTab(_ tab: TodoEditorFeature.EditorTab) async { + await store.send(.binding(.set(\.tabViewTag, tab))) { + $0.tabViewTag = tab + } + await drainReceivedActions() + } + + func setTitle(_ title: String) async { + await store.send(.binding(.set(\.title, title))) { + $0.title = title + } + await drainReceivedActions() + } + + func upsertTodo() async { + await store.send(.upsertTodo) { + $0.saveResult = nil + } + await receiveBeginLoading() + } + + func drainReceivedActions() async { + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + await store.skipReceivedActions(strict: false) + } + + private func expectedDueDate(for dueDate: Date?) -> Date? { + guard let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), + let dueDate else { + return nil + } + + return max(dueDate, tomorrowDate) + } + + private func receiveBeginLoading() async { + await store.receive(.loading(.begin(target: .default, mode: .immediate))) { + $0.loading.setImmediateLoading() + } + } +} + +private extension LoadingFeature.State { + mutating func setImmediateLoading() { + let target = LoadingFeature.Target.default + immediateCountByTarget[target] = 1 + visibleTargets.insert(target) + isLoading = !visibleTargets.isEmpty + } +} + +final class TodoEditorFetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + var preferences: [TodoCategoryPreference] + private(set) var executeCallCount = 0 + + init(preferences: [TodoCategoryPreference] = []) { + self.preferences = preferences + } + + func execute() async throws -> [TodoCategoryPreference] { + executeCallCount += 1 + return preferences + } +} + +final class TodoEditorFetchReferenceItemsUseCaseSpy: FetchReferenceItemsUseCase { + var references: [Int: TodoReference] + var error: Error? + private(set) var numbers: [[Int]] = [] + + init(references: [Int: TodoReference] = [:]) { + self.references = references + } + + func execute(_ numbers: [Int]) async throws -> [Int: TodoReference] { + self.numbers.append(numbers) + + if let error { + throw error + } + + return references + } +} + +final class TodoEditorUpsertTodoUseCaseSpy: UpsertTodoUseCase { + var error: Error? + var shouldSuspend = false + private(set) var todos: [Todo] = [] + private(set) var todoDrafts: [TodoDraft] = [] + private var continuation: CheckedContinuation? + private var shouldResume = false + + func execute(_ todo: Todo) async throws { + todos.append(todo) + await suspendIfNeeded() + + if let error { + throw error + } + } + + func execute(_ draft: TodoDraft) async throws { + todoDrafts.append(draft) + await suspendIfNeeded() + + if let error { + throw error + } + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } + + private func suspendIfNeeded() async { + guard shouldSuspend else { return } + + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } +} + +final class TodoEditorTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events: [AnalyticsEvent] = [] + var hasTrackedTodoCreate: Bool { + events.contains { + guard case .todoCreate = $0 else { return false } + return true + } + } + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +enum TodoEditorTestError: Error { + case failure +} + +func expectedTodoEditorErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } +} + +func makeTodoEditorTodo( + id: String = "todo-1", + isPinned: Bool = false, + isCompleted: Bool = false, + isChecked: Bool = false, + number: Int = 1, + title: String = "Todo", + content: String = "content", + createdAt: Date = Date(timeIntervalSince1970: 0), + updatedAt: Date = Date(timeIntervalSince1970: 0), + completedAt: Date? = nil, + deletedAt: Date? = nil, + dueDate: Date? = nil, + tags: [String] = [], + category: TodoCategory = .system(.doc) +) -> Todo { + Todo( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + number: number, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: tags, + category: category + ) +} + +func makeTodoEditorReference( + id: String, + title: String = "Reference" +) -> TodoReference { + TodoReference( + id: id, + title: title, + category: .system(.issue) + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift new file mode 100644 index 00000000..e93283d0 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTests.swift @@ -0,0 +1,250 @@ +// +// TodoEditorFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct TodoEditorFeatureTests { + @Test("새 Todo 작성 상태는 선택한 카테고리로 초기화된다") + func 새_Todo_작성_상태는_선택한_카테고리로_초기화된다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + #expect(adapter.category == TodoCategoryItem(from: .system(.doc))) + #expect(adapter.categories == [TodoCategoryItem(from: .system(.doc))]) + #expect(adapter.title.isEmpty) + #expect(adapter.content.isEmpty) + #expect(adapter.tags.isEmpty) + #expect(adapter.isReadyToSubmit == false) + } + + @Test("기존 Todo 편집 상태는 Todo 값으로 초기화되고 변경 여부를 계산한다") + func 기존_Todo_편집_상태는_Todo_값으로_초기화되고_변경_여부를_계산한다() async { + let todo = makeTodoEditorTodo( + id: "todo-1", + isPinned: true, + isCompleted: true, + title: "Original", + content: "Body", + completedAt: Date(timeIntervalSince1970: 10), + dueDate: Date(timeIntervalSince1970: 20), + tags: ["swift", "tca"], + category: .system(.issue) + ) + let adapter = TodoEditorStoreTestAdapter(todo: todo) + + #expect(adapter.isPinned) + #expect(adapter.isCompleted) + #expect(adapter.title == "Original") + #expect(adapter.content == "Body") + #expect(adapter.completedAt == Date(timeIntervalSince1970: 10)) + #expect(adapter.dueDate == Date(timeIntervalSince1970: 20)) + #expect(adapter.tags == ["swift", "tca"]) + #expect(adapter.category == TodoCategoryItem(from: .system(.issue))) + #expect(adapter.hasChanges == false) + #expect(adapter.isReadyToSubmit == false) + + await adapter.setTitle("Changed") + + #expect(adapter.hasChanges) + #expect(adapter.isReadyToSubmit) + } + + @Test("onAppear는 Todo 카테고리 설정을 가져와 상태에 반영한다") + func onAppear는_Todo_카테고리_설정을_가져와_상태에_반영한다() async { + let fetchSpy = TodoEditorFetchPreferencesUseCaseSpy(preferences: [ + TodoCategoryPreference(category: .system(.doc), isVisible: true), + TodoCategoryPreference(category: .system(.issue), isVisible: false) + ]) + let adapter = TodoEditorStoreTestAdapter( + category: .system(.etc), + fetchPreferencesUseCase: fetchSpy + ) + + await adapter.onAppear() + + #expect(fetchSpy.executeCallCount == 1) + #expect(adapter.categories == [ + TodoCategoryItem(from: TodoCategoryPreference(category: .system(.doc), isVisible: true)), + TodoCategoryItem(from: TodoCategoryPreference(category: .system(.issue), isVisible: false)) + ]) + } + + @Test("태그 추가와 삭제는 OrderedSet 상태를 변경한다") + func 태그_추가와_삭제는_OrderedSet_상태를_변경한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + await adapter.addTag("swift") + await adapter.addTag("tca") + await adapter.addTag("swift") + await adapter.removeTag("swift") + + #expect(adapter.tags == ["tca"]) + } + + @Test("완료 상태가 바뀔 때 completedAt을 함께 갱신한다") + func 완료_상태가_바뀔_때_completedAt을_함께_갱신한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + + await adapter.setCompleted(true) + + #expect(adapter.isCompleted) + #expect(adapter.completedAt == todoEditorNow) + + await adapter.setCompleted(false) + + #expect(adapter.isCompleted == false) + #expect(adapter.completedAt == nil) + } + + @Test("미래 마감일은 그대로 반영하고 nil 입력은 마감일을 제거한다") + func 미래_마감일은_그대로_반영하고_nil_입력은_마감일을_제거한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + let dueDate = Date(timeIntervalSince1970: 4_102_444_800) + + await adapter.setDueDate(dueDate) + + #expect(adapter.dueDate == dueDate) + + await adapter.setDueDate(nil) + + #expect(adapter.dueDate == nil) + } + + @Test("미리보기 전환은 본문의 참조 Todo를 해석해 상태에 반영한다") + func 미리보기_전환은_본문의_참조_Todo를_해석해_상태에_반영한다() async { + let reference3 = makeTodoEditorReference(id: "todo-3", title: "Reference 3") + let reference5 = makeTodoEditorReference(id: "todo-5", title: "Reference 5") + let referenceSpy = TodoEditorFetchReferenceItemsUseCaseSpy(references: [ + 3: reference3, + 5: reference5 + ]) + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + fetchReferenceItemsUseCase: referenceSpy + ) + + await adapter.setContent(""" + body + - refs #3 + - refs #5 + - refs #3 + """) + await adapter.setTab(.preview) + + #expect(referenceSpy.numbers == [[3, 5]]) + #expect(adapter.referenceItems[3] == TodoReferenceItem(from: reference3)) + #expect(adapter.referenceItems[5] == TodoReferenceItem(from: reference5)) + } + + @Test("정보와 참조 Todo 시트 상태를 액션에 맞게 변경한다") + func 정보와_참조_Todo_시트_상태를_액션에_맞게_변경한다() async { + let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) + let item = TodoIdItem(id: "todo-2") + + await adapter.setSheet(.info) + + #expect(adapter.sheet == .info) + + await adapter.dismissSheet() + + #expect(adapter.sheet == nil) + + await adapter.setSheet(.todo(item)) + + #expect(adapter.sheet == .todo(item)) + + await adapter.tapSheetCloseButton() + + #expect(adapter.sheet == nil) + } + + @Test("새 Todo 저장 성공은 draft를 저장하고 생성 완료 상태를 남긴다") + func 새_Todo_저장_성공은_draft를_저장하고_생성_완료_상태를_남긴다() async { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.shouldSuspend = true + let analyticsSpy = TodoEditorTrackAnalyticsEventUseCaseSpy() + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + upsertTodoUseCase: upsertSpy, + trackAnalyticsEventUseCase: analyticsSpy + ) + let dueDate = Date(timeIntervalSince1970: 4_102_444_800) + + await adapter.setTitle("Title") + await adapter.setContent("Content") + await adapter.setPinned(true) + await adapter.setDueDate(dueDate) + await adapter.addTag("swift") + await adapter.upsertTodo() + + #expect(adapter.isLoading) + #expect(upsertSpy.todoDrafts.first?.title == "Title") + #expect(upsertSpy.todoDrafts.first?.content == "Content") + #expect(upsertSpy.todoDrafts.first?.isPinned == true) + #expect(upsertSpy.todoDrafts.first?.dueDate == dueDate) + #expect(upsertSpy.todoDrafts.first?.tags == ["swift"]) + #expect(upsertSpy.todoDrafts.first?.category == .system(.doc)) + + upsertSpy.resume() + await adapter.drainReceivedActions() + + #expect(adapter.saveResult == .created) + #expect(adapter.isLoading == false) + #expect(analyticsSpy.hasTrackedTodoCreate) + } + + @Test("기존 Todo 저장 성공은 수정 Todo를 저장하고 수정 완료 상태를 남긴다") + func 기존_Todo_저장_성공은_수정_Todo를_저장하고_수정_완료_상태를_남긴다() async throws { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + let todo = makeTodoEditorTodo( + id: "todo-1", + number: 7, + title: "Original", + content: "Body", + createdAt: Date(timeIntervalSince1970: 10), + category: .system(.issue) + ) + let adapter = TodoEditorStoreTestAdapter(todo: todo, upsertTodoUseCase: upsertSpy) + + await adapter.setTitle("Changed") + await adapter.setContent("Changed body") + await adapter.upsertTodo() + await adapter.drainReceivedActions() + + let updated = try #require(upsertSpy.todos.first) + + #expect(updated.id == "todo-1") + #expect(updated.number == 7) + #expect(updated.title == "Changed") + #expect(updated.content == "Changed body") + #expect(updated.createdAt == Date(timeIntervalSince1970: 10)) + #expect(updated.updatedAt == todoEditorNow) + #expect(updated.category == .system(.issue)) + #expect(adapter.saveResult == .updated(updated)) + } + + @Test("저장 실패는 공통 에러 알림을 표시하고 로딩을 해제한다") + func 저장_실패는_공통_에러_알림을_표시하고_로딩을_해제한다() async { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.error = TodoEditorTestError.failure + let adapter = TodoEditorStoreTestAdapter( + category: .system(.doc), + upsertTodoUseCase: upsertSpy + ) + + await adapter.setTitle("Title") + await adapter.upsertTodo() + await adapter.drainReceivedActions() + + #expect(adapter.hasErrorAlert) + #expect(adapter.isLoading == false) + #expect(adapter.saveResult == nil) + } +}