diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift index 9fea958d..9f32e727 100644 --- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift @@ -167,15 +167,9 @@ final class TodoServiceImpl: TodoService { let snapshot = try await firestoreQuery.getDocuments() let todos = snapshot.documents.compactMap { makeResponse(from: $0) } - let todoNumber = searchedTodoNumber(from: trimmedKeyword) + let numberKeyword = TodoResponse.normalizedNumberKeyword(from: trimmedKeyword) ?? trimmedKeyword let filtered = todos.filter { todo in - if let todoNumber, todo.number == todoNumber { - return true - } - - return todo.title.localizedCaseInsensitiveContains(trimmedKeyword) - || todo.content.localizedCaseInsensitiveContains(trimmedKeyword) - || todo.tags.contains { $0.localizedCaseInsensitiveContains(trimmedKeyword) } + todo.matchesSearchKeyword(trimmedKeyword, numberKeyword: numberKeyword) } return TodoPageResponse(items: filtered, nextCursor: nil) @@ -330,6 +324,38 @@ final class TodoServiceImpl: TodoService { } } +extension TodoResponse { + func matchesSearchKeyword(_ keyword: String, numberKeyword: String? = nil) -> Bool { + let resolvedNumberKeyword = numberKeyword ?? Self.normalizedNumberKeyword(from: keyword) ?? keyword + + if keyword.hasPrefix("#"), + 1 < keyword.count, + "#\(number)".localizedCaseInsensitiveContains(resolvedNumberKeyword) { + return true + } + + return title.localizedCaseInsensitiveContains(keyword) + || content.localizedCaseInsensitiveContains(keyword) + || tags.contains { $0.localizedCaseInsensitiveContains(keyword) } + } + + static func normalizedNumberKeyword(from keyword: String) -> String? { + guard keyword.hasPrefix("#") else { + return nil + } + + let digits = keyword.dropFirst() + guard !digits.isEmpty, digits.allSatisfy(\.isNumber) else { + return nil + } + + let normalizedDigits = digits.drop(while: { $0 == "0" }) + let numberText = normalizedDigits.isEmpty ? "0" : String(normalizedDigits) + + return "#\(numberText)" + } +} + private extension TodoServiceImpl { private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( @@ -527,19 +553,6 @@ private extension TodoServiceImpl { ) } - func searchedTodoNumber(from keyword: String) -> Int? { - guard keyword.hasPrefix("#") else { - return nil - } - - let numberText = String(keyword.dropFirst()) - guard !numberText.isEmpty, numberText.allSatisfy(\.isNumber) else { - return nil - } - - return Int(numberText) - } - enum TodoFieldKey: String { case id case isPinned diff --git a/Application/DevLogInfra/Tests/Service/TodoSearchMatchingTests.swift b/Application/DevLogInfra/Tests/Service/TodoSearchMatchingTests.swift new file mode 100644 index 00000000..90fd34f1 --- /dev/null +++ b/Application/DevLogInfra/Tests/Service/TodoSearchMatchingTests.swift @@ -0,0 +1,50 @@ +// +// TodoSearchMatchingTests.swift +// DevLogInfraTests +// +// Created by opfic on 6/26/26. +// + +import Foundation +import Testing +import DevLogData +@testable import DevLogInfra + +struct TodoSearchMatchingTests { + @Test("#숫자 검색어는 Todo 번호를 문자열 기반으로 부분 검색한다") + func 해시_숫자_검색어는_Todo_번호를_문자열_기반으로_부분_검색한다() { + let todo = makeTodo(number: 123) + let numberKeyword = TodoResponse.normalizedNumberKeyword(from: "#0001") + + #expect(todo.matchesSearchKeyword("#1")) + #expect(todo.matchesSearchKeyword("#12")) + #expect(todo.matchesSearchKeyword("#0001")) + #expect(todo.matchesSearchKeyword("#0001", numberKeyword: numberKeyword)) + } + + @Test("# 단독 검색어는 Todo 번호로 매칭하지 않는다") + func 해시_단독_검색어는_Todo_번호로_매칭하지_않는다() { + let todo = makeTodo(number: 123) + + #expect(!todo.matchesSearchKeyword("#")) + } + + private func makeTodo(number: Int) -> TodoResponse { + TodoResponse( + id: "todo-id", + isPinned: false, + isCompleted: false, + isChecked: false, + number: number, + title: "title", + content: "content", + createdAt: .now, + updatedAt: .now, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .raw("feature") + ) + } +}