Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions MLS/MLSCore/Sources/MLSCore/Extension/String+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public final class DictionarySearchReactor: Reactor {
case none
case dismiss
case search(String)
case standaloneJamoError
}

public enum Action {
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public final class DictionarySearchResultReactor: Reactor {
public enum Route {
case none
case dismiss
case standaloneJamoError
}

public enum Action {
Expand Down Expand Up @@ -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)))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 - 최근 검색어 추가")
Expand Down
Loading