diff --git a/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift index 7d86d85..a761101 100644 --- a/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift +++ b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift @@ -8,13 +8,25 @@ import Domain /// App 모듈 기본 격리가 MainActor라 이 클래스는 암시적 Sendable — 프로토콜의 /// nonisolated async 요구사항은 격리 witness로 충족된다. final class CoreLocationServiceAdapter: LocationService { + /// CLLocationUpdate의 거부 플래그 3종 → LocationError 매핑(Phase 17). nil = 진행 중. + /// 우선순위: restricted > 전역 OFF > 앱 권한 거부 — 더 좁은 회복 경로가 이긴다 + /// (restricted는 설정으로 못 풀고, 전역 OFF는 앱 권한 상태를 무의미하게 만든다). + static func classify(denied: Bool, deniedGlobally: Bool, restricted: Bool) -> LocationError? { + if restricted { return .restricted } + if deniedGlobally { return .servicesDisabled } + if denied { return .permissionDenied } + return nil + } + func currentLocation() async throws -> Coordinate { do { for try await update in CLLocationUpdate.liveUpdates() { - if update.authorizationDenied - || update.authorizationDeniedGlobally - || update.authorizationRestricted { - throw LocationError.permissionDenied + if let error = Self.classify( + denied: update.authorizationDenied, + deniedGlobally: update.authorizationDeniedGlobally, + restricted: update.authorizationRestricted + ) { + throw error } if let location = update.location { return Coordinate( diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift index ea2f75f..cd5def6 100644 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift @@ -13,7 +13,27 @@ import Foundation struct DevDemoFallbackPlaceRepository: PlaceRepository { let base: any PlaceRepository + /// Phase 17 검수 훅 — 이 좌표의 장소를 도착지로 고르면 경로 검색이 빈 목록을 반환한다 + /// (빈 목록 정규화 = serviceEnded 표면의 유일한 DEV 재현 수단). + static let emptyRouteCoordinate = Coordinate(latitude: 0, longitude: 0) + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + // Phase 17 검수 훅 — 예약 키워드는 base보다 먼저 판정한다("실패 시에만" 원칙의 + // 명시적 예외, 검수 결정론 확보). 빈 상태·serviceEnded 화면의 재현 재료. + if keyword == "결과없음" { + print("⚠️ [DEV 검수 훅] 예약 키워드 → 장소 0건 반환") + return [] + } + if keyword == "경로없음" { + print("⚠️ [DEV 검수 훅] 예약 키워드 → 빈 경로 유도 데모 장소 반환") + return [ + Place( + name: "경로없음 (데모)", + address: "도착지로 선택하면 빈 경로 응답을 시연해요", + coordinate: Self.emptyRouteCoordinate + ), + ] + } do { return try await base.searchPlaces(keyword: keyword, near: coordinate) } catch { print("⚠️ [DEV 우회] 장소 검색 실패 → 데모 장소 반환: \(error)") @@ -45,15 +65,22 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { let base: any LastRouteRepository func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { + // Phase 17 검수 훅 — 예약 도착지(경로없음 데모 장소)는 base보다 먼저 판정한다. + // 빈 목록은 정규화 가정(미확정 #3)에 따라 serviceEnded로 표면화된다. + if end == DevDemoFallbackPlaceRepository.emptyRouteCoordinate { + print("⚠️ [DEV 검수 훅] 예약 도착지 → 빈 경로 목록 반환 (serviceEnded 정규화 재료)") + return [] + } do { return try await base.searchLastRoutes(start: start, end: end) } catch { print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)") let route = Self.demoRoute(start: start, end: end) // 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다. + // 대안(내일 출발)은 기준 시각에 관여하지 않는다 — index 0 정합 불변. await DevChangeSimulator.shared.noteKnownSession( routeId: route.id, departureTime: route.departureTime ) - return [route] + return [route, Self.tomorrowDemoRoute(start: start, end: end)] } } @@ -76,15 +103,30 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { } } + /// 오늘/내일 라벨(Phase 17) 검수 재료 — 더보기의 대안 경로로 항상 "내일" 출발이 + /// 표시되게 다음 자정+10분을 출발 시각으로 잡는다(검수 시각 무관 결정론). + /// 알람 등록 검수 플로우는 featured(index 0)를 쓰므로 이 경로와 무관하다. + private static func tomorrowDemoRoute(start: Coordinate, end: Coordinate) -> LastRoute { + let startOfToday = Calendar.current.startOfDay(for: Date()) + let nextMidnight = Calendar.current.date(byAdding: .day, value: 1, to: startOfToday) + ?? startOfToday.addingTimeInterval(24 * 60 * 60) + return demoRoute( + start: start, + end: end, + departure: nextMidnight.addingTimeInterval(10 * 60), + id: "dev-demo-route-tomorrow" + ) + } + /// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분 /// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준). /// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다. private static func demoRoute( - start: Coordinate, end: Coordinate, departure: Date? = nil + start: Coordinate, end: Coordinate, departure: Date? = nil, id: String = "dev-demo-route" ) -> LastRoute { let departure = departure ?? Date().addingTimeInterval(8 * 60) return LastRoute( - id: "dev-demo-route", + id: id, departureTime: departure, totalTime: 2940, totalWalkTime: 480, diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index e6ab172..7b6d331 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -67,9 +67,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { /// 스플래시 단계의 탭이면 popToRoot가 스플래시에 머무를 뿐 — 부트스트랩 후 홈 자연 랜딩. func returnToHome() { navigationController.presentedViewController?.dismiss(animated: false) - // 프로그램적 pop은 SearchCoordinator.closeFlow()를 타지 않아 자식 코디네이터가 - // 잔존할 수 있다 — 스와이프 백 누수와 같은 계열이라 Phase 17 - // (UINavigationControllerDelegate 정리)이 일괄 해소한다. 여기서 선취하지 않는다. + // 프로그램적 pop도 SearchCoordinator의 didShow 정리 경로를 탄다(Phase 17) — + // 백 버튼·스와이프 백과 같은 단일 지점에서 자식 코디네이터가 finish된다. navigationController.popToRootViewController(animated: false) } diff --git a/Projects/App/Tests/LocationErrorClassifyTests.swift b/Projects/App/Tests/LocationErrorClassifyTests.swift new file mode 100644 index 0000000..3ce8691 --- /dev/null +++ b/Projects/App/Tests/LocationErrorClassifyTests.swift @@ -0,0 +1,38 @@ +@testable import AtchaV2 +import Domain +import Testing + +/// CLLocationUpdate 거부 플래그 → LocationError 매핑(Phase 17) — 사유별 안내 분기의 원천. +@MainActor +struct LocationErrorClassifyTests { + @Test + func eachFlag_mapsToItsOwnCase() { + #expect(CoreLocationServiceAdapter.classify( + denied: true, deniedGlobally: false, restricted: false + ) == .permissionDenied) + #expect(CoreLocationServiceAdapter.classify( + denied: false, deniedGlobally: true, restricted: false + ) == .servicesDisabled) + #expect(CoreLocationServiceAdapter.classify( + denied: false, deniedGlobally: false, restricted: true + ) == .restricted) + } + + @Test + func noFlags_meansStillInProgress() { + #expect(CoreLocationServiceAdapter.classify( + denied: false, deniedGlobally: false, restricted: false + ) == nil) + } + + @Test + func priority_narrowerRecoveryPathWins() { + // restricted > 전역 OFF > 앱 권한 거부 — 복합 플래그에선 더 좁은 회복 경로가 이긴다. + #expect(CoreLocationServiceAdapter.classify( + denied: true, deniedGlobally: true, restricted: true + ) == .restricted) + #expect(CoreLocationServiceAdapter.classify( + denied: true, deniedGlobally: true, restricted: false + ) == .servicesDisabled) + } +} diff --git a/Projects/Domain/Sources/Entities/LocationError.swift b/Projects/Domain/Sources/Entities/LocationError.swift index a3c3033..b1b9d2d 100644 --- a/Projects/Domain/Sources/Entities/LocationError.swift +++ b/Projects/Domain/Sources/Entities/LocationError.swift @@ -1,5 +1,10 @@ -/// 위치 조회 실패 사유 — 권한 거부를 구분해야 Feature가 "검색 유도 + 설정 이동" UX로 분기할 수 있다. +/// 위치 조회 실패 사유 — 사유별로 회복 경로가 다르므로 Feature가 안내를 분기한다(Phase 17). public enum LocationError: Error, Equatable, Sendable { + /// 이 앱의 권한 거부 — "설정으로 이동"이 유효한 회복 경로다. case permissionDenied + /// 스크린타임·MDM 제약 — 사용자가 설정으로 못 푼다. "설정으로 이동" 안내 금지. + case restricted + /// 기기 전역 위치 서비스 OFF — 앱 권한이 아니라 시스템 설정의 문제. + case servicesDisabled case unavailable } diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index e47752f..fa88555 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -121,7 +121,8 @@ struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { func makeSearchCoordinator( navigationController: UINavigationController, - onRouteSelected: @escaping (LastRoute) -> Void + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void ) -> any Coordinator { PreviewSearchCoordinator(onRouteSelected: onRouteSelected) } @@ -131,17 +132,26 @@ final class PreviewSearchCoordinator: Coordinator { var childCoordinators: [any Coordinator] = [] weak var finishDelegate: (any CoordinatorFinishDelegate)? - private let onRouteSelected: (LastRoute) -> Void + private let onRouteSelected: (LastRoute, Place) -> Void - init(onRouteSelected: @escaping (LastRoute) -> Void) { + init(onRouteSelected: @escaping (LastRoute, Place) -> Void) { self.onRouteSelected = onRouteSelected } func start() { - onRouteSelected(Self.makeCannedRoute()) + onRouteSelected(Self.makeCannedRoute(), Self.makeCannedArrival()) finish() } + // 도착지 필드 바인딩(Phase 17) 시연용 — canned 경로의 하차지와 같은 동네. + nonisolated static func makeCannedArrival() -> Place { + Place( + name: "구로디지털단지역", + address: "서울 구로구 도림천로 486", + coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015) + ) + } + // nonisolated: 상세 재조회 스텁(nonisolated async)에서도 공유한다. nonisolated static func makeCannedRoute() -> LastRoute { let departure = Date().addingTimeInterval(42 * 60) diff --git a/Projects/Feature/Home/Sources/HomeCoordinator.swift b/Projects/Feature/Home/Sources/HomeCoordinator.swift index 0f90c97..9d358ab 100644 --- a/Projects/Feature/Home/Sources/HomeCoordinator.swift +++ b/Projects/Feature/Home/Sources/HomeCoordinator.swift @@ -1,5 +1,6 @@ import CoreCoordinator import Domain +import SearchFeatureInterface import UIKit final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { @@ -17,8 +18,8 @@ final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { func start() { let viewController = container.makeHomeViewController( - onSearchRequested: { [weak self] onRouteSelected in - self?.startSearchFlow(onRouteSelected: onRouteSelected) + onSearchRequested: { [weak self] initialField, onRouteSelected in + self?.startSearchFlow(initialField: initialField, onRouteSelected: onRouteSelected) } ) navigationController?.pushViewController(viewController, animated: false) @@ -26,10 +27,14 @@ final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { // MARK: - 검색 플로우 - private func startSearchFlow(onRouteSelected: @escaping (LastRoute) -> Void) { + private func startSearchFlow( + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void + ) { guard let navigationController else { return } let child = container.makeSearchCoordinator( navigationController: navigationController, + initialField: initialField, onRouteSelected: onRouteSelected ) // start() 안에서 동기로 finish()될 수 있으므로(예: Example 스텁) 배선을 먼저 끝낸다. diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index faf9c6a..0a13b70 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -45,7 +45,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { } func makeHomeViewController( - onSearchRequested: @escaping (_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void + onSearchRequested: @escaping ( + _ initialField: SearchEntryField, + _ onRouteSelected: @escaping (LastRoute, Place) -> Void + ) -> Void ) -> UIViewController { let viewModel = HomeViewModel( getCurrentLocationUseCase: getCurrentLocationUseCase, @@ -63,10 +66,12 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { func makeSearchCoordinator( navigationController: UINavigationController, - onRouteSelected: @escaping (LastRoute) -> Void + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void ) -> any Coordinator { searchCoordinatorBuildable.makeSearchCoordinator( navigationController: navigationController, + initialField: initialField, onRouteSelected: onRouteSelected ) } diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index b569e92..4b14de7 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -1,5 +1,6 @@ import DesignSystem import Domain +import SearchFeatureInterface import SnapKit import UIKit @@ -18,8 +19,12 @@ final class HomeViewController: UIViewController { private let banner = DSBanner() private let departureField = DSTextField(placeholder: "출발지를 검색해 주세요", showsAccentDot: true) private let arrivalField = DSTextField(placeholder: "도착지를 검색해 주세요") - private lazy var departureRow = makeFieldRow(icon: DSIcon.myLocation24, field: departureField) - private lazy var arrivalRow = makeFieldRow(icon: DSIcon.place24, field: arrivalField) + private lazy var departureRow = makeFieldRow( + icon: DSIcon.myLocation24, field: departureField, entry: .departure + ) + private lazy var arrivalRow = makeFieldRow( + icon: DSIcon.place24, field: arrivalField, entry: .arrival + ) private let routeCard = DSRouteCard() private let registerButton = DSButton(title: "알람 등록하기") // DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다. @@ -125,7 +130,10 @@ final class HomeViewController: UIViewController { /// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이 /// 없으므로 필드 터치를 통째로 죽이고 UIControl 래퍼가 탭을 가져간다. - private func makeFieldRow(icon: UIImage, field: DSTextField) -> UIControl { + /// 탭한 필드가 검색 진입 슬롯이 된다(Phase 17) — entry가 그대로 넘어간다. + private func makeFieldRow( + icon: UIImage, field: DSTextField, entry: SearchEntryField + ) -> UIControl { let row = UIControl() let iconView = UIImageView(image: icon) iconView.tintColor = DSColor.Icon.default @@ -143,7 +151,7 @@ final class HomeViewController: UIViewController { make.top.trailing.bottom.equalToSuperview() } row.addAction( - UIAction { [weak self] _ in self?.viewModel.searchFieldTapped() }, + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(entry) }, for: .touchUpInside ) return row @@ -210,6 +218,9 @@ final class HomeViewController: UIViewController { departureField.setText("") } + // 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다. + arrivalField.setText(state.arrivalText ?? "") + if let card = state.routeCard { // 신선도 스탬프(Phase 16)는 세션 상태라 State가 따로 나른다 — 표출 시점 합성. routeCard.configure(with: card.dsContent(footnote: state.freshnessText)) @@ -254,6 +265,18 @@ final class HomeViewController: UIViewController { UIApplication.shared.open(url) } ) + case .locationServicesDisabled: + DSToast.show( + "기기의 위치 서비스가 꺼져 있어요", + in: view, + action: .init(title: "설정으로 이동") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + ) + case .locationRestricted: + // restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17). + DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view) case .alarmPermissionNeeded: DSToast.show( "알람 권한이 꺼져 있어요", diff --git a/Projects/Feature/Home/Sources/HomeViewData.swift b/Projects/Feature/Home/Sources/HomeViewData.swift index 9ad020a..8e37174 100644 --- a/Projects/Feature/Home/Sources/HomeViewData.swift +++ b/Projects/Feature/Home/Sources/HomeViewData.swift @@ -19,6 +19,14 @@ extension HomeViewModel { } } +/// 자정 넘김 표기(Phase 17) — 내일이면 "내일 " 접두, 오늘·그 외는 무라벨(무라벨 = 오늘). +/// 이틀+ 미래·과거는 막차 도메인상 비발생 — 방어적 무라벨. 피처 간 공유 모듈을 만들지 +/// 않으므로 SearchFeature와 중복이다(TransportBadgeMapper 선례). +func dayPrefix(for date: Date, now: Date, calendar: Calendar = .current) -> String { + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) else { return "" } + return calendar.isDate(date, inSameDayAs: tomorrow) ? "내일 " : "" +} + /// 홈에 표출되는 선택 경로 카드. Entity를 뷰에 직접 노출하지 않는다. struct RouteCardViewData: Equatable { /// 카드 톤 — past는 유예 경과 후의 "지난 막차" 상태(비활성 시각, Phase 13). @@ -34,14 +42,16 @@ struct RouteCardViewData: Equatable { let destinationText: String let tone: Tone - init(entity: LastRoute) { + init(entity: LastRoute, now: Date) { badgeText = "가장 늦은 차" - departureTimeText = "\(timeFormatter.string(from: entity.departureTime)) 출발" + let departurePrefix = dayPrefix(for: entity.departureTime, now: now) + departureTimeText = "\(departurePrefix)\(timeFormatter.string(from: entity.departureTime)) 출발" legs = TransportBadgeMapper.kinds(for: entity.legs) summaryText = Self.summary(from: entity.legs) let arrival = entity.departureTime.addingTimeInterval(TimeInterval(entity.totalTime)) - destinationText = "도착 \(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회" + let arrivalPrefix = dayPrefix(for: arrival, now: now) + destinationText = "도착 \(arrivalPrefix)\(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회" tone = .normal } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 52f9872..3f68fc9 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -1,5 +1,6 @@ import Domain import Foundation +import SearchFeatureInterface // Convention: every ViewModel in the codebase is @MainActor. @MainActor @@ -9,7 +10,8 @@ final class HomeViewModel { /// 역지오코딩된 현재 위치 라벨. case current(name: String) /// 위치를 쓸 수 없어 검색으로 출발지를 정해야 하는 상태. - case needsSearch(deniedPermission: Bool) + /// 실패 사유는 ToastEvent가 나른다(Phase 17) — 표시 상태는 사유와 무관하다. + case needsSearch } nonisolated struct BannerViewData: Equatable { @@ -28,6 +30,9 @@ final class HomeViewModel { struct State: Equatable { var departure: DepartureState = .loading + /// 도착지 필드 표시값(Phase 17) — 선택 경로의 도착지명. nil이면 placeholder. + /// 재실행 복원 경로는 도착지 명칭 원천이 없어 채우지 않는다(결정사항 — 수용). + var arrivalText: String? var routeCard: RouteCardViewData? var banner: BannerViewData? var isAlarmBusy = false @@ -41,6 +46,10 @@ final class HomeViewModel { /// 재방출되면 안 되는 원샷 안내 — 상태와 분리한다. enum ToastEvent: Equatable { case locationPermissionNeeded + /// 기기 전역 위치 서비스 OFF(Phase 17) — 앱 권한이 아니라 시스템 설정의 문제. + case locationServicesDisabled + /// 스크린타임·MDM 제약(Phase 17) — 설정으로 못 푼다. "설정으로 이동" 안내 금지. + case locationRestricted case alarmPermissionNeeded case alarmRegisterFailed case alarmCancelFailed @@ -63,8 +72,12 @@ final class HomeViewModel { /// pull-to-refresh 종료 훅(Phase 16) — 성공/실패 불문 동기화가 끝나면 불린다 /// (VC가 refreshControl.endRefreshing). 실패는 무음 — 결과 표출은 스트림·스탬프 몫. var onManualSyncFinished: (() -> Void)? - /// Set by the Coordinator: 검색 플로우를 열고, 선택 경로를 reply 클로저로 돌려받는다. - var onSearchRequested: ((_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void)? + /// Set by the Coordinator: 검색 플로우를 열고, 선택 경로·도착지를 reply 클로저로 + /// 돌려받는다. initialField = 탭한 필드가 그대로 진입 슬롯이 된다(Phase 17). + var onSearchRequested: (( + _ initialField: SearchEntryField, + _ onRouteSelected: @escaping (LastRoute, Place) -> Void + ) -> Void)? private(set) var state = State() { didSet { if state != oldValue { onStateChange?(state) } } @@ -170,17 +183,19 @@ final class HomeViewModel { } } - /// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다. - func searchFieldTapped() { - onSearchRequested? { [weak self] route in - self?.routeSelected(route) + /// 탭한 필드가 그대로 검색 진입 슬롯이 된다(Phase 17) — 도착지 탭이면 도착지부터. + func searchFieldTapped(_ field: SearchEntryField) { + onSearchRequested?(field) { [weak self] route, arrival in + self?.routeSelected(route, arrival: arrival) } } - func routeSelected(_ route: LastRoute) { + func routeSelected(_ route: LastRoute, arrival: Place) { selectedRoute = route var newState = state - newState.routeCard = RouteCardViewData(entity: route) + newState.routeCard = RouteCardViewData(entity: route, now: now()) + // 도착지 필드를 선택 경로의 도착지명과 정합시킨다(Phase 17) — placeholder 공존 해소. + newState.arrivalText = arrival.name // 새 경로 선택 = 기존 배너는 더 이상 유효하지 않다 (재등록 전까지 숨김). newState.banner = nil newState.alarmButton = Self.alarmButtonMode( @@ -320,7 +335,8 @@ final class HomeViewModel { self.state.routeCard == nil else { return } self.selectedRoute = route var newState = self.state - newState.routeCard = RouteCardViewData(entity: route) + // 복원 경로는 도착지 명칭 원천이 없다 — arrivalText는 placeholder 유지(Phase 17 수용). + newState.routeCard = RouteCardViewData(entity: route, now: self.now()) newState.alarmButton = Self.alarmButtonMode( selectedRouteId: route.id, registeredRouteId: self.registeredRouteId @@ -399,14 +415,21 @@ final class HomeViewModel { let place = try await geocodeUseCase.execute(coordinate: coordinate) guard !Task.isCancelled else { return } self?.state.departure = .current(name: place.name) - } catch LocationError.permissionDenied { + } catch let error as LocationError { guard !Task.isCancelled else { return } - self?.state.departure = .needsSearch(deniedPermission: true) - self?.onToast?(.locationPermissionNeeded) + self?.state.departure = .needsSearch + // 사유별 안내 분기(Phase 17) — 회복 경로가 다르다. restricted는 설정으로 + // 못 푸는 제약이라 "설정으로 이동"을 안내하지 않는다(VC 매핑). + switch error { + case .permissionDenied: self?.onToast?(.locationPermissionNeeded) + case .servicesDisabled: self?.onToast?(.locationServicesDisabled) + case .restricted: self?.onToast?(.locationRestricted) + case .unavailable: break // 일시 실패 — 검색 유도만, 안내 없음. + } } catch { guard !Task.isCancelled else { return } // 역지오코딩 실패 포함 — 검색으로 출발지를 정하면 된다. - self?.state.departure = .needsSearch(deniedPermission: false) + self?.state.departure = .needsSearch } } } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 24cb6c5..2a4c908 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -1,6 +1,7 @@ import Domain import Foundation @testable import HomeFeature +import SearchFeatureInterface import Testing private struct StubError: Error {} @@ -114,6 +115,15 @@ private final class StateRecorder { private nonisolated let fixedNow = Date(timeIntervalSince1970: 1_755_800_000) +/// 검색 플로우가 동반하는 확정 도착지(Phase 17) — 도착지 필드 바인딩의 원천. +private nonisolated func makeArrival(_ name: String = "구로디지털단지역") -> Place { + Place( + name: name, + address: "서울 구로구 도림천로 486", + coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015) + ) +} + private nonisolated func makeRoute(id: String, departure: Date) -> LastRoute { LastRoute( id: id, @@ -214,7 +224,7 @@ struct HomeViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: true) } + await recorder.waitUntilLast { $0.departure == .needsSearch } #expect(recorder.toasts == [.locationPermissionNeeded]) } @@ -226,7 +236,7 @@ struct HomeViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: false) } + await recorder.waitUntilLast { $0.departure == .needsSearch } #expect(recorder.toasts.isEmpty) } @@ -244,7 +254,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: true) } + await recorder.waitUntilLast { $0.departure == .needsSearch } let statesBefore = recorder.states.count granted.update { _ in true } @@ -267,7 +277,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: true) } + await recorder.waitUntilLast { $0.departure == .needsSearch } let statesBefore = recorder.states.count sut.didBecomeActive() @@ -305,9 +315,9 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) - #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + #expect(sut.state.routeCard == RouteCardViewData(entity: route, now: fixedNow)) #expect(sut.state.banner == nil) } @@ -318,7 +328,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() #expect(sut.state.isAlarmBusy) await recorder.waitUntilLast { $0.banner != nil } @@ -339,7 +349,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() while recorder.toasts.isEmpty { await Task.yield() } // 배너는 틱 태스크가 비동기로 채운다 — 토스트와 별도로 기다린다. @@ -357,7 +367,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -371,7 +381,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() while recorder.toasts.isEmpty { await Task.yield() } @@ -387,11 +397,14 @@ struct HomeViewModelTests { let sut = makeSUT() let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } - sut.routeSelected(makeRoute(id: "r2", departure: fixedNow.addingTimeInterval(30 * 60))) + sut.routeSelected( + makeRoute(id: "r2", departure: fixedNow.addingTimeInterval(30 * 60)), + arrival: makeArrival() + ) #expect(sut.state.banner == nil) #expect(sut.state.routeCard?.departureTimeText != nil) @@ -465,7 +478,7 @@ struct HomeViewModelTests { let sut = makeSUT(now: { clock.get() }, bannerTickInterval: .milliseconds(1)) let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -491,7 +504,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } @@ -508,7 +521,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() while recorder.toasts.isEmpty { await Task.yield() } @@ -524,7 +537,7 @@ struct HomeViewModelTests { let sut = makeSUT() let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -543,7 +556,7 @@ struct HomeViewModelTests { let sut = makeSUT(cancel: { _ in throw StubError() }) let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -585,7 +598,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } @@ -670,7 +683,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -701,7 +714,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } @@ -807,7 +820,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -823,7 +836,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() while recorder.toasts.isEmpty { await Task.yield() } @@ -875,7 +888,7 @@ struct HomeViewModelTests { )) await recorder.waitUntilLast { $0.routeCard != nil } - #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + #expect(sut.state.routeCard == RouteCardViewData(entity: route, now: fixedNow)) #expect(sut.state.alarmButton == .cancel) // 배너도 상세의 도보 초 반영 기준으로 재시작됐다 (42분 − 2분 도보 − 3분 버퍼 = 37분). await recorder.waitUntilLast { $0.banner?.text == "출발까지 37분" } @@ -917,7 +930,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -932,7 +945,7 @@ struct HomeViewModelTests { for _ in 0..<20 { await Task.yield() } #expect(!fetched.value) - #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + #expect(sut.state.routeCard == RouteCardViewData(entity: route, now: fixedNow)) } // MARK: - 수동 갱신 (Phase 16 pull-to-refresh) @@ -998,7 +1011,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) #expect(sut.state.freshnessText == nil) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } @@ -1044,7 +1057,7 @@ struct HomeViewModelTests { let sut = makeSUT() let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.freshnessText != nil } let stampBefore = sut.state.freshnessText @@ -1064,7 +1077,7 @@ struct HomeViewModelTests { let sut = makeSUT() let recorder = StateRecorder() recorder.attach(to: sut) - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.freshnessText != nil } @@ -1074,6 +1087,113 @@ struct HomeViewModelTests { #expect(sut.state.freshnessText == nil) } + // MARK: - 검색·홈 마찰 팩 (Phase 17) + + @Test + func routeSelected_bindsArrivalFieldText() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + + #expect(sut.state.arrivalText == nil) + sut.routeSelected(route, arrival: makeArrival("신림동")) + + #expect(sut.state.arrivalText == "신림동") + // 새 경로 선택은 도착지명을 교체한다. + sut.routeSelected( + makeRoute(id: "r2", departure: fixedNow.addingTimeInterval(30 * 60)), + arrival: makeArrival("당산역") + ) + #expect(sut.state.arrivalText == "당산역") + } + + @Test + func searchFieldTapped_forwardsTappedFieldAsEntry() async { + let sut = makeSUT() + let received = ValueBox<[SearchEntryField]>([]) + sut.onSearchRequested = { field, _ in received.update { $0 + [field] } } + + sut.searchFieldTapped(.arrival) + sut.searchFieldTapped(.departure) + + #expect(received.get() == [.arrival, .departure]) + } + + @Test + func searchReply_bindsRouteCardAndArrival() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let arrival = makeArrival("신림동") + let sut = makeSUT() + sut.onSearchRequested = { _, reply in reply(route, arrival) } + + sut.searchFieldTapped(.arrival) + + #expect(sut.state.routeCard == RouteCardViewData(entity: route, now: fixedNow)) + #expect(sut.state.arrivalText == "신림동") + } + + @Test + func viewDidLoad_servicesDisabled_needsSearchAndEmitsSystemToast() async { + let sut = makeSUT(location: { throw LocationError.servicesDisabled }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .needsSearch } + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.locationServicesDisabled]) + } + + @Test + func viewDidLoad_restricted_needsSearchAndEmitsRestrictedToast() async { + // restricted는 설정으로 못 푸는 제약 — 별도 안내(설정 이동 없음, VC 매핑)로 분기한다. + let sut = makeSUT(location: { throw LocationError.restricted }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .needsSearch } + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.locationRestricted]) + } + + @Test + func viewDidLoad_locationUnavailable_needsSearchWithoutToast() async { + let sut = makeSUT(location: { throw LocationError.unavailable }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .needsSearch } + for _ in 0..<20 { await Task.yield() } + + #expect(recorder.toasts.isEmpty) + } + + @Test + func routeCardViewData_labelsTomorrowDepartureAndArrival() { + // 자정 넘김 표기 — 내일 출발 경로는 출발·도착 모두 "내일" 접두가 붙는다. + let tomorrowDeparture = Calendar.current.date(byAdding: .day, value: 1, to: fixedNow)! + let card = RouteCardViewData( + entity: makeRoute(id: "r1", departure: tomorrowDeparture), now: fixedNow + ) + #expect(card.departureTimeText.hasPrefix("내일 ")) + #expect(card.destinationText.hasPrefix("도착 내일 ")) + + // 오늘 경로는 무라벨 — 무라벨 = 오늘. + let today = RouteCardViewData( + entity: makeRoute(id: "r2", departure: fixedNow.addingTimeInterval(3600)), now: fixedNow + ) + #expect(!today.departureTimeText.hasPrefix("내일 ")) + #expect(!today.destinationText.contains("내일")) + + // "지난 막차" 카드는 라벨 스코프 밖 — 기존 문구 유지. + let past = card.asPastTrain(departure: tomorrowDeparture) + #expect(past.departureTimeText.hasSuffix("출발이었어요")) + #expect(!past.departureTimeText.hasPrefix("내일 ")) + } + @Test func sessionEndedAndExpiry_clearFreshnessStamp() async { // 종료·만료 정리와 함께 스탬프도 사라진다 — "지난 막차"에 신선도는 무의미하다. @@ -1089,7 +1209,7 @@ struct HomeViewModelTests { let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - sut.routeSelected(route) + sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() // 스탬프는 배너보다 먼저 설정된다 — 배너까지 뜬 뒤에 종료를 흘려야 정리를 검증한다. await recorder.waitUntilLast { $0.banner != nil && $0.freshnessText != nil } diff --git a/Projects/Feature/Search/Example/ExampleApp.swift b/Projects/Feature/Search/Example/ExampleApp.swift index 6f2f6c8..29e78c0 100644 --- a/Projects/Feature/Search/Example/ExampleApp.swift +++ b/Projects/Feature/Search/Example/ExampleApp.swift @@ -52,19 +52,22 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) let coordinator = container.makeSearchCoordinator( navigationController: navigationController, - onRouteSelected: { [weak self] route in self?.showSelectedRoute(route) } + initialField: .departure, + onRouteSelected: { [weak self] route, arrival in + self?.showSelectedRoute(route, arrival: arrival) + } ) coordinator.finishDelegate = self searchCoordinator = coordinator coordinator.start() } - private func showSelectedRoute(_ route: LastRoute) { + private func showSelectedRoute(_ route: LastRoute, arrival: Place) { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" let alert = UIAlertController( title: "경로 선택됨", - message: "\(formatter.string(from: route.departureTime)) 출발 · 환승 \(route.transferCount)회 (id: \(route.id))", + message: "\(formatter.string(from: route.departureTime)) 출발 → \(arrival.name) · 환승 \(route.transferCount)회 (id: \(route.id))", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "확인", style: .default)) diff --git a/Projects/Feature/Search/Interface/SearchCoordinatorBuildable.swift b/Projects/Feature/Search/Interface/SearchCoordinatorBuildable.swift index 6382ad1..afcc2b2 100644 --- a/Projects/Feature/Search/Interface/SearchCoordinatorBuildable.swift +++ b/Projects/Feature/Search/Interface/SearchCoordinatorBuildable.swift @@ -2,13 +2,21 @@ import CoreCoordinator import Domain import UIKit +/// 검색 진입 시 활성화할 슬롯 — 홈의 어느 필드를 탭했는지가 그대로 넘어온다(Phase 17). +public enum SearchEntryField: Sendable, Equatable { + case departure + case arrival +} + /// Entry point other modules use to start the Search flow. /// The concrete builder is the feature's DIContainer; wiring happens at the /// App composition root. @MainActor public protocol SearchCoordinatorBuildable { + /// onRouteSelected의 Place는 확정된 도착지 — 홈 도착지 필드가 이 이름과 정합한다(Phase 17). func makeSearchCoordinator( navigationController: UINavigationController, - onRouteSelected: @escaping (LastRoute) -> Void + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void ) -> any Coordinator } diff --git a/Projects/Feature/Search/Sources/SearchCoordinator.swift b/Projects/Feature/Search/Sources/SearchCoordinator.swift index d15948c..8cb030d 100644 --- a/Projects/Feature/Search/Sources/SearchCoordinator.swift +++ b/Projects/Feature/Search/Sources/SearchCoordinator.swift @@ -1,41 +1,75 @@ import CoreCoordinator import Domain +import SearchFeatureInterface import UIKit -final class SearchCoordinator: Coordinator { +// NSObject 상속은 UINavigationControllerDelegate(NSObjectProtocol) 요구 — 이탈 경로 +// (백 버튼·스와이프 백·popToRoot)를 didShow 한 지점에서 정리하기 위해서다(Phase 17). +final class SearchCoordinator: NSObject, Coordinator { var childCoordinators: [any Coordinator] = [] weak var finishDelegate: (any CoordinatorFinishDelegate)? // The App (window root) owns the navigation controller. private weak var navigationController: UINavigationController? private let container: SearchDIContainer - private let onRouteSelected: (LastRoute) -> Void + private let initialField: SearchEntryField + private let onRouteSelected: (LastRoute, Place) -> Void + // didShow 정리 판정용 — pop이 끝나 이 VC가 스택에 없으면 플로우 종료다. + private weak var searchViewController: UIViewController? + private weak var previousNavigationDelegate: (any UINavigationControllerDelegate)? + // 모든 이탈 경로가 didShow로 수렴한다 — finish는 1회만. + private var isFinished = false init( navigationController: UINavigationController, container: SearchDIContainer, - onRouteSelected: @escaping (LastRoute) -> Void + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void ) { self.navigationController = navigationController self.container = container + self.initialField = initialField self.onRouteSelected = onRouteSelected } func start() { + guard let navigationController else { return } let viewController = container.makeSearchViewController( - onRouteChosen: { [weak self] route in - self?.onRouteSelected(route) + initialField: initialField, + onRouteChosen: { [weak self] route, arrival in + self?.onRouteSelected(route, arrival) self?.closeFlow() }, onBack: { [weak self] in self?.closeFlow() } ) - navigationController?.pushViewController(viewController, animated: true) + searchViewController = viewController + previousNavigationDelegate = navigationController.delegate + navigationController.delegate = self + navigationController.pushViewController(viewController, animated: true) } + /// pop만 한다 — 코디네이터 정리는 didShow가 수행한다(이탈 경로 공통의 단일 지점). private func closeFlow() { navigationController?.popViewController(animated: true) + } +} + +extension SearchCoordinator: UINavigationControllerDelegate { + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + guard !isFinished else { return } + // 검색 VC가 아직 스택에 있으면(최초 push·스와이프 취소) 플로우 진행 중이다. + if let searchViewController, + navigationController.viewControllers.contains(searchViewController) { + return + } + isFinished = true + navigationController.delegate = previousNavigationDelegate finish() } } diff --git a/Projects/Feature/Search/Sources/SearchDIContainer.swift b/Projects/Feature/Search/Sources/SearchDIContainer.swift index 61f41d8..35cea9c 100644 --- a/Projects/Feature/Search/Sources/SearchDIContainer.swift +++ b/Projects/Feature/Search/Sources/SearchDIContainer.swift @@ -26,24 +26,28 @@ public final class SearchDIContainer: SearchCoordinatorBuildable { public func makeSearchCoordinator( navigationController: UINavigationController, - onRouteSelected: @escaping (LastRoute) -> Void + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void ) -> any Coordinator { SearchCoordinator( navigationController: navigationController, container: self, + initialField: initialField, onRouteSelected: onRouteSelected ) } func makeSearchViewController( - onRouteChosen: @escaping (LastRoute) -> Void, + initialField: SearchEntryField, + onRouteChosen: @escaping (LastRoute, Place) -> Void, onBack: @escaping () -> Void ) -> UIViewController { let viewModel = SearchViewModel( searchPlacesUseCase: searchPlacesUseCase, searchLastRoutesUseCase: searchLastRoutesUseCase, recentSearchesUseCase: recentSearchesUseCase, - getCurrentLocationUseCase: getCurrentLocationUseCase + getCurrentLocationUseCase: getCurrentLocationUseCase, + initialField: initialField ) viewModel.onRouteChosen = onRouteChosen viewModel.onBackRequested = onBack diff --git a/Projects/Feature/Search/Sources/SearchViewController.swift b/Projects/Feature/Search/Sources/SearchViewController.swift index 1331650..9e218ad 100644 --- a/Projects/Feature/Search/Sources/SearchViewController.swift +++ b/Projects/Feature/Search/Sources/SearchViewController.swift @@ -49,6 +49,13 @@ final class SearchViewController: UIViewController { navigationController?.setNavigationBarHidden(true, animated: animated) } + // 숨긴 내비바는 스와이프 백 제스처를 죽인다 — delegate를 잡아 되살린다(Phase 17). + // 코디네이터 정리는 pop 경로 공통의 didShow(SearchCoordinator)가 수행한다. + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.interactivePopGestureRecognizer?.delegate = self + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) @@ -95,12 +102,24 @@ final class SearchViewController: UIViewController { make.top.equalTo(separator.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } + // 빈 상태·스피너는 키보드 위 가시 영역의 중앙에 둔다(Phase 17) — 타이핑 중 뜨는 + // 빈 상태(최근 0건·결과 0건)의 문구가 키보드에 가리지 않아야 한다. + // keyboardLayoutGuide는 키보드가 없으면 화면 하단을 따르므로 분기가 필요 없다. + let aboveKeyboardArea = UILayoutGuide() + view.addLayoutGuide(aboveKeyboardArea) + NSLayoutConstraint.activate([ + aboveKeyboardArea.topAnchor.constraint(equalTo: separator.bottomAnchor), + aboveKeyboardArea.leadingAnchor.constraint(equalTo: view.leadingAnchor), + aboveKeyboardArea.trailingAnchor.constraint(equalTo: view.trailingAnchor), + aboveKeyboardArea.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + ]) emptyState.snp.makeConstraints { make in - make.center.equalTo(tableView) + make.centerY.equalTo(aboveKeyboardArea.snp.centerY) make.leading.trailing.equalToSuperview().inset(DSSpacing.lg) } activityIndicator.snp.makeConstraints { make in - make.center.equalTo(tableView) + make.centerX.equalToSuperview() + make.centerY.equalTo(aboveKeyboardArea.snp.centerY) } navigationBar.onBack = { [weak self] in self?.viewModel.didTapBack() } @@ -155,8 +174,27 @@ final class SearchViewController: UIViewController { case let .recent(places): showsRecentHeader = true rows = places.map { .place($0, showsDelete: true) } + // 빈 상태 2종(Phase 17) — 백지 대신 다음 행동을 말한다. 액션 버튼 없음 + // (타이핑이 곧 회복 경로). 빈 목록엔 "최근 검색" 헤더도 없다(rows.isEmpty 가드). + if places.isEmpty { + showEmptyState( + title: "최근 검색이 없어요", + message: "도착지를 검색해 막차 시간을 확인해 보세요" + ) + } case let .places(places): rows = places.map { .place($0, showsDelete: false) } + if places.isEmpty { + showEmptyState( + title: "검색 결과가 없어요", + message: "다른 키워드로 검색해 보세요" + ) + } + case .loadingPlaces: + // 키워드 검색 로딩(Phase 17) — loadingRoutes와 달리 키보드를 유지한다 + // (타이핑 계속이 정상 흐름). + rows = [] + activityIndicator.startAnimating() case .loadingRoutes: rows = [] view.endEditing(true) @@ -211,7 +249,7 @@ final class SearchViewController: UIViewController { return rows } - private func showEmptyState(title: String, message: String, actionTitle: String) { + private func showEmptyState(title: String, message: String, actionTitle: String? = nil) { emptyState.configure( with: .init( icon: DSIcon.illustCharacterGray, @@ -313,6 +351,20 @@ extension SearchViewController: UITableViewDelegate { } } + // 최근 검색 스와이프 삭제(Phase 17, 기획서 요구) — 기존 X 버튼과 같은 경로를 탄다. + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + guard case .place(_, showsDelete: true) = rows[indexPath.row] else { return nil } + let delete = UIContextualAction(style: .destructive, title: "삭제") { + [weak self] _, _, completion in + self?.viewModel.didDeleteRecent(at: indexPath.row) + completion(true) + } + return UISwipeActionsConfiguration(actions: [delete]) + } + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard showsRecentHeader, !rows.isEmpty else { return nil } return DSSectionHeader(title: "최근 검색") @@ -324,6 +376,13 @@ extension SearchViewController: UITableViewDelegate { } } +extension SearchViewController: UIGestureRecognizerDelegate { + // 루트에서는 시작 금지 — 검색 화면이 스택 위에 있을 때만 스와이프 백을 허용한다. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + (navigationController?.viewControllers.count ?? 0) > 1 + } +} + // DSRouteCard는 UIView라 리스트에 태우려면 셀 래퍼가 필요하다 — // 셀이면 didSelectRowAt이 탭 처리까지 해결한다. private final class SearchRouteCardCell: UITableViewCell { diff --git a/Projects/Feature/Search/Sources/SearchViewData.swift b/Projects/Feature/Search/Sources/SearchViewData.swift index d087ad2..6366c46 100644 --- a/Projects/Feature/Search/Sources/SearchViewData.swift +++ b/Projects/Feature/Search/Sources/SearchViewData.swift @@ -11,6 +11,14 @@ private let timeFormatter: DateFormatter = { return formatter }() +/// 자정 넘김 표기(Phase 17) — 내일이면 "내일 " 접두, 오늘·그 외는 무라벨(무라벨 = 오늘). +/// 이틀+ 미래·과거는 막차 도메인상 비발생 — 방어적 무라벨. 피처 간 공유 모듈을 만들지 +/// 않으므로 HomeFeature와 중복이다(TransportBadgeMapper 선례). +func dayPrefix(for date: Date, now: Date, calendar: Calendar = .current) -> String { + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) else { return "" } + return calendar.isDate(date, inSameDayAs: tomorrow) ? "내일 " : "" +} + struct PlaceViewData: Equatable { let name: String let address: String @@ -28,14 +36,16 @@ struct RouteViewData: Equatable { let summaryText: String? let destinationText: String - init(entity: LastRoute, isFeatured: Bool) { + init(entity: LastRoute, isFeatured: Bool, now: Date) { badgeText = isFeatured ? "가장 늦은 차" : nil - departureTimeText = "\(timeFormatter.string(from: entity.departureTime)) 출발" + let departurePrefix = dayPrefix(for: entity.departureTime, now: now) + departureTimeText = "\(departurePrefix)\(timeFormatter.string(from: entity.departureTime)) 출발" legs = TransportBadgeMapper.kinds(for: entity.legs) summaryText = Self.summary(from: entity.legs) let arrival = entity.departureTime.addingTimeInterval(TimeInterval(entity.totalTime)) - destinationText = "도착 \(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회" + let arrivalPrefix = dayPrefix(for: arrival, now: now) + destinationText = "도착 \(arrivalPrefix)\(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회" } // "탑승지 → 환승지 → 하차지" — 도보 구간은 경유지로 세지 않는다. @@ -55,9 +65,11 @@ struct RouteResultsViewData: Equatable { let isExpanded: Bool /// `entities`는 비어 있지 않아야 한다 (`.available`이 상류에서 보장). - init(entities: [LastRoute], isExpanded: Bool) { - featured = RouteViewData(entity: entities[0], isFeatured: true) - alternatives = entities.dropFirst().map { RouteViewData(entity: $0, isFeatured: false) } + init(entities: [LastRoute], isExpanded: Bool, now: Date) { + featured = RouteViewData(entity: entities[0], isFeatured: true, now: now) + alternatives = entities.dropFirst().map { + RouteViewData(entity: $0, isFeatured: false, now: now) + } self.isExpanded = isExpanded } } diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 7e8a8f9..60b7da3 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -1,5 +1,6 @@ import Domain import Foundation +import SearchFeatureInterface // Convention: every ViewModel in the codebase is @MainActor. @MainActor @@ -15,6 +16,8 @@ final class SearchViewModel { case idle case recent([PlaceViewData]) case places([PlaceViewData]) + /// 키워드 검색 진행 중(Phase 17) — 디바운스 통과 후에만 진입한다(타이핑 깜빡임 방지). + case loadingPlaces case loadingRoutes case routes(RouteResultsViewData) case serviceEnded @@ -32,8 +35,8 @@ final class SearchViewModel { /// Set by the ViewController; always invoked on the main actor. var onStateChange: ((State) -> Void)? var onFieldsChange: ((FieldsViewData) -> Void)? - /// Set by the Coordinator. - var onRouteChosen: ((LastRoute) -> Void)? + /// Set by the Coordinator. Place = 확정 도착지 — 홈 도착지 필드의 원천이다(Phase 17). + var onRouteChosen: ((LastRoute, Place) -> Void)? var onBackRequested: (() -> Void)? private(set) var state: State = .idle { @@ -64,6 +67,8 @@ final class SearchViewModel { private let recentSearchesUseCase: any RecentSearchesUseCase // nil이면(예: Example 스텁 구성) 프리필·near 바이어스 없이 동작한다. private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? + // 오늘/내일 라벨 판정의 기준 시각(Phase 17) — 실 Date() 직접 호출 대신 주입(기존 VM 관례). + private let now: @Sendable () -> Date private let debounceInterval: Duration private var searchTask: Task? @@ -78,13 +83,18 @@ final class SearchViewModel { searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil, + initialField: SearchEntryField = .departure, + now: @escaping @Sendable () -> Date = { Date() }, debounceInterval: Duration = .milliseconds(300) ) { self.searchPlacesUseCase = searchPlacesUseCase self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.now = now self.debounceInterval = debounceInterval + // 탭한 필드로 진입한다(Phase 17) — 초기 활성 슬롯만 정하고 프리필 정책은 불변. + fields.activeField = (initialField == .arrival) ? .arrival : .departure } deinit { @@ -142,6 +152,8 @@ final class SearchViewModel { let useCase = self?.searchPlacesUseCase else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } + // 디바운스 통과 = 이 키워드로 실제 요청한다 — 로딩은 여기서부터(Phase 17). + self?.state = .loadingPlaces do { // 현재 위치가 확보된 경우에만 근처 우선 정렬 바이어스를 건다. let places = try await useCase.execute(keyword: trimmed, near: self?.currentCoordinate) @@ -183,19 +195,32 @@ final class SearchViewModel { func didTapMore() { guard case .routes = state, !availableRoutes.isEmpty else { return } isExpanded.toggle() - state = .routes(RouteResultsViewData(entities: availableRoutes, isExpanded: isExpanded)) + state = .routes( + RouteResultsViewData(entities: availableRoutes, isExpanded: isExpanded, now: now()) + ) } func didSelectRoute(at index: Int) { guard case .routes = state, availableRoutes.indices.contains(index) else { return } - onRouteChosen?(availableRoutes[index]) + // 불변식: routes 상태는 두 슬롯 확정 시에만 존재한다 — arrival은 항상 있다(방어 가드). + guard let arrival else { return } + onRouteChosen?(availableRoutes[index], arrival) } func didTapEmptyAction() { switch state { case .failed where departure != nil && arrival != nil: searchRoutes() - case .failed, .serviceEnded, .noRoute: + case .serviceEnded, .noRoute: + // "다시 검색하기" 실동작(Phase 17) — 도착지 슬롯을 비우고 포커스를 넘겨 + // 즉시 재검색이 가능하게 한다. 출발지는 유지(대개 현재 위치). + arrival = nil + var newFields = fields + newFields.arrivalText = "" + newFields.activeField = .arrival + fields = newFields + showRecent() + case .failed: showRecent() default: break @@ -267,9 +292,12 @@ final class SearchViewModel { guard !Task.isCancelled else { return } switch result { case let .available(routes) where !routes.isEmpty: - self?.availableRoutes = routes - self?.isExpanded = false - self?.state = .routes(RouteResultsViewData(entities: routes, isExpanded: false)) + guard let self else { return } + self.availableRoutes = routes + self.isExpanded = false + self.state = .routes( + RouteResultsViewData(entities: routes, isExpanded: false, now: self.now()) + ) case .available: // 정규화가 놓친 빈 목록 방어. self?.state = .noRoute diff --git a/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift new file mode 100644 index 0000000..e618c70 --- /dev/null +++ b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift @@ -0,0 +1,106 @@ +import CoreCoordinator +import Domain +@testable import SearchFeature +import SearchFeatureInterface +import Testing +import UIKit + +// 코디네이터 조립용 최소 스텁 — Tests/Example 중복은 의도된 트레이드오프(기존 규약). +private struct StubSearchPlacesUseCase: SearchPlacesUseCase { + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } +} + +private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { + func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult { + .noRoute + } +} + +private struct StubRecentSearchesUseCase: RecentSearchesUseCase { + func fetch() async throws -> [Place] { [] } + func save(_ place: Place) async throws {} + func remove(_ place: Place) async throws {} +} + +@MainActor +private final class FinishRecorder: CoordinatorFinishDelegate { + private(set) var finishedCount = 0 + func coordinatorDidFinish(_ coordinator: any Coordinator) { finishedCount += 1 } +} + +/// 이전 delegate 복원 검증용 더미. +private final class PriorNavigationDelegate: NSObject, UINavigationControllerDelegate {} + +@MainActor +private func makeCoordinator(navigationController: UINavigationController) -> SearchCoordinator { + SearchCoordinator( + navigationController: navigationController, + container: SearchDIContainer( + searchPlacesUseCase: StubSearchPlacesUseCase(), + searchLastRoutesUseCase: StubSearchLastRoutesUseCase(), + recentSearchesUseCase: StubRecentSearchesUseCase() + ), + initialField: .departure, + onRouteSelected: { _, _ in } + ) +} + +/// 이탈 경로(백 버튼·스와이프 백·popToRoot) 공통의 didShow 정리(Phase 17) — +/// 실제 전환 없이 didShow를 직접 호출해 판정 로직을 검증한다. +@MainActor +struct SearchCoordinatorTests { + @Test + func start_installsSelfAsNavigationDelegate() { + let navigationController = UINavigationController(rootViewController: UIViewController()) + let prior = PriorNavigationDelegate() + navigationController.delegate = prior + let sut = makeCoordinator(navigationController: navigationController) + + sut.start() + + #expect(navigationController.delegate === sut) + #expect(navigationController.viewControllers.count == 2) + } + + @Test + func didShow_whileSearchStillOnStack_doesNotFinish() { + let navigationController = UINavigationController(rootViewController: UIViewController()) + let sut = makeCoordinator(navigationController: navigationController) + let recorder = FinishRecorder() + sut.finishDelegate = recorder + sut.start() + + // 최초 push의 didShow — 검색 VC가 스택에 있으므로 플로우 진행 중이다. + sut.navigationController( + navigationController, + didShow: navigationController.viewControllers[1], + animated: false + ) + + #expect(recorder.finishedCount == 0) + #expect(navigationController.delegate === sut) + } + + @Test + func didShow_afterPop_finishesOnceAndRestoresDelegate() { + let navigationController = UINavigationController(rootViewController: UIViewController()) + let prior = PriorNavigationDelegate() + navigationController.delegate = prior + let sut = makeCoordinator(navigationController: navigationController) + let recorder = FinishRecorder() + sut.finishDelegate = recorder + sut.start() + + // pop 결과 시뮬레이션 — 백 버튼·스와이프 백·popToRoot 전부 같은 스택 상태로 끝난다. + let root = navigationController.viewControllers[0] + navigationController.viewControllers = [root] + sut.navigationController(navigationController, didShow: root, animated: false) + + #expect(recorder.finishedCount == 1) + #expect(navigationController.delegate === prior) + + // 중복 didShow는 no-op — finish는 1회 가드. + sut.navigationController(navigationController, didShow: root, animated: false) + #expect(recorder.finishedCount == 1) + } +} diff --git a/Projects/Feature/Search/Tests/SearchViewModelTests.swift b/Projects/Feature/Search/Tests/SearchViewModelTests.swift index abf112d..240b0a3 100644 --- a/Projects/Feature/Search/Tests/SearchViewModelTests.swift +++ b/Projects/Feature/Search/Tests/SearchViewModelTests.swift @@ -1,6 +1,7 @@ import Domain import Foundation @testable import SearchFeature +import SearchFeatureInterface import Testing private struct StubSearchPlacesUseCase: SearchPlacesUseCase { @@ -91,6 +92,8 @@ private final class StateRecorder { } } +private nonisolated let fixedNow = Date(timeIntervalSince1970: 1_755_800_000) + private nonisolated func makePlace(_ name: String) -> Place { Place( name: name, @@ -132,13 +135,16 @@ private func makeSUT( placesHandler: @escaping @Sendable (String) async throws -> [Place] = { _ in [] }, routesHandler: @escaping @Sendable () async throws -> LastRouteSearchResult = { .available([]) }, store: RecentStore = RecentStore(), - location: (@Sendable () async throws -> Coordinate)? = nil + location: (@Sendable () async throws -> Coordinate)? = nil, + initialField: SearchEntryField = .departure ) -> SearchViewModel { SearchViewModel( searchPlacesUseCase: StubSearchPlacesUseCase(handler: placesHandler), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: routesHandler), recentSearchesUseCase: StubRecentSearchesUseCase(store: store), getCurrentLocationUseCase: location.map(StubGetCurrentLocationUseCase.init(handler:)), + initialField: initialField, + now: { fixedNow }, debounceInterval: .zero ) } @@ -212,7 +218,7 @@ struct SearchViewModelTests { await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } #expect(recorder.states.contains(.loadingRoutes)) - let expected = RouteResultsViewData(entities: routes, isExpanded: false) + let expected = RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow) #expect(recorder.states.last == .routes(expected)) #expect(expected.featured.badgeText == "가장 늦은 차") #expect(expected.alternatives.count == 1) @@ -288,10 +294,10 @@ struct SearchViewModelTests { await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } sut.didTapMore() - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: true))) + #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: true, now: fixedNow))) sut.didTapMore() - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false))) + #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) } @Test @@ -419,18 +425,24 @@ struct SearchViewModelTests { sut.didSelectListItem(at: 0) await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } - #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false))) + #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false, now: fixedNow))) } @Test - func selectRoute_forwardsEntityToOnRouteChosen() async { + func selectRoute_forwardsEntityAndConfirmedArrival() async { + // 도착지 동반(Phase 17) — 홈 도착지 필드의 원천이므로 확정된 그 장소여야 한다. + let arrival = makePlace("회사") let routes = [makeRoute(id: "r1"), makeRoute(id: "r2")] let sut = makeSUT( - placesHandler: { _ in [makePlace("강남역")] }, + placesHandler: { _ in [arrival] }, routesHandler: { .available(routes) } ) var chosen: LastRoute? - sut.onRouteChosen = { chosen = $0 } + var chosenArrival: Place? + sut.onRouteChosen = { route, place in + chosen = route + chosenArrival = place + } let recorder = StateRecorder() recorder.attach(to: sut) await driveBothSlotsConfirmed(sut: sut, recorder: recorder) @@ -439,5 +451,126 @@ struct SearchViewModelTests { sut.didSelectRoute(at: 0) #expect(chosen == routes[0]) + #expect(chosenArrival == arrival) + } + + // MARK: - Phase 17: 진입 필드·로딩·빈 결과·다시 검색하기·내일 라벨 + + @Test + func initialFieldArrival_startsWithArrivalSlotActive() { + let sut = makeSUT(initialField: .arrival) + #expect(sut.fields.activeField == .arrival) + + let departureEntry = makeSUT(initialField: .departure) + #expect(departureEntry.fields.activeField == .departure) + } + + @Test + func keywordSearch_passesThroughLoadingPlaces() async { + let place = makePlace("강남역") + let sut = makeSUT(placesHandler: { _ in [place] }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.keywordDidChange("강남", in: .departure) + await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + + // 디바운스 통과 후 요청 직전의 로딩 상태를 반드시 거친다. + #expect(recorder.states.contains(.loadingPlaces)) + } + + @Test + func keywordSearch_zeroResults_showsEmptyPlaces() async { + let sut = makeSUT(placesHandler: { _ in [] }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.keywordDidChange("결과없는키워드", in: .arrival) + await recorder.waitUntilLast { $0 == .places([]) } + + // 0건도 .places로 흐른다 — VC가 빈 상태("검색 결과가 없어요")를 그린다. + #expect(recorder.states.last == .places([])) + } + + @Test + func emptyResultAction_resetsArrivalSlotAndReturnsToRecent() async { + // "다시 검색하기" 실동작: 도착지 슬롯 초기화 + 포커스 + 최근 검색 복귀. + let sut = makeSUT( + placesHandler: { _ in [makePlace("강남역")] }, + routesHandler: { .serviceEnded } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + await driveBothSlotsConfirmed(sut: sut, recorder: recorder) + await recorder.waitUntilLast { $0 == .serviceEnded } + #expect(!sut.fields.arrivalText.isEmpty) + + sut.didTapEmptyAction() + await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + + #expect(sut.fields.arrivalText.isEmpty) + #expect(sut.fields.activeField == .arrival) + // 출발지는 유지 — 즉시 새 도착지 검색이 가능하다. + #expect(sut.fields.departureText == "강남역") + } + + @Test + func noRouteAction_alsoResetsArrivalSlot() async { + let sut = makeSUT( + placesHandler: { _ in [makePlace("강남역")] }, + routesHandler: { .noRoute } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + await driveBothSlotsConfirmed(sut: sut, recorder: recorder) + await recorder.waitUntilLast { $0 == .noRoute } + + sut.didTapEmptyAction() + await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + + #expect(sut.fields.arrivalText.isEmpty) + #expect(sut.fields.activeField == .arrival) + } + + @Test + func dayPrefix_labelsOnlyTomorrow() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + let now = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 23, hour: 23, minute: 40) + )! + let lateTonight = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 23, hour: 23, minute: 55) + )! + let afterMidnight = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 24, hour: 0, minute: 29) + )! + let dayAfterTomorrow = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 25, hour: 0, minute: 10) + )! + + #expect(dayPrefix(for: lateTonight, now: now, calendar: calendar) == "") + #expect(dayPrefix(for: afterMidnight, now: now, calendar: calendar) == "내일 ") + // 이틀+ 미래는 막차 도메인상 비발생 — 방어적 무라벨. + #expect(dayPrefix(for: dayAfterTomorrow, now: now, calendar: calendar) == "") + } + + @Test + func routeViewData_labelsTomorrowDepartureAndArrival() { + let tomorrowDeparture = Calendar.current.date(byAdding: .day, value: 1, to: fixedNow)! + let tomorrow = RouteViewData( + entity: makeRoute(id: "r1", departureOffset: tomorrowDeparture.timeIntervalSince(fixedNow)), + isFeatured: true, + now: fixedNow + ) + #expect(tomorrow.departureTimeText.hasPrefix("내일 ")) + #expect(tomorrow.destinationText.hasPrefix("도착 내일 ")) + + // 오늘 출발·오늘 도착은 무라벨 — 무라벨 = 오늘. + let today = RouteViewData( + entity: makeRoute(id: "r2", departureOffset: 60), isFeatured: true, now: fixedNow + ) + #expect(!today.departureTimeText.hasPrefix("내일 ")) + #expect(!today.destinationText.contains("내일")) } } diff --git a/docs/prompts/atcha-v2-auto-verification.md b/docs/prompts/atcha-v2-auto-verification.md index 61769c9..731ace6 100644 --- a/docs/prompts/atcha-v2-auto-verification.md +++ b/docs/prompts/atcha-v2-auto-verification.md @@ -23,6 +23,9 @@ 추가 실측 규약: +- **Mac 화면 잠금 중 대체 경로 (2026-08-24 실측)**: 잠금 중에는 System Events·orca 모두 창 AX 트리가 비어 조작 불가지만, **XCUITest 하니스는 성립한다** — 스크래치 Tuist 프로젝트(더미 host 앱 + `.uiTests` 타겟)에서 `XCUIApplication(bundleIdentifier: "com.atcha.iOS.v2")`로 앱을 구동하면 XCTRunner가 시뮬레이터 디바이스 내부에서 이벤트를 주입하므로 잠금·AX와 무관하다. 증적은 `XCUIScreen.main.screenshot()`을 호스트 경로에 직접 기록(시뮬레이터 프로세스 = 호스트 FS 공유). 시스템 권한 알럿은 `XCUIApplication(bundleIdentifier: "com.apple.springboard")`의 버튼으로 처리하고, **알람 등록 검수는 알림 권한 알럿 응답까지 등록 UseCase가 대기하므로 배너 표출까지 알럿을 반복 소거**해야 한다. 엣지 스와이프 백(좌표 드래그)·셀 스와이프(`swipeLeft`)도 이 경로로 신뢰 가능 — AX 좌표 클릭 불신뢰 제약의 대체다. 한글 `typeText` 입력도 성립. +- Simulator.app의 AX 트리가 응답하지 않으면(창 열거 0·entire contents 타임아웃) `killall "System Events"` 후 재시도, 그래도 안 되면 Mac 잠금 상태부터 확인할 것(잠금이 원인인 경우가 많다). + - **강제 종료 재현은 `xcrun simctl terminate`** — 앱 스위처 스와이프 자동화 불필요. - **LA dismiss 검수는 DEV dismiss 토글 사용** — 잠금화면 스와이프 삭제 자동화가 불안정해서 그 용도로 만들어진 수단(플로팅 디버그 메뉴의 dismiss 기록 토글, `devToggleDismissedByUser`)을 쓴다. - **막차 변경 주입은 DEV 플로팅 "변경" 버튼** (SceneDelegate) — 앞당김/늦춤/운행 종료. actionable 경로가 필요하면 늦춤 먼저 → 앞당김 순서로 주입한다(실측 관례). diff --git a/docs/prompts/atcha-v2-friction-pack-prompt.md b/docs/prompts/atcha-v2-friction-pack-prompt.md new file mode 100644 index 0000000..38adc05 --- /dev/null +++ b/docs/prompts/atcha-v2-friction-pack-prompt.md @@ -0,0 +1,191 @@ +# AtchaV2 검색·홈 마찰 팩 구현 프롬프트 — 빈 상태 + 필드 정합 + 진입·이탈 경로 + 오늘/내일 라벨 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase 17을 진행해"`라고 지시한다. +> 실행 에이전트는 [마스터 프롬프트](atcha-v2-master-prompt.md)의 **진행 프로토콜·공통 규칙·공통 acceptance를 그대로 상속**하며, 한 번에 한 Phase만 수행한다. +> 갭 분석·우선순위 정본은 [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "Phase 17 — 검색·홈 마찰 팩" 절. 충돌 시 **CLAUDE.md > 마스터 프롬프트 > [LA 프롬프트](atcha-v2-live-activity-prompt.md) > [세션 수명주기 프롬프트](atcha-v2-session-lifecycle-prompt.md) > [인지 채널 방어선 프롬프트](atcha-v2-channel-defense-prompt.md) > [갱신 신뢰성 프롬프트](atcha-v2-refresh-reliability-prompt.md) > 이 문서** 순. +> **검수는 사람 검수가 아니라 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다** — 실행 에이전트가 computer use로 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관한다. +> 작성일: 2026-08-23. + +--- + +## Goal (최상위) + +**첫 90초의 이탈 요인을 제거한다 — 빈 화면이 말을 하게 하고, 탭한 필드로 들어가게 하고, 나가는 모든 경로가 흔적 없이 닫히게 하고, 심야의 시각이 어느 날인지 말하게 한다.** 권한을 전부 거부한 "조회 전용 사용자"의 최소 동작 보장이기도 하다. + +현재 검색 화면은 최근 검색 0건·검색 결과 0건에서 **완전한 백지**고(DSEmptyState는 있는데 이 두 상태엔 안 쓴다), 장소 검색엔 로딩 상태가 없어 타이핑 후 침묵한다. 홈 `arrivalField`는 어떤 상태와도 연결되지 않아 경로 카드 옆에 placeholder("도착지를 검색해 주세요")가 영구 공존하고, 출발지·도착지 어느 필드를 탭해도 같은 화면·같은 슬롯으로 들어간다. 검색 화면의 이탈 경로는 `closeFlow()` 하나만 코디네이터를 정리한다 — 스와이프 백은 (숨긴 내비바 때문에) 아예 비활성이고, 노티 탭 랜딩의 `popToRootViewController`는 `SearchCoordinator`를 누수시킨다(AppCoordinator에 "Phase 17이 일괄 해소" 주석으로 기록된 빚). `LocationError`는 2케이스뿐이라 전역 위치 OFF·restricted가 전부 "권한 거부 + 설정으로 이동"으로 뭉개지고(restricted엔 설정 이동이 무의미하다), 심야 앱인데 "도착 00:29"가 오늘인지 내일인지 아무도 말하지 않는다. `serviceEnded`/`noRoute`의 "다시 검색하기"는 최근 검색 화면 복귀만 하고(재검색 실동작 없음), 기획서가 요구한 최근 검색 스와이프 삭제는 누락돼 있다. 이 문서는 그 일곱 구멍을 막는다. Phase 번호는 갱신 신뢰성(16)에 이어 **17**. + +마찰 팩의 완성 상태 (이 문서가 만드는 구조): + +``` +홈 + 출발지/도착지 필드 ── 탭한 필드로 진입 ──────► 검색 (SearchEntryField — Interface 신설) + arrivalField ◄── 선택 경로의 도착지명 ──────── onRouteSelected가 (LastRoute, Place) 동반 + 위치 실패 3분기 ── denied(설정 이동) / 전역 OFF(설정 이동) / restricted(설정 이동 없음) + +검색 + 최근 검색 0건·결과 0건 ──► DSEmptyState (백지 해소) 키워드 검색 ──► .loadingPlaces 스피너 + serviceEnded·noRoute "다시 검색하기" ──► 도착지 슬롯 초기화 + 포커스 (실동작) + 최근 검색 행 ──► trailing 스와이프 삭제 (기존 X 버튼 유지) + +이탈 경로 — didShow 단일 정리 지점 (UINavigationControllerDelegate) + 백 버튼 pop ─┬─► navigationController(_:didShow:) → 검색 VC가 스택에 없으면 + 스와이프 백 ─┤ SearchCoordinator.finish() 1회 (스와이프 백은 이번에 활성화) + popToRoot ───┘ → returnToHome 누수 빚 청산 (AppCoordinator 주석 마감) + +시각 표기 — "내일"만 접두, 무라벨 = 오늘 + "내일 00:10 출발" / "도착 내일 00:29 · 환승 1회" (검색 결과 카드·대안 행·홈 카드 공통) +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것 — [로드맵](../planning/atcha-v2-post12-roadmap.md) 2026-08-23 확정): + +| 항목 | 결정 | +|---|---| +| 검색 빈 상태 2종 | `.recent([])` → "최근 검색이 없어요" + "도착지를 검색해 막차 시간을 확인해 보세요" / `.places([])` → "검색 결과가 없어요" + "다른 키워드로 검색해 보세요". 둘 다 **액션 버튼 없음**(타이핑이 곧 회복 경로), 아이콘은 기존 `DSIcon.illustCharacterGray` 재사용. 빈 recent엔 "최근 검색" 헤더도 없다(기존 `rows.isEmpty` 가드 유지) | +| 장소 검색 로딩 | `State`에 `.loadingPlaces` 신설 — **디바운스 통과 후** 요청 직전에 진입(타이핑 중 매 글자 깜빡임 방지). 기존 `activityIndicator` 재사용, `loadingRoutes`와 달리 **키보드를 내리지 않는다**(타이핑 계속이 정상 흐름) | +| 도착지 반환 | `onRouteSelected` 콜백을 `(LastRoute, Place) -> Void`로 확장 — Place는 확정된 도착지(경로 검색의 불변식상 항상 존재). 홈 `State.arrivalText: String?`에 바인딩. **재실행 복원 경로는 placeholder 유지 수용** — 복원엔 도착지 명칭 원천이 없다(스냅샷 확장은 스코프 밖) | +| 필드 구분 진입 | Interface에 `public enum SearchEntryField: Sendable, Equatable { case departure, arrival }` 신설 + `makeSearchCoordinator(navigationController:initialField:onRouteSelected:)`. initialField는 **초기 활성 슬롯만** 결정 — 현재 위치 프리필 정책(성공 시 도착지로 포커스 이동, 사용자가 만진 슬롯 불가침)은 불변 | +| 코디네이터 정리 | `SearchCoordinator`가 `NSObject` + `UINavigationControllerDelegate` 채택, `didShow`에서 검색 VC가 스택에 없으면 정리 — **백 버튼·스와이프 백·popToRoot가 전부 이 한 지점으로 수렴**한다(`closeFlow`는 pop만 남긴다). 이전 delegate 보관·복원. 스와이프 백 제스처는 검색 화면에서 활성화(숨긴 내비바로 현재 죽어 있음 — `interactivePopGestureRecognizer` delegate 조정, 루트에선 시작 금지) | +| LocationError | `restricted`·`servicesDisabled` 케이스 신설(기존 2케이스 유지 — 추가만). 어댑터 매핑은 **순수 함수** `classify(denied:deniedGlobally:restricted:)`로 분리(AtchaV2Tests 대상): restricted → `.restricted` > deniedGlobally → `.servicesDisabled` > denied → `.permissionDenied` 우선순위. 홈 토스트 3분기: denied → 기존 문구+설정 이동 / 전역 OFF → "기기의 위치 서비스가 꺼져 있어요"+설정 이동 / restricted → "이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요" **설정 이동 없음** | +| 오늘/내일 라벨 | **"내일"만 접두, 무라벨 = 오늘** (라벨 소음 최소화). 적용 표면: 검색 결과 카드·대안 행·홈 경로 카드의 출발/도착 시각. 판정은 순수 함수(캘린더 일자 비교, `Calendar` 주입으로 테스트 고정) — 오늘 nil / 내일 "내일" / 그 외 nil(막차 도메인상 비발생, 방어). "지난 막차" 카드(`asPastTrain`)는 **제외**(과거 시각 라벨은 스코프 밖). ViewData 생성에 `now` 주입 — SearchViewModel에 `now` 주입 신설(기존 VM 관례와 동일) | +| 다시 검색하기 실동작 | `serviceEnded`/`noRoute`의 액션 = **도착지 슬롯 초기화**(arrival 미확정 + arrivalText 비움 + activeField `.arrival`) + 최근 검색 복귀 — 키보드가 도착지로 포커스돼 즉시 재검색 가능. `.failed`(두 슬롯 확정)의 "다시 시도" = 경로 재검색은 현행 유지 | +| 최근 검색 스와이프 삭제 | `trailingSwipeActionsConfigurationForRowAt` — `.recent` 행에만 destructive "삭제" → 기존 `didDeleteRecent(at:)` 경유. **기존 X 버튼(accessory .delete)은 유지**(스와이프는 추가 수단, 기획서 요구 충족) | +| DEV 검수 훅 | `DevDemoFallbacks`에 검수 재료 3건 추가(DEV 한정 — 변경 시뮬레이터와 동일 취급): ① 예약 키워드 "결과없음" → 장소 0건 ② 예약 키워드 "경로없음" → 좌표 (0,0) 데모 장소 1건 + 경로 검색이 end (0,0)이면 빈 목록(→ 정규화 가정에 따라 serviceEnded) ③ 데모 대안 경로 1건(id `dev-demo-route-tomorrow`, **다음 자정+10분 출발**) — 더보기 확장·내일 라벨 검수 재료. 예약 훅은 base 호출 **전에** 판정(결정론 확보). featured(index 0)는 기존 데모 경로 그대로 — 변경 시뮬레이터 기준 시각(`noteKnownSession`) 정합 불변 | + +--- + +## 이 문서가 다시 정의하지 않는 것 (중복 금지) + +아래는 기존 Phase 산출물이다. **계약을 바꾸지 않고 명시된 지점만 확장한다.** 이 목록의 계약을 깨고 싶어지면 멈추고 사용자에게 물을 것. + +| 산출물 | 소속 | 이 문서에서의 취급 | +|---|---|---| +| `DSEmptyState`·`DSListCell`·`DSTextField`·`DSToast` API | Phase 4 | **DesignSystem 소스 변경 0이 기본** — 기존 API로 전부 성립한다(빈 상태는 configure 재사용, 스와이프는 tableView 레벨). 부족이 발견되면 멈추고 물을 것 | +| 검색 상태 머신 불변식 (`.loadingRoutes`/`.routes`는 두 슬롯 확정 시에만) | Phase 5 | 불변 — `.loadingPlaces`는 키워드 검색 전용 상태로 **추가**만 한다. 디바운스+이전 Task cancel 구조 불변 | +| 현재 위치 프리필 정책 (실패 무음, 사용자가 만진 슬롯 불가침, 늦은 도착 마감) | Phase 5·6 | 불변 — initialField는 초기 활성 슬롯만 정한다. 권한 안내 UX는 홈 담당 유지 | +| Phase 16 산출물 전체 (pull-to-refresh·스탬프·`AlarmSyncRequesting`·홈 스크롤 구조) | Phase 16 | 불변 — 홈 렌더에 arrivalField 바인딩 한 줄이 얹힐 뿐. 배너·카드·스탬프 표출 로직 무변경 | +| `AlarmSyncService` 판정·폴백·만료 분기 / LA·알람·세션 수명주기 | Phase 8~16 | **무변경.** 이 Phase는 알람 경로를 건드리지 않는다 | +| `DefaultSearchLastRoutesUseCase` 정규화(빈 목록 = serviceEnded 가정, 미확정 #3 TODO) | Phase 1 | 불변 — "다시 검색하기"는 표출 이후의 회복 동작만 바꾼다. responseCode 매핑 추가 금지 | +| DEV 데모 폴백·변경 시뮬레이터 성격 (실패 시에만 대체, 실서버 확정 시 일괄 제거) | Phase 7·11·14 | 성격 불변 — 검수 훅 3건을 **같은 파일·같은 제거 단위**로 추가한다. 예약 훅의 사전 판정은 "실패 시에만"의 명시적 예외로 주석에 기록 | +| 서버 계약 전체 | 마스터 공통 규칙 | **서버 변경 0.** Domain 변경은 `LocationError` 케이스 추가뿐 | + +## 전제 조건 + +1. **Phase 16 완료가 전제** (`feat/v2-phase16-refresh-reliability` 기준). 이 문서 내부의 병렬 없음 — Phase 17 단일. +2. 서버 트랙(로드맵 S1~S4)과 **완전 독립** — 미확정 입력을 새로 요구하지 않는다. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)에 따라 DEV 데모 폴백(+이번 검수 훅)을 재료로 에이전트가 직접 수행한다. +3. **스와이프 제스처(엣지 스와이프 백·셀 스와이프 삭제) 자동화는 실측 제약**(AX 액션 기반 조작만 신뢰)에 걸릴 수 있다 — 불가로 판명되면 자동 검수 절에 명시된 대체 경로(백 버튼 pop = 같은 didShow 정리 경로 / X 버튼 삭제 = 같은 `didDeleteRecent` 경로 + 단위 테스트)로 판정하고 실제 제스처만 실기기 잔여로 이관한다. + +--- + +## Phase 17 — 검색·홈 마찰 팩 + +### Goal +빈 화면이 다음 행동을 말하고(빈 상태 2종+로딩), 홈 필드가 상태와 정합하고(arrivalField·필드 구분 진입), 검색 화면의 모든 이탈 경로가 코디네이터를 정리하고(didShow 일원화+스와이프 백 활성화), 위치 실패가 사유별로 정직하게 안내되고(LocationError 세분화), 심야의 시각이 날짜를 말하고(내일 라벨), 막힌 검색이 한 탭으로 재개된다(다시 검색하기 실동작+스와이프 삭제). + +### Requirements + +- **Domain — `LocationError` 세분화 (추가만)**: + ```swift + public enum LocationError: Error, Equatable, Sendable { + case permissionDenied // 이 앱의 권한 거부 — "설정으로 이동"이 유효 + /// 스크린타임·MDM 제약 — 사용자가 설정으로 못 푼다. "설정으로 이동" 무의미. + case restricted + /// 기기 전역 위치 서비스 OFF — 앱 설정이 아니라 시스템 설정의 문제. + case servicesDisabled + case unavailable + } + ``` +- **App — `CoreLocationServiceAdapter` 매핑 분리**: 판정을 순수 함수로 추출하고 어댑터는 이를 경유한다: + ```swift + /// CLLocationUpdate의 3개 거부 플래그 → LocationError. nil이면 계속 대기(진행 중). + /// 우선순위: restricted > servicesDisabled > permissionDenied — 더 좁은 회복 경로가 이긴다. + static func classify(denied: Bool, deniedGlobally: Bool, restricted: Bool) -> LocationError? + ``` + `AtchaV2Tests`에 4분기(+전부 false → nil) 테스트. +- **SearchFeatureInterface — 진입 필드 + 도착지 반환 (파괴적 변경 2건, 이 두 건 외 시그니처 불변)**: + ```swift + /// 검색 진입 시 활성화할 슬롯 — 홈의 어느 필드를 탭했는지가 그대로 넘어온다(Phase 17). + public enum SearchEntryField: Sendable, Equatable { + case departure + case arrival + } + @MainActor + public protocol SearchCoordinatorBuildable { + func makeSearchCoordinator( + navigationController: UINavigationController, + initialField: SearchEntryField, + onRouteSelected: @escaping (LastRoute, Place) -> Void // Place = 확정된 도착지 + ) -> any Coordinator + } + ``` + 콜사이트는 `HomeDIContainer`·Home Example 스텁뿐 — 전부 따라간다. +- **SearchFeature — 빈 상태·로딩·진입 필드·다시 검색하기·스와이프 삭제·내일 라벨**: + - `SearchViewModel`: `init`에 `initialField: SearchEntryField = .departure`(→ `fields.activeField` 초깃값 매핑)와 `now: @escaping @Sendable () -> Date = { Date() }` 주입 추가. `State`에 `case loadingPlaces` 추가 — `keywordDidChange`의 검색 Task가 **디바운스 통과 후** `.loadingPlaces`로 전이하고 성공 시 `.places`(0건 포함), 실패 시 기존 `.failed`. `onRouteChosen: ((LastRoute, Place) -> Void)?`로 확장 — `didSelectRoute`가 확정 `arrival`을 동반한다(불변식상 항상 존재, `guard let` 방어). `didTapEmptyAction`의 `serviceEnded`/`noRoute` 분기: `arrival = nil` + `arrivalText` 비움 + `activeField = .arrival` + `showRecent()` — 키보드 포커스는 기존 `renderFields` diff가 처리한다. `.failed` 분기 현행 유지. + - `RouteViewData`/`RouteResultsViewData`: `now` 파라미터 추가, 출발·도착 시각에 내일 접두 — `"내일 00:10 출발"` / `"도착 내일 00:29 · 환승 1회"`. 내일 판정 순수 함수는 파일 스코프 헬퍼로 두고 **HomeFeature와 중복을 허용**한다(`TransportBadgeMapper` 선례 — 피처 간 공유 모듈 신설 금지): + ```swift + /// 내일이면 "내일 " 접두, 오늘·그 외는 빈 문자열 (무라벨 = 오늘. 이틀+ 미래·과거는 막차 도메인상 비발생 — 방어적 무라벨). + func dayPrefix(for date: Date, now: Date, calendar: Calendar = .current) -> String + ``` + - `SearchViewController`: `.recent([])`·`.places([])`에 결정사항 문구로 `DSEmptyState` 표출(액션 버튼 없음), `.loadingPlaces`에 `activityIndicator` 표출(키보드 유지·rows 비움). `.recent` 행에 trailing 스와이프 "삭제"(destructive) → `didDeleteRecent(at:)`. 스와이프 백 활성화: `viewDidAppear`에서 `interactivePopGestureRecognizer` delegate를 잡고 `gestureRecognizerShouldBegin`에서 `viewControllers.count > 1`일 때만 허용. + - `SearchCoordinator`: `NSObject` 상속 + `UINavigationControllerDelegate`. `start()`에서 이전 delegate 보관 후 자신을 설치하고 pushed VC를 weak 보관. `navigationController(_:didShow:)`에서 **검색 VC가 스택에 없으면** 이전 delegate 복원 + `finish()` — 1회 가드(finished 플래그). `closeFlow()`는 pop만 한다(finish는 didShow로 일원화 — 백 버튼·경로 선택·스와이프 백·popToRoot가 전부 같은 지점에서 정리). 테스트: didShow를 직접 호출해 pop 후 정리 1회·재호출 no-op·검색 VC 잔존 시 no-op을 검증. + - `SearchDIContainer`: `initialField` 전달 + 콜백 시그니처 추종. Example 앱 스텁도 따라간다. +- **HomeFeature — arrivalField 바인딩 + 필드 구분 진입 + 위치 3분기 + 내일 라벨**: + - `HomeViewModel`: `import SearchFeatureInterface`(의존 그래프상 기존 허용). `searchFieldTapped(_ field: SearchEntryField)`로 확장, `onSearchRequested: ((_ initialField: SearchEntryField, _ onRouteSelected: @escaping (LastRoute, Place) -> Void) -> Void)?`. `routeSelected(_ route: LastRoute, arrival: Place)` — `State.arrivalText: String?`에 `arrival.name` 세팅(다음 선택 시 교체, 세션 정리에도 유지 — 도착지는 세션이 끝나도 사실이다). 재실행 복원 경로는 세팅하지 않는다(결정사항 — placeholder 수용, 주석으로 기록). `DepartureState.needsSearch`의 `deniedPermission` 연관값 제거(미사용 — 사유는 ToastEvent가 나른다). `ToastEvent`에 `locationServicesDisabled`·`locationRestricted` 추가, `loadCurrentLocation`의 catch를 `LocationError` 케이스별 3분기로(restricted·servicesDisabled → `.needsSearch` + 각 토스트, `unavailable`·기타 → 기존 무토스트 `.needsSearch`). `didBecomeActive` 재확인 가드는 불변(사유 불문 `.needsSearch`면 재조회 — restricted에도 무해). + - `RouteCardViewData`: `init(entity:now:)`로 확장 — 출발·도착 시각 내일 접두(검색과 같은 규칙·중복 헬퍼). `asPastTrain`은 불변. 콜사이트(`routeSelected`·`restoreRouteCardIfNeeded`)는 VM의 `now()`를 넘긴다. + - `HomeViewController`: `render`가 `arrivalField.setText(state.arrivalText ?? "")` 반영. 필드 행 액션을 필드별로 분리(`searchFieldTapped(.departure)`/`.arrival`). 토스트 3분기 — restricted만 액션 없음. + - `HomeCoordinator`·`HomeDIContainer`: `initialField`·`(LastRoute, Place)` 배선 통과. Example 앱 스텁 추종. +- **App — 누수 빚 청산 + DEV 검수 훅**: + - `AppCoordinator.returnToHome`: 누수 주석을 해소 기록으로 교체 — popToRoot가 didShow 정리 경로를 타므로 추가 코드 없음(주석만 갱신). + - `DevDemoFallbacks`: 결정사항의 검수 훅 3건 — ① `searchPlaces`가 base 호출 전에 키워드 "결과없음"이면 `[]` ② "경로없음"이면 좌표 (0,0) 데모 장소 1건, `searchLastRoutes`가 base 호출 전에 end (0,0)이면 `[]`(빈 목록 정규화 = serviceEnded 표면) ③ 데모 경로 목록에 대안 1건 추가 — id `dev-demo-route-tomorrow`, 출발 = **다음 자정+10분**(`Calendar.startOfDay` 기준 계산, 검수 시각 무관 항상 "내일"), 기존 legs 재사용 가능. `noteKnownSession`은 계속 index 0(기존 데모 경로) 기준 — 변경 시뮬레이터·알람 검수 플로우 불변. +- **테스트 (Swift Testing — 스텁·주입, 실 Date()·실 UserDefaults 금지)**: + - `SearchFeatureTests`: ① initialField `.arrival` → 초기 `fields.activeField == .arrival` ② 디바운스 통과 후 `.loadingPlaces` → 성공 `.places` / 0건 `.places([])` / 실패 `.failed` ③ `didSelectRoute` → `(route, arrivalPlace)` 전달 ④ `didTapEmptyAction`(serviceEnded·noRoute) → arrivalText 비움 + activeField `.arrival` + `.recent` 복귀, 이후 장소 선택 시 도착지 슬롯에 반영 ⑤ `dayPrefix`·`RouteViewData` 내일 접두(고정 now·고정 캘린더) ⑥ `SearchCoordinator` didShow 정리(1회 가드·잔존 시 no-op·delegate 복원). + - `HomeFeatureTests`: ① `routeSelected(_:arrival:)` → `arrivalText` 세팅 + 기존 카드·배너 동작 회귀 ② `searchFieldTapped(.arrival)`/`(.departure)` → `onSearchRequested`에 그대로 전달 ③ `LocationError` 3분기 토스트(restricted·servicesDisabled·permissionDenied) + `.needsSearch` 전이 ④ `RouteCardViewData` 내일 접두(고정 now) + `asPastTrain` 무라벨 유지. + - `AtchaV2Tests`: `classify` 4분기 + 우선순위. + +### Constraints +- **Phase 18 선취 금지**: 최근 경로 원탭 칩, 홈에서의 즉시 재검색, `SearchLastRoutesUseCase`의 홈 주입은 이 문서 밖. 재실행 복원의 arrivalText를 스냅샷·최근 검색으로 채우려는 시도 금지(결정사항 — placeholder 수용). +- 미확정 #3(serviceEnded/noRoute의 responseCode 실측) 해소 시도 금지 — 정규화 로직·TODO 불변. +- DesignSystem 소스 변경 0이 기본(기존 컴포넌트 API로 성립). `Tuist/Package.swift`·`Package.resolved`·매니페스트(`Project.swift`/`Workspace.swift`) 무변경 — 신규 타겟 없음, `tuist generate` 불필요. +- 알람·LA·sync·스냅샷 경로 무변경 (`AlarmSyncService`·`RefreshAlarmUseCase`·`AlarmSessionSnapshot` 등). 홈 pull-to-refresh·스탬프 표출 회귀 금지. +- `UIApplication`·CoreLocation 심볼의 Domain·Feature 유입 금지(기존 규약 — `SearchEntryField`·`LocationError`는 순수 타입). Feature 간 공유 모듈 신설 금지 — 내일 라벨 헬퍼는 중복 허용(선례 명시). +- Interface 파괴적 변경은 명시된 2건(initialField·arrival 동반)뿐 — 그 외 공개 시그니처 불변. 레거시 무변경. + +### Acceptance +공통 acceptance(Debug+Stage 빌드) + `-scheme Domain test` + `-scheme SearchFeature test` + `-scheme HomeFeature test` + `-scheme AtchaV2 test` + `SearchFeatureExample`·`HomeFeatureExample` Debug 빌드(스텁 시그니처 추종 확인) + `git diff --stat`으로 DesignSystem·매니페스트·`Package.resolved` 무변경 확인. + +### 자동 검수 (블로킹 — [자동 검수 규약](atcha-v2-auto-verification.md)) +iOS 26 시뮬레이터(Debug/DEV)에서 에이전트가 직접 수행하고 단계별 스크린샷 증적으로 보고. 재료: DEV 데모 폴백 + 이번 검수 훅 3건. 시나리오 ①·②·⑤·⑥은 검색 진입·타이핑·AXPress만으로 성립한다. +① **빈 상태 — 최근 검색 0건**: 앱 데이터 초기화 상태(`simctl uninstall` 후 재설치)로 실행 → 검색 진입 → "최근 검색이 없어요" 빈 상태 스크린샷 (백지 아님의 직접 증명). +② **빈 상태 — 검색 결과 0건 + 로딩**: 도착지 슬롯에 예약 키워드 "결과없음" 입력 → "검색 결과가 없어요" 빈 상태 스크린샷. 로딩 스피너는 순간적이라 스크린샷 타이밍이 플레이키하다 — 포착되면 증적, 못 잡으면 "관찰 불가(순간 상태)"로 기록하고 단위 테스트(②)로 갈음한다. +③ **도착지 탭 진입 + arrivalField 바인딩**: 홈 도착지 필드 탭 → 검색 화면 **도착지 슬롯 활성**(키보드 포커스) 증적 / 홈 복귀 후 출발지 필드 탭 → 출발지 슬롯 활성 증적. 이어서 데모 경로 선택 → 홈 복귀 → **arrivalField에 도착지명 표출**(placeholder 아님) 스크린샷. +④ **스와이프 백 + 코디네이터 정리**: 검색 진입 → 좌측 엣지 스와이프 백 시도(computer use). 제스처 자동화 불가로 판명되면(규약 실측 제약): 판정을 "자동화 불가"로 기록하고 — 백 버튼 pop이 **같은 didShow 정리 경로**이므로 백 버튼 검수 + 단위 테스트 ⑥ 증적으로 갈음, 실제 스와이프 제스처만 실기기 잔여로 이관한다. 어느 경로든 **pop 후 재진입 → 검색 플로우 정상 동작**(검색·선택 재수행 가능) 스크린샷 필수. +⑤ **오늘/내일 라벨**: 데모 검색(아무 키워드) → 결과 화면 "더보기" AXPress → 대안 행에 "내일 HH:mm" 라벨 + featured(오늘)는 무라벨 **대비** 스크린샷 → 대안(내일 경로) 선택 → 홈 카드 "내일 HH:mm 출발" 스크린샷. 알람 등록은 하지 않는다(라벨 검수 전용 경로). +⑥ **다시 검색하기 실동작**: 도착지 슬롯에 "경로없음" 입력 → 데모 장소 선택 → "오늘 막차가 끊겼어요"(빈 목록 정규화) 화면 → "다시 검색하기" AXPress → 최근 검색 복귀 + **도착지 슬롯이 비워지고 활성**(재검색 즉시 가능) 스크린샷. +⑦ **최근 검색 스와이프 삭제**: 최근 검색 행 스와이프 시도 — 불가 시 기존 X 버튼 삭제로 갈음(같은 `didDeleteRecent` 경로) + 실제 스와이프는 실기기 잔여 이관. 삭제 후 목록 갱신(마지막 1건 삭제 시 ①의 빈 상태 표출) 스크린샷. +⑧ **위치 거부 분기 + 회귀**: 재설치 직후 위치 권한 팝업 "허용 안 함" AXPress → "위치 권한이 꺼져 있어요"+설정 이동 토스트 증적(denied 분기). 이후 권한 허용 재설치 상태에서 데모 경로 알람 등록 → **배너+스탬프+arrivalField 동시 표출** 정상(Phase 16 표면 회귀 없음) 스크린샷. +전역 위치 OFF는 설정 앱 AX 조작이 성립할 때만 시도(플레이키하면 "자동화 불가" 기록 후 실기기 잔여) — restricted는 시뮬레이터 재현 불가(스크린타임 제약)로 항상 실기기 잔여, 매핑은 `AtchaV2Tests`가 담보한다. + +**실기기 잔여**: 엣지 스와이프 백·셀 스와이프 삭제의 실제 제스처 감각(자동화 불가 판명 시). restricted(스크린타임 제약) 실측. 전역 위치 서비스 OFF 실측(설정 조작 자동화 불가 시). 사일런트 푸시 실수신(기존 항상-잔여 항목 — 이 Phase 무관 승계). + +> **검수 결과 기록 (2026-08-24 실측)**: Mac 화면 잠금으로 AX 조작이 전면 불가한 상황에서 [규약의 잠금 시 대체 경로](atcha-v2-auto-verification.md)(XCUITest 하니스)로 ①~⑧ 전 시나리오 자동 검수 완료 — **스와이프 백(엣지 드래그)·셀 스와이프 삭제 제스처 자동화가 XCUITest로 성립**해 ④·⑦의 불가 시 분기가 필요 없었다. 검수 중 발견·수정 1건: 타이핑 중 빈 상태 문구가 키보드에 가림 → 빈 상태·스피너를 `keyboardLayoutGuide` 위 가시 영역 중앙으로 이동. 실기기 잔여로 남은 것은 restricted(스크린타임)·전역 위치 OFF 실측(설정 앱 조작 미시도 — `classify` 매핑은 AtchaV2Tests 담보)과 제스처 실감각뿐. + +--- + +## 진행 프로토콜 + +[마스터 프롬프트의 진행 프로토콜](atcha-v2-master-prompt.md#진행-프로토콜) 1~6을 그대로 상속한다. 추가 규칙: + +1. 이 문서는 **Phase 17 단일** — 내부 병렬 없음. Phase 16 완료 커밋 위에서 진행한다. +2. ["이 문서가 다시 정의하지 않는 것"](#이-문서가-다시-정의하지-않는-것-중복-금지) 표의 계약을 바꾸고 싶어지면 멈추고 사용자에게 물을 것. +3. **Phase 18 산출물을 선취하지 않는다** — [로드맵](../planning/atcha-v2-post12-roadmap.md)의 몫. +4. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다 — "자동 검수 (블로킹)"는 에이전트가 computer use로 수행·증적 보고를 마쳐야 하고, "실기기 잔여"만 사용자에게 이관한다. **acceptance 전부 실행·보고 + 자동 검수 증적 보고 후 정지** — 다음 Phase로 진행하지 않는다. +5. 제스처 자동화 불가가 확정되면 결과를 **이 문서 자동 검수 절에 기록**하고 명시된 대체 경로로 검수를 완료한다. + +## 미확정 입력 (참조) + +이 문서는 새 미확정 입력을 만들지 않는다. 관련 기존 항목의 취급: + +| # | 항목 | 이 문서에서의 취급 | +|---|---|---| +| 3 | "막차 종료"/"경로 없음"의 서버 표현 (responseCode 실측값) | **미해소 유지** — "다시 검색하기"는 표출 이후의 회복 동작만 실동작으로 바꾼다. 빈 목록 = serviceEnded 가정·TODO 그대로. 검수 훅 ②는 이 가정 위에서 serviceEnded 표면을 재료로 쓴다 | +| 11 | 서버의 "등록된 알람 없음" 표현 | 이 문서 밖 — 알람 경로 무변경 | +| 원장 전체 | [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "미확정 입력 원장" 절 참조 | |