diff --git a/Projects/.DS_Store b/Projects/.DS_Store new file mode 100644 index 00000000..df6ae3b4 Binary files /dev/null and b/Projects/.DS_Store differ diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 1ae93911..fb975e36 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -194,7 +194,7 @@ final class AppDIContainer { } func makeSettingsDIContainer( - getCurrentLocationUseCase: any GetCurrentLocationUseCase + locationService: any LocationService ) -> any SettingsCoordinatorBuildable { SettingsDIContainer( userRepository: userRepository, @@ -204,9 +204,8 @@ final class AppDIContainer { userRepository: userRepository, placeRepository: placeRepository ), - searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + placeRepository: placeRepository, + locationService: locationService, checkAppUpdateUseCase: DefaultCheckAppUpdateUseCase( repository: AppVersionRepositoryImpl(networkClient: networkClient) ), @@ -241,26 +240,21 @@ final class AppDIContainer { func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) - let locationService = CoreLocationServiceAdapter() - let getCurrentLocation: any GetCurrentLocationUseCase = - DefaultGetCurrentLocationUseCase(locationService: locationService) + let locationService: any LocationService = CoreLocationServiceAdapter() // 원탭 칩(Phase 18) — 검색과 홈이 같은 인스턴스를 봐야 검색의 저장·삭제가 // 칩에 그대로 비친다 (recentSearchRepository 1회 생성과 같은 이유). let searchLastRoutes: any SearchLastRoutesUseCase = DefaultSearchLastRoutesUseCase(repository: lastRouteRepository) - let recentSearches: any RecentSearchesUseCase = - DefaultRecentSearchesUseCase(repository: recentSearchRepository) - let searchContainer = SearchDIContainer( - searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), + placeRepository: placeRepository, searchLastRoutesUseCase: searchLastRoutes, - recentSearchesUseCase: recentSearches, - getCurrentLocationUseCase: getCurrentLocation + recentSearchRepository: recentSearchRepository, + locationService: locationService ) return HomeDIContainer( - getCurrentLocationUseCase: getCurrentLocation, - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + locationService: locationService, + placeRepository: placeRepository, registerAlarmUseCase: DefaultRegisterAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler, @@ -285,10 +279,10 @@ final class AppDIContainer { lastRouteRepository: lastRouteRepository, // 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조). searchLastRoutesUseCase: searchLastRoutes, - recentSearchesUseCase: recentSearches, + recentSearchRepository: recentSearchRepository, searchCoordinatorBuildable: searchContainer, settingsCoordinatorBuildable: makeSettingsDIContainer( - getCurrentLocationUseCase: getCurrentLocation + locationService: locationService ) ) } diff --git a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift deleted file mode 100644 index 80cac688..00000000 --- a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol GetCurrentLocationUseCase: Sendable { - func execute() async throws -> Coordinate -} - -public struct DefaultGetCurrentLocationUseCase: GetCurrentLocationUseCase { - private let locationService: any LocationService - - public init(locationService: any LocationService) { - self.locationService = locationService - } - - public func execute() async throws -> Coordinate { - try await locationService.currentLocation() - } -} diff --git a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift deleted file mode 100644 index 59497f38..00000000 --- a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -public protocol RecentSearchesUseCase: Sendable { - func fetch() async throws -> [Place] - func save(_ place: Place) async throws - func remove(_ place: Place) async throws -} - -public struct DefaultRecentSearchesUseCase: RecentSearchesUseCase { - private let repository: any RecentSearchRepository - - public init(repository: any RecentSearchRepository) { - self.repository = repository - } - - public func fetch() async throws -> [Place] { - try await repository.recentSearches() - } - - public func save(_ place: Place) async throws { - try await repository.save(place) - } - - public func remove(_ place: Place) async throws { - try await repository.remove(place) - } -} diff --git a/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift b/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift deleted file mode 100644 index 6eb73e60..00000000 --- a/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol ReverseGeocodeUseCase: Sendable { - func execute(coordinate: Coordinate) async throws -> Place -} - -public struct DefaultReverseGeocodeUseCase: ReverseGeocodeUseCase { - private let repository: any PlaceRepository - - public init(repository: any PlaceRepository) { - self.repository = repository - } - - public func execute(coordinate: Coordinate) async throws -> Place { - try await repository.reverseGeocode(coordinate) - } -} diff --git a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift deleted file mode 100644 index bef88198..00000000 --- a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol SearchPlacesUseCase: Sendable { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] -} - -public struct DefaultSearchPlacesUseCase: SearchPlacesUseCase { - private let repository: any PlaceRepository - - public init(repository: any PlaceRepository) { - self.repository = repository - } - - public func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - try await repository.searchPlaces(keyword: keyword, near: coordinate) - } -} diff --git a/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift b/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift deleted file mode 100644 index ba4007c9..00000000 --- a/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubError: Error {} - -private struct StubLastRouteRepository: LastRouteRepository { - var result: Result - - func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { [] } - - func lastRoute(id: String) async throws -> LastRoute { - try result.get() - } -} - -private let fixture = LastRoute( - id: "route-1", departureTime: Date(timeIntervalSince1970: 1_000), totalTime: 0, - totalWalkTime: 0, transferCount: 0, totalDistance: 0, totalWalkDistance: 0, legs: [] -) - -struct DefaultGetLastRouteDetailUseCaseTests { - @Test - func execute_returnsRepositoryDetail() async throws { - let sut = DefaultGetLastRouteDetailUseCase( - repository: StubLastRouteRepository(result: .success(fixture)) - ) - #expect(try await sut.execute(routeId: "route-1") == fixture) - } - - @Test - func execute_repositoryFails_rethrows() async { - let sut = DefaultGetLastRouteDetailUseCase( - repository: StubLastRouteRepository(result: .failure(StubError())) - ) - await #expect(throws: StubError.self) { - _ = try await sut.execute(routeId: "route-1") - } - } -} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift deleted file mode 100644 index ebf36cd2..00000000 --- a/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubAlarmChangeEvents: AlarmChangeEvents { - let handler: @Sendable () -> AsyncStream - func changes() -> AsyncStream { handler() } -} - -struct DefaultObserveAlarmChangeUseCaseTests { - @Test - func execute_forwardsPortStream() async { - let sut = DefaultObserveAlarmChangeUseCase( - events: StubAlarmChangeEvents { - AsyncStream { continuation in - continuation.yield(.delayed(by: 600)) - continuation.yield(.sessionEnded) - continuation.finish() - } - } - ) - - var received: [AlarmChangeVerdict] = [] - for await verdict in sut.execute() { - received.append(verdict) - } - - #expect(received == [.delayed(by: 600), .sessionEnded]) - } -} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift deleted file mode 100644 index 274f4799..00000000 --- a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubAlarmSyncEvents: AlarmSyncEvents { - let handler: @Sendable () -> AsyncStream - func updates() -> AsyncStream { handler() } -} - -struct DefaultObserveAlarmUseCaseTests { - @Test - func execute_forwardsPortStream() async { - let update = AlarmSyncUpdate( - info: AlarmInfo( - lastRouteId: "r1", - departureTime: Date(timeIntervalSince1970: 1_755_800_000), - updatedAt: nil, - isReal: true - ), - // 확인 시각이 함께 흘러야 한다 — 신선도 스탬프의 원천(Phase 16). - checkedAt: Date(timeIntervalSince1970: 1_755_790_000) - ) - let sut = DefaultObserveAlarmUseCase( - events: StubAlarmSyncEvents { - AsyncStream { continuation in - continuation.yield(update) - continuation.finish() - } - } - ) - - var received: [AlarmSyncUpdate] = [] - for await value in sut.execute() { - received.append(value) - } - - #expect(received == [update]) - } -} diff --git a/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift b/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift deleted file mode 100644 index 7d8c4e54..00000000 --- a/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private actor SpyAlarmSyncRequesting: AlarmSyncRequesting { - private(set) var syncNowCount = 0 - func syncNow() async { syncNowCount += 1 } -} - -struct DefaultRequestAlarmSyncUseCaseTests { - @Test - func execute_forwardsToPortOncePerCall() async { - let spy = SpyAlarmSyncRequesting() - let sut = DefaultRequestAlarmSyncUseCase(requesting: spy) - - await sut.execute() - await sut.execute() - - // 합류·중복 억제는 포트 구현(AlarmSyncService inFlight)의 몫 — UseCase는 위임만. - #expect(await spy.syncNowCount == 2) - } -} diff --git a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift deleted file mode 100644 index 81f50d13..00000000 --- a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubError: Error {} - -private actor CallLog { - private(set) var coordinates: [Coordinate] = [] - func append(_ coordinate: Coordinate) { coordinates.append(coordinate) } -} - -private struct SpyPlaceRepository: PlaceRepository { - let log: CallLog - var place: Place? = nil - - func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - [] - } - - func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { - await log.append(coordinate) - guard let place else { throw StubError() } - return place - } - - func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } -} - -struct DefaultReverseGeocodeUseCaseTests { - @Test - func execute_forwardsCoordinateAndReturnsPlace() async throws { - let log = CallLog() - let expected = Place( - name: "강남역", - address: "서울 강남구 강남대로 396", - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ) - let sut = DefaultReverseGeocodeUseCase( - repository: SpyPlaceRepository(log: log, place: expected) - ) - - let place = try await sut.execute( - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ) - - #expect(place == expected) - #expect(await log.coordinates == [Coordinate(latitude: 37.4979, longitude: 127.0276)]) - } - - @Test - func execute_propagatesRepositoryError() async { - let sut = DefaultReverseGeocodeUseCase( - repository: SpyPlaceRepository(log: CallLog()) - ) - - await #expect(throws: StubError.self) { - _ = try await sut.execute(coordinate: Coordinate(latitude: 0, longitude: 0)) - } - } -} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index 70bb8177..d2fe625f 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -35,8 +35,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } let navigationController = UINavigationController() let container = HomeDIContainer( - getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), - reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + locationService: PreviewLocationService(), + placeRepository: PreviewPlaceRepository(), registerAlarmUseCase: PreviewRegisterAlarmUseCase(), cancelAlarmUseCase: PreviewCancelAlarmUseCase(), alarmSyncEvents: PreviewAlarmSyncEvents(), @@ -44,7 +44,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { alarmSyncRequesting: PreviewAlarmSyncRequesting(), lastRouteRepository: PreviewLastRouteRepository(), searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), - recentSearchesUseCase: PreviewRecentSearchesUseCase(), + recentSearchRepository: PreviewRecentSearchRepository(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -60,18 +60,22 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Example apps wire stub use cases — no Data/network dependency. -struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { - func execute() async throws -> Coordinate { +struct PreviewLocationService: LocationService { + func currentLocation() async throws -> Coordinate { try? await Task.sleep(for: .milliseconds(400)) return Coordinate(latitude: 37.4979, longitude: 127.0276) } } -struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { +struct PreviewPlaceRepository: PlaceRepository { + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { try? await Task.sleep(for: .milliseconds(200)) return Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) } + + // 홈은 역지오코딩만 쓴다 — 나머지는 프리뷰에서 호출되지 않는다. + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } struct PreviewRegisterAlarmUseCase: RegisterAlarmUseCase { @@ -130,8 +134,8 @@ struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase { } /// 최근 검색 스텁(Phase 18) — canned 1건으로 칩이 즉시 표출된다. save/remove는 no-op. -struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { - func fetch() async throws -> [Place] { +struct PreviewRecentSearchRepository: RecentSearchRepository { + func recentSearches() async throws -> [Place] { [PreviewSearchCoordinator.makeCannedArrival()] } diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 854d0330..c5846fbd 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -9,8 +9,8 @@ import UIKit /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class HomeDIContainer: HomeCoordinatorBuildable { - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationService: any LocationService + private let placeRepository: any PlaceRepository private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase private let alarmSyncEvents: any AlarmSyncEvents @@ -18,14 +18,14 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let alarmSyncRequesting: any AlarmSyncRequesting private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository private let searchCoordinatorBuildable: any SearchCoordinatorBuildable /// nil이면 톱니바퀴가 아무 일도 하지 않는다(Example 구성 호환). private let settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? public init( - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationService: any LocationService, + placeRepository: any PlaceRepository, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, alarmSyncEvents: any AlarmSyncEvents, @@ -33,12 +33,12 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { alarmSyncRequesting: any AlarmSyncRequesting, lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, + recentSearchRepository: any RecentSearchRepository, searchCoordinatorBuildable: any SearchCoordinatorBuildable, settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? = nil ) { - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationService = locationService + self.placeRepository = placeRepository self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.alarmSyncEvents = alarmSyncEvents @@ -46,7 +46,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.alarmSyncRequesting = alarmSyncRequesting self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase + self.recentSearchRepository = recentSearchRepository self.searchCoordinatorBuildable = searchCoordinatorBuildable self.settingsCoordinatorBuildable = settingsCoordinatorBuildable } @@ -63,8 +63,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { onSettingsRequested: @escaping () -> Void ) -> UIViewController { let viewModel = HomeViewModel( - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: reverseGeocodeUseCase, + locationService: locationService, + placeRepository: placeRepository, registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, alarmSyncEvents: alarmSyncEvents, @@ -72,7 +72,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { alarmSyncRequesting: alarmSyncRequesting, lastRouteRepository: lastRouteRepository, searchLastRoutesUseCase: searchLastRoutesUseCase, - recentSearchesUseCase: recentSearchesUseCase + recentSearchRepository: recentSearchRepository ) viewModel.onSearchRequested = onSearchRequested viewModel.onSettingsRequested = onSettingsRequested diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 7f20a34e..27c570b5 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -124,8 +124,8 @@ final class HomeViewModel { didSet { if state != oldValue { onStateChange?(state) } } } - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationService: any LocationService + private let placeRepository: any PlaceRepository private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase // Domain 포트를 직접 받는다. 이전에는 같은 프로토콜을 한 겹 더 감싼 UseCase를 @@ -135,7 +135,7 @@ final class HomeViewModel { private let alarmSyncRequesting: any AlarmSyncRequesting private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository private let now: @Sendable () -> Date /// 화면에 떠 있는 경로. id는 State가 파생(알람 버튼)에 쓰므로 **함께 갱신한다** — @@ -158,8 +158,8 @@ final class HomeViewModel { private var chipSearchTask: Task? init( - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationService: any LocationService, + placeRepository: any PlaceRepository, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, alarmSyncEvents: any AlarmSyncEvents, @@ -167,11 +167,11 @@ final class HomeViewModel { alarmSyncRequesting: any AlarmSyncRequesting, lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, + recentSearchRepository: any RecentSearchRepository, now: @escaping @Sendable () -> Date = { Date() } ) { - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationService = locationService + self.placeRepository = placeRepository self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.alarmSyncEvents = alarmSyncEvents @@ -179,7 +179,7 @@ final class HomeViewModel { self.alarmSyncRequesting = alarmSyncRequesting self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase + self.recentSearchRepository = recentSearchRepository self.now = now } @@ -218,11 +218,11 @@ final class HomeViewModel { guard case .needsSearch = state.departure else { return } locationTask?.cancel() locationTask = Task { [weak self] in - guard let locationUseCase = self?.getCurrentLocationUseCase, - let coordinate = try? await locationUseCase.execute() else { return } + guard let locationUseCase = self?.locationService, + let coordinate = try? await locationUseCase.currentLocation() else { return } guard !Task.isCancelled, - let geocodeUseCase = self?.reverseGeocodeUseCase, - let place = try? await geocodeUseCase.execute(coordinate: coordinate) + let geocodeUseCase = self?.placeRepository, + let place = try? await geocodeUseCase.reverseGeocode(coordinate) else { return } guard !Task.isCancelled else { return } self?.state.departure = .current(name: place.name) @@ -283,8 +283,8 @@ final class HomeViewModel { let start: Coordinate do { - guard let locationUseCase = self?.getCurrentLocationUseCase else { return } - start = try await locationUseCase.execute() + guard let locationUseCase = self?.locationService else { return } + start = try await locationUseCase.currentLocation() } catch let error as LocationError { guard !Task.isCancelled else { return } // 사유별 안내는 기존 3분기 이벤트 재사용(Phase 17 문구·액션 그대로). @@ -522,9 +522,9 @@ final class HomeViewModel { guard chipSaveTask == nil else { return } chipTask?.cancel() chipTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let repository = self?.recentSearchRepository else { return } // 로드 실패는 칩 없음으로 무해화한다 (검색 화면의 최근 목록과 같은 취급). - let latest = ((try? await useCase.fetch()) ?? []).first + let latest = ((try? await repository.recentSearches()) ?? []).first guard !Task.isCancelled, let self else { return } self.chipPlace = latest self.state.recentRouteChipText = latest.map { "→ \($0.name)" } @@ -538,7 +538,7 @@ final class HomeViewModel { private func promoteChipDestination(_ place: Place) { chipSaveTask?.cancel() chipSaveTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let useCase = self?.recentSearchRepository else { return } try? await useCase.save(place) guard !Task.isCancelled else { return } self?.chipSaveTask = nil @@ -550,11 +550,11 @@ final class HomeViewModel { state.departure = .loading locationTask = Task { [weak self] in do { - guard let locationUseCase = self?.getCurrentLocationUseCase else { return } - let coordinate = try await locationUseCase.execute() + guard let locationUseCase = self?.locationService else { return } + let coordinate = try await locationUseCase.currentLocation() guard !Task.isCancelled, - let geocodeUseCase = self?.reverseGeocodeUseCase else { return } - let place = try await geocodeUseCase.execute(coordinate: coordinate) + let geocodeUseCase = self?.placeRepository else { return } + let place = try await geocodeUseCase.reverseGeocode(coordinate) guard !Task.isCancelled else { return } self?.state.departure = .current(name: place.name) } catch let error as LocationError { diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 1a493805..0f534741 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -6,14 +6,18 @@ import Testing private struct StubError: Error {} -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +private struct StubLocationService: LocationService { let handler: @Sendable () async throws -> Coordinate - func execute() async throws -> Coordinate { try await handler() } + func currentLocation() async throws -> Coordinate { try await handler() } } -private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { +private struct StubPlaceRepository: PlaceRepository { let handler: @Sendable (Coordinate) async throws -> Place - func execute(coordinate: Coordinate) async throws -> Place { try await handler(coordinate) } + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { try await handler(coordinate) } + + // 홈은 역지오코딩만 쓴다 — 나머지는 호출되지 않는다. + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubRegisterAlarmUseCase: RegisterAlarmUseCase { @@ -63,10 +67,10 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { +private struct StubRecentSearchRepository: RecentSearchRepository { let fetchHandler: @Sendable () async throws -> [Place] let saveHandler: @Sendable (Place) async throws -> Void - func fetch() async throws -> [Place] { try await fetchHandler() } + func recentSearches() async throws -> [Place] { try await fetchHandler() } func save(_ place: Place) async throws { try await saveHandler(place) } func remove(_ place: Place) async throws {} } @@ -199,8 +203,8 @@ private func makeSUT( now: @escaping @Sendable () -> Date = { fixedNow } ) -> HomeViewModel { HomeViewModel( - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: location), - reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode), + locationService: StubLocationService(handler: location), + placeRepository: StubPlaceRepository(handler: geocode), registerAlarmUseCase: StubRegisterAlarmUseCase( handler: register, authorizationOutcome: registerOutcome ), @@ -210,7 +214,7 @@ private func makeSUT( alarmSyncRequesting: StubAlarmSyncRequesting(handler: requestSync), lastRouteRepository: StubLastRouteRepository(handler: routeDetail), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: searchRoutes), - recentSearchesUseCase: StubRecentSearchesUseCase( + recentSearchRepository: StubRecentSearchRepository( fetchHandler: recentFetch, saveHandler: recentSave ), now: now diff --git a/Projects/Feature/Search/Example/ExampleApp.swift b/Projects/Feature/Search/Example/ExampleApp.swift index 29e78c0e..18c8e91d 100644 --- a/Projects/Feature/Search/Example/ExampleApp.swift +++ b/Projects/Feature/Search/Example/ExampleApp.swift @@ -46,9 +46,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func startSearchFlow() { let container = SearchDIContainer( - searchPlacesUseCase: PreviewSearchPlacesUseCase(), + placeRepository: PreviewPlaceRepository(), searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), - recentSearchesUseCase: PreviewRecentSearchesUseCase() + recentSearchRepository: PreviewRecentSearchRepository() ) let coordinator = container.makeSearchCoordinator( navigationController: navigationController, @@ -122,13 +122,20 @@ nonisolated enum PreviewScenario { ] } -struct PreviewSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { +struct PreviewPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { // 디바운스가 체감되도록 실서버 지연을 흉내낸다. try? await Task.sleep(for: .milliseconds(300)) let matches = PreviewScenario.catalog.filter { $0.name.localizedStandardContains(keyword) } return matches.isEmpty ? PreviewScenario.catalog : matches } + + // 검색은 장소 검색만 쓴다 — 나머지는 프리뷰에서 호출되지 않는다. + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -212,7 +219,7 @@ actor PreviewRecentSearchesStore { } } -struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { +struct PreviewRecentSearchRepository: RecentSearchRepository { // 데모 장소를 최근 검색에 심어 3가지 상태를 바로 시연할 수 있게 한다. private let store = PreviewRecentSearchesStore(seed: [ PreviewScenario.catalog[0], @@ -220,7 +227,7 @@ struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { PreviewScenario.catalog[5], ]) - func fetch() async throws -> [Place] { await store.fetch() } + func recentSearches() async throws -> [Place] { await store.fetch() } func save(_ place: Place) async throws { await store.save(place) } func remove(_ place: Place) async throws { await store.remove(place) } } diff --git a/Projects/Feature/Search/Sources/SearchDIContainer.swift b/Projects/Feature/Search/Sources/SearchDIContainer.swift index 35cea9c5..431efc70 100644 --- a/Projects/Feature/Search/Sources/SearchDIContainer.swift +++ b/Projects/Feature/Search/Sources/SearchDIContainer.swift @@ -7,21 +7,21 @@ import UIKit /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class SearchDIContainer: SearchCoordinatorBuildable { - private let searchPlacesUseCase: any SearchPlacesUseCase + private let placeRepository: any PlaceRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase - private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? + private let recentSearchRepository: any RecentSearchRepository + private let locationService: (any LocationService)? public init( - searchPlacesUseCase: any SearchPlacesUseCase, + placeRepository: any PlaceRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, - getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil + recentSearchRepository: any RecentSearchRepository, + locationService: (any LocationService)? = nil ) { - self.searchPlacesUseCase = searchPlacesUseCase + self.placeRepository = placeRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.recentSearchRepository = recentSearchRepository + self.locationService = locationService } public func makeSearchCoordinator( @@ -43,10 +43,10 @@ public final class SearchDIContainer: SearchCoordinatorBuildable { onBack: @escaping () -> Void ) -> UIViewController { let viewModel = SearchViewModel( - searchPlacesUseCase: searchPlacesUseCase, + placeRepository: placeRepository, searchLastRoutesUseCase: searchLastRoutesUseCase, - recentSearchesUseCase: recentSearchesUseCase, - getCurrentLocationUseCase: getCurrentLocationUseCase, + recentSearchRepository: recentSearchRepository, + locationService: locationService, initialField: initialField ) viewModel.onRouteChosen = onRouteChosen diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 2f35fd78..4669e7e3 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -66,11 +66,11 @@ final class SearchViewModel { private var availableRoutes: [LastRoute] = [] private var isExpanded = false - private let searchPlacesUseCase: any SearchPlacesUseCase + private let placeRepository: any PlaceRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository // nil이면(예: Example 스텁 구성) 프리필·near 바이어스 없이 동작한다. - private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? + private let locationService: (any LocationService)? // 오늘/내일 라벨 판정의 기준 시각(Phase 17) — 실 Date() 직접 호출 대신 주입(기존 VM 관례). private let now: @Sendable () -> Date private let debounceInterval: Duration @@ -83,18 +83,18 @@ final class SearchViewModel { private var saveTask: Task? init( - searchPlacesUseCase: any SearchPlacesUseCase, + placeRepository: any PlaceRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, - getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil, + recentSearchRepository: any RecentSearchRepository, + locationService: (any LocationService)? = nil, initialField: SearchEntryField = .departure, now: @escaping @Sendable () -> Date = { Date() }, debounceInterval: Duration = .milliseconds(300) ) { - self.searchPlacesUseCase = searchPlacesUseCase + self.placeRepository = placeRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.recentSearchRepository = recentSearchRepository + self.locationService = locationService self.now = now self.debounceInterval = debounceInterval // 탭한 필드로 진입한다(Phase 17) — 초기 활성 슬롯만 정하고 프리필 정책은 불변. @@ -153,14 +153,14 @@ final class SearchViewModel { // [weak self]: the in-flight task must not keep the ViewModel alive. searchTask = Task { [weak self] in guard let interval = self?.debounceInterval, - let useCase = self?.searchPlacesUseCase else { return } + let useCase = self?.placeRepository else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } // 디바운스 통과 = 이 키워드로 실제 요청한다 — 로딩은 여기서부터(Phase 17). self?.state.content = .loadingPlaces do { // 현재 위치가 확보된 경우에만 근처 우선 정렬 바이어스를 건다. - let places = try await useCase.execute(keyword: trimmed, near: self?.currentCoordinate) + let places = try await useCase.searchPlaces(keyword: trimmed, near: self?.currentCoordinate) guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .places(places.map(PlaceViewData.init(entity:))) @@ -187,9 +187,9 @@ final class SearchViewModel { let place = listedPlaces[index] recentTask?.cancel() recentTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } - try? await useCase.remove(place) - let places = (try? await useCase.fetch()) ?? [] + guard let repository = self?.recentSearchRepository else { return } + try? await repository.remove(place) + let places = (try? await repository.recentSearches()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) @@ -239,9 +239,9 @@ final class SearchViewModel { /// 출발지 기본값 = 현재 위치. 실패·권한 거부는 조용히 무시한다 (권한 안내 UX는 홈 담당). private func prefillDepartureWithCurrentLocation() { - guard let useCase = getCurrentLocationUseCase else { return } + guard let locationService else { return } locationTask = Task { [weak self] in - guard let coordinate = try? await useCase.execute() else { return } + guard let coordinate = try? await locationService.currentLocation() else { return } guard !Task.isCancelled, let self else { return } self.currentCoordinate = coordinate // 사용자가 이미 출발지를 만졌다면 덮어쓰지 않는다. @@ -259,7 +259,7 @@ final class SearchViewModel { private func confirm(_ place: Place, in field: Field) { searchTask?.cancel() saveTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let useCase = self?.recentSearchRepository else { return } try? await useCase.save(place) } @@ -320,9 +320,9 @@ final class SearchViewModel { private func showRecent() { recentTask?.cancel() recentTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let repository = self?.recentSearchRepository else { return } // 최근 검색 로드 실패는 빈 목록으로 무해화한다. - let places = (try? await useCase.fetch()) ?? [] + let places = (try? await repository.recentSearches()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) diff --git a/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift index e618c709..1f2d8aec 100644 --- a/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift +++ b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift @@ -6,8 +6,14 @@ import Testing import UIKit // 코디네이터 조립용 최소 스텁 — Tests/Example 중복은 의도된 트레이드오프(기존 규약). -private struct StubSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } +private struct StubPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -16,8 +22,8 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { - func fetch() async throws -> [Place] { [] } +private struct StubRecentSearchRepository: RecentSearchRepository { + func recentSearches() async throws -> [Place] { [] } func save(_ place: Place) async throws {} func remove(_ place: Place) async throws {} } @@ -36,9 +42,9 @@ private func makeCoordinator(navigationController: UINavigationController) -> Se SearchCoordinator( navigationController: navigationController, container: SearchDIContainer( - searchPlacesUseCase: StubSearchPlacesUseCase(), + placeRepository: StubPlaceRepository(), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(), - recentSearchesUseCase: StubRecentSearchesUseCase() + recentSearchRepository: StubRecentSearchRepository() ), initialField: .departure, onRouteSelected: { _, _ in } diff --git a/Projects/Feature/Search/Tests/SearchViewModelTests.swift b/Projects/Feature/Search/Tests/SearchViewModelTests.swift index df336fb0..7e4a0235 100644 --- a/Projects/Feature/Search/Tests/SearchViewModelTests.swift +++ b/Projects/Feature/Search/Tests/SearchViewModelTests.swift @@ -4,11 +4,18 @@ import Foundation import SearchFeatureInterface import Testing -private struct StubSearchPlacesUseCase: SearchPlacesUseCase { +private struct StubPlaceRepository: PlaceRepository { let handler: @Sendable (String) async throws -> [Place] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { try await handler(keyword) } + + // 검색은 장소 검색만 쓴다 — 나머지는 호출되지 않는다. + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -18,9 +25,9 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +private struct StubLocationService: LocationService { let handler: @Sendable () async throws -> Coordinate - func execute() async throws -> Coordinate { try await handler() } + func currentLocation() async throws -> Coordinate { try await handler() } } // 키워드 검색이 받은 near 좌표를 기록한다. @@ -29,14 +36,20 @@ private actor NearLog { func append(_ coordinate: Coordinate?) { coordinates.append(coordinate) } } -private struct NearRecordingSearchPlacesUseCase: SearchPlacesUseCase { +private struct NearRecordingPlaceRepository: PlaceRepository { let log: NearLog let places: [Place] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { await log.append(coordinate) return places } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } // save/remove 호출 기록 + fetch 응답을 한곳에서 관리. @@ -63,9 +76,9 @@ private actor RecentStore { func fetch() -> [Place] { places } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { +private struct StubRecentSearchRepository: RecentSearchRepository { let store: RecentStore - func fetch() async throws -> [Place] { await store.fetch() } + func recentSearches() async throws -> [Place] { await store.fetch() } func save(_ place: Place) async throws { await store.save(place) } func remove(_ place: Place) async throws { await store.remove(place) } } @@ -139,10 +152,10 @@ private func makeSUT( initialField: SearchEntryField = .departure ) -> SearchViewModel { SearchViewModel( - searchPlacesUseCase: StubSearchPlacesUseCase(handler: placesHandler), + placeRepository: StubPlaceRepository(handler: placesHandler), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: routesHandler), - recentSearchesUseCase: StubRecentSearchesUseCase(store: store), - getCurrentLocationUseCase: location.map(StubGetCurrentLocationUseCase.init(handler:)), + recentSearchRepository: StubRecentSearchRepository(store: store), + locationService: location.map(StubLocationService.init(handler:)), initialField: initialField, now: { fixedNow }, debounceInterval: .zero @@ -390,10 +403,10 @@ struct SearchViewModelTests { let coordinate = Coordinate(latitude: 37.49, longitude: 127.02) let log = NearLog() let sut = SearchViewModel( - searchPlacesUseCase: NearRecordingSearchPlacesUseCase(log: log, places: [makePlace("회사")]), + placeRepository: NearRecordingPlaceRepository(log: log, places: [makePlace("회사")]), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: { .available([]) }), - recentSearchesUseCase: StubRecentSearchesUseCase(store: RecentStore()), - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: { coordinate }), + recentSearchRepository: StubRecentSearchRepository(store: RecentStore()), + locationService: StubLocationService(handler: { coordinate }), debounceInterval: .zero ) let recorder = StateRecorder() diff --git a/Projects/Feature/Settings/Example/ExampleApp.swift b/Projects/Feature/Settings/Example/ExampleApp.swift index 3667ae2b..6c6ce2d9 100644 --- a/Projects/Feature/Settings/Example/ExampleApp.swift +++ b/Projects/Feature/Settings/Example/ExampleApp.swift @@ -50,9 +50,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { logoutUseCase: PreviewLogoutUseCase(), withdrawUseCase: PreviewWithdrawUseCase(), updateHomeAddressUseCase: PreviewUpdateHomeAddressUseCase(), - searchPlacesUseCase: PreviewSearchPlacesUseCase(), - getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), - reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + placeRepository: PreviewPlaceRepository(), + locationService: PreviewLocationService(), checkAppUpdateUseCase: PreviewCheckAppUpdateUseCase(), currentVersion: "2.0.0", appStoreURL: URL(string: "https://apps.apple.com") @@ -128,27 +127,29 @@ private struct PreviewUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { } } -private struct PreviewSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - [ - Place(name: "\(keyword) 서울점", address: "서울 중구 세종대로 110", coordinate: Coordinate(latitude: 37.5665, longitude: 126.9780)), - Place(name: "\(keyword) 부산점", address: "부산 해운대구 해운대해변로 264", coordinate: Coordinate(latitude: 35.1587, longitude: 129.1604)), +private struct PreviewPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + try? await Task.sleep(for: .milliseconds(300)) + return [ + Place(name: "서울시청", address: "서울 중구 세종대로 110", + coordinate: Coordinate(latitude: 37.5663, longitude: 126.9779)), ] } -} -private struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { - func execute() async throws -> Coordinate { - Coordinate(latitude: 37.5665, longitude: 126.9780) + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } -private struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { - Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) +private struct PreviewLocationService: LocationService { + func currentLocation() async throws -> Coordinate { + Coordinate(latitude: 37.5665, longitude: 126.9780) } } + private struct PreviewCheckAppUpdateUseCase: CheckAppUpdateUseCase { func execute(currentVersion: String) async throws -> AppUpdateStatus { .recommended(latest: "2.1.0") diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift index 5136246a..7b576a75 100644 --- a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift +++ b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift @@ -36,9 +36,8 @@ final class HomeAddressViewModel { } private var listedPlaces: [Place] = [] - private let searchPlacesUseCase: any SearchPlacesUseCase - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let placeRepository: any PlaceRepository + private let locationService: any LocationService private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase private let debounceInterval: Duration @@ -46,15 +45,13 @@ final class HomeAddressViewModel { private var saveTask: Task? init( - searchPlacesUseCase: any SearchPlacesUseCase, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + placeRepository: any PlaceRepository, + locationService: any LocationService, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, debounceInterval: Duration = .milliseconds(300) ) { - self.searchPlacesUseCase = searchPlacesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.placeRepository = placeRepository + self.locationService = locationService self.updateHomeAddressUseCase = updateHomeAddressUseCase self.debounceInterval = debounceInterval } @@ -75,12 +72,12 @@ final class HomeAddressViewModel { // [weak self]: the in-flight task must not keep the ViewModel alive. searchTask = Task { [weak self] in guard let interval = self?.debounceInterval, - let useCase = self?.searchPlacesUseCase else { return } + let useCase = self?.placeRepository else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } self?.state.content = .loading do { - let places = try await useCase.execute(keyword: trimmed, near: nil) + let places = try await useCase.searchPlaces(keyword: trimmed, near: nil) guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = places.isEmpty @@ -103,11 +100,11 @@ final class HomeAddressViewModel { guard !state.isSaving else { return } state.isSaving = true saveTask = Task { [weak self] in - guard let locate = self?.getCurrentLocationUseCase, - let geocode = self?.reverseGeocodeUseCase else { return } + guard let locate = self?.locationService, + let geocode = self?.placeRepository else { return } do { - let coordinate = try await locate.execute() - let place = try await geocode.execute(coordinate: coordinate) + let coordinate = try await locate.currentLocation() + let place = try await geocode.reverseGeocode(coordinate) guard !Task.isCancelled else { return } self?.state.isSaving = false self?.save(place) diff --git a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift index d3e2079a..6e0828e4 100644 --- a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift +++ b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift @@ -11,9 +11,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { private let logoutUseCase: any LogoutUseCase private let withdrawUseCase: any WithdrawUseCase private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase - private let searchPlacesUseCase: any SearchPlacesUseCase - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let placeRepository: any PlaceRepository + private let locationService: any LocationService private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? private let currentVersion: String private let appStoreURL: URL? @@ -23,9 +22,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { logoutUseCase: any LogoutUseCase, withdrawUseCase: any WithdrawUseCase, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, - searchPlacesUseCase: any SearchPlacesUseCase, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + placeRepository: any PlaceRepository, + locationService: any LocationService, checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, currentVersion: String, appStoreURL: URL? = nil @@ -34,9 +32,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { self.logoutUseCase = logoutUseCase self.withdrawUseCase = withdrawUseCase self.updateHomeAddressUseCase = updateHomeAddressUseCase - self.searchPlacesUseCase = searchPlacesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.placeRepository = placeRepository + self.locationService = locationService self.checkAppUpdateUseCase = checkAppUpdateUseCase self.currentVersion = currentVersion self.appStoreURL = appStoreURL @@ -63,9 +60,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { func makeHomeAddressViewController(onSaved: @escaping () -> Void) -> UIViewController { let viewModel = HomeAddressViewModel( - searchPlacesUseCase: searchPlacesUseCase, - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: reverseGeocodeUseCase, + placeRepository: placeRepository, + locationService: locationService, updateHomeAddressUseCase: updateHomeAddressUseCase ) viewModel.onSaved = onSaved diff --git a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift index 39cba0d7..32735f9a 100644 --- a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift +++ b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift @@ -14,9 +14,8 @@ struct HomeAddressViewModelTests { locationFails: Bool = false ) -> HomeAddressViewModel { HomeAddressViewModel( - searchPlacesUseCase: StubSearchPlacesUseCase(places: [place]), - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(fails: locationFails), - reverseGeocodeUseCase: StubReverseGeocodeUseCase(), + placeRepository: StubPlaceRepository(places: [place]), + locationService: StubLocationService(fails: locationFails), updateHomeAddressUseCase: update, debounceInterval: .zero ) diff --git a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift index cfb57578..0e438bd2 100644 --- a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift +++ b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift @@ -61,25 +61,28 @@ struct StubCheckAppUpdateUseCase: CheckAppUpdateUseCase { func execute(currentVersion: String) async throws -> AppUpdateStatus { status } } -struct StubSearchPlacesUseCase: SearchPlacesUseCase { +struct StubPlaceRepository: PlaceRepository { var places: [Place] = [] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { places } + + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + places + } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } -struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +struct StubLocationService: LocationService { var fails = false - func execute() async throws -> Coordinate { + func currentLocation() async throws -> Coordinate { if fails { throw StubFailure() } return Coordinate(latitude: 37.5665, longitude: 126.9780) } } -struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { - Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) - } -} - final class SpyUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { private let log = Mutex<[String?]>([]) private let stored = Mutex<(any Error)?>(nil)