diff --git a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift index bd534495..b78dbeb1 100644 --- a/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift @@ -163,7 +163,7 @@ struct CategoryManageFeature { } case .tapDeleteUserCategory(let item): if item.isUserCategory { - state.alert = deleteAlertState(for: item) + state.alert = Self.deleteAlertState(for: item) } case .tapDoneButton: break @@ -204,7 +204,7 @@ private struct CategoryManageSheetFeature: Reducer { } private extension CategoryManageFeature { - func deleteAlertState(for item: TodoCategoryItem) -> AlertState { + static func deleteAlertState(for item: TodoCategoryItem) -> AlertState { AlertState { TextState(String(localized: "todo_manage_delete_category_title")) } actions: { diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift index 5f3ea046..0f5087f1 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift @@ -108,7 +108,7 @@ struct TodoDetailFeature { case .onAppear: return fetchTodoEffect(todoId: state.todoId) case .fetchFailed: - state.alert = alertState() + state.alert = Self.alertState() case .setSheet(let sheet): state.sheet = sheet case .setFullScreenCover(let cover): @@ -209,7 +209,7 @@ private extension TodoDetailFeature { } } - func alertState() -> AlertState { + static func alertState() -> AlertState { AlertState { TextState(String(localized: "common_error_title")) } actions: { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index cd39134e..c3a073a9 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -58,7 +58,7 @@ struct HomeView: View { get: { coordinator.viewModel.state.showSearchView }, set: { coordinator.viewModel.send(.setPresentation(.searchView, $0)) } )) { - SearchView(viewModel: coordinator.makeSearchViewModel()) + SearchView(store: coordinator.makeSearchStore()) } .alert( coordinator.viewModel.state.alertTitle, diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 9ab2d22c..1bd6c929 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -6,6 +6,7 @@ // import Combine +import ComposableArchitecture import Foundation import DevLogCore import DevLogDomain @@ -90,12 +91,17 @@ final class HomeViewCoordinator { ) } - func makeSearchViewModel() -> SearchViewModel { - SearchViewModel( - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), - updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) - ) + func makeSearchStore() -> StoreOf { + Store( + initialState: SearchFeature.State( + recentQueries: container.resolve(FetchRecentSearchQueriesUseCase.self).execute() + ) + ) { + SearchFeature() + } withDependencies: { + $0.searchFetchWebPagesUseCase = self.container.resolve(FetchWebPagesUseCase.self) + $0.searchFetchTodosUseCase = self.container.resolve(FetchTodosUseCase.self) + $0.searchUpdateRecentQueriesUseCase = self.container.resolve(UpdateRecentSearchQueriesUseCase.self) + } } } diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 8aaf018b..fa2a72f3 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -14,14 +14,18 @@ struct LoginFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? - var isLoading = false + var loading = LoadingFeature.State() + + var isLoading: Bool { + loading.isLoading + } } enum Action { case alert(PresentationAction) case tapSignInButton(AuthProvider) case signInFailed(AlertType) - case signInCancelled + case loading(LoadingFeature.Action) } enum AlertType: Equatable { @@ -32,28 +36,19 @@ struct LoginFeature { @Dependency(\.signInUseCase) var signInUseCase var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } Reduce { state, action in switch action { case .alert: break case .tapSignInButton(let provider): - state.isLoading = true - return .run { [signInUseCase] send in - do { - try await signInUseCase.execute(provider) - } catch { - if error.isSocialLoginCancelled { - await send(.signInCancelled) - return - } - await send(.signInFailed(alertType(for: error))) - } - } - case .signInCancelled: - state.isLoading = false + return signInEffect(provider) case .signInFailed(let alertType): - state.isLoading = false - state.alert = alertState(for: alertType) + state.alert = Self.alertState(for: alertType) + case .loading: + break } return .none } @@ -79,7 +74,21 @@ private enum SignInUseCaseKey: DependencyKey { } private extension LoginFeature { - func alertState(for alertType: AlertType) -> AlertState { + func signInEffect(_ provider: AuthProvider) -> Effect { + .run { [signInUseCase] send in + await send(.loading(.begin(target: .default, mode: .immediate))) + do { + try await signInUseCase.execute(provider) + // 유스케이스 완료가 화면 전환 완료를 의미하지 않으므로 LoginView가 교체될 때까지 로딩을 유지한다. + } catch { + await send(.loading(.end(target: .default, mode: .immediate))) + if error.isSocialLoginCancelled { return } + await send(.signInFailed(Self.alertType(for: error))) + } + } + } + + static func alertState(for alertType: AlertType) -> AlertState { let title: String let message: String @@ -103,7 +112,7 @@ private extension LoginFeature { } } - func alertType(for error: Error) -> AlertType { + static func alertType(for error: Error) -> AlertType { if case AuthError.emailNotFound = error { return .emailUnavailable } diff --git a/Application/DevLogPresentation/Sources/Search/SearchFeature.swift b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift new file mode 100644 index 00000000..9fa0fda4 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift @@ -0,0 +1,300 @@ +// +// SearchFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import Foundation +import OrderedCollections +import DevLogCore +import DevLogDomain + +@Reducer +struct SearchFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var loading = LoadingFeature.State() + var isSearching = false + var searchQuery = "" + var webPages: [WebPageItem] = [] + var todos: [TodoListItem] = [] + var recentQueries = OrderedSet() + var showAllTodos = false + var showAllWebPages = false + let contentsLimit = 5 + + init(recentQueries: [String] = []) { + self.recentQueries = OrderedSet(recentQueries) + } + + var isLoading: Bool { + loading.isLoading + } + + var visibleTodos: [TodoListItem] { + if showAllTodos { + return todos + } + + return Array(todos.prefix(contentsLimit)) + } + + var visibleWebPages: [WebPageItem] { + if showAllWebPages { + return webPages + } + + return Array(webPages.prefix(contentsLimit)) + } + + var shouldShowMoreTodos: Bool { + !showAllTodos && contentsLimit < todos.count + } + + var shouldShowMoreWebPages: Bool { + !showAllWebPages && contentsLimit < webPages.count + } + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case fetchWebPage([WebPageItem]) + case fetchTodos([TodoListItem]) + case addRecentQuery(String) + case removeRecentQuery(String) + case clearRecentQueries + case applySearchQuery(String) + case setAlert(Bool) + case setShowAllTodos(Bool) + case setShowAllWebPages(Bool) + case loading(LoadingFeature.Action) + } + + private enum CancelID: Hashable { + case debounce + case request + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.searchFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.searchFetchWebPagesUseCase) var fetchWebPagesUseCase + @Dependency(\.searchUpdateRecentQueriesUseCase) var updateRecentSearchQueriesUseCase + + private let maxRecentQueries = 20 + private let searchDebounceDelay = Duration.seconds(0.4) + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .binding(\.isSearching): + if !state.isSearching { + return Self.cancelSearchEffect(isLoading: state.isLoading) + } + case .binding(\.searchQuery): + state.showAllTodos = false + state.showAllWebPages = false + let trimmed = state.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.webPages = [] + state.todos = [] + return Self.cancelSearchEffect(isLoading: state.isLoading) + } else { + return .concatenate( + Self.cancelSearchEffect(isLoading: state.isLoading), + debounceFetchEffect(trimmed) + ) + } + case .binding: + break + case .fetchWebPage(let items): + state.webPages = items + case .fetchTodos(let items): + state.todos = items + case .addRecentQuery(let query): + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { break } + state.recentQueries.remove(trimmed) + state.recentQueries.insert(trimmed, at: 0) + if maxRecentQueries < state.recentQueries.count { + state.recentQueries = OrderedSet(state.recentQueries.prefix(maxRecentQueries)) + } + return saveRecentQueriesEffect(state.recentQueries) + case .removeRecentQuery(let query): + state.recentQueries.remove(query) + return saveRecentQueriesEffect(state.recentQueries) + case .clearRecentQueries: + state.recentQueries = [] + return saveRecentQueriesEffect([]) + case .applySearchQuery(let query): + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.webPages = [] + state.todos = [] + return Self.cancelSearchEffect(isLoading: state.isLoading) + } else { + return fetchEffect(trimmed, isLoading: state.isLoading) + } + case .setAlert(let isPresented): + state.alert = isPresented ? Self.alertState() : nil + case .setShowAllTodos(let shouldShowAll): + state.showAllTodos = shouldShowAll + case .setShowAllWebPages(let shouldShowAll): + state.showAllWebPages = shouldShowAll + case .loading: + break + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var searchFetchTodosUseCase: FetchTodosUseCase { + get { self[SearchFetchTodosUseCaseKey.self] } + set { self[SearchFetchTodosUseCaseKey.self] = newValue } + } + + var searchFetchWebPagesUseCase: FetchWebPagesUseCase { + get { self[SearchFetchWebPagesUseCaseKey.self] } + set { self[SearchFetchWebPagesUseCaseKey.self] = newValue } + } + + var searchUpdateRecentQueriesUseCase: UpdateRecentSearchQueriesUseCase { + get { self[SearchUpdateRecentQueriesUseCaseKey.self] } + set { self[SearchUpdateRecentQueriesUseCaseKey.self] = newValue } + } +} + +private enum SearchFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum SearchFetchWebPagesUseCaseKey: DependencyKey { + static var liveValue: FetchWebPagesUseCase { + preconditionFailure("FetchWebPagesUseCase must be provided.") + } + + static var testValue: FetchWebPagesUseCase { + liveValue + } +} + +private enum SearchUpdateRecentQueriesUseCaseKey: DependencyKey { + static var liveValue: UpdateRecentSearchQueriesUseCase { + preconditionFailure("UpdateRecentSearchQueriesUseCase must be provided.") + } + + static var testValue: UpdateRecentSearchQueriesUseCase { + liveValue + } +} + +private extension SearchFeature { + static func cancelSearchEffect(isLoading: Bool) -> Effect { + .merge( + .cancel(id: CancelID.debounce), + .cancel(id: CancelID.request), + Self.endLoadingEffect(isLoading: isLoading) + ) + } + + func debounceFetchEffect(_ query: String) -> Effect { + .concatenate( + .send(.loading(.begin(target: .default, mode: .immediate))), + .run { [clock, searchDebounceDelay] send in + try await clock.sleep(for: searchDebounceDelay) + await send(.applySearchQuery(query)) + } + .cancellable(id: CancelID.debounce, cancelInFlight: true) + ) + } + + func fetchEffect(_ query: String, isLoading: Bool) -> Effect { + let searchesTodoOnly = Self.searchesTodoOnly(query) + + return .run { [fetchTodosUseCase, fetchWebPagesUseCase] send in + do { + async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) + async let webPageItems = Self.fetchWebPageItems( + query: query, + searchesTodoOnly: searchesTodoOnly, + fetchWebPagesUseCase: fetchWebPagesUseCase + ) + let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } + let resolvedWebPageItems = try await webPageItems + await send(.fetchTodos(todoItems)) + await send(.fetchWebPage(resolvedWebPageItems)) + if isLoading { + await send(.loading(.end(target: .default, mode: .immediate))) + } + } catch is CancellationError { + return + } catch { + if isLoading { + await send(.loading(.end(target: .default, mode: .immediate))) + } + await send(.setAlert(true)) + } + } + .cancellable(id: CancelID.request, cancelInFlight: true) + } + + static func endLoadingEffect(isLoading: Bool) -> Effect { + guard isLoading else { return .none } + return .send(.loading(.end(target: .default, mode: .immediate))) + } + + func saveRecentQueriesEffect(_ queries: OrderedSet) -> Effect { + let values = Array(queries) + return .run { [updateRecentSearchQueriesUseCase] _ in + updateRecentSearchQueriesUseCase.execute(values) + } + } + + static func searchesTodoOnly(_ query: String) -> Bool { + query.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#") + } + + static func fetchWebPageItems( + query: String, + searchesTodoOnly: Bool, + fetchWebPagesUseCase: FetchWebPagesUseCase + ) async throws -> [WebPageItem] { + if searchesTodoOnly { + return [] + } + + let webPages = try await fetchWebPagesUseCase.execute(query) + return webPages.map { WebPageItem(from: $0) } + } + + 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/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index 7355b791..32e112dc 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() - @State var viewModel: SearchViewModel + @Bindable var store: StoreOf var body: some View { NavigationStack(path: $router.path) { @@ -41,41 +41,30 @@ struct SearchView: View { } } } - .onAppear { - DispatchQueue.main.async { - viewModel.send(.setSearching(true)) - } - } - .onChange(of: viewModel.state.isSearching) { _, isSearching in + .onAppear { store.send(.binding(.set(\.isSearching, true))) } + .onChange(of: store.isSearching) { _, isSearching in if !isSearching { dismiss() } } - .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) - } + .alert($store.scope(state: \.alert, action: \.alert)) } } @ViewBuilder private var searchableContent: some View { Group { - if viewModel.state.searchQuery.isEmpty { - if viewModel.state.recentQueries.isEmpty { + if store.searchQuery.isEmpty { + if store.recentQueries.isEmpty { searchInstruction } else { ScrollView { recentQueries } } - } else if viewModel.state.isLoading { + } else if store.isLoading { LoadingView() - } else if viewModel.state.webPages.isEmpty && viewModel.state.todos.isEmpty { + } else if store.webPages.isEmpty && store.todos.isEmpty { emptySearchResult } else { ScrollView { @@ -85,19 +74,13 @@ struct SearchView: View { } } .searchable( - text: Binding( - get: { viewModel.state.searchQuery }, - set: { viewModel.send(.setSearchQuery($0)) } - ), - isPresented: Binding( - get: { viewModel.state.isSearching }, - set: { viewModel.send(.setSearching($0)) } - ), + text: $store.searchQuery, + isPresented: $store.isSearching, placement: .navigationBarDrawer(displayMode: .always), prompt: Text(String(localized: "search_prompt")) ) .onSubmit(of: .search) { - viewModel.send(.addRecentQuery(viewModel.state.searchQuery)) + store.send(.addRecentQuery(store.searchQuery)) } } @@ -123,10 +106,10 @@ struct SearchView: View { private var searchResults: some View { VStack(alignment: .leading, spacing: 16) { - if !viewModel.state.todos.isEmpty { + if !store.todos.isEmpty { todoResults } - if !viewModel.state.webPages.isEmpty { + if !store.webPages.isEmpty { webPages } } @@ -134,10 +117,7 @@ struct SearchView: View { } private var todoResults: some View { - let limit = viewModel.contentsLimit - let todos = viewModel.state.showAllTodos - ? viewModel.state.todos - : Array(viewModel.state.todos.prefix(limit)) + let todos = store.visibleTodos return VStack(alignment: .leading, spacing: 12) { Text("Todos") @@ -150,9 +130,9 @@ struct SearchView: View { } } .padding(.top, -12) - if !viewModel.state.showAllTodos && limit < viewModel.state.todos.count { + if store.shouldShowMoreTodos { Button(String(localized: "search_show_more")) { - viewModel.send(.setShowAllTodos(true)) + store.send(.setShowAllTodos(true)) } .font(.subheadline) .foregroundStyle(Color.gray) @@ -164,10 +144,7 @@ struct SearchView: View { } private var webPages: some View { - let limit = viewModel.contentsLimit - let pages = viewModel.state.showAllWebPages - ? viewModel.state.webPages - : Array(viewModel.state.webPages.prefix(limit)) + let pages = store.visibleWebPages return VStack(alignment: .leading, spacing: 12) { Text("Web Pages") @@ -180,9 +157,9 @@ struct SearchView: View { } } .padding(.top, -12) - if !viewModel.state.showAllWebPages && limit < viewModel.state.webPages.count { + if store.shouldShowMoreWebPages { Button(String(localized: "search_show_more")) { - viewModel.send(.setShowAllWebPages(true)) + store.send(.setShowAllWebPages(true)) } .font(.subheadline) .foregroundStyle(Color.gray) @@ -221,13 +198,13 @@ struct SearchView: View { .foregroundStyle(Color(.label)) Spacer() Button(String(localized: "search_clear_all")) { - viewModel.send(.clearRecentQueries) + store.send(.clearRecentQueries) } .font(.subheadline) .foregroundStyle(Color.gray) } - ForEach(viewModel.state.recentQueries, id: \.self) { query in + ForEach(store.recentQueries, id: \.self) { query in HStack { Image(systemName: "clock.arrow.circlepath") .foregroundStyle(Color.gray) @@ -235,7 +212,7 @@ struct SearchView: View { .foregroundStyle(Color.primary) Spacer() Button { - viewModel.send(.removeRecentQuery(query)) + store.send(.removeRecentQuery(query)) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Color.gray) @@ -245,8 +222,8 @@ struct SearchView: View { .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { - viewModel.send(.setSearchQuery(query)) - viewModel.send(.setSearching(true)) + store.send(.binding(.set(\.searchQuery, query))) + store.send(.binding(.set(\.isSearching, true))) } } } diff --git a/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift b/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift deleted file mode 100644 index dc14f08e..00000000 --- a/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift +++ /dev/null @@ -1,245 +0,0 @@ -// -// SearchViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 2/8/26. -// - -import Foundation -import OrderedCollections -import DevLogCore -import DevLogDomain - -@Observable -final class SearchViewModel: StorePattern { - struct State: Equatable { - var isLoading: Bool = false - var isSearching: Bool = false - var searchQuery: String = "" - var webPages: [WebPageItem] = [] - var todos: [TodoListItem] = [] - var recentQueries: OrderedSet = [] - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var showAllTodos: Bool = false - var showAllWebPages: Bool = false - } - - enum Action { - case fetchWebPage([WebPageItem]) - case fetchTodos([TodoListItem]) - case addRecentQuery(String) - case removeRecentQuery(String) - case clearRecentQueries - case applySearchQuery(String) // 뷰모델에서 쿼리에 대해 디바운스 적용 - case setAlert(Bool) - case setLoading(Bool) - case setSearching(Bool) - case setSearchQuery(String) // 뷰에서 쿼리 입력을 적용 - case setShowAllTodos(Bool) - case setShowAllWebPages(Bool) - } - - enum SideEffect { - case cancelSearch - case debounceFetch(String) - case fetch(String) - } - - private enum SearchTaskKind: Hashable { - case debounce - case request - } - - private(set) var state: State = .init() - private let fetchWebPagesUseCase: FetchWebPagesUseCase - private let fetchTodosUseCase: FetchTodosUseCase - private let fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase - private let updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase - private let loadingState = LoadingState() - let contentsLimit: Int = 5 - - private let maxRecentQueries = 20 - private let searchDebounceDelay: Double = 0.4 - private var searchTasks: [SearchTaskKind: Task] = [:] - - init( - fetchWebPagesUseCase: FetchWebPagesUseCase, - fetchTodosUseCase: FetchTodosUseCase, - fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase, - updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase - ) { - self.fetchWebPagesUseCase = fetchWebPagesUseCase - self.fetchTodosUseCase = fetchTodosUseCase - self.fetchRecentSearchQueriesUseCase = fetchRecentSearchQueriesUseCase - self.updateRecentSearchQueriesUseCase = updateRecentSearchQueriesUseCase - self.state.recentQueries = OrderedSet(fetchRecentSearchQueriesUseCase.execute()) - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .fetchWebPage(let items): - state.webPages = items - case .fetchTodos(let items): - state.todos = items - case .addRecentQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { break } - state.recentQueries.remove(trimmed) - state.recentQueries.insert(trimmed, at: 0) - if maxRecentQueries < state.recentQueries.count { - state.recentQueries = OrderedSet(state.recentQueries.prefix(maxRecentQueries)) - } - saveRecentQueries(state.recentQueries) - case .removeRecentQuery(let query): - state.recentQueries.remove(query) - saveRecentQueries(state.recentQueries) - case .clearRecentQueries: - state.recentQueries = [] - saveRecentQueries([]) - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setLoading(let isLoading): - state.isLoading = isLoading - case .setSearching(let isSearching): - state.isSearching = isSearching - if !isSearching { - effects = [.cancelSearch] - } - case .setSearchQuery(let query): - guard state.searchQuery != query else { return [] } - state.searchQuery = query - state.showAllTodos = false - state.showAllWebPages = false - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.webPages = [] - state.todos = [] - effects = [.cancelSearch] - } else { - effects = [.cancelSearch, .debounceFetch(trimmed)] - } - case .applySearchQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.webPages = [] - state.todos = [] - effects = [.cancelSearch] - } else { - effects = [.fetch(trimmed)] - } - case .setShowAllTodos(let shouldShowAll): - state.showAllTodos = shouldShowAll - case .setShowAllWebPages(let shouldShowAll): - state.showAllWebPages = shouldShowAll - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .cancelSearch: - cancelSearch() - case .debounceFetch(let query): - beginLoading(.immediate) - scheduleDebouncedFetch(query) - case .fetch(let query): - searchTasks[.request]?.cancel() - let requestTask = Task { [weak self] in - guard let self else { return } - do { - defer { - self.searchTasks[.request] = nil - if !Task.isCancelled { - self.endLoading(.immediate) - } - } - let searchesTodoOnly = searchesTodoOnly(query) - async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) - async let webPageItems = fetchWebPageItems( - query: query, - searchesTodoOnly: searchesTodoOnly - ) - let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } - let resolvedWebPageItems = try await webPageItems - if Task.isCancelled { return } - send(.fetchTodos(todoItems)) - send(.fetchWebPage(resolvedWebPageItems)) - } catch { - if error is CancellationError { return } - send(.setAlert(true)) - } - } - searchTasks[.request] = requestTask - } - } -} - -private extension SearchViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - func scheduleDebouncedFetch(_ query: String) { - searchTasks[.debounce]?.cancel() - let debounceTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: .seconds(searchDebounceDelay)) - if Task.isCancelled { return } - await MainActor.run { - self.searchTasks[.debounce] = nil - self.send(.applySearchQuery(query)) - } - } - searchTasks[.debounce] = debounceTask - } - - func cancelSearch() { - searchTasks.values.forEach { $0.cancel() } - searchTasks = [:] - endLoading(.immediate) - } - - func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - func searchesTodoOnly(_ query: String) -> Bool { - query.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#") - } - - func fetchWebPageItems( - query: String, - searchesTodoOnly: Bool - ) async throws -> [WebPageItem] { - if searchesTodoOnly { - return [] - } - - let webPages = try await fetchWebPagesUseCase.execute(query) - return webPages.map { WebPageItem(from: $0) } - } - - func saveRecentQueries(_ queries: OrderedSet) { - updateRecentSearchQueriesUseCase.execute(Array(queries)) - } -} diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift index 25c49172..1be3b104 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -60,7 +60,7 @@ struct AccountFeature { case .unlinkFromProvider(let provider): return unlinkProviderEffect(provider) case .setAlert(let type): - state.alert = alertState(for: type) + state.alert = Self.alertState(for: type) case .setProviders(let currentProvider, let allProviders): state.currentProvider = currentProvider state.connectedProviders = allProviders.filter { $0 != currentProvider } @@ -154,7 +154,7 @@ private extension AccountFeature { if error.isSocialLoginCancelled { return } - await send(.setAlert(linkAlertType(for: error))) + await send(.setAlert(Self.linkAlertType(for: error))) } } } @@ -177,51 +177,51 @@ private extension AccountFeature { } } } -} -private func linkAlertType(for error: Error) -> AccountFeature.AlertType { - guard let authError = error as? AuthError else { - return .error - } + static func linkAlertType(for error: Error) -> AlertType { + guard let authError = error as? AuthError else { + return .error + } - switch authError { - case .linkEmailNotFound: - return .linkEmailNotFound - case .linkEmailMismatch: - return .linkEmailMismatch - case .linkCredentialAlreadyInUse: - return .linkCredentialAlreadyInUse - case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: - return .error + switch authError { + case .linkEmailNotFound: + return .linkEmailNotFound + case .linkEmailMismatch: + return .linkEmailMismatch + case .linkCredentialAlreadyInUse: + return .linkCredentialAlreadyInUse + case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: + return .error + } } -} -private func alertState(for type: AccountFeature.AlertType) -> AlertState { - let title: String - let message: String - - switch type { - case .linkEmailNotFound: - title = String(localized: "account_alert_email_unavailable_title") - message = String(localized: "account_alert_email_unavailable_message") - case .linkEmailMismatch: - title = String(localized: "account_alert_cannot_link_title") - message = String(localized: "account_alert_cannot_link_message") - case .linkCredentialAlreadyInUse: - title = String(localized: "account_alert_already_linked_title") - message = String(localized: "account_alert_already_linked_message") - case .error: - title = String(localized: "common_error_title") - message = String(localized: "common_error_message") - } - - return AlertState { - TextState(title) - } actions: { - ButtonState(role: .cancel) { - TextState(String(localized: "common_close")) + static func alertState(for type: AlertType) -> AlertState { + let title: String + let message: String + + switch type { + case .linkEmailNotFound: + title = String(localized: "account_alert_email_unavailable_title") + message = String(localized: "account_alert_email_unavailable_message") + case .linkEmailMismatch: + title = String(localized: "account_alert_cannot_link_title") + message = String(localized: "account_alert_cannot_link_message") + case .linkCredentialAlreadyInUse: + title = String(localized: "account_alert_already_linked_title") + message = String(localized: "account_alert_already_linked_message") + case .error: + title = String(localized: "common_error_title") + message = String(localized: "common_error_message") + } + + return AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) } - } message: { - TextState(message) } } diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift index fa52bde0..93e8a4ce 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -67,7 +67,7 @@ struct PushNotificationSettingsFeature { case .alert: break case .binding(\.pushNotificationEnable): - return updatePushNotificationSettingsEffect(settings: settings(from: state)) + return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .binding(\.viewPushNotificationTime): let time = state.viewPushNotificationTime state.timePicker?.time = time @@ -81,19 +81,19 @@ struct PushNotificationSettingsFeature { guard let time = state.timePicker?.time else { break } state.timePicker = nil state.viewPushNotificationTime = time - return updatePushNotificationSettingsEffect(settings: settings(from: state)) + return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .timePicker: break case .fetchSettings: return fetchPushNotificationSettingsEffect() case .setAlert: - state.alert = alertState() + state.alert = Self.alertState() case .tapCustomTime: state.timePicker = TimePickerState(time: state.viewPushNotificationTime) case .selectPresetTime(let date): state.viewPushNotificationTime = date state.timePicker?.time = date - return updatePushNotificationSettingsEffect(settings: settings(from: state)) + return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .loading: break } @@ -182,7 +182,7 @@ private extension PushNotificationSettingsFeature { } } - func settings(from state: State) -> PushNotificationSettings { + static func settings(from state: State) -> PushNotificationSettings { let date = state.timePicker?.time ?? state.viewPushNotificationTime let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date) return PushNotificationSettings( @@ -191,7 +191,7 @@ private extension PushNotificationSettingsFeature { ) } - func alertState() -> AlertState { + static func alertState() -> AlertState { AlertState { TextState(String(localized: "common_error_title")) } actions: { diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index 54802972..49c15ecc 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -104,14 +104,14 @@ struct SettingsFeature { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected case .setAlert(let type): - state.alert = alertState(for: type) + state.alert = Self.alertState(for: type) state.alertType = type case .setDirSize(let value): state.dirSize = value case .updateDirSize: return fetchWebPageImageDirSizeEffect() case .tapRemoveCacheButton: - state.alert = alertState(for: .removeCache) + state.alert = Self.alertState(for: .removeCache) state.alertType = .removeCache case .loading: break @@ -301,7 +301,7 @@ private extension SettingsFeature { } } - func alertState(for type: Action.AlertType) -> AlertState { + static func alertState(for type: Action.AlertType) -> AlertState { switch type { case .signOut: return AlertState { diff --git a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift new file mode 100644 index 00000000..7a087483 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift @@ -0,0 +1,286 @@ +// +// SearchFeatureTestDoubles.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import Foundation +import OrderedCollections +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct SearchStoreTestAdapter { + private let store: TestStoreOf + + var searchQuery: String { store.state.searchQuery } + var isSearching: Bool { store.state.isSearching } + var isLoading: Bool { store.state.isLoading } + var todos: [TodoListItem] { store.state.todos } + var webPages: [WebPageItem] { store.state.webPages } + var recentQueries: [String] { Array(store.state.recentQueries) } + var showAllTodos: Bool { store.state.showAllTodos } + var showAllWebPages: Bool { store.state.showAllWebPages } + var alert: AlertState? { store.state.alert } + + init( + recentQueries: [String] = [], + initialTodos: [TodoListItem] = [], + initialWebPages: [WebPageItem] = [], + isSearching: Bool = false, + isLoading: Bool = false, + fetchWebPagesUseCase: FetchWebPagesUseCase = SearchFetchWebPagesUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = SearchFetchTodosUseCaseSpy(), + updateRecentQueriesUseCase: UpdateRecentSearchQueriesUseCase = SearchUpdateRecentQueriesUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + var state = SearchFeature.State(recentQueries: recentQueries) + state.todos = initialTodos + state.webPages = initialWebPages + state.isSearching = isSearching + if isLoading { + state.loading.setImmediateLoading() + } + store = TestStore(initialState: state) { + SearchFeature() + } withDependencies: { + $0.searchFetchWebPagesUseCase = fetchWebPagesUseCase + $0.searchFetchTodosUseCase = fetchTodosUseCase + $0.searchUpdateRecentQueriesUseCase = updateRecentQueriesUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func addRecentQuery(_ query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let maxRecentQueries = 20 + await store.send(.addRecentQuery(query)) { + guard !trimmed.isEmpty else { return } + $0.recentQueries.remove(trimmed) + $0.recentQueries.insert(trimmed, at: 0) + if maxRecentQueries < $0.recentQueries.count { + $0.recentQueries = OrderedSet($0.recentQueries.prefix(maxRecentQueries)) + } + } + } + + func removeRecentQuery(_ query: String) async { + await store.send(.removeRecentQuery(query)) { + $0.recentQueries.remove(query) + } + } + + func clearRecentQueries() async { + await store.send(.clearRecentQueries) { + $0.recentQueries = [] + } + } + + func setShowAllTodos(_ value: Bool) async { + await store.send(.setShowAllTodos(value)) { + $0.showAllTodos = value + } + } + + func setShowAllWebPages(_ value: Bool) async { + await store.send(.setShowAllWebPages(value)) { + $0.showAllWebPages = value + } + } + + func setSearchQuery(_ query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let wasLoading = store.state.isLoading + await store.send(.binding(.set(\.searchQuery, query))) { + $0.searchQuery = query + $0.showAllTodos = false + $0.showAllWebPages = false + if trimmed.isEmpty { + $0.todos = [] + $0.webPages = [] + } + } + if wasLoading { + await receiveEndLoading() + } + if !trimmed.isEmpty { + await receiveBeginLoading() + } + } + + func applySearchQuery(_ query: String) async { + await store.send(.applySearchQuery(query)) + } + + func setSearching(_ value: Bool) async { + let wasLoading = store.state.isLoading + await store.send(.binding(.set(\.isSearching, value))) { + $0.isSearching = value + } + if !value, wasLoading { + await receiveEndLoading() + } + } + + func receiveAppliedSearchQuery(_ query: String) async { + await store.receive(.applySearchQuery(query)) + } + + func receiveSearchResults( + todos: [TodoListItem], + webPages: [WebPageItem] + ) async { + let wasLoading = store.state.isLoading + await store.receive(.fetchTodos(todos)) { + $0.todos = todos + } + await store.receive(.fetchWebPage(webPages)) { + $0.webPages = webPages + } + if wasLoading { + await receiveEndLoading() + } + } + + func receiveSearchFailure() async { + let wasLoading = store.state.isLoading + if wasLoading { + await receiveEndLoading() + } + await store.receive(.setAlert(true)) { + $0.alert = expectedSearchErrorAlert() + } + } + + private func receiveBeginLoading() async { + await store.receive(.loading(.begin(target: .default, mode: .immediate))) { + $0.loading.setImmediateLoading() + } + } + + private func receiveEndLoading() async { + await store.receive(.loading(.end(target: .default, mode: .immediate))) { + $0.loading.setImmediateLoadingFinished() + } + } +} + +private extension LoadingFeature.State { + mutating func setImmediateLoading() { + let target = LoadingFeature.Target.default + immediateCountByTarget[target] = 1 + visibleTargets.insert(target) + isLoading = !visibleTargets.isEmpty + } + + mutating func setImmediateLoadingFinished() { + let target = LoadingFeature.Target.default + immediateCountByTarget[target] = 0 + visibleTargets.remove(target) + isLoading = !visibleTargets.isEmpty + } +} + +final class SearchFetchTodosUseCaseSpy: FetchTodosUseCase { + var page: TodoPage + var error: Error? + private(set) var queries = [TodoQuery]() + + init(page: TodoPage = TodoPage(items: [], nextCursor: nil)) { + self.page = page + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + + if let error { + throw error + } + + return page + } +} + +final class SearchFetchWebPagesUseCaseSpy: FetchWebPagesUseCase { + var webPages: [WebPage] + var error: Error? + private(set) var queries = [String]() + + init(webPages: [WebPage] = []) { + self.webPages = webPages + } + + func execute(_ query: String) async throws -> [WebPage] { + queries.append(query) + + if let error { + throw error + } + + return webPages + } +} + +final class SearchUpdateRecentQueriesUseCaseSpy: UpdateRecentSearchQueriesUseCase { + private(set) var queries = [[String]]() + + func execute(_ queries: [String]) { + self.queries.append(queries) + } +} + +enum SearchFeatureTestError: Error { + case failure +} + +func makeSearchTodo( + id: String = "todo-id", + title: String = "Todo" +) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: title, + content: "content", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature) + ) +} + +func makeSearchWebPage( + title: String? = "Web", + urlString: String = "https://example.com" +) -> WebPage { + let url = URL(string: urlString)! + return WebPage( + title: title, + url: url, + displayURL: url, + imageURL: nil + ) +} + +func expectedSearchErrorAlert() -> 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/Tests/Search/SearchFeatureTests.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift new file mode 100644 index 00000000..8bfb8507 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift @@ -0,0 +1,166 @@ +// +// SearchFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct SearchFeatureTests { + @Test("초기화 시 최근 검색어를 상태에 반영한다") + func 초기화_시_최근_검색어를_상태에_반영한다() { + let adapter = SearchStoreTestAdapter(recentQueries: ["swift", "tca"]) + + #expect(adapter.recentQueries == ["swift", "tca"]) + } + + @Test("addRecentQuery는 공백을 제거하고 중복 제거 후 앞에 추가한다") + func addRecentQuery는_공백을_제거하고_중복_제거_후_앞에_추가한다() async { + let updateSpy = SearchUpdateRecentQueriesUseCaseSpy() + let adapter = SearchStoreTestAdapter( + recentQueries: ["swift", "tca"], + updateRecentQueriesUseCase: updateSpy + ) + + await adapter.addRecentQuery(" tca ") + + await waitUntil { + updateSpy.queries == [["tca", "swift"]] + } + + #expect(adapter.recentQueries == ["tca", "swift"]) + #expect(updateSpy.queries == [["tca", "swift"]]) + } + + @Test("addRecentQuery는 최대 20개까지만 유지한다") + func addRecentQuery는_최대_20개까지만_유지한다() async { + let queries = (0..<20).map { "query-\($0)" } + let adapter = SearchStoreTestAdapter(recentQueries: queries) + + await adapter.addRecentQuery("latest") + + #expect(adapter.recentQueries.count == 20) + #expect(adapter.recentQueries.first == "latest") + #expect(!adapter.recentQueries.contains("query-19")) + } + + @Test("removeRecentQuery와 clearRecentQueries는 최근 검색어 저장소를 갱신한다") + func removeRecentQuery와_clearRecentQueries는_최근_검색어_저장소를_갱신한다() async { + let updateSpy = SearchUpdateRecentQueriesUseCaseSpy() + let adapter = SearchStoreTestAdapter( + recentQueries: ["swift", "tca"], + updateRecentQueriesUseCase: updateSpy + ) + + await adapter.removeRecentQuery("swift") + await adapter.clearRecentQueries() + + await waitUntil { + updateSpy.queries == [["tca"], []] + } + + #expect(adapter.recentQueries.isEmpty) + #expect(updateSpy.queries == [["tca"], []]) + } + + @Test("setSearchQuery는 표시 범위를 초기화하고 디바운스 후 검색 결과를 반영한다") + func setSearchQuery는_표시_범위를_초기화하고_디바운스_후_검색_결과를_반영한다() async { + let todo = makeSearchTodo(id: "todo-1", title: "Swift") + let webPage = makeSearchWebPage(title: "Swift", urlString: "https://swift.org") + let todoSpy = SearchFetchTodosUseCaseSpy(page: TodoPage(items: [todo], nextCursor: nil)) + let webSpy = SearchFetchWebPagesUseCaseSpy(webPages: [webPage]) + let clock = TestClock() + let adapter = SearchStoreTestAdapter( + fetchWebPagesUseCase: webSpy, + fetchTodosUseCase: todoSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.setShowAllTodos(true) + await adapter.setShowAllWebPages(true) + await adapter.setSearchQuery(" swift ") + await clock.advance(by: .milliseconds(400)) + await adapter.receiveAppliedSearchQuery("swift") + await adapter.receiveSearchResults( + todos: [TodoListItem(from: todo)!], + webPages: [WebPageItem(from: webPage)] + ) + + #expect(adapter.searchQuery == " swift ") + #expect(!adapter.showAllTodos) + #expect(!adapter.showAllWebPages) + #expect(todoSpy.queries.map(\.keyword) == ["swift"]) + #expect(webSpy.queries == ["swift"]) + #expect(adapter.todos == [TodoListItem(from: todo)]) + #expect(adapter.webPages == [WebPageItem(from: webPage)]) + #expect(!adapter.isLoading) + } + + @Test("빈 검색어는 검색 결과를 비우고 로딩을 종료한다") + func 빈_검색어는_검색_결과를_비우고_로딩을_종료한다() async { + let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! + let webPage = WebPageItem(from: makeSearchWebPage(urlString: "https://swift.org")) + let adapter = SearchStoreTestAdapter( + initialTodos: [todo], + initialWebPages: [webPage], + isLoading: true + ) + + await adapter.setSearchQuery(" ") + + #expect(adapter.todos.isEmpty) + #expect(adapter.webPages.isEmpty) + #expect(!adapter.isLoading) + } + + @Test("# 검색어는 WebPage 조회를 생략하고 Todo만 반영한다") + func 해시태그_검색어는_WebPage_조회를_생략하고_Todo만_반영한다() async { + let todo = makeSearchTodo(id: "todo-1", title: "Issue") + let todoSpy = SearchFetchTodosUseCaseSpy(page: TodoPage(items: [todo], nextCursor: nil)) + let webSpy = SearchFetchWebPagesUseCaseSpy(webPages: [makeSearchWebPage()]) + let adapter = SearchStoreTestAdapter(fetchWebPagesUseCase: webSpy, fetchTodosUseCase: todoSpy) + + await adapter.applySearchQuery(" #123 ") + await adapter.receiveSearchResults( + todos: [TodoListItem(from: todo)!], + webPages: [] + ) + + #expect(todoSpy.queries.map(\.keyword) == ["#123"]) + #expect(webSpy.queries.isEmpty) + #expect(adapter.webPages.isEmpty) + #expect(adapter.todos == [TodoListItem(from: todo)]) + } + + @Test("검색 실패 시 공통 에러 알림을 표시하고 로딩을 종료한다") + func 검색_실패_시_공통_에러_알림을_표시하고_로딩을_종료한다() async { + let todoSpy = SearchFetchTodosUseCaseSpy() + todoSpy.error = SearchFeatureTestError.failure + let adapter = SearchStoreTestAdapter(isLoading: true, fetchTodosUseCase: todoSpy) + + await adapter.applySearchQuery("swift") + await adapter.receiveSearchFailure() + + #expect(adapter.alert == expectedSearchErrorAlert()) + #expect(!adapter.isLoading) + } + + @Test("setSearching false는 검색을 취소하고 로딩을 종료한다") + func setSearching_false는_검색을_취소하고_로딩을_종료한다() async { + let adapter = SearchStoreTestAdapter(isSearching: true, isLoading: true) + + await adapter.setSearching(false) + + #expect(!adapter.isSearching) + #expect(!adapter.isLoading) + } +}