diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift index 72f0b89c..768a4932 100644 --- a/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift +++ b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift @@ -19,6 +19,18 @@ public extension String { } } + /// 특수기호, 공백을 제거한 검색용 문자열 반환 + func sanitizedForSearch() -> String { + let allowed = CharacterSet.letters.union(.decimalDigits) + return String(self.unicodeScalars.filter { allowed.contains($0) }) + } + + /// 단독 자음(ㄱ-ㅎ) 또는 단독 모음(ㅏ-ㅣ)이 하나라도 포함된 문자열인지 확인 + /// 예: "kkㄱ", "ㄱㄴ", "kkㅏ", "ㅏㅓ" 모두 true + func containsStandaloneJamo() -> Bool { + return self.unicodeScalars.contains { (0x3131...0x3163).contains($0.value) } + } + func toDisplayDateString() -> String { let inputFormatter = DateFormatter() inputFormatter.locale = Locale(identifier: "ko_KR") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift index ff9be5f9..10c5dfee 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift @@ -15,6 +15,7 @@ public final class DictionarySearchReactor: Reactor { case none case dismiss case search(String) + case standaloneJamoError } public enum Action { @@ -92,13 +93,16 @@ public final class DictionarySearchReactor: Reactor { case .backButtonTapped: return Observable.just(.navigateTo(.dismiss)) case .searchButtonTapped(let keyword): - return recentSearchRepository.addRecentSearch(keyword: keyword) + let sanitized = keyword.sanitizedForSearch() + guard sanitized.count >= 2 else { return .empty() } + guard !sanitized.containsStandaloneJamo() else { return .just(.navigateTo(.standaloneJamoError)) } + return recentSearchRepository.addRecentSearch(keyword: sanitized) .andThen( - currentState.recentResult.contains(keyword) - ? .just(.navigateTo(.search(keyword))) + currentState.recentResult.contains(sanitized) + ? .just(.navigateTo(.search(sanitized))) : .concat([ - .just(.addRecentItem(keyword)), - .just(.navigateTo(.search(keyword))) + .just(.addRecentItem(sanitized)), + .just(.navigateTo(.search(sanitized))) ]) ) case .cancelRecentButtonTapped(let keyword): diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift index 9f515a9a..bdef85d2 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift @@ -159,13 +159,11 @@ extension DictionarySearchViewController { case .dismiss: owner.navigationController?.popViewController(animated: true) case .search(let keyword): - if !keyword.isOnlyKorean() { - GuideAlertFactory.show(mainText: "초성은 검색할 수 없습니다.", ctaText: "확인", ctaAction: {}) - } else { - owner.mainView.searchBar.textField.text = "" - let viewController = owner.searchResultFactory.make(keyword: keyword) - owner.navigationController?.pushViewController(viewController, animated: true) - } + owner.mainView.searchBar.textField.text = "" + let viewController = owner.searchResultFactory.make(keyword: keyword) + owner.navigationController?.pushViewController(viewController, animated: true) + case .standaloneJamoError: + GuideAlertFactory.show(mainText: "초성은 검색이 불가능 합니다.", ctaText: "확인", ctaAction: {}) default: break } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift index 23451aa3..c2b3fda6 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift @@ -7,6 +7,7 @@ public final class DictionarySearchResultReactor: Reactor { public enum Route { case none case dismiss + case standaloneJamoError } public enum Action { @@ -66,9 +67,11 @@ public final class DictionarySearchResultReactor: Reactor { } // 검색 결과 화면에서 재검색 시 case .searchButtonTapped(let keyword): - let keyword = keyword ?? "" - return recentSearchRepository.addRecentSearch(keyword: keyword) - .andThen(.just(.setKeyword(keyword))) + let sanitized = (keyword ?? "").sanitizedForSearch() + guard sanitized.count >= 2 else { return .empty() } + guard !sanitized.containsStandaloneJamo() else { return .just(.navigateTo(.standaloneJamoError)) } + return recentSearchRepository.addRecentSearch(keyword: sanitized) + .andThen(.just(.setKeyword(sanitized))) } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift index ee6471bf..ba03d018 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift @@ -65,6 +65,8 @@ public extension DictionarySearchResultViewController { guard let reactor = reactor else { return } let type = reactor.currentState.type + mainView.searchBar.textField.text = keyword + // 기존 viewControllers 제거 for viewController in viewControllers { viewController.removeFromParent() @@ -117,6 +119,7 @@ private extension DictionarySearchResultViewController { func configureUI() { mainView.searchBar.searchDelegate = self + mainView.searchBar.textField.text = reactor?.currentState.keyword mainView.searchBar.textField.becomeFirstResponder() mainView.pageViewController.delegate = self @@ -192,6 +195,8 @@ public extension DictionarySearchResultViewController { switch route { case .dismiss: owner.navigationController?.popViewController(animated: true) + case .standaloneJamoError: + GuideAlertFactory.show(mainText: "초성은 검색이 불가능 합니다.", ctaText: "확인", ctaAction: {}) default: break } @@ -206,11 +211,7 @@ public extension DictionarySearchResultViewController { .skip(1) .observe(on: MainScheduler.instance) .bind(with: self) { owner, newKeyword in - if !newKeyword.isOnlyKorean() { - GuideAlertFactory.show(mainText: "초성은 검색할 수 없습니다.", ctaText: "확인", ctaAction: {}) - } else { - owner.updateViewControllers(keyword: newKeyword) - } + owner.updateViewControllers(keyword: newKeyword) } .disposed(by: disposeBag) diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift index 765f3101..11558d76 100644 --- a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift @@ -146,6 +146,200 @@ struct DictionarySearchReactorTests { } } + // MARK: - 단독 자음/모음 검색 검증 + + @Test("단독 자음만 입력 시 standaloneJamoError route 반환") + func test_searchButtonTapped_withChosung_returnsStandaloneJamoError() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("ㄱㄴ")) + .toBlocking() + .toArray() + + let hasStandaloneJamoError = mutations.contains { + if case .navigateTo(let route) = $0, case .standaloneJamoError = route { return true } + return false + } + + #expect(hasStandaloneJamoError) + } + + @Test("단독 모음만 입력 시 standaloneJamoError route 반환") + func test_searchButtonTapped_withVowel_returnsStandaloneJamoError() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("ㅏㅓ")) + .toBlocking() + .toArray() + + let hasStandaloneJamoError = mutations.contains { + if case .navigateTo(let route) = $0, case .standaloneJamoError = route { return true } + return false + } + + #expect(hasStandaloneJamoError) + } + + @Test("영문+단독 자음 혼합 입력 시 standaloneJamoError route 반환") + func test_searchButtonTapped_withMixedChosungAndEnglish_returnsStandaloneJamoError() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("kkㄱ")) + .toBlocking() + .toArray() + + let hasStandaloneJamoError = mutations.contains { + if case .navigateTo(let route) = $0, case .standaloneJamoError = route { return true } + return false + } + + #expect(hasStandaloneJamoError) + } + + @Test("영문+단독 모음 혼합 입력 시 standaloneJamoError route 반환") + func test_searchButtonTapped_withMixedVowelAndEnglish_returnsStandaloneJamoError() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("kkㅏ")) + .toBlocking() + .toArray() + + let hasStandaloneJamoError = mutations.contains { + if case .navigateTo(let route) = $0, case .standaloneJamoError = route { return true } + return false + } + + #expect(hasStandaloneJamoError) + } + + @Test("한글+단독 자음 혼합 입력 시 standaloneJamoError route 반환") + func test_searchButtonTapped_withMixedChosungAndKorean_returnsStandaloneJamoError() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("슬라임ㄱ")) + .toBlocking() + .toArray() + + let hasStandaloneJamoError = mutations.contains { + if case .navigateTo(let route) = $0, case .standaloneJamoError = route { return true } + return false + } + + #expect(hasStandaloneJamoError) + } + + @Test("단독 자음 한 글자 입력 시 아무 mutation 없음 (최소 2자 미달)") + func test_searchButtonTapped_singleStandaloneJamo_emitsNothing() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("ㄱ")) + .toBlocking() + .toArray() + + #expect(mutations.isEmpty) + } + + @Test("1자 이하 입력 시 아무 mutation 없음") + func test_searchButtonTapped_tooShort_emitsNothing() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("가")) + .toBlocking() + .toArray() + + #expect(mutations.isEmpty) + } + + @Test("빈 문자열 입력 시 아무 mutation 없음") + func test_searchButtonTapped_empty_emitsNothing() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("")) + .toBlocking() + .toArray() + + #expect(mutations.isEmpty) + } + + // MARK: - 특수기호/공백 처리 검증 + + @Test("특수기호 포함 입력 시 특수기호 제거 후 검색") + func test_searchButtonTapped_withSpecialChars_sanitizesKeyword() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("슬라임!!")) + .toBlocking() + .toArray() + + let navigatesWithSanitized = mutations.contains { + if case .navigateTo(let route) = $0, case .search(let keyword) = route { + return keyword == "슬라임" + } + return false + } + + #expect(navigatesWithSanitized) + } + + @Test("공백 포함 입력 시 공백 제거 후 검색") + func test_searchButtonTapped_withSpaces_sanitizesKeyword() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("슬라 임")) + .toBlocking() + .toArray() + + let navigatesWithSanitized = mutations.contains { + if case .navigateTo(let route) = $0, case .search(let keyword) = route { + return keyword == "슬라임" + } + return false + } + + #expect(navigatesWithSanitized) + } + + @Test("특수기호만 입력 시 아무 mutation 없음 (sanitize 후 2자 미달)") + func test_searchButtonTapped_onlySpecialChars_emitsNothing() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("!!!")) + .toBlocking() + .toArray() + + #expect(mutations.isEmpty) + } + + @Test("특수기호 제거 후 정확히 2자면 검색 진행") + func test_searchButtonTapped_twoCharsAfterSanitize_navigates() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("가나!!")) + .toBlocking() + .toArray() + + let hasNavigateMutation = mutations.contains { + if case .navigateTo(let route) = $0, case .search(let keyword) = route { + return keyword == "가나" + } + return false + } + + #expect(hasNavigateMutation) + } + // MARK: - Reduce @Test("reduce - 최근 검색어 추가")