diff --git a/InMyMemory/Presentation/Package.swift b/InMyMemory/Presentation/Package.swift index 094dd77..0188c80 100644 --- a/InMyMemory/Presentation/Package.swift +++ b/InMyMemory/Presentation/Package.swift @@ -402,6 +402,7 @@ let package = Package( "BasePresentation", "DesignKit", "SearchInterface", + "MemoryDetailInterface", .product(name: "CoreKit", package: "Shared"), .product(name: "Entities", package: "Domain"), .product(name: "Interfaces", package: "Domain"), diff --git a/InMyMemory/Presentation/Sources/SearchPresentation/SearchFlow.swift b/InMyMemory/Presentation/Sources/SearchPresentation/SearchFlow.swift index 5536bf2..379a829 100644 --- a/InMyMemory/Presentation/Sources/SearchPresentation/SearchFlow.swift +++ b/InMyMemory/Presentation/Sources/SearchPresentation/SearchFlow.swift @@ -10,6 +10,7 @@ import BasePresentation import RxFlow import CoreKit import Interfaces +import MemoryDetailInterface public final class SearchFlow: Flow { @@ -35,6 +36,12 @@ public final class SearchFlow: Flow { case .searchIsComplete: return .end(forwardToParentFlowWithStep: AppStep.searchIsComplete) + case .memoryDetailIsRequired(let memoryID): + return navigationToMemoryDetail(memoryID: memoryID) + + case .memoryDetailIsComplete: + return popMemoryDetail() + default: return .none } @@ -47,4 +54,21 @@ public final class SearchFlow: Flow { )) } + private func navigationToMemoryDetail(memoryID: UUID) -> FlowContributors { + let flow = injector.resolve(MemoryDetailBuildable.self).build(memoryID: memoryID, injector: injector) + Flows.use(flow, when: .created) { [weak self] root in + self?.rootViewController.navigationController?.pushViewController(root, animated: true) + } + return .one(flowContributor: .contribute( + withNextPresentable: flow, + withNextStepper: OneStepper(withSingleStep: AppStep.memoryDetailIsRequired(memoryID)) + )) + } + + private func popMemoryDetail() -> FlowContributors { + rootViewController.navigationController?.popViewController(animated: true) + rootViewController.refresh() + return .none + } + } diff --git a/InMyMemory/Presentation/Sources/SearchPresentation/SearchReactor.swift b/InMyMemory/Presentation/Sources/SearchPresentation/SearchReactor.swift index e38fb9d..24e8bf2 100644 --- a/InMyMemory/Presentation/Sources/SearchPresentation/SearchReactor.swift +++ b/InMyMemory/Presentation/Sources/SearchPresentation/SearchReactor.swift @@ -18,18 +18,21 @@ import Then final class SearchReactor: Reactor, Stepper { enum Action { - case search(String) + case search + case updateKeyword(String?) case closeDidTap case itemDidTap(IndexPath) } struct State { + var keyword: String? var isLoading: Bool = false var isEmpty: Bool = true var sections: [SearchSection] = [] } enum Mutation { + case setKeyword(String?) case setLoading(Bool) case setSections([Memory], [Emotion], [Todo]) } @@ -53,19 +56,35 @@ final class SearchReactor: Reactor, Stepper { func mutate(action: Action) -> Observable { switch action { - case .search(let keyword): + case .search: + guard let keyword = currentState.keyword else { return .empty() } return .concat([ .just(.setLoading(true)), search(keyword: keyword), .just(.setLoading(false)) ]) + case .updateKeyword(let keyword): + return .just(.setKeyword(keyword)) + case .closeDidTap: steps.accept(AppStep.searchIsComplete) return .empty() case .itemDidTap(let indexPath): - print(indexPath) + guard let item = currentState.sections[safe: indexPath.section]?.items[safe: indexPath.row] else { + return .empty() + } + switch item { + case .emotion(let model): + print("Emotion Did Tap") + + case .memory(let model): + steps.accept(AppStep.memoryDetailIsRequired(model.id)) + + case .todo(let model): + print("Todo Did Tap") + } return .empty() } } @@ -73,6 +92,9 @@ final class SearchReactor: Reactor, Stepper { func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { + case .setKeyword(let keyword): + newState.keyword = keyword + case .setLoading(let isLoading): newState.isLoading = isLoading diff --git a/InMyMemory/Presentation/Sources/SearchPresentation/SearchViewController.swift b/InMyMemory/Presentation/Sources/SearchPresentation/SearchViewController.swift index dd8e504..d4ec395 100644 --- a/InMyMemory/Presentation/Sources/SearchPresentation/SearchViewController.swift +++ b/InMyMemory/Presentation/Sources/SearchPresentation/SearchViewController.swift @@ -81,19 +81,28 @@ final class SearchViewController: BaseViewController { } override func bind(reactor: SearchReactor) { - bindAction(reactor) bindState(reactor) + bindAction(reactor) bindETC(reactor) } + override func refresh() { + reactor?.action.onNext(.search) + } + private func bindAction(_ reactor: SearchReactor) { navigationView.rx.leftButtonDidTap .map { Reactor.Action.closeDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) + searchInputView.rx.text + .map { Reactor.Action.updateKeyword($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + searchInputView.rx.searchDidTap - .map { Reactor.Action.search($0) } + .map { _ in Reactor.Action.search } .bind(to: reactor.action) .disposed(by: disposeBag) diff --git a/InMyMemory/Presentation/Tests/SearchPresentationTests/SearchReactorTests.swift b/InMyMemory/Presentation/Tests/SearchPresentationTests/SearchReactorTests.swift new file mode 100644 index 0000000..6f96bb4 --- /dev/null +++ b/InMyMemory/Presentation/Tests/SearchPresentationTests/SearchReactorTests.swift @@ -0,0 +1,196 @@ +// +// SearchReactorTests.swift +// +// +// Created by 홍성준 on 1/23/24. +// + +@testable import SearchPresentation +import PresentationTestSupport +import DomainTestSupport +import Entities +import Interfaces +import UseCases +import CoreKit +import BasePresentation +import XCTest +import Quick +import Nimble +import ReactorKit +import RxSwift +import RxRelay +import RxFlow + +final class SearchReactorTests: QuickSpec { + + override class func spec() { + var sut: SearchReactor! + var useCase: SearchUseCaseInterface! + var emotionRepository: EmotionRepositoryMock! + var memoryRepository: MemoryRepositoryMock! + var todoRepository: TodoRepositoryMock! + var stepBinder: StepBinder! + var disposeBag: DisposeBag! + + describe("SearchReactor 테스트") { + beforeEach { + emotionRepository = .init() + memoryRepository = .init() + todoRepository = .init() + useCase = SearchUseCase( + emotionRepository: emotionRepository, + memoryRepository: memoryRepository, + todoRepository: todoRepository + ) + stepBinder = .init() + disposeBag = .init() + sut = .init(useCase: useCase) + sut.steps + .bind(to: stepBinder.step) + .disposed(by: disposeBag) + } + + context("updateKeyword가 호출되면") { + let keyword = "Test Keyword" + beforeEach { + sut.action.onNext(.updateKeyword(keyword)) + } + + it("Keyword 상태 값이 변경된다") { + expect(sut.currentState.keyword) == keyword + } + } + + describe("nil인 문자가 입력되었을 때") { + beforeEach { + sut.action.onNext(.updateKeyword(nil)) + } + + context("검색을 누르면") { + beforeEach { + sut.action.onNext(.search) + } + + it("검색을 호출하지 않는다") { + expect { memoryRepository.readKeywordCallCount } == 0 + expect { emotionRepository.readKeywordCallCount } == 0 + expect { todoRepository.readKeywordCallCount } == 0 + } + } + } + + describe("값이 있는 문자가 입력되었을 때") { + beforeEach { + sut.action.onNext(.updateKeyword("Test Keyword")) + } + + context("검색을 누르면") { + beforeEach { + sut.action.onNext(.search) + } + + it("검색을 호출한다") { + expect { memoryRepository.readKeywordCallCount } == 1 + expect { emotionRepository.readKeywordCallCount } == 1 + expect { todoRepository.readKeywordCallCount } == 1 + } + } + } + + describe("Section 테스트") { + let memoryID: UUID = .init() + let emotionID: UUID = .init() + let todoID: UUID = .init() + let memoryIndexPath: IndexPath = .init(row: 0, section: 0) + let emotionIndexPath: IndexPath = .init(row: 0, section: 1) + let todoIndexPath: IndexPath = .init(row: 0, section: 2) + + beforeEach { + memoryRepository.readKeywordMemories = [.init(id: memoryID, images: [], note: "Memory", date: .init())] + emotionRepository.readKeywordEmotions = [.init(id: emotionID, note: "Emotion", emotionType: .good, date: .init())] + todoRepository.readKeywordTodos = [.init(id: todoID, note: "Todo", isCompleted: true, date: .init())] + sut.action.onNext(.updateKeyword("Test Keyword")) + sut.action.onNext(.search) + } + + it("Section은 Memory, Emotion, Todo 순으로 저장된다") { + expect { + guard case .memory(let model) = sut.currentState.sections[memoryIndexPath.section].items[memoryIndexPath.row] else { + return .failed(reason: "올바르지 않은 데이터 타입") + } + return model.id == memoryID ? .succeeded : .failed(reason: "올바르지 않은 ID") + }.to(succeed()) + + expect { + guard case .emotion(let model) = sut.currentState.sections[emotionIndexPath.section].items[emotionIndexPath.row] else { + return .failed(reason: "올바르지 않은 데이터 타입") + } + return model.id == emotionID ? .succeeded : .failed(reason: "올바르지 않은 ID") + }.to(succeed()) + + expect { + guard case .todo(let model) = sut.currentState.sections[todoIndexPath.section].items[todoIndexPath.row] else { + return .failed(reason: "올바르지 않은 데이터 타입") + } + return model.id == todoID ? .succeeded : .failed(reason: "올바르지 않은 ID") + }.to(succeed()) + } + + context("Memory 아이템이 선택되면") { + beforeEach { + sut.action.onNext(.itemDidTap(memoryIndexPath)) + } + + it("memoryDetailIsRequired로 라우팅된다") { + let step = try unwrap(stepBinder.steps.last as? AppStep) + expect { + guard case .memoryDetailIsRequired(let id) = step else { + return .failed(reason: "올바르지 않은 라우팅") + } + return id == memoryID ? .succeeded : .failed(reason: "올바르지 않은 ID") + }.to(succeed()) + } + } + + context("Emotion 아이템이 선택되면") { + beforeEach { + stepBinder.steps = [] + sut.action.onNext(.itemDidTap(emotionIndexPath)) + } + + it("라우팅이 호출되지 않는다") { + expect { stepBinder.steps.isEmpty } == true + } + } + + context("Todo 아이템이 선택되면") { + beforeEach { + stepBinder.steps = [] + sut.action.onNext(.itemDidTap(todoIndexPath)) + } + + it("라우팅이 호출되지 않는다") { + expect { stepBinder.steps.isEmpty } == true + } + } + } + + context("closeDidTap을 호출하면") { + beforeEach { + sut.action.onNext(.closeDidTap) + } + + it("searchIsComplete로 라우팅된다") { + let step = try unwrap(stepBinder.steps.last as? AppStep) + expect { + guard case .searchIsComplete = step else { + return .failed(reason: "올바르지 않은 라우팅") + } + return .succeeded + }.to(succeed()) + } + } + } + } + +}