From a9903727c36a283d743d47b384fbe8e8b02d9620 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 25 Aug 2026 16:58:35 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat/#351:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?sanitize=20=EB=B0=8F=20=EB=8B=A8=EB=8F=85=20=EC=9E=90=EB=AA=A8?= =?UTF-8?q?=20=EA=B0=90=EC=A7=80=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sanitizedForSearch(): 특수기호·공백 제거 - containsStandaloneJamo(): 단독 자음(ㄱ-ㅎ) 또는 모음(ㅏ-ㅣ) 포함 여부 확인 --- MLS/MLSCore/Sources/MLSCore/Extension/String+.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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") From ee9ad3d13e68548e90e4df32a8ababe4311c4d2c Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 25 Aug 2026 16:58:43 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat/#351:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 특수기호·공백 제거 후 검색 처리 - 2자 미만 입력 시 검색 미실행 - 단독 자음·모음 포함 시 standaloneJamoError route 반환 --- .../DictionarySearch/DictionarySearchReactor.swift | 14 +++++++++----- .../DictionarySearchResultReactor.swift | 9 ++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) 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/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))) } } From 452c0d4ec7a1f41156297ebe7c83eceba0fd71da Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 25 Aug 2026 16:58:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat/#351:=20=EA=B2=80=EC=83=89=20ViewContr?= =?UTF-8?q?oller=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - standaloneJamoError route 처리: 자음·모음 검색 불가 알림 표시 - 검색 결과 화면 진입 및 재검색 시 searchBar에 검색어 반영 --- .../DictionarySearchViewController.swift | 12 +++++------- .../DictionarySearchResultViewController.swift | 11 ++++++----- 2 files changed, 11 insertions(+), 12 deletions(-) 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/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) From 2175dd07ef3b11bb0160735e786f405ae6a8c98d Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 25 Aug 2026 16:58:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test/#351:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단독 자음·모음 입력 시 standaloneJamoError 반환 검증 - 영문+자음, 영문+모음 혼합 입력 검증 - 2자 미만·빈 문자열 입력 시 mutation 없음 검증 - 특수기호·공백 제거 후 검색 처리 검증 --- .../DictionarySearchReactorTests.swift | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) 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 - 최근 검색어 추가")