From 0159ec926fa365383fb8a9dbc7d1a1da11085a0e Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 23 Aug 2026 14:24:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AtchaV2=20Phase=2014=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=20=EC=A0=95=ED=95=A9=EC=84=B1=20(?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20+=20=EA=B3=A0=EC=95=84=20LA=20?= =?UTF-8?q?=EC=9E=AC=EB=B6=80=EC=B0=A9=20+=20=EB=8F=84=EB=B3=B4=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: AlarmSessionSnapshot(info·도보 초·표시명·수단·확인·만료) + AlarmSessionSnapshotStore 포트 신설, AlarmInfo·TransportMode Codable, LastRoute 세션 파생값(첫 도보 초·탑승 구간· 표시명 — LA 어댑터의 표시명 로직을 Domain 순수 계산으로 이동) - 도보 시간 반영(미확정 #7 클라 임시안 확정): AlarmTiming.alarmFireDate(departureTime: firstWalkSeconds:) — 기본값 없이 등록/refresh 재스케줄/LA alarmTime/홈 배너 4곳이 같은 기준을 강제로 탄다(이중 시각 금지). refresh의 도보 출처는 스냅샷(routeId 일치 시) - RegisterAlarmUseCase: 과거 fireDate 사전 가드(AlarmError.tooLate — 권한 팝업·서버 등록 전 차단) + 등록 성공 시 스냅샷 저장. Cancel: clear. 홈 토스트 "이미 출발 시간이 지난 경로예요" - AlarmSyncService: 첫 sync 진입 시 스냅샷 시딩(diff 휘발 해소 — lastInfo 복원 + 구독자 전파로 오프라인 재실행 복원, expired 톰스톤은 2차 방어 복원) / sync 성공 시 병합 저장 + 죽은 세션 LA 로컬 재시작 위임 / 만료 확정 시 톰스톤(expired=true), 서버 sessionEnded 시 clear / "당겨짐" 배지 만료 보존(조용한 갱신이 10분을 못 채우고 지우던 문제) - LA 어댑터: 부트스트랩 직후 Activity.activities 스캔 — 스냅샷 일치+미만료+미dismiss는 adopt(관찰 재개), 그 외 즉시 정리. sync 성공 후 죽은 세션(활성 LA 없음·미확인·미dismiss) 로컬 재시작(8시간 한도·시작 실패 커버, push-to-start 무관). 최초 start 긴급도를 알람 시각 기준으로 통일 - 홈: alarmSynced 수신 시 카드 없으면 GetLastRouteDetailUseCase(신설 — 기존 미사용 detail 엔드포인트 재활용)로 카드 복원 + 도보 반영 배너 재시작. 변경 분 표기 .up 통일 - 위젯: Attributes에 수단·도보 필드(optional — 구버전 LA 재부착 디코딩 안전) → DI 컴팩트 지하철/버스 아이콘 분기 + 잠금화면 3행 "HH:mm 출발 · 정류장 도보 N분" - stopIntent 확인 기록 영속화(Phase 13 연동) — 확인된 세션은 LA 재시작 금지 - DEV: 데모 경로에 도보 leg(120초) 추가(검수 ③ 재료), 변경 시뮬레이터 기준 세션 영속화 (재실행 후 주입 diff·카드 복원 시각 정합) Co-Authored-By: Claude Fable 5 --- .../AlarmSessionSnapshotStoreAdapter.swift | 29 +++ .../Sources/Adapters/DevDemoFallbacks.swift | 60 ++++- .../LastTrainLiveActivityAdapter.swift | 132 +++++++++-- .../AlarmSessionLifecycleService.swift | 20 +- Projects/App/Sources/AlarmSyncService.swift | 129 +++++++++- Projects/App/Sources/AppDIContainer.swift | 50 +++- Projects/App/Sources/AppDelegate.swift | 5 + .../Sources/LastTrainLiveActivityWidget.swift | 48 ++-- .../Sources/LastTrainActivityAttributes.swift | 23 +- .../LastTrainActivityAttributesTests.swift | 28 ++- .../Domain/Sources/Entities/AlarmError.swift | 3 + .../Domain/Sources/Entities/AlarmInfo.swift | 3 +- .../Entities/AlarmSessionSnapshot.swift | 61 +++++ .../Domain/Sources/Entities/AlarmTiming.swift | 14 +- .../Domain/Sources/Entities/LastRoute.swift | 4 +- .../Entities/LastRouteSessionDerivation.swift | 38 +++ .../Sources/UseCases/CancelAlarmUseCase.swift | 8 +- .../UseCases/GetLastRouteDetailUseCase.swift | 17 ++ .../UseCases/RefreshAlarmUseCase.swift | 20 +- .../UseCases/RegisterAlarmUseCase.swift | 48 +++- .../Tests/AlarmSessionSnapshotTests.swift | 65 ++++++ Projects/Domain/Tests/AlarmTimingTests.swift | 22 +- .../DefaultCancelAlarmUseCaseTests.swift | 39 ++++ ...efaultGetLastRouteDetailUseCaseTests.swift | 40 ++++ .../DefaultRefreshAlarmUseCaseTests.swift | 87 ++++++- .../DefaultRegisterAlarmUseCaseTests.swift | 149 +++++++++++- .../LastRouteSessionDerivationTests.swift | 99 ++++++++ .../LastTrainActivityLifecycleTests.swift | 22 +- .../Feature/Home/Example/ExampleApp.swift | 13 +- .../Home/Sources/HomeDIContainer.swift | 6 +- .../Home/Sources/HomeViewController.swift | 2 + .../Feature/Home/Sources/HomeViewModel.swift | 85 ++++++- .../Home/Tests/HomeViewModelTests.swift | 221 +++++++++++++++++- 33 files changed, 1462 insertions(+), 128 deletions(-) create mode 100644 Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift create mode 100644 Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift create mode 100644 Projects/Domain/Sources/Entities/LastRouteSessionDerivation.swift create mode 100644 Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift create mode 100644 Projects/Domain/Tests/AlarmSessionSnapshotTests.swift create mode 100644 Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift create mode 100644 Projects/Domain/Tests/LastRouteSessionDerivationTests.swift diff --git a/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift b/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift new file mode 100644 index 0000000..3826317 --- /dev/null +++ b/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift @@ -0,0 +1,29 @@ +import CoreStorage +import Domain +import Foundation + +/// Domain `AlarmSessionSnapshotStore` → CoreStorage(UserDefaults 백엔드) 어댑터 (Phase 14). +/// 스냅샷은 재실행 브리지이지 정본이 아니다 — 저장·삭제 실패는 조용히 흡수하고(다음 +/// 등록/sync가 자가치유), 필드 추가 등으로 인한 디코딩 실패도 nil로 무해화한다 +/// (최근 검색 저장소의 자가치유 패턴 재사용). +struct AlarmSessionSnapshotStoreAdapter: AlarmSessionSnapshotStore { + private static let storageKey = "alarm.sessionSnapshot" + + private let store: any KeyValueStore + + init(store: any KeyValueStore = UserDefaultsKeyValueStore()) { + self.store = store + } + + func load() async -> AlarmSessionSnapshot? { + (try? store.value(AlarmSessionSnapshot.self, forKey: Self.storageKey)) ?? nil + } + + func save(_ snapshot: AlarmSessionSnapshot) async { + try? store.setValue(snapshot, forKey: Self.storageKey) + } + + func clear() async { + try? store.removeValue(forKey: Self.storageKey) + } +} diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift index 918744b..ea2f75f 100644 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift @@ -61,9 +61,13 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { do { return try await base.lastRoute(id: id) } catch { print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)") + // 카드 복원(Phase 14) 검수 정합: 알려진 세션(재실행 후에도 영속)의 출발 시각을 + // 재사용해야 배너·LA와 카드의 시각이 어긋나지 않는다. 모르면 새 데모 시각. + let knownDeparture = await DevChangeSimulator.shared.knownSessionDeparture(routeId: id) let route = Self.demoRoute( start: Coordinate(latitude: 37.4979, longitude: 127.0276), - end: Coordinate(latitude: 37.4853, longitude: 126.9015) + end: Coordinate(latitude: 37.4853, longitude: 126.9015), + departure: knownDeparture ) await DevChangeSimulator.shared.noteKnownSession( routeId: route.id, departureTime: route.departureTime @@ -72,10 +76,13 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { } } - /// 출발 시각은 8분 뒤 — 알람이 버퍼(−3분) 반영으로 +5분 시점에 걸린다. - /// (3분이면 알람 시각 ≈ now라 등록 탭 시점에 이미 과거가 되어 AlarmKit이 거부한다.) - private static func demoRoute(start: Coordinate, end: Coordinate) -> LastRoute { - let departure = Date().addingTimeInterval(8 * 60) + /// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분 + /// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준). + /// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다. + private static func demoRoute( + start: Coordinate, end: Coordinate, departure: Date? = nil + ) -> LastRoute { + let departure = departure ?? Date().addingTimeInterval(8 * 60) return LastRoute( id: "dev-demo-route", departureTime: departure, @@ -85,6 +92,20 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { totalDistance: 14200, totalWalkDistance: 700, legs: [ + TransportLeg( + mode: .walk, + sectionTime: 120, + distance: 150, + departureTime: nil, + routeName: nil, + lineType: nil, + start: nil, + end: RoutePoint(name: "강남역", coordinate: start), + subwayFinalStation: nil, + subwayDirection: nil, + isExpressSubway: false, + isLastSubway: false + ), TransportLeg( mode: .subway, sectionTime: 1500, @@ -168,12 +189,21 @@ final class DevChangeSimulator { case end } + /// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서 + /// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다. + private static let knownRouteIdKey = "dev.sim.knownRouteId" + private static let knownDepartureKey = "dev.sim.knownDeparture" + private var pending: Injection? /// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다. private var knownRouteId: String? private var knownDepartureTime: Date? - private init() {} + private init() { + knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey) + let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey) + knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil + } /// 주입 예약 — 다음 refresh() 1회가 소비한다. func inject(_ injection: Injection) { @@ -184,7 +214,18 @@ final class DevChangeSimulator { /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). func noteKnownSession(routeId: String, departureTime: Date?) { knownRouteId = routeId - if let departureTime { knownDepartureTime = departureTime } + UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey) + if let departureTime { + knownDepartureTime = departureTime + UserDefaults.standard.set( + departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } + } + + /// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (데모 상세의 시각 정합용). + func knownSessionDeparture(routeId: String) -> Date? { + knownRouteId == routeId ? knownDepartureTime : nil } /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. @@ -204,6 +245,11 @@ final class DevChangeSimulator { case .end: departure = nil } knownDepartureTime = departure + if let departure { + UserDefaults.standard.set( + departure.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } return AlarmInfo( lastRouteId: routeId, departureTime: departure, diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift index 8223944..a76d94d 100644 --- a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -28,6 +28,21 @@ nonisolated protocol LastTrainDepartureEnding: Sendable { func endAsDeparted() async } +/// Phase 14 재실행 정합성 경로 — 고아 LA 재부착과 죽은 세션 재시작. Domain 포트는 +/// 시그니처 고정 계약이라 App 내부 확장 포트로 둔다(재부착은 어댑터 내부 동작). +nonisolated protocol LastTrainSessionRestoring: Sendable { + /// 부트스트랩 직후 1회 — 프로세스가 죽는 사이 잠금화면에 남은 + /// `Activity.activities`를 스캔해, 스냅샷과 routeId가 일치하고 미만료인 세션은 + /// **adopt**(보관 + 상태 관찰 재개 — 이후 update/end가 정상 동작)하고, + /// 불일치·만료·스냅샷 없음은 즉시 정리한다. + func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async + /// sync 성공 후 — 스냅샷은 살아 있는데(미만료·미확인) 활성 activity가 없고 dismiss + /// 기록도 없으면 LA를 로컬 재시작한다. 8시간 한도로 시스템이 내린 세션·시작 실패 + /// 세션 커버 — push-to-start 금지 정책과 무관(그 정책은 유저가 지운 LA의 재생성 금지, + /// dismiss 기록이 있으면 여기서도 재시작하지 않는다). + func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async +} + /// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. /// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체). /// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다. @@ -36,7 +51,7 @@ nonisolated protocol LastTrainDepartureEnding: Sendable { /// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가). /// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다. actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting, - LastTrainDepartureEnding { + LastTrainDepartureEnding, LastTrainSessionRestoring { /// 유저 스와이프 dismiss 기록 키 — 앱 재실행 후에도 남아야 Phase 12 폴백 트리거 재료가 된다. private static let dismissedDefaultsKey = "la.dismissedByUser" @@ -79,12 +94,18 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti } let departureTime = session.departureTime ?? route.departureTime + // 로컬 알람 발화 시각 — register/refresh와 동일 기준(출발 − 도보 − 버퍼, Phase 14). + let alarmTime = AlarmTiming.alarmFireDate( + departureTime: departureTime, + firstWalkSeconds: route.firstWalkSectionSeconds + ) let initialState = LastTrainActivityState( departureTime: departureTime, - // 로컬 알람 발화 시각 — register/refresh와 동일한 버퍼 반영값(출발 − 3분). - alarmTime: AlarmTiming.alarmFireDate(departureTime: departureTime), + alarmTime: alarmTime, + // 긴급도는 이후 갱신과 같은 척도인 **알람 시각** 기준(Phase 14 정합 — 최초 + // start만 출발 시각 기준이던 불일치 제거). urgency: Domain.LastTrainUrgency.forTimeRemaining( - departureTime.timeIntervalSinceNow + alarmTime.timeIntervalSinceNow ), changeBadgeExpiry: nil, phase: .active @@ -94,7 +115,9 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti let requested = try Activity.request( attributes: LastTrainActivityAttributes( routeId: route.id, - routeName: Self.displayRouteName(for: route) + routeName: route.sessionDisplayName, + transportKind: Self.transportKind(from: route.boardingLeg?.mode), + firstWalkSeconds: route.firstWalkSectionSeconds ), // staleDate = 출발 시각: 갱신이 끊긴 LA가 출발 시각이 지난 뒤에도 // 오래된 정보를 신선한 것처럼 보이지 않게 시스템이 stale 처리하도록 방어. @@ -110,6 +133,76 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti } } + // MARK: - LastTrainSessionRestoring (Phase 14) + + func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async { + // 살아 있는 세션을 이미 보관 중이면(이론상 재부착 전 start 경합) 손대지 않는다. + var adopted = activity != nil + for orphan in Activity.activities { + if !adopted, + // dismiss 기록이 있는 세션은 재부착도 하지 않는다 — 유저가 지운 LA는 + // 어떤 경로로도 되살리지 않는 정책과 한 몸(정상 흐름에선 지워진 LA가 + // 목록에 없지만, 기록·상태가 어긋난 경우에도 지운 의사가 이긴다). + !dismissedByUser, + let snapshot, !snapshot.expired, + orphan.attributes.routeId == snapshot.info.lastRouteId, + let departure = snapshot.info.departureTime, + !AlarmTiming.isSessionExpired(departureTime: departure, now: now), + orphan.activityState == .active || orphan.activityState == .stale { + // adopt — 보관 + 상태 관찰 재개. 이후 update/end가 정상 동작한다. + activity = orphan + observeActivityState(orphan) + adopted = true + } else { + // 불일치·만료·스냅샷 없음(고아) — 잠금화면에서 즉시 정리한다. + await orphan.end(nil, dismissalPolicy: .immediate) + } + } + } + + func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async { + // dismiss 존중(유저가 지운 LA 재생성 금지)·확인된 세션(departed 소멸 예약 완료) + // 재시작 금지. 활성 activity가 있으면 당연히 재시작하지 않는다 — adopt된 세션 포함. + guard activity == nil, + !dismissedByUser, + !snapshot.expired, + !snapshot.acknowledged, + let departure = snapshot.info.departureTime, + !AlarmTiming.isSessionExpired(departureTime: departure, now: now), + ActivityAuthorizationInfo().areActivitiesEnabled + else { return } + + let alarmTime = AlarmTiming.alarmFireDate( + departureTime: departure, + firstWalkSeconds: snapshot.firstWalkSeconds + ) + let state = LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: Domain.LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), + changeBadgeExpiry: nil, + phase: .active + ) + do { + let requested = try Activity.request( + attributes: LastTrainActivityAttributes( + routeId: snapshot.info.lastRouteId, + routeName: snapshot.routeDisplayName.isEmpty ? "막차" : snapshot.routeDisplayName, + transportKind: Self.transportKind(from: snapshot.transportMode), + firstWalkSeconds: snapshot.firstWalkSeconds + ), + content: ActivityContent( + state: Self.contentState(from: state), + staleDate: departure + ) + ) + activity = requested + observeActivityState(requested) + } catch { + // 재시작 실패도 흡수한다 — 알람·홈 배너만으로 동작(포트 계약과 동일 태도). + } + } + func update(state: LastTrainActivityState, alert: Bool) async { // 문구 없는 Bool 경로(Domain 포트) — alert=true면 범용 폴백 문구로 위임한다. // Phase 11 훅은 이 경로 대신 LastTrainChangeAlerting으로 변경 유형별 문구를 싣는다. @@ -260,26 +353,15 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti ) } - /// 노선 표시명 — 막차 탑승 구간(departureTime이 있는 첫 대중교통 구간, 없으면 첫 대중교통 구간) 기준. - /// 버스 routeName은 "타입:번호"(예: "간선:472") → "472번 버스", 지하철은 노선명 그대로(급행이면 " 급행"). - private nonisolated static func displayRouteName(for route: LastRoute) -> String { - let transitLegs = route.legs.filter { $0.mode == .bus || $0.mode == .subway } - guard let leg = transitLegs.first(where: { $0.departureTime != nil }) ?? transitLegs.first - else { return "막차" } - - switch leg.mode { - case .subway: - guard let name = leg.routeName else { return "지하철" } - return leg.isExpressSubway ? "\(name) 급행" : name - case .bus: - guard let routeName = leg.routeName else { return "버스" } - guard let colonIndex = routeName.firstIndex(of: ":") else { - return "\(routeName)번 버스" - } - let number = String(routeName[routeName.index(after: colonIndex)...]) - return "\(number)번 버스" - case .walk, .unknown: - return "막차" + /// Domain 수단 → 위젯 계약 수단 키 (Phase 14 DI 아이콘 분기). + private nonisolated static func transportKind( + from mode: TransportMode? + ) -> LastTrainTransportKind? { + switch mode { + case .bus: .bus + case .subway: .subway + case .walk, .unknown: .other + case nil: nil } } } diff --git a/Projects/App/Sources/AlarmSessionLifecycleService.swift b/Projects/App/Sources/AlarmSessionLifecycleService.swift index e9c4dac..d13be02 100644 --- a/Projects/App/Sources/AlarmSessionLifecycleService.swift +++ b/Projects/App/Sources/AlarmSessionLifecycleService.swift @@ -1,3 +1,4 @@ +import Domain import Foundation import os @@ -8,19 +9,25 @@ import os final class AlarmSessionLifecycleService { /// departed 전환·예약 소멸 경로 — LA 어댑터가 구현한다(실패 전부 흡수, non-throwing). private let liveActivity: any LastTrainDepartureEnding + /// 확인 기록 영속화 (Phase 14) — 강제 종료·재실행 후에도 남아 재시작 판정 + /// (확인된 세션 재시작 금지)의 재료가 된다. + private let snapshotStore: any AlarmSessionSnapshotStore private static let logger = Logger( subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle" ) - /// stopIntent 확인 기록 — Phase 14 스냅샷 `acknowledged` 필드의 인메모리 선행. - /// 프로세스가 죽으면 사라진다(강제 종료 케이스의 영속화는 Phase 14 몫). + /// stopIntent 확인 기록 — 스냅샷 영속화의 인메모리 미러. private(set) var isAcknowledged = false - init(liveActivity: any LastTrainDepartureEnding) { + init( + liveActivity: any LastTrainDepartureEnding, + snapshotStore: any AlarmSessionSnapshotStore + ) { self.liveActivity = liveActivity + self.snapshotStore = snapshotStore } - /// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록 ② LA departed 전환 + /// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록(스냅샷 영속화) ② LA departed 전환 /// ③ 출발+10분 자동 소멸 예약. ②③은 어댑터의 end 한 번으로 구현된다 /// (final content = departed, dismissalPolicy = .after) — 앱이 다시 깨지 /// 않아도 잠금화면에서 시스템이 내린다. @@ -28,6 +35,11 @@ final class AlarmSessionLifecycleService { // 로그는 자동 검수 ②(강제 종료 후 인텐트 실행 여부 판정)의 증적 채널이다. Self.logger.info("알람 확인(stopIntent) 수신 — departed 전환 + 자동 소멸 예약") isAcknowledged = true + // 세션 스냅샷이 있을 때만 기록한다 — 스냅샷 없는 확인(이론상 경합)은 남길 곳이 없고, + // 그 경우의 정리는 wake 시점 리컨실이 맡는다. + if let snapshot = await snapshotStore.load() { + await snapshotStore.save(snapshot.updating(acknowledged: true)) + } await liveActivity.endAsDeparted() } } diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index a5bc009..7c5b44a 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -24,6 +24,13 @@ import os /// 성공해 **미래 출발 시각**을 반환하면 서버 우선(만료 취소, 정상 갱신 경로). /// 만료 확정 세션은 기록해 이후 refresh가 같은 과거 세션으로 배너를 되살리지 못하게 /// 한다(홈의 미래 시각 가드가 1차 방어, 이 기록이 2차). +/// +/// Phase 14 재실행 정합성: 첫 sync 진입 시 스냅샷을 시딩해 ⑤ `lastInfo`(diff의 "이전 +/// 값")가 재실행 후에도 살아 종료 중 발생한 변경을 실제로 판정하고, 만료 판정도 프로세스 +/// 수명과 무관하게 성립한다(expired 톰스톤은 2차 방어 복원). ⑥ sync 성공 시 스냅샷을 +/// 병합 저장하고, 스냅샷은 살아 있는데 활성 LA가 없는 죽은 세션은 로컬 재시작을 +/// 위임한다(dismiss·확인 기록 존중은 어댑터 몫). 도보 초는 스냅샷에서 읽어 LA 알람 +/// 시각 계산이 등록/refresh와 같은 기준을 탄다(이중 시각 금지). // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor @@ -37,6 +44,11 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private let localNotification: any LocalNotificationPort /// sessionEnded 시 로컬 알람 취소용(Phase 12) — 등록/갱신 UseCase와 같은 스케줄러를 공유한다. private let alarmScheduler: any AlarmScheduler + /// 세션 스냅샷(Phase 14) — 재실행 브리지. sync 성공 시 병합 저장, 만료 확정 시 톰스톤, + /// 서버 sessionEnded 시 clear. + private let snapshotStore: any AlarmSessionSnapshotStore + /// 죽은 세션 LA 재시작 경로(Phase 14) — dismiss·확인 기록 판정은 어댑터가 한다. + private let sessionRestorer: any LastTrainSessionRestoring private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. @@ -51,6 +63,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { /// Phase 13 만료 2차 방어 — 로컬 만료를 확정한 세션. 이후 refresh가 같은 routeId의 /// 과거 세션을 반환해도 무시한다(미래 출발이 오면 서버 우선으로 해제). private var locallyExpiredSession: AlarmInfo? + /// Phase 14 스냅샷 시딩 1회 게이트 — 첫 sync 진입 전에 반드시 시딩이 끝나야 + /// diff·만료 판정이 재실행 전 세션을 본다(트리거 3경로 공용이라 sync 안에서 게이트). + private var isSeededFromSnapshot = false + /// 현재 세션의 첫 도보 구간(초) — LA 알람 시각 계산용(등록/refresh와 같은 기준, + /// 이중 시각 금지). 스냅샷 시딩·병합 저장 시 갱신된다. + private var sessionWalkSeconds: Int? + /// "⚠ 당겨짐" 배지의 현재 만료 시각 — 조용한 갱신(unchanged/delayed)이 배지를 10분 + /// 정책보다 일찍 지우지 않도록 보존한다(Phase 14 정합). + private var changeBadgeExpiry: Date? /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. private var inFlight: Task? private var foregroundObserver: (any NSObjectProtocol)? @@ -61,13 +82,17 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { evaluateChangeUseCase: any EvaluateAlarmChangeUseCase, liveActivity: any LastTrainChangeAlerting, localNotification: any LocalNotificationPort, - alarmScheduler: any AlarmScheduler + alarmScheduler: any AlarmScheduler, + snapshotStore: any AlarmSessionSnapshotStore, + sessionRestorer: any LastTrainSessionRestoring ) { self.refreshAlarmUseCase = refreshAlarmUseCase self.evaluateChangeUseCase = evaluateChangeUseCase self.liveActivity = liveActivity self.localNotification = localNotification self.alarmScheduler = alarmScheduler + self.snapshotStore = snapshotStore + self.sessionRestorer = sessionRestorer } /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 @@ -98,6 +123,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { return await inFlight.value } Self.logger.info("알람 동기화 시작") + // Phase 14 시딩 — 첫 진입에서 스냅샷을 lastInfo(diff 이전 값)·만료 기록으로 복원. + // 만료 선행 판정보다 먼저여야 재실행 직후의 과거 세션도 리컨실에 걸린다. + await seedFromSnapshotIfNeeded() // Phase 13 선행 판정 — 확정은 refresh 결과를 본 뒤(미래 출발이면 서버 우선 취소). let expiryCandidate = expireLocallyIfNeeded(now: Date()) let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in @@ -137,15 +165,68 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") let previous = lastInfo lastInfo = info + // Phase 14 — sync 성공은 스냅샷 저장 시점. 도보·표시명은 같은 세션이면 보존한다. + let snapshot = await persistSyncedSnapshot(for: info) for continuation in subscribers.values { continuation.yield(info) } + // Phase 14 죽은 세션 재시작 — 스냅샷은 살아 있는데 활성 LA가 없고 dismiss·확인 + // 기록도 없으면 로컬 재시작(판정은 어댑터). 8시간 한도·시작 실패 세션 커버. + await sessionRestorer.restartIfNeeded(snapshot: snapshot, now: Date()) // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. await propagateChange(previous: previous, latest: info) return info } + // MARK: - Phase 14 스냅샷 시딩·병합 + + /// 첫 sync 진입 전 1회 — 재실행 브리지 복원. expired 톰스톤은 2차 방어로, + /// 살아 있는 스냅샷은 diff의 "이전 값"으로 시딩하고 **구독자에게도 흘린다** — + /// refresh가 실패해도(오프라인·실서버 미인증) 홈이 배너·해제 버튼·카드를 복원할 + /// 수 있어야 재실행이 정보를 잃지 않는다. 직후의 만료 판정·refresh가 이 값을 + /// 즉시 교정하므로(서버 우선) 스냅샷이 정본 행세를 하는 창은 한 sync 이내다. + private func seedFromSnapshotIfNeeded() async { + guard !isSeededFromSnapshot else { return } + isSeededFromSnapshot = true + guard let snapshot = await snapshotStore.load() else { return } + sessionWalkSeconds = snapshot.firstWalkSeconds + if snapshot.expired { + locallyExpiredSession = snapshot.info + } else if lastInfo == nil { + lastInfo = snapshot.info + Self.logger.info( + "스냅샷 시딩: route=\(snapshot.info.lastRouteId, privacy: .public)" + ) + for continuation in subscribers.values { + continuation.yield(snapshot.info) + } + } + } + + /// sync 성공 시 스냅샷 병합 저장 — 같은 세션(routeId 일치)이면 등록 시점 사실 + /// (도보·표시명·수단·확인 기록)을 보존하고 info만 갱신, 다른 세션이면 아는 것만 담는다 + /// (표시명 공백은 LA 재시작 시 "막차" 폴백, 카드는 상세 재조회가 채운다). + private func persistSyncedSnapshot(for info: AlarmInfo) async -> AlarmSessionSnapshot { + let existing = await snapshotStore.load() + let snapshot: AlarmSessionSnapshot + if let existing, existing.info.lastRouteId == info.lastRouteId { + snapshot = existing.updating(info: info, expired: false) + } else { + snapshot = AlarmSessionSnapshot( + info: info, + firstWalkSeconds: nil, + routeDisplayName: "", + transportMode: nil, + acknowledged: false, + expired: false + ) + } + await snapshotStore.save(snapshot) + sessionWalkSeconds = snapshot.firstWalkSeconds + return snapshot + } + // MARK: - Phase 13 클라 자체 만료 (wake 시점 판정) /// sync 진입 선행 판정 — 보유 세션이 만료 유예(출발+60초, AlarmTiming 단일 기준)를 @@ -167,6 +248,22 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { locallyExpiredSession = session // replay-1이 죽은 세션을 재구독자에게 되살리지 않도록 비운다. lastInfo = nil + // Phase 14 — 만료 기록을 톰스톤(expired=true)으로 영속화: 재실행 후에도 같은 + // 과거 세션의 refresh가 배너·재부착·재시작을 되살리지 못한다(2차 방어의 영속화). + // clear가 아니라 톰스톤인 이유: 지워 버리면 다음 실행의 2차 방어가 사라진다. + let existing = await snapshotStore.load() + if let existing, existing.info.lastRouteId == session.lastRouteId { + await snapshotStore.save(existing.updating(expired: true)) + } else { + await snapshotStore.save(AlarmSessionSnapshot( + info: session, + firstWalkSeconds: sessionWalkSeconds, + routeDisplayName: "", + transportMode: nil, + acknowledged: false, + expired: true + )) + } await presentSessionEnded(previous: session, now: Date()) yieldChange(.sessionEnded) } @@ -207,6 +304,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { case .sessionEnded: // 운행 종료·경로 소멸 — LA 최종 상태 종료 + 로컬 알람 취소. // 배너 정리는 changes yield를 받은 홈의 몫. + // 서버가 세션 종료를 확정했으므로 재실행 브리지(스냅샷)도 지운다(Phase 14). + await snapshotStore.clear() + sessionWalkSeconds = nil await presentSessionEnded(previous: previous, now: now) yieldChange(verdict) } @@ -220,8 +320,12 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { now: Date ) async { guard let departure = latest.departureTime else { return } - let alarmTime = AlarmTiming.alarmFireDate(departureTime: departure) + let alarmTime = AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: sessionWalkSeconds + ) let badgeExpiry = now.addingTimeInterval(Self.changeBadgeDuration) + // 조용한 후속 갱신(unchanged/delayed)이 배지를 10분보다 일찍 지우지 않도록 보존한다. + changeBadgeExpiry = badgeExpiry guard alarmTime > now else { // 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람 @@ -285,9 +389,13 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private func presentMissed(latest: AlarmInfo, now: Date) async { // actionable=false 판정은 departureTime이 있을 때만 나온다(없으면 sessionEnded). guard let departure = latest.departureTime else { return } + // 실패 상태로 내려가면 "당겨짐" 배지는 의미를 잃는다 — 보존 기록도 접는다. + changeBadgeExpiry = nil let state = LastTrainActivityState( departureTime: departure, - alarmTime: AlarmTiming.alarmFireDate(departureTime: departure), + alarmTime: AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: sessionWalkSeconds + ), urgency: .imminent, changeBadgeExpiry: nil, phase: .missed @@ -315,6 +423,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private func presentSessionEnded(previous: AlarmInfo?, now: Date) async { // 더는 울리면 안 되는 것이 정책의 핵심 — 표출(LA 종료)보다 알람 취소를 먼저 한다. await alarmScheduler.cancelAlarm() + changeBadgeExpiry = nil // sessionEnded 응답에는 departureTime이 없다 — 종료 시각 정보용으로 직전 스냅샷 // (previous = 갱신 전 lastInfo)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. @@ -323,7 +432,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { // 로컬 노티 폴백도 없다(배너 정리는 changes 스트림을 받은 홈이 한다). await liveActivity.end(final: LastTrainActivityState( departureTime: departure, - alarmTime: AlarmTiming.alarmFireDate(departureTime: departure), + alarmTime: AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: sessionWalkSeconds + ), // 위젯은 serviceEnded phase 키로 그린다 — urgency는 종료 화면에선 의미 없는 방어값. urgency: .imminent, changeBadgeExpiry: nil, @@ -332,14 +443,20 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { } /// 갱신된 AlarmInfo → LA 상태. departureTime이 없으면(세션 종료 등) 만들 수 없다. + /// 조용한 갱신도 살아 있는 "당겨짐" 배지는 그대로 싣는다 — 10분 정책 보존(Phase 14). private func activityState(for info: AlarmInfo, now: Date) -> LastTrainActivityState? { guard let departure = info.departureTime else { return nil } - let alarmTime = AlarmTiming.alarmFireDate(departureTime: departure) + let alarmTime = AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: sessionWalkSeconds + ) + if let badgeExpiry = changeBadgeExpiry, badgeExpiry <= now { + changeBadgeExpiry = nil // 만료된 배지 기록은 정리한다. + } return LastTrainActivityState( departureTime: departure, alarmTime: alarmTime, urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), - changeBadgeExpiry: nil, + changeBadgeExpiry: changeBadgeExpiry, phase: .active ) } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index dd143bf..3484abf 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -27,13 +27,18 @@ final class AppDIContainer { // 로컬 노티도 1회 생성 공유 — 권한 요청 훅(등록 UseCase)과 dismiss 폴백 발송(AlarmSyncService)이 // 같은 요청 이력을 봐야 한다. UNUserNotificationCenter를 아는 곳은 이 어댑터뿐. private let localNotificationPort: any LocalNotificationPort + // 구체 어댑터 보유 — 고아 LA 재부착(부트스트랩 직후, Phase 14)과 DEV 검수 토글의 통로. + private let liveActivityAdapter: LastTrainLiveActivityAdapter + /// 세션 스냅샷(Phase 14 재실행 브리지) — 등록/취소/refresh UseCase·수명 서비스· + /// 동기화 서비스가 같은 저장소를 봐야 한다(1회 생성 공유). + private let alarmSessionSnapshotStore: any AlarmSessionSnapshotStore let alarmSyncService: AlarmSyncService /// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를 /// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다. let alarmSessionLifecycle: AlarmSessionLifecycleService #if DEV /// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수). - let devLiveActivityAdapter: LastTrainLiveActivityAdapter + var devLiveActivityAdapter: LastTrainLiveActivityAdapter { liveActivityAdapter } #endif init() { @@ -93,29 +98,45 @@ final class AppDIContainer { scheduling: AlarmKitScheduler(stopIntent: AlarmAcknowledgeIntent()) ) self.alarmScheduler = alarmScheduler - // 구체 어댑터로 들고 있다가 세 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase), + // 구체 어댑터로 들고 있다가 네 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase), // App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅), - // 발화 확인 경로(LastTrainDepartureEnding, Phase 13). + // 발화 확인 경로(LastTrainDepartureEnding, Phase 13), + // 재실행 복원 경로(LastTrainSessionRestoring, Phase 14). let liveActivityAdapter = LastTrainLiveActivityAdapter() + self.liveActivityAdapter = liveActivityAdapter self.liveActivityPort = liveActivityAdapter - self.alarmSessionLifecycle = AlarmSessionLifecycleService(liveActivity: liveActivityAdapter) - #if DEV - self.devLiveActivityAdapter = liveActivityAdapter - #endif + let snapshotStore = AlarmSessionSnapshotStoreAdapter() + self.alarmSessionSnapshotStore = snapshotStore + self.alarmSessionLifecycle = AlarmSessionLifecycleService( + liveActivity: liveActivityAdapter, + snapshotStore: snapshotStore + ) let localNotificationAdapter = LocalNotificationAdapter() self.localNotificationPort = localNotificationAdapter self.alarmSyncService = AlarmSyncService( refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, - scheduler: alarmScheduler + scheduler: alarmScheduler, + // refresh 응답에는 도보 정보가 없다 — 등록 시점 스냅샷이 도보 초의 출처. + snapshotStore: snapshotStore ), evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), liveActivity: liveActivityAdapter, localNotification: localNotificationAdapter, - alarmScheduler: alarmScheduler + alarmScheduler: alarmScheduler, + snapshotStore: snapshotStore, + sessionRestorer: liveActivityAdapter ) } + /// 부트스트랩 직후 1회(AppDelegate) — 프로세스가 죽는 사이 잠금화면에 남은 고아 LA를 + /// 스냅샷과 대조해 재부착하거나 정리한다(Phase 14). 인증·네트워크와 무관한 로컬 + /// 리컨실이라 앱 시작 최전선에서 수행한다(부트스트랩 실패로 고아가 방치되지 않게). + func reattachOrphanLiveActivities() async { + let snapshot = await alarmSessionSnapshotStore.load() + await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) + } + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() @@ -137,15 +158,22 @@ final class AppDIContainer { scheduler: alarmScheduler, activityPort: liveActivityPort, // 알림 권한 요청의 유일한 시점(등록 성공 직후) — UseCase 내부 훅이 호출한다. - notificationPort: localNotificationPort + notificationPort: localNotificationPort, + // 등록 성공 = 스냅샷 저장 시점(Phase 14). + snapshotStore: alarmSessionSnapshotStore ), cancelAlarmUseCase: DefaultCancelAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler, - activityPort: liveActivityPort + activityPort: liveActivityPort, + snapshotStore: alarmSessionSnapshotStore ), observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmSyncService), + // 재실행 카드 복원(Phase 14) — 기존 미사용 자산(detail 엔드포인트) 재활용. + getLastRouteDetailUseCase: DefaultGetLastRouteDetailUseCase( + repository: lastRouteRepository + ), searchCoordinatorBuildable: searchContainer ) } diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 034e602..5e4d2df 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -19,6 +19,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // 고아 LA 재부착/정리 (Phase 14) — 인증 부트스트랩과 무관한 로컬 리컨실이라 + // 앱 시작 최전선에서 1회. 첫 sync보다 먼저 끝나는 것이 보통이지만, 늦어도 + // 어댑터(actor)가 직렬화하므로 안전하다. + let container = container + Task { await container.reattachOrphanLiveActivities() } configureFirebaseIfAvailable() if isFirebaseEnabled { Messaging.messaging().delegate = self diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift index afe9f53..5f4dce1 100644 --- a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -21,7 +21,7 @@ struct LastTrainLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LastTrainActivityAttributes.self) { context in LastTrainLockScreenView( - routeName: context.attributes.routeName, + attributes: context.attributes, state: context.state, isStale: context.isStale ) @@ -35,7 +35,7 @@ struct LastTrainLiveActivityWidget: Widget { return DynamicIsland { DynamicIslandExpandedRegion(.leading) { HStack(spacing: DSSpacing.xs) { - Image(systemName: "bus.fill") + Image(systemName: context.attributes.transportSymbolName) .font(.subheadline) .foregroundStyle(state.glanceColor) Text(context.attributes.routeName) @@ -83,8 +83,8 @@ struct LastTrainLiveActivityWidget: Widget { } } } compactLeading: { - // 노선 수단(버스/지하철) 구분 필드가 계약에 없어 버스 아이콘 고정. - Image(systemName: "bus.fill") + // 수단 필드(Phase 14) 기준 지하철/버스 아이콘 분기 — 구버전 LA(필드 없음)는 버스 폴백. + Image(systemName: context.attributes.transportSymbolName) .foregroundStyle(state.glanceColor) } compactTrailing: { switch state.status { @@ -117,7 +117,7 @@ struct LastTrainLiveActivityWidget: Widget { .foregroundStyle(state.glanceColor) } } minimal: { - Image(systemName: "bus.fill") + Image(systemName: context.attributes.transportSymbolName) .foregroundStyle(state.glanceColor) } .keylineTint(Color(ds: DSColor.Accent.default)) @@ -138,10 +138,10 @@ struct LastTrainLiveActivityWidget: Widget { /// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 /// 숫자·아이콘에 긴급도 색을 상시 적용한다. private struct LastTrainLockScreenView: View { - let routeName: String + let attributes: LastTrainActivityAttributes let state: LastTrainActivityAttributes.ContentState /// staleDate(=출발 시각)가 지나도록 갱신이 없었다 — 강제 종료·고아 케이스의 UI 완충 - /// (Phase 13). 근본 해소(재부착)는 Phase 14 몫. + /// (Phase 13). 근본 해소는 Phase 14 재부착 — 이 분기는 최후 방어선으로 유지. let isStale: Bool var body: some View { @@ -156,10 +156,10 @@ private struct LastTrainLockScreenView: View { /// 1행: 노선명 + "⚠ 당겨짐" 배지 슬롯. private var headerRow: some View { HStack(spacing: DSSpacing.xs) { - Image(systemName: "bus.fill") + Image(systemName: attributes.transportSymbolName) .font(.subheadline) .foregroundStyle(Color(ds: DSColor.Icon.default)) - Text("\(routeName) 막차") + Text("\(attributes.routeName) 막차") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color(ds: DSColor.Text.primary)) .lineLimit(1) @@ -205,16 +205,36 @@ private struct LastTrainLockScreenView: View { } } - /// 3행: 출발 시각. 목업의 "정류장 도보 8분" 자리는 비워둔다 — - /// ContentState에 도보 필드가 없고 Phase 9 wire 계약은 고정이라 - /// attributes/state 확장 금지. Phase 11 미확정 #7(도보 소요 데이터) - /// 해소 시 "HH:mm 출발 · 정류장 도보 N분" 형태로 재검토. + /// 3행: 출발 시각 + 정류장 도보 소요 (Phase 14 — 미확정 #7 클라 임시안으로 목업 공석 해소). + /// 도보 데이터가 없는 세션(구버전 LA·도보 없는 경로)은 출발 시각만 표시한다. private var departureRow: some View { - Text("\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발") + Text(departureRowText) .font(.footnote) .foregroundStyle(Color(ds: DSColor.Text.secondary)) .lineLimit(1) } + + private var departureRowText: String { + let departureText = "\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발" + guard let walkSeconds = attributes.firstWalkSeconds, walkSeconds > 0 else { + return departureText + } + // 올림 표기 — 변경 분 표기와 같은 방향(.up), "0분 도보"는 존재하지 않는다. + let walkMinutes = max(1, Int((Double(walkSeconds) / 60).rounded(.up))) + return "\(departureText) · 정류장 도보 \(walkMinutes)분" + } +} + +// MARK: - Attributes presentation helpers + +private extension LastTrainActivityAttributes { + /// 수단 → SF Symbol. 필드가 없는 구버전 LA는 버스 폴백(기존 표시 유지). + var transportSymbolName: String { + switch transportKind { + case .subway: "tram.fill" + case .bus, .other, nil: "bus.fill" + } + } } /// "⚠ 당겨짐" 배지 — 변경 직후 10분 노출 정책의 표시 슬롯. diff --git a/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift index 2e3019c..8ee2dc9 100644 --- a/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift +++ b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift @@ -10,10 +10,22 @@ public struct LastTrainActivityAttributes: ActivityAttributes, Sendable { public let routeId: String /// 노선명 (예: "9호선 급행") — 잠금화면·다이나믹 아일랜드 타이틀. public let routeName: String + /// 탑승 수단 (Phase 14) — DI 컴팩트 아이콘 분기용. 고정 정보라 Attributes가 맞는 자리. + /// optional인 이유: 구버전이 남긴 활성 LA의 재부착 디코딩이 새 키 부재로 깨지면 안 된다. + public let transportKind: LastTrainTransportKind? + /// 등록 시점 경로의 첫 도보 구간(초, Phase 14) — 잠금화면 3행 "정류장 도보 N분" 표시용. + public let firstWalkSeconds: Int? - public init(routeId: String, routeName: String) { + public init( + routeId: String, + routeName: String, + transportKind: LastTrainTransportKind?, + firstWalkSeconds: Int? + ) { self.routeId = routeId self.routeName = routeName + self.transportKind = transportKind + self.firstWalkSeconds = firstWalkSeconds } /// Mutable snapshot pushed on every update. @@ -45,6 +57,15 @@ public struct LastTrainActivityAttributes: ActivityAttributes, Sendable { } } +/// 탑승 수단 (Phase 14) — 위젯 아이콘 키. 세분류(간선/지선 등)는 표시에 불필요해 +/// 버스/지하철 2종 + 방어값(other)만 둔다. 미지 rawValue 디코딩 실패는 어댑터가 +/// optional 필드로 무해화한다(위젯은 nil이면 버스 아이콘 폴백). +public enum LastTrainTransportKind: String, Codable, Hashable, Sendable { + case bus + case subway + case other +} + /// 긴급도 3단계 (여유/주의/임박). public enum LastTrainUrgency: String, Codable, Hashable, Sendable { case relaxed diff --git a/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift index 69473d1..bf22b19 100644 --- a/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift +++ b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift @@ -35,11 +35,37 @@ struct LastTrainActivityAttributesTests { @Test func attributes_roundTripCodable_preservesFixedSessionInfo() throws { - let attributes = LastTrainActivityAttributes(routeId: "route-42", routeName: "9호선 급행") + let attributes = LastTrainActivityAttributes( + routeId: "route-42", + routeName: "9호선 급행", + transportKind: .subway, + firstWalkSeconds: 120 + ) let data = try JSONEncoder().encode(attributes) let decoded = try JSONDecoder().decode(LastTrainActivityAttributes.self, from: data) #expect(decoded.routeId == "route-42") #expect(decoded.routeName == "9호선 급행") + #expect(decoded.transportKind == .subway) + #expect(decoded.firstWalkSeconds == 120) + } + + @Test + func attributes_legacyPayloadWithoutPhase14Fields_stillDecodes() throws { + // 재부착(Phase 14)은 구버전 앱이 남긴 활성 LA의 attributes를 디코딩한다 — + // 새 필드(수단·도보)가 없는 payload가 실패하면 재부착 자체가 불가능해진다. + let legacy = Data(#"{"routeId":"route-42","routeName":"9호선 급행"}"#.utf8) + let decoded = try JSONDecoder().decode(LastTrainActivityAttributes.self, from: legacy) + #expect(decoded.routeId == "route-42") + #expect(decoded.transportKind == nil) + #expect(decoded.firstWalkSeconds == nil) + } + + @Test + func transportKind_rawValuesAreStable() { + // Raw values ride inside ActivityKit's persisted attributes — wire 계약. + #expect(LastTrainTransportKind.bus.rawValue == "bus") + #expect(LastTrainTransportKind.subway.rawValue == "subway") + #expect(LastTrainTransportKind.other.rawValue == "other") } @Test diff --git a/Projects/Domain/Sources/Entities/AlarmError.swift b/Projects/Domain/Sources/Entities/AlarmError.swift index aca3ae3..a22a408 100644 --- a/Projects/Domain/Sources/Entities/AlarmError.swift +++ b/Projects/Domain/Sources/Entities/AlarmError.swift @@ -2,4 +2,7 @@ public enum AlarmError: Error, Equatable, Sendable { /// AlarmKit 권한 거부 — 서버 등록을 보류하고 설정 이동을 안내한다. case permissionDenied + /// 알람 발화 시각(출발 − 도보 − 버퍼)이 이미 과거 — 서버 등록 성공 후 로컬 스케줄이 + /// 실패하는 서버/로컬 불일치를 사전 차단한다(등록 자체를 시작하지 않는다). + case tooLate } diff --git a/Projects/Domain/Sources/Entities/AlarmInfo.swift b/Projects/Domain/Sources/Entities/AlarmInfo.swift index 41acf7c..adfe5a2 100644 --- a/Projects/Domain/Sources/Entities/AlarmInfo.swift +++ b/Projects/Domain/Sources/Entities/AlarmInfo.swift @@ -2,7 +2,8 @@ import Foundation // TODO: [미확정] 서버 계산 "알람 시각" 필드는 refresh 응답에 없다(실측: departureTime뿐) — // 알람 시각 스펙이 확정되면 여기에 추가한다. -public struct AlarmInfo: Equatable, Sendable { +/// Codable: 세션 스냅샷(`AlarmSessionSnapshot`) 영속화용 — 재실행 브리지이지 정본이 아니다. +public struct AlarmInfo: Equatable, Sendable, Codable { public let lastRouteId: String /// 막차 출발 시각 (서버 재계산 값) public let departureTime: Date? diff --git a/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift b/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift new file mode 100644 index 0000000..9790b5d --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift @@ -0,0 +1,61 @@ +import Foundation + +/// 알람 세션의 재실행 브리지 (Phase 14) — 앱 프로세스 수명과 알람 세션 수명을 분리한다. +/// 정본은 여전히 서버(refresh)다: 스냅샷만으로 알람을 새로 만들지 않고, 서버 결과와 +/// 충돌하면 항상 서버가 이긴다. 기록 시점: 등록 성공·sync 성공 시 save, +/// 취소·서버 sessionEnded 시 clear, 로컬 만료 확정 시 expired=true 톰스톤 +/// (재실행 후에도 같은 과거 세션의 refresh가 배너를 되살리지 못하게 하는 2차 방어). +public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { + public let info: AlarmInfo + /// 등록 시점 경로의 첫 도보 구간(초) — 알람 기준 시각 계산용 (없으면 nil). + public let firstWalkSeconds: Int? + /// LA·카드 복원용 표시명 (예: "6411번 버스"). + public let routeDisplayName: String + /// 탑승 수단 — LA 재시작 시 아이콘 분기용 (확정 결정: 스냅샷 = 표시명·수단 포함). + public let transportMode: TransportMode? + /// stopIntent 확인 기록 (Phase 13 연동) — 확인된 세션은 LA를 재시작하지 않는다 + /// (departed 소멸 예약이 이미 잡혀 있다). + public let acknowledged: Bool + /// 로컬 만료 기록 (Phase 13 연동) — true면 죽은 세션 톰스톤. + public let expired: Bool + + public init( + info: AlarmInfo, + firstWalkSeconds: Int?, + routeDisplayName: String, + transportMode: TransportMode?, + acknowledged: Bool, + expired: Bool + ) { + self.info = info + self.firstWalkSeconds = firstWalkSeconds + self.routeDisplayName = routeDisplayName + self.transportMode = transportMode + self.acknowledged = acknowledged + self.expired = expired + } + + /// 세션 사실(도보·표시명·수단)은 유지하고 기록 필드만 바꾼 사본. + public func updating( + info: AlarmInfo? = nil, + acknowledged: Bool? = nil, + expired: Bool? = nil + ) -> AlarmSessionSnapshot { + AlarmSessionSnapshot( + info: info ?? self.info, + firstWalkSeconds: firstWalkSeconds, + routeDisplayName: routeDisplayName, + transportMode: transportMode, + acknowledged: acknowledged ?? self.acknowledged, + expired: expired ?? self.expired + ) + } +} + +/// 스냅샷 영속화 포트 — 어댑터(UserDefaults 백엔드)는 App에 둔다. +/// 디코딩 실패는 어댑터가 nil로 무해화한다(자가치유 — 다음 save가 덮어쓴다). +public protocol AlarmSessionSnapshotStore: Sendable { + func load() async -> AlarmSessionSnapshot? + func save(_ snapshot: AlarmSessionSnapshot) async + func clear() async +} diff --git a/Projects/Domain/Sources/Entities/AlarmTiming.swift b/Projects/Domain/Sources/Entities/AlarmTiming.swift index a3c8d4c..178a036 100644 --- a/Projects/Domain/Sources/Entities/AlarmTiming.swift +++ b/Projects/Domain/Sources/Entities/AlarmTiming.swift @@ -1,14 +1,18 @@ import Foundation /// 버퍼 = 스누즈 예산 (정책 5). 알람은 기준 시각 − 버퍼에 울린다. 버퍼 고정 3분, 설정 없음(v1). -/// TODO(미확정 #7): 기준 시각 = departureTime − 첫 도보 구간 시간이 원칙이나, refresh 응답에 -/// 도보 정보가 없어 register/refresh 이중 시각이 생기므로 확정 전까지 버퍼만 균일 적용한다. +/// 기준 시각(미확정 #7 클라 임시안, Phase 14 확정) = departureTime − 첫 도보 구간 시간 — +/// 도보 초는 등록 시점 경로에서 취득해 스냅샷에 저장하고, 서버 필드가 생기면 대체한다. public enum AlarmTiming { public static let bufferSeconds: TimeInterval = 180 - /// 로컬 알람 발화 시각 = departureTime − bufferSeconds. - public static func alarmFireDate(departureTime: Date) -> Date { - departureTime.addingTimeInterval(-bufferSeconds) + /// 로컬 알람 발화 시각 = departureTime − firstWalkSeconds − bufferSeconds. + /// 등록/refresh 재스케줄/LA alarmTime/홈 배너 4곳이 전부 이 함수를 탄다(이중 시각 금지) — + /// 기본값을 두지 않아 콜사이트가 도보 초의 출처(경로·스냅샷·없음)를 명시하게 강제한다. + public static func alarmFireDate(departureTime: Date, firstWalkSeconds: Int?) -> Date { + departureTime.addingTimeInterval( + -(TimeInterval(firstWalkSeconds ?? 0) + bufferSeconds) + ) } /// 클라 자체 만료 유예 — 출발 시각 + 60초가 지나면 세션을 로컬 만료로 판정한다 diff --git a/Projects/Domain/Sources/Entities/LastRoute.swift b/Projects/Domain/Sources/Entities/LastRoute.swift index cbefa7a..23fa5e6 100644 --- a/Projects/Domain/Sources/Entities/LastRoute.swift +++ b/Projects/Domain/Sources/Entities/LastRoute.swift @@ -1,6 +1,8 @@ import Foundation -public enum TransportMode: Equatable, Sendable { +/// Codable: 세션 스냅샷의 수단 기록용(LA 재시작 아이콘 분기) — 미지 케이스 디코딩 실패는 +/// 스냅샷 어댑터가 nil로 무해화한다. +public enum TransportMode: Equatable, Sendable, Codable { case walk case bus case subway diff --git a/Projects/Domain/Sources/Entities/LastRouteSessionDerivation.swift b/Projects/Domain/Sources/Entities/LastRouteSessionDerivation.swift new file mode 100644 index 0000000..544b1e8 --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastRouteSessionDerivation.swift @@ -0,0 +1,38 @@ +import Foundation + +// 알람 세션이 경로에서 취하는 파생값 — 등록(스냅샷 저장)·LA 어댑터·홈이 같은 계산을 +// 봐야 하므로 Domain 순수 계산으로 둔다(이중 계산 금지, 미확정 #7 클라 임시안). +public extension LastRoute { + /// 알람 기준 시각에 반영하는 첫 도보 구간 시간(초) — legs의 **첫 `.walk` leg**의 + /// sectionTime. 도보 구간이 없으면 nil(호출자는 버퍼만 적용으로 폴백). + var firstWalkSectionSeconds: Int? { + legs.first(where: { $0.mode == .walk })?.sectionTime + } + + /// 막차 탑승 구간 — departureTime이 있는 첫 대중교통 구간, 없으면 첫 대중교통 구간. + var boardingLeg: TransportLeg? { + let transitLegs = legs.filter { $0.mode == .bus || $0.mode == .subway } + return transitLegs.first(where: { $0.departureTime != nil }) ?? transitLegs.first + } + + /// 노선 표시명 — LA 타이틀·스냅샷(재실행 복원)용. + /// 버스 routeName은 "타입:번호"(예: "간선:472") → "472번 버스", 지하철은 노선명 + /// 그대로(급행이면 " 급행"). 탑승 구간이 없으면 "막차". + var sessionDisplayName: String { + guard let leg = boardingLeg else { return "막차" } + switch leg.mode { + case .subway: + guard let name = leg.routeName else { return "지하철" } + return leg.isExpressSubway ? "\(name) 급행" : name + case .bus: + guard let routeName = leg.routeName else { return "버스" } + guard let colonIndex = routeName.firstIndex(of: ":") else { + return "\(routeName)번 버스" + } + let number = String(routeName[routeName.index(after: colonIndex)...]) + return "\(number)번 버스" + case .walk, .unknown: + return "막차" + } + } +} diff --git a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift index f0a1cc2..2c6ac94 100644 --- a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift @@ -9,20 +9,26 @@ public struct DefaultCancelAlarmUseCase: CancelAlarmUseCase { private let scheduler: any AlarmScheduler /// Live Activity 포트 — nil이면 LA 없이 동작한다(Example·기존 콜사이트 호환). private let activityPort: (any LastTrainActivityPort)? + /// 세션 스냅샷(Phase 14) — 취소는 clear 시점이다. nil이면 영속화 없이 동작한다. + private let snapshotStore: (any AlarmSessionSnapshotStore)? public init( repository: any AlarmRepository, scheduler: any AlarmScheduler, - activityPort: (any LastTrainActivityPort)? = nil + activityPort: (any LastTrainActivityPort)? = nil, + snapshotStore: (any AlarmSessionSnapshotStore)? = nil ) { self.repository = repository self.scheduler = scheduler self.activityPort = activityPort + self.snapshotStore = snapshotStore } public func execute(lastRouteId: String) async throws { try await repository.cancel(lastRouteId: lastRouteId) await scheduler.cancelAlarm() + // 세션이 유저 의사로 끝났다 — 재실행 브리지(스냅샷)도 함께 지운다. + await snapshotStore?.clear() // 수명 정책: 알람 세션이 끝나면 LA도 끝낸다. 유저 취소는 실패 상태가 아니므로 // phase는 .active로 종료한다. end는 즉시 닫는 경로라 시각 값은 표시에 쓰이지 않는다. if let activityPort { diff --git a/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift b/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift new file mode 100644 index 0000000..c964daa --- /dev/null +++ b/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift @@ -0,0 +1,17 @@ +/// 경로 상세 재조회 (Phase 14 카드 복원) — 재실행 후 "알람은 있는데 무슨 경로인지 +/// 모르는" 상태를 해소한다. 홈은 alarmSynced 수신 시 카드가 없으면 이걸로 복원한다. +public protocol GetLastRouteDetailUseCase: Sendable { + func execute(routeId: String) async throws -> LastRoute +} + +public struct DefaultGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { + private let repository: any LastRouteRepository + + public init(repository: any LastRouteRepository) { + self.repository = repository + } + + public func execute(routeId: String) async throws -> LastRoute { + try await repository.lastRoute(id: routeId) + } +} diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift index 2c76034..2a604c5 100644 --- a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -7,25 +7,39 @@ public protocol RefreshAlarmUseCase: Sendable { public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { private let repository: any AlarmRepository private let scheduler: any AlarmScheduler + /// 도보 초의 출처(Phase 14) — refresh 응답에는 도보 정보가 없으므로 등록 시점 + /// 스냅샷에서 읽는다. nil이면 버퍼만 적용(등록 경로와 같은 폴백). + private let snapshotStore: (any AlarmSessionSnapshotStore)? private let now: @Sendable () -> Date public init( repository: any AlarmRepository, scheduler: any AlarmScheduler, + snapshotStore: (any AlarmSessionSnapshotStore)? = nil, now: @escaping @Sendable () -> Date = { Date() } ) { self.repository = repository self.scheduler = scheduler + self.snapshotStore = snapshotStore self.now = now } public func execute() async throws -> AlarmInfo { let info = try await repository.refresh() // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. - // 기대 발화 시각은 버퍼 반영값(AlarmTiming) — register와 동일한 기준으로 비교해야 - // 변경이 없어도 매 refresh마다 재스케줄되는 헛돎을 막는다. + // 기대 발화 시각은 register와 같은 기준(출발 − 도보 − 버퍼)으로 비교해야 + // 변경이 없어도 매 refresh마다 재스케줄되는 헛돎이 없다(이중 시각 금지). if let departure = info.departureTime { - let expectedFireDate = AlarmTiming.alarmFireDate(departureTime: departure) + let snapshot = await snapshotStore?.load() + // 스냅샷의 도보 초는 같은 세션(routeId 일치)일 때만 유효하다 — + // 서버가 다른 경로로 갈아탔으면 그 경로의 도보를 모른다(버퍼만 적용). + let firstWalkSeconds = snapshot?.info.lastRouteId == info.lastRouteId + ? snapshot?.firstWalkSeconds + : nil + let expectedFireDate = AlarmTiming.alarmFireDate( + departureTime: departure, + firstWalkSeconds: firstWalkSeconds + ) // 새 알람 시각이 이미 과거면 재스케줄하지 않는다 — 과거 fixed 스케줄은 AlarmKit이 // 거부해 refresh 전체를 실패시킬 수 있다. 이 경우의 인지는 갱신 성공 이후 // 훅의 즉시 최후통첩이 담당한다(정책 4 "알람 발화 후 변경"). diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index 8f2e7f7..129c996 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -1,3 +1,5 @@ +import Foundation + public protocol RegisterAlarmUseCase: Sendable { func execute(route: LastRoute) async throws } @@ -9,21 +11,38 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { private let activityPort: (any LastTrainActivityPort)? /// 로컬 노티 포트 — nil이면 알림 권한 요청 없이 동작한다(Example·기존 콜사이트 호환). private let notificationPort: (any LocalNotificationPort)? + /// 세션 스냅샷(Phase 14 재실행 브리지) — nil이면 영속화 없이 동작한다(Example 호환). + private let snapshotStore: (any AlarmSessionSnapshotStore)? + private let now: @Sendable () -> Date public init( repository: any AlarmRepository, scheduler: any AlarmScheduler, activityPort: (any LastTrainActivityPort)? = nil, - notificationPort: (any LocalNotificationPort)? = nil + notificationPort: (any LocalNotificationPort)? = nil, + snapshotStore: (any AlarmSessionSnapshotStore)? = nil, + now: @escaping @Sendable () -> Date = { Date() } ) { self.repository = repository self.scheduler = scheduler self.activityPort = activityPort self.notificationPort = notificationPort + self.snapshotStore = snapshotStore + self.now = now } public func execute(route: LastRoute) async throws { - // 권한 요청이 최초 진입점 — 거부면 서버 등록까지 전부 보류한다. + // 발화 시각(출발 − 도보 − 버퍼, 미확정 #7 클라 임시안)이 이미 과거면 시작조차 + // 하지 않는다 — 서버 등록이 성공한 뒤 로컬 스케줄(AlarmKit 과거 fixed 거부)이 + // 실패하는 서버/로컬 불일치의 사전 가드. 권한 팝업보다도 앞선다(죽은 경로에 + // 권한을 묻지 않는다). + let fireDate = AlarmTiming.alarmFireDate( + departureTime: route.departureTime, + firstWalkSeconds: route.firstWalkSectionSeconds + ) + guard fireDate > now() else { throw AlarmError.tooLate } + + // 권한 요청이 다음 진입점 — 거부면 서버 등록까지 전부 보류한다. guard await scheduler.requestAuthorization() else { throw AlarmError.permissionDenied } @@ -34,21 +53,30 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { } try await repository.register(lastRouteId: route.id) // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. - // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각 − 버퍼로 스케줄한다. try await scheduler.replaceAlarm( id: route.id, - fireDate: AlarmTiming.alarmFireDate(departureTime: route.departureTime), + fireDate: fireDate, title: AlarmSchedulingDefaults.title ) + let session = AlarmInfo( + lastRouteId: route.id, + departureTime: route.departureTime, + updatedAt: nil, // 등록 직후라 서버 재계산 값이 아직 없다 — refresh가 갱신한다. + isReal: true + ) + // 등록 성공 = 스냅샷 저장 시점(Phase 14) — 재실행 시 diff 기준·LA 재부착·카드 + // 복원의 재료. 도보 초·표시명·수단은 등록 시점 경로에서만 얻을 수 있다. + await snapshotStore?.save(AlarmSessionSnapshot( + info: session, + firstWalkSeconds: route.firstWalkSectionSeconds, + routeDisplayName: route.sessionDisplayName, + transportMode: route.boardingLeg?.mode, + acknowledged: false, + expired: false + )) // 수명 정책: 알람 등록(서버+로컬)이 전부 성공한 뒤에만 LA를 시작한다. // start는 throws가 아니므로 LA 실패가 알람 등록을 실패시킬 수 없다. if let activityPort { - let session = AlarmInfo( - lastRouteId: route.id, - departureTime: route.departureTime, - updatedAt: nil, // 등록 직후라 서버 재계산 값이 아직 없다 — refresh가 갱신한다. - isReal: true - ) await activityPort.start(session: session, route: route) } // 알림 권한 요청 — [미확정 #8 임시] 알람 등록 성공 직후가 유일한 요청 시점. diff --git a/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift b/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift new file mode 100644 index 0000000..fc85562 --- /dev/null +++ b/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift @@ -0,0 +1,65 @@ +@testable import Domain +import Foundation +import Testing + +struct AlarmSessionSnapshotTests { + private let snapshot = AlarmSessionSnapshot( + info: AlarmInfo( + lastRouteId: "route-1", + departureTime: Date(timeIntervalSince1970: 1_756_000_000), + updatedAt: Date(timeIntervalSince1970: 1_755_990_000), + isReal: true + ), + firstWalkSeconds: 120, + routeDisplayName: "6411번 버스", + transportMode: .bus, + acknowledged: false, + expired: false + ) + + @Test + func roundTripsCodable() throws { + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(AlarmSessionSnapshot.self, from: data) + #expect(decoded == snapshot) + } + + @Test + func nilOptionalFields_roundTripCodable() throws { + let sparse = AlarmSessionSnapshot( + info: AlarmInfo(lastRouteId: "r", departureTime: nil, updatedAt: nil, isReal: false), + firstWalkSeconds: nil, + routeDisplayName: "", + transportMode: nil, + acknowledged: true, + expired: true + ) + let data = try JSONEncoder().encode(sparse) + let decoded = try JSONDecoder().decode(AlarmSessionSnapshot.self, from: data) + #expect(decoded == sparse) + #expect(decoded.firstWalkSeconds == nil) + #expect(decoded.transportMode == nil) + } + + @Test + func updating_replacesOnlyGivenFields() { + let newInfo = AlarmInfo( + lastRouteId: "route-1", + departureTime: Date(timeIntervalSince1970: 1_756_000_600), + updatedAt: nil, + isReal: true + ) + let updated = snapshot.updating(info: newInfo, acknowledged: true) + // 세션 사실(도보·표시명·수단)은 유지된다. + #expect(updated.info == newInfo) + #expect(updated.acknowledged) + #expect(!updated.expired) + #expect(updated.firstWalkSeconds == 120) + #expect(updated.routeDisplayName == "6411번 버스") + #expect(updated.transportMode == .bus) + + let tombstone = snapshot.updating(expired: true) + #expect(tombstone.expired) + #expect(tombstone.info == snapshot.info) + } +} diff --git a/Projects/Domain/Tests/AlarmTimingTests.swift b/Projects/Domain/Tests/AlarmTimingTests.swift index a5a9b02..b7bceef 100644 --- a/Projects/Domain/Tests/AlarmTimingTests.swift +++ b/Projects/Domain/Tests/AlarmTimingTests.swift @@ -4,9 +4,27 @@ import Testing struct AlarmTimingTests { @Test - func alarmFireDate_isDepartureMinusBuffer() { + func alarmFireDate_withoutWalk_isDepartureMinusBuffer() { let departure = Date(timeIntervalSince1970: 1_000) - #expect(AlarmTiming.alarmFireDate(departureTime: departure) == Date(timeIntervalSince1970: 820)) + #expect( + AlarmTiming.alarmFireDate(departureTime: departure, firstWalkSeconds: nil) + == Date(timeIntervalSince1970: 820) + ) + } + + @Test + func alarmFireDate_withWalk_subtractsWalkAndBuffer() { + // Phase 14 (미확정 #7 클라 임시안): 기준 시각 = 출발 − 도보 − 버퍼. + let departure = Date(timeIntervalSince1970: 1_000) + #expect( + AlarmTiming.alarmFireDate(departureTime: departure, firstWalkSeconds: 120) + == Date(timeIntervalSince1970: 700) + ) + // 0초 도보는 없음과 동일하다. + #expect( + AlarmTiming.alarmFireDate(departureTime: departure, firstWalkSeconds: 0) + == Date(timeIntervalSince1970: 820) + ) } @Test diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift index 2b23a32..ad53443 100644 --- a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -44,6 +44,14 @@ private struct SpyAlarmScheduler: AlarmScheduler { func scheduledFireDate() async -> Date? { nil } } +private actor SpySnapshotStore: AlarmSessionSnapshotStore { + private(set) var clearCount = 0 + + func load() async -> AlarmSessionSnapshot? { nil } + func save(_ snapshot: AlarmSessionSnapshot) async {} + func clear() async { clearCount += 1 } +} + struct DefaultCancelAlarmUseCaseTests { @Test func execute_serverCancelSucceeds_thenCancelsLocalAlarm() async throws { @@ -68,4 +76,35 @@ struct DefaultCancelAlarmUseCaseTests { } #expect(await log.events == ["cancel:route-1"]) } + + // MARK: - 세션 스냅샷 (Phase 14) + + @Test + func execute_success_clearsSnapshot() async throws { + let log = CallLog() + let store = SpySnapshotStore() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + snapshotStore: store + ) + try await sut.execute(lastRouteId: "route-1") + #expect(await store.clearCount == 1) + } + + @Test + func execute_serverCancelFails_keepsSnapshot() async { + // 세션은 아직 살아 있다 — 실패한 취소가 재실행 브리지를 지우면 안 된다. + let log = CallLog() + let store = SpySnapshotStore() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log, cancelError: StubError()), + scheduler: SpyAlarmScheduler(log: log), + snapshotStore: store + ) + await #expect(throws: StubError.self) { + try await sut.execute(lastRouteId: "route-1") + } + #expect(await store.clearCount == 0) + } } diff --git a/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift b/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift new file mode 100644 index 0000000..ba4007c --- /dev/null +++ b/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift @@ -0,0 +1,40 @@ +@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/DefaultRefreshAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift index ec6243d..dd8979a 100644 --- a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift @@ -51,6 +51,25 @@ private func makeInfo(departureTime: Date?) -> AlarmInfo { AlarmInfo(lastRouteId: "route-1", departureTime: departureTime, updatedAt: nil, isReal: true) } +private struct StubSnapshotStore: AlarmSessionSnapshotStore { + let snapshot: AlarmSessionSnapshot? + + func load() async -> AlarmSessionSnapshot? { snapshot } + func save(_ snapshot: AlarmSessionSnapshot) async {} + func clear() async {} +} + +private func makeSnapshot(routeId: String, firstWalkSeconds: Int?) -> AlarmSessionSnapshot { + AlarmSessionSnapshot( + info: AlarmInfo(lastRouteId: routeId, departureTime: nil, updatedAt: nil, isReal: true), + firstWalkSeconds: firstWalkSeconds, + routeDisplayName: "6411번 버스", + transportMode: .bus, + acknowledged: false, + expired: false + ) +} + /// 픽스처가 1970 부근의 작은 epoch를 쓰므로, "기대 발화 시각이 미래일 때만 재스케줄" 가드를 /// 통과시키려면 now도 그보다 이른 고정값으로 주입한다. private let fixedNow: @Sendable () -> Date = { Date(timeIntervalSince1970: 0) } @@ -78,7 +97,12 @@ struct DefaultRefreshAlarmUseCaseTests { let sut = DefaultRefreshAlarmUseCase( repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: departure))), // 로컬 알람은 버퍼 반영값으로 스케줄돼 있으므로, 같은 기준으로 비교해야 헛재스케줄이 없다. - scheduler: SpyAlarmScheduler(log: log, scheduledDate: AlarmTiming.alarmFireDate(departureTime: departure)), + scheduler: SpyAlarmScheduler( + log: log, + scheduledDate: AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: nil + ) + ), now: fixedNow ) _ = try await sut.execute() @@ -127,6 +151,67 @@ struct DefaultRefreshAlarmUseCaseTests { #expect(await log.events == ["refresh"]) } + // MARK: - 도보 반영 (Phase 14 — 스냅샷이 도보 초의 출처) + + @Test + func execute_snapshotWalkSeconds_rescheduleUsesWalkAwareFireDate() async throws { + let log = CallLog() + let departure = Date(timeIntervalSince1970: 2_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository( + log: log, refreshResult: .success(makeInfo(departureTime: departure)) + ), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), + snapshotStore: StubSnapshotStore( + snapshot: makeSnapshot(routeId: "route-1", firstWalkSeconds: 120) + ), + now: fixedNow + ) + _ = try await sut.execute() + // 기대 발화 시각 = 2000 − 120(도보) − 180(버퍼) = 1700 — 등록 경로와 같은 기준. + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1700"]) + } + + @Test + func execute_snapshotWalkMatchingSchedule_doesNotReschedule() async throws { + // 로컬 알람이 이미 도보 반영값으로 걸려 있으면 재스케줄하지 않는다(헛돎 금지). + let log = CallLog() + let departure = Date(timeIntervalSince1970: 2_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository( + log: log, refreshResult: .success(makeInfo(departureTime: departure)) + ), + scheduler: SpyAlarmScheduler( + log: log, scheduledDate: Date(timeIntervalSince1970: 1_700) + ), + snapshotStore: StubSnapshotStore( + snapshot: makeSnapshot(routeId: "route-1", firstWalkSeconds: 120) + ), + now: fixedNow + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh", "scheduledFireDate"]) + } + + @Test + func execute_snapshotForDifferentRoute_ignoresItsWalkSeconds() async throws { + // 서버가 다른 경로로 갈아탔으면 옛 경로의 도보 초는 무효 — 버퍼만 적용한다. + let log = CallLog() + let departure = Date(timeIntervalSince1970: 2_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository( + log: log, refreshResult: .success(makeInfo(departureTime: departure)) + ), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), + snapshotStore: StubSnapshotStore( + snapshot: makeSnapshot(routeId: "other-route", firstWalkSeconds: 120) + ), + now: fixedNow + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1820"]) + } + @Test func execute_refreshFails_throwsWithoutScheduling() async { let log = CallLog() diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index a313622..6a27986 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -50,8 +50,26 @@ private struct SpyAlarmScheduler: AlarmScheduler { func scheduledFireDate() async -> Date? { nil } } +private actor SpySnapshotStore: AlarmSessionSnapshotStore { + private(set) var saved: [AlarmSessionSnapshot] = [] + private(set) var clearCount = 0 + var stored: AlarmSessionSnapshot? + + func load() async -> AlarmSessionSnapshot? { stored } + + func save(_ snapshot: AlarmSessionSnapshot) async { + saved.append(snapshot) + stored = snapshot + } + + func clear() async { + clearCount += 1 + stored = nil + } +} + private extension LastRoute { - static func fixture(id: String) -> LastRoute { + static func fixture(id: String, legs: [TransportLeg] = []) -> LastRoute { LastRoute( id: id, departureTime: Date(timeIntervalSince1970: 1_000), @@ -60,11 +78,33 @@ private extension LastRoute { transferCount: 0, totalDistance: 0, totalWalkDistance: 0, - legs: [] + legs: legs ) } } +private func walkLeg(sectionTime: Int) -> TransportLeg { + TransportLeg( + mode: .walk, sectionTime: sectionTime, distance: 100, departureTime: nil, + routeName: nil, lineType: nil, start: nil, end: nil, + subwayFinalStation: nil, subwayDirection: nil, + isExpressSubway: false, isLastSubway: false + ) +} + +private func busLeg(routeName: String) -> TransportLeg { + TransportLeg( + mode: .bus, sectionTime: 900, distance: 4_000, departureTime: nil, + routeName: routeName, lineType: "11", start: nil, end: nil, + subwayFinalStation: nil, subwayDirection: nil, + isExpressSubway: false, isLastSubway: false + ) +} + +/// 픽스처가 1970 부근의 작은 epoch를 쓰므로, tooLate 사전 가드(발화 시각 > now)를 +/// 통과시키려면 now도 그보다 이른 고정값으로 주입한다. +private let fixedNow: @Sendable () -> Date = { Date(timeIntervalSince1970: 0) } + struct DefaultRegisterAlarmUseCaseTests { @Test func execute_existingAlarm_cancelsThenRegistersThenSchedules() async throws { @@ -72,7 +112,8 @@ struct DefaultRegisterAlarmUseCaseTests { let existing = AlarmInfo(lastRouteId: "old", departureTime: nil, updatedAt: nil, isReal: false) let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log, existing: existing), - scheduler: SpyAlarmScheduler(log: log) + scheduler: SpyAlarmScheduler(log: log), + now: fixedNow ) try await sut.execute(route: .fixture(id: "new")) // 스케줄 시각은 버퍼 반영값: 출발 1000 − 180 = 820 @@ -84,18 +125,33 @@ struct DefaultRegisterAlarmUseCaseTests { let log = CallLog() let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), - scheduler: SpyAlarmScheduler(log: log) + scheduler: SpyAlarmScheduler(log: log), + now: fixedNow ) try await sut.execute(route: .fixture(id: "new")) #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new@820"]) } + @Test + func execute_routeWithWalkLeg_schedulesWalkAwareFireDate() async throws { + // Phase 14: 발화 시각 = 출발 − 첫 도보 − 버퍼 = 1000 − 120 − 180 = 700. + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + now: fixedNow + ) + try await sut.execute(route: .fixture(id: "new", legs: [walkLeg(sectionTime: 120)])) + #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new@700"]) + } + @Test func execute_serverRegisterFails_doesNotSchedule() async { let log = CallLog() let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log, registerError: StubError()), - scheduler: SpyAlarmScheduler(log: log) + scheduler: SpyAlarmScheduler(log: log), + now: fixedNow ) await #expect(throws: StubError.self) { try await sut.execute(route: .fixture(id: "new")) @@ -108,7 +164,8 @@ struct DefaultRegisterAlarmUseCaseTests { let log = CallLog() let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), - scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false) + scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false), + now: fixedNow ) await #expect(throws: AlarmError.permissionDenied) { try await sut.execute(route: .fixture(id: "new")) @@ -116,4 +173,84 @@ struct DefaultRegisterAlarmUseCaseTests { // 권한 거부 시 서버 등록·삭제·로컬 스케줄 어디에도 도달하면 안 된다. #expect(await log.events == ["auth:false"]) } + + // MARK: - tooLate 사전 가드 (Phase 14) + + @Test + func execute_fireDateAlreadyPast_throwsTooLateBeforeAnySideEffect() async { + let log = CallLog() + let store = SpySnapshotStore() + // 발화 시각 820 ≤ now 820 — 경계 포함 과거로 본다. + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + snapshotStore: store, + now: { Date(timeIntervalSince1970: 820) } + ) + await #expect(throws: AlarmError.tooLate) { + try await sut.execute(route: .fixture(id: "new")) + } + // 권한 팝업·서버 등록·스케줄·스냅샷 어디에도 도달하지 않는다(사전 가드). + #expect(await log.events == []) + #expect(await store.saved.isEmpty) + } + + @Test + func execute_walkMakesFireDatePast_throwsTooLate() async { + // 도보 반영으로 발화 시각(700)이 now(750)보다 과거가 되는 경우도 가드에 걸린다. + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + now: { Date(timeIntervalSince1970: 750) } + ) + await #expect(throws: AlarmError.tooLate) { + try await sut.execute(route: .fixture(id: "new", legs: [walkLeg(sectionTime: 120)])) + } + #expect(await log.events == []) + } + + // MARK: - 세션 스냅샷 (Phase 14) + + @Test + func execute_success_savesSnapshotWithRouteFacts() async throws { + let log = CallLog() + let store = SpySnapshotStore() + let route = LastRoute.fixture( + id: "new", legs: [walkLeg(sectionTime: 120), busLeg(routeName: "간선:6411")] + ) + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + snapshotStore: store, + now: fixedNow + ) + try await sut.execute(route: route) + + let saved = await store.saved + #expect(saved.count == 1) + #expect(saved.first?.info.lastRouteId == "new") + #expect(saved.first?.info.departureTime == route.departureTime) + #expect(saved.first?.firstWalkSeconds == 120) + #expect(saved.first?.routeDisplayName == "6411번 버스") + #expect(saved.first?.transportMode == .bus) + #expect(saved.first?.acknowledged == false) + #expect(saved.first?.expired == false) + } + + @Test + func execute_serverRegisterFails_doesNotSaveSnapshot() async { + let log = CallLog() + let store = SpySnapshotStore() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, registerError: StubError()), + scheduler: SpyAlarmScheduler(log: log), + snapshotStore: store, + now: fixedNow + ) + await #expect(throws: StubError.self) { + try await sut.execute(route: .fixture(id: "new")) + } + #expect(await store.saved.isEmpty) + } } diff --git a/Projects/Domain/Tests/LastRouteSessionDerivationTests.swift b/Projects/Domain/Tests/LastRouteSessionDerivationTests.swift new file mode 100644 index 0000000..384eb13 --- /dev/null +++ b/Projects/Domain/Tests/LastRouteSessionDerivationTests.swift @@ -0,0 +1,99 @@ +@testable import Domain +import Foundation +import Testing + +private func leg( + mode: TransportMode, + sectionTime: Int = 600, + departureTime: Date? = nil, + routeName: String? = nil, + isExpressSubway: Bool = false +) -> TransportLeg { + TransportLeg( + mode: mode, sectionTime: sectionTime, distance: 100, departureTime: departureTime, + routeName: routeName, lineType: nil, start: nil, end: nil, + subwayFinalStation: nil, subwayDirection: nil, + isExpressSubway: isExpressSubway, isLastSubway: false + ) +} + +private func route(legs: [TransportLeg]) -> LastRoute { + LastRoute( + id: "r", departureTime: Date(timeIntervalSince1970: 1_000), totalTime: 0, + totalWalkTime: 0, transferCount: 0, totalDistance: 0, totalWalkDistance: 0, legs: legs + ) +} + +struct LastRouteSessionDerivationTests { + @Test + func firstWalkSectionSeconds_takesFirstWalkLegOnly() { + // 첫 .walk leg의 sectionTime — 뒤쪽 도보(환승·하차)는 반영하지 않는다. + let sut = route(legs: [ + leg(mode: .walk, sectionTime: 120), + leg(mode: .subway, routeName: "2호선"), + leg(mode: .walk, sectionTime: 300), + ]) + #expect(sut.firstWalkSectionSeconds == 120) + } + + @Test + func firstWalkSectionSeconds_noWalkLeg_isNil() { + #expect(route(legs: [leg(mode: .bus, routeName: "간선:6411")]).firstWalkSectionSeconds == nil) + #expect(route(legs: []).firstWalkSectionSeconds == nil) + } + + @Test + func firstWalkSectionSeconds_walkAfterTransit_stillCounts() { + // "첫 .walk leg"는 위치와 무관하게 목록에서 처음 나오는 도보 구간이다 — + // 문서 명세(route.legs의 첫 .walk leg sectionTime) 그대로. + let sut = route(legs: [ + leg(mode: .subway, routeName: "2호선"), + leg(mode: .walk, sectionTime: 240), + ]) + #expect(sut.firstWalkSectionSeconds == 240) + } + + @Test + func boardingLeg_prefersLegWithDepartureTime() { + let departure = Date(timeIntervalSince1970: 1_000) + let sut = route(legs: [ + leg(mode: .walk), + leg(mode: .bus, routeName: "간선:472"), + leg(mode: .subway, departureTime: departure, routeName: "9호선"), + ]) + #expect(sut.boardingLeg?.routeName == "9호선") + } + + @Test + func boardingLeg_fallsBackToFirstTransitLeg() { + let sut = route(legs: [ + leg(mode: .walk), + leg(mode: .bus, routeName: "간선:472"), + leg(mode: .subway, routeName: "9호선"), + ]) + #expect(sut.boardingLeg?.routeName == "간선:472") + } + + @Test + func sessionDisplayName_busParsesTypePrefix() { + #expect(route(legs: [leg(mode: .bus, routeName: "간선:6411")]).sessionDisplayName == "6411번 버스") + #expect(route(legs: [leg(mode: .bus, routeName: "472")]).sessionDisplayName == "472번 버스") + #expect(route(legs: [leg(mode: .bus)]).sessionDisplayName == "버스") + } + + @Test + func sessionDisplayName_subwayKeepsLineName() { + #expect(route(legs: [leg(mode: .subway, routeName: "2호선")]).sessionDisplayName == "2호선") + #expect( + route(legs: [leg(mode: .subway, routeName: "9호선", isExpressSubway: true)]) + .sessionDisplayName == "9호선 급행" + ) + #expect(route(legs: [leg(mode: .subway)]).sessionDisplayName == "지하철") + } + + @Test + func sessionDisplayName_noTransitLeg_fallsBack() { + #expect(route(legs: [leg(mode: .walk)]).sessionDisplayName == "막차") + #expect(route(legs: []).sessionDisplayName == "막차") + } +} diff --git a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift index deb808c..eb26ece 100644 --- a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift +++ b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift @@ -91,6 +91,10 @@ private struct SpyLocalNotificationPort: LocalNotificationPort { } } +/// 픽스처가 1970 부근의 작은 epoch를 쓰므로, tooLate 사전 가드(발화 시각 > now)를 +/// 통과시키려면 now도 그보다 이른 고정값으로 주입한다. +private let fixedNow: @Sendable () -> Date = { Date(timeIntervalSince1970: 0) } + private extension LastRoute { static func fixture(id: String) -> LastRoute { LastRoute( @@ -115,7 +119,8 @@ struct LastTrainActivityLifecycleTests { let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log), - activityPort: SpyActivityPort(log: log, box: box) + activityPort: SpyActivityPort(log: log, box: box), + now: fixedNow ) try await sut.execute(route: route) // 순서 계약: 알람(서버 등록 → 로컬 교체)이 항상 LA 시작에 선행한다. @@ -135,7 +140,8 @@ struct LastTrainActivityLifecycleTests { let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log, registerError: StubError()), scheduler: SpyAlarmScheduler(log: log), - activityPort: SpyActivityPort(log: log, box: box) + activityPort: SpyActivityPort(log: log, box: box), + now: fixedNow ) await #expect(throws: StubError.self) { try await sut.execute(route: .fixture(id: "new")) @@ -152,7 +158,8 @@ struct LastTrainActivityLifecycleTests { let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false), - activityPort: SpyActivityPort(log: log, box: box) + activityPort: SpyActivityPort(log: log, box: box), + now: fixedNow ) await #expect(throws: AlarmError.permissionDenied) { try await sut.execute(route: .fixture(id: "new")) @@ -169,7 +176,8 @@ struct LastTrainActivityLifecycleTests { repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log), activityPort: SpyActivityPort(log: log, box: box), - notificationPort: SpyLocalNotificationPort(log: log) + notificationPort: SpyLocalNotificationPort(log: log), + now: fixedNow ) try await sut.execute(route: .fixture(id: "new")) // 순서 계약: 알림 권한 요청은 흐름의 맨 끝 — LA 시작 뒤에 정확히 1회. @@ -186,7 +194,8 @@ struct LastTrainActivityLifecycleTests { repository: SpyAlarmRepository(log: log, registerError: StubError()), scheduler: SpyAlarmScheduler(log: log), activityPort: SpyActivityPort(log: log, box: box), - notificationPort: SpyLocalNotificationPort(log: log) + notificationPort: SpyLocalNotificationPort(log: log), + now: fixedNow ) await #expect(throws: StubError.self) { try await sut.execute(route: .fixture(id: "new")) @@ -203,7 +212,8 @@ struct LastTrainActivityLifecycleTests { repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false), activityPort: SpyActivityPort(log: log, box: box), - notificationPort: SpyLocalNotificationPort(log: log) + notificationPort: SpyLocalNotificationPort(log: log), + now: fixedNow ) await #expect(throws: AlarmError.permissionDenied) { try await sut.execute(route: .fixture(id: "new")) diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index 3102c72..0b35925 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -41,6 +41,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { cancelAlarmUseCase: PreviewCancelAlarmUseCase(), observeAlarmUseCase: PreviewObserveAlarmUseCase(), observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(), + getLastRouteDetailUseCase: PreviewGetLastRouteDetailUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -96,6 +97,15 @@ struct PreviewObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { } } +/// 재실행 카드 복원 경로 스텁 — 동기화 이벤트가 없어 호출되지 않지만, 호출돼도 +/// canned 경로를 돌려줘 플로우가 성립한다. +struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { + func execute(routeId: String) async throws -> LastRoute { + try? await Task.sleep(for: .milliseconds(300)) + return PreviewSearchCoordinator.makeCannedRoute() + } +} + /// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다. /// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다. struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { @@ -122,7 +132,8 @@ final class PreviewSearchCoordinator: Coordinator { finish() } - private static func makeCannedRoute() -> LastRoute { + // nonisolated: 상세 재조회 스텁(nonisolated async)에서도 공유한다. + nonisolated static func makeCannedRoute() -> LastRoute { let departure = Date().addingTimeInterval(42 * 60) return LastRoute( id: "preview-route", diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 4d86076..5cfdc10 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -14,6 +14,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase + private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable public init( @@ -23,6 +24,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, + getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase @@ -31,6 +33,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase self.observeAlarmChangeUseCase = observeAlarmChangeUseCase + self.getLastRouteDetailUseCase = getLastRouteDetailUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -47,7 +50,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, observeAlarmUseCase: observeAlarmUseCase, - observeAlarmChangeUseCase: observeAlarmChangeUseCase + observeAlarmChangeUseCase: observeAlarmChangeUseCase, + getLastRouteDetailUseCase: getLastRouteDetailUseCase ) viewModel.onSearchRequested = onSearchRequested return HomeViewController(viewModel: viewModel) diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 9e1e005..bb7ff40 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -220,6 +220,8 @@ final class HomeViewController: UIViewController { ) case .alarmRegisterFailed: DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: view) + case .alarmTooLate: + DSToast.show("이미 출발 시간이 지난 경로예요", in: view) case .alarmCancelFailed: DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: view) case let .lastTrainAdvanced(minutes): diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index db9bcb1..01af5d6 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -40,6 +40,8 @@ final class HomeViewModel { case alarmPermissionNeeded case alarmRegisterFailed case alarmCancelFailed + /// 발화 시각이 이미 과거인 경로의 등록 시도(사전 가드, Phase 14) — 서버 등록 전에 차단됐다. + case alarmTooLate /// 포그라운드 인앱 채널: 막차가 당겨졌다는 판정 — VC가 토스트 + 배너 강조로 표출한다. case lastTrainAdvanced(minutes: Int) /// 이미 못 타는 앞당김(actionable=false) — 배너는 실패 문구로 고정되고 원샷 안내만 나간다. @@ -64,6 +66,7 @@ final class HomeViewModel { private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase + private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration @@ -75,6 +78,7 @@ final class HomeViewModel { private var observeTask: Task? private var changeTask: Task? private var bannerTask: Task? + private var restoreCardTask: Task? init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, @@ -83,6 +87,7 @@ final class HomeViewModel { cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, + getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) ) { @@ -92,6 +97,7 @@ final class HomeViewModel { self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase self.observeAlarmChangeUseCase = observeAlarmChangeUseCase + self.getLastRouteDetailUseCase = getLastRouteDetailUseCase self.now = now self.bannerTickInterval = bannerTickInterval } @@ -102,6 +108,7 @@ final class HomeViewModel { observeTask?.cancel() changeTask?.cancel() bannerTask?.cancel() + restoreCardTask?.cancel() } // MARK: - 입력 @@ -146,11 +153,18 @@ final class HomeViewModel { self?.registeredRouteId = route.id self?.state.isAlarmBusy = false self?.refreshAlarmButton() - self?.startBannerTimer(departure: route.departureTime) + self?.startBannerTimer( + departure: route.departureTime, + firstWalkSeconds: route.firstWalkSectionSeconds + ) } catch AlarmError.permissionDenied { guard !Task.isCancelled else { return } self?.state.isAlarmBusy = false self?.onToast?(.alarmPermissionNeeded) + } catch AlarmError.tooLate { + guard !Task.isCancelled else { return } + self?.state.isAlarmBusy = false + self?.onToast?(.alarmTooLate) } catch { guard !Task.isCancelled else { return } self?.state.isAlarmBusy = false @@ -213,7 +227,47 @@ final class HomeViewModel { // 창의 "지금 출발하세요"(2단계)도 동기화 복원 대상이다. if let departure = info.departureTime, !AlarmTiming.isSessionExpired(departureTime: departure, now: now()) { - startBannerTimer(departure: departure) + // 도보 초는 등록 시점 경로에서만 안다 — 같은 경로가 화면에 있으면 그 값, + // 재실행 복원(카드 없음)이면 상세 재조회가 끝난 뒤 재시작하며 반영한다. + startBannerTimer( + departure: departure, + firstWalkSeconds: selectedRoute?.id == info.lastRouteId + ? selectedRoute?.firstWalkSectionSeconds + : nil + ) + } + restoreRouteCardIfNeeded(info) + } + + /// 재실행 복원 (Phase 14): 알람은 있는데 카드가 없으면 경로 상세를 재조회해 카드를 + /// 복원한다 — "무슨 경로인지 모르는 해제 버튼" 상태 해소. 실패는 현행 폴백(카드 없이 + /// 해제 버튼)으로 조용히 남는다 — 만료 정리가 있어 유령이 오래가지 않는다. + private func restoreRouteCardIfNeeded(_ info: AlarmInfo) { + guard state.routeCard == nil, restoreCardTask == nil else { return } + restoreCardTask = Task { [weak self] in + defer { self?.restoreCardTask = nil } + guard let useCase = self?.getLastRouteDetailUseCase else { return } + guard let route = try? await useCase.execute(routeId: info.lastRouteId) else { return } + guard !Task.isCancelled, let self else { return } + // 복원 도중 상태가 변했으면(새 경로 선택·세션 종료) 낡은 복원을 버린다. + guard self.registeredRouteId == info.lastRouteId, + self.state.routeCard == nil else { return } + self.selectedRoute = route + var newState = self.state + newState.routeCard = RouteCardViewData(entity: route) + newState.alarmButton = Self.alarmButtonMode( + selectedRouteId: route.id, + registeredRouteId: self.registeredRouteId + ) + self.state = newState + // 배너를 도보 반영 기준으로 재시작한다(등록/refresh/LA와 같은 값 — 이중 시각 금지). + if let departure = info.departureTime, + !AlarmTiming.isSessionExpired(departureTime: departure, now: self.now()) { + self.startBannerTimer( + departure: departure, + firstWalkSeconds: route.firstWalkSectionSeconds + ) + } } } @@ -234,8 +288,9 @@ final class HomeViewModel { private func alarmChanged(_ verdict: AlarmChangeVerdict) { switch verdict { case let .advanced(by: interval, actionable: true): - // 반올림하되 최소 1분 — "0분 당겨졌어요"는 말이 안 된다. - let minutes = max(1, Int((interval / 60).rounded())) + // 올림 + 최소 1분 — LA 잠금화면 문구(.up)와 같은 분으로 읽혀야 한다(표기 통일, + // Phase 14). "0분 당겨졌어요"도 자연히 존재하지 않는다. + let minutes = max(1, Int((interval / 60).rounded(.up))) onToast?(.lastTrainAdvanced(minutes: minutes)) case .advanced(by: _, actionable: false): // 이미 못 타는 앞당김 — 카운트다운을 멈추고 배너를 실패 문구로 고정한다. @@ -295,13 +350,15 @@ final class HomeViewModel { ) } - private func startBannerTimer(departure: Date) { + private func startBannerTimer(departure: Date, firstWalkSeconds: Int?) { bannerTask?.cancel() // 매 틱 departure 기준으로 재계산 — 누적 드리프트가 없다. bannerTask = Task { [weak self] in while !Task.isCancelled { guard let now = self?.now() else { return } - guard let banner = Self.makeBanner(departure: departure, now: now) else { + guard let banner = Self.makeBanner( + departure: departure, firstWalkSeconds: firstWalkSeconds, now: now + ) else { // 유예 경과(3단계) — 배너·버튼을 내리고 카드를 "지난 막차"로 전환, // 틱 종료. 알람·LA·서버 정리는 App(AlarmSyncService)의 wake 판정 몫. self?.sessionExpired(departure: departure) @@ -345,19 +402,25 @@ final class HomeViewModel { max(0, Int(ceil(departure.timeIntervalSince(now) / 60))) } - /// 카운트다운·긴급도 모두 **버퍼 포함 알람 발화 시각**(AlarmTiming) 기준 — LA와 기준을 - /// 통일한다(이중 시각 금지). 그래서 문구도 "막차 출발까지"가 아니라 사용자가 출발해야 - /// 할 시각 기준의 "출발까지"다. + /// 카운트다운·긴급도 모두 **도보·버퍼 포함 알람 발화 시각**(AlarmTiming) 기준 — 등록/ + /// refresh/LA와 기준을 통일한다(이중 시각 금지). 그래서 문구도 "막차 출발까지"가 + /// 아니라 사용자가 출발해야 할 시각 기준의 "출발까지"다. /// /// Phase 13 배너 3단계 전이: /// 1) now < 알람 시각: "출발까지 N분" /// 2) 알람 시각 ≤ now < 출발+유예: "지금 출발하세요" (imminent 고정 — "출발까지 0분" 제거) /// 3) 유예 경과: nil — 호출자가 배너를 내리고 카드를 "지난 막차"로 전환할 시점. - nonisolated static func makeBanner(departure: Date, now: Date) -> BannerViewData? { + nonisolated static func makeBanner( + departure: Date, + firstWalkSeconds: Int?, + now: Date + ) -> BannerViewData? { guard !AlarmTiming.isSessionExpired(departureTime: departure, now: now) else { return nil } - let alarmDate = AlarmTiming.alarmFireDate(departureTime: departure) + let alarmDate = AlarmTiming.alarmFireDate( + departureTime: departure, firstWalkSeconds: firstWalkSeconds + ) guard now < alarmDate else { return BannerViewData(text: "지금 출발하세요", urgency: .imminent) } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index b72d7a7..7607ac0 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -35,6 +35,11 @@ private struct StubObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { func execute() -> AsyncStream { handler() } } +private struct StubGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { + let handler: @Sendable (String) async throws -> LastRoute + func execute(routeId: String) async throws -> LastRoute { try await handler(routeId) } +} + /// 테스트 도중 `now()`를 전진시키기 위한 가변 시계. private nonisolated final class NowBox: @unchecked Sendable { private let lock = NSLock() @@ -121,6 +126,9 @@ private func makeSUT( alarmChanges: @escaping @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } }, + routeDetail: @escaping @Sendable (String) async throws -> LastRoute = { _ in + throw StubError() + }, now: @escaping @Sendable () -> Date = { fixedNow }, bannerTickInterval: Duration = .seconds(60) ) -> HomeViewModel { @@ -131,6 +139,7 @@ private func makeSUT( cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates), observeAlarmChangeUseCase: StubObserveAlarmChangeUseCase(handler: alarmChanges), + getLastRouteDetailUseCase: StubGetLastRouteDetailUseCase(handler: routeDetail), now: now, bannerTickInterval: bannerTickInterval ) @@ -261,19 +270,19 @@ struct HomeViewModelTests { // 카운트다운·긴급도 모두 알람 발화 시각(출발 − 3분 버퍼) 기준 — 이중 시각 금지. // 출발까지 13분 = 알람까지 정확히 600초: imminent 경계. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(13 * 60), now: fixedNow + departure: fixedNow.addingTimeInterval(13 * 60), firstWalkSeconds: nil, now: fixedNow ) == .init(text: "출발까지 10분", urgency: .imminent)) // 알람까지 601초: caution으로 넘어간다. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(13 * 60 + 1), now: fixedNow + departure: fixedNow.addingTimeInterval(13 * 60 + 1), firstWalkSeconds: nil, now: fixedNow )?.urgency == .caution) // 출발까지 33분 = 알람까지 정확히 1800초: caution 경계. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(33 * 60), now: fixedNow + departure: fixedNow.addingTimeInterval(33 * 60), firstWalkSeconds: nil, now: fixedNow ) == .init(text: "출발까지 30분", urgency: .caution)) // 알람까지 1801초: relaxed. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(33 * 60 + 1), now: fixedNow + departure: fixedNow.addingTimeInterval(33 * 60 + 1), firstWalkSeconds: nil, now: fixedNow )?.urgency == .relaxed) } @@ -282,22 +291,22 @@ struct HomeViewModelTests { // Phase 13: 1단계(카운트다운) → 2단계(지금 출발하세요) → 3단계(nil = 지난 막차). // 알람 직전(출발 3분 1초 전): 아직 1단계 — "출발까지 1분". #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(181), now: fixedNow + departure: fixedNow.addingTimeInterval(181), firstWalkSeconds: nil, now: fixedNow ) == .init(text: "출발까지 1분", urgency: .imminent)) // 알람 시각 정각(출발 3분 전): 2단계 진입 — "출발까지 0분"은 존재하지 않는다. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(180), now: fixedNow + departure: fixedNow.addingTimeInterval(180), firstWalkSeconds: nil, now: fixedNow ) == .init(text: "지금 출발하세요", urgency: .imminent)) // 출발 정각·유예 마지막 초까지 2단계 유지. #expect(HomeViewModel.makeBanner( - departure: fixedNow, now: fixedNow + departure: fixedNow, firstWalkSeconds: nil, now: fixedNow ) == .init(text: "지금 출발하세요", urgency: .imminent)) #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(-59), now: fixedNow + departure: fixedNow.addingTimeInterval(-59), firstWalkSeconds: nil, now: fixedNow ) == .init(text: "지금 출발하세요", urgency: .imminent)) // 유예 경계(출발+60초)부터 3단계 — nil. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(-60), now: fixedNow + departure: fixedNow.addingTimeInterval(-60), firstWalkSeconds: nil, now: fixedNow ) == nil) } @@ -624,7 +633,199 @@ struct HomeViewModelTests { from: DateComponents(year: 2026, month: 8, day: 23, hour: 0, minute: 10) )! - #expect(HomeViewModel.makeBanner(departure: departure, now: now) + #expect(HomeViewModel.makeBanner(departure: departure, firstWalkSeconds: nil, now: now) == .init(text: "출발까지 27분", urgency: .caution)) } + + // MARK: - 도보 시간 반영 (Phase 14) + + @Test + func makeBanner_walkSeconds_shiftAlarmBase() { + // 기준 = 출발 − 도보 − 버퍼: 출발 42분 뒤, 도보 120초면 알람까지 37분. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(42 * 60), firstWalkSeconds: 120, now: fixedNow + ) == .init(text: "출발까지 37분", urgency: .relaxed)) + // 도보 반영으로 알람 시각이 이미 지났으면(출발 5분 전, 도보 120초 → 알람 정각) + // 2단계 "지금 출발하세요"에 진입한다. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(5 * 60), firstWalkSeconds: 120, now: fixedNow + ) == .init(text: "지금 출발하세요", urgency: .imminent)) + } + + @Test + func registerAlarm_routeWithWalk_bannerUsesWalkAwareBase() async { + // 등록 배너도 등록/LA와 같은 기준(출발 − 도보 − 버퍼)을 쓴다 — 이중 시각 금지. + let route = makeWalkRoute( + id: "r1", departure: fixedNow.addingTimeInterval(42 * 60), walkSeconds: 120 + ) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + #expect(sut.state.banner == .init(text: "출발까지 37분", urgency: .relaxed)) + } + + // MARK: - tooLate 사전 가드 (Phase 14) + + @Test + func registerAlarm_tooLate_emitsToastAndKeepsRegisterButton() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(60)) + let sut = makeSUT(register: { _ in throw AlarmError.tooLate }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.alarmTooLate]) + #expect(sut.state.banner == nil) + #expect(!sut.state.isAlarmBusy) + #expect(sut.state.alarmButton == .register) + } + + // MARK: - 변경 분 표기 올림 통일 (Phase 14) + + @Test + func changeStream_advancedToast_roundsMinutesUp() async { + // 61초 앞당김 → 올림 2분 — LA 잠금화면 문구(.up)와 같은 분으로 읽힌다. + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmChanges: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield(.advanced(by: 61, actionable: true)) + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.lastTrainAdvanced(minutes: 2)]) + } + + // MARK: - 재실행 카드 복원 (Phase 14) + + @Test + func alarmSync_withoutCard_restoresCardFromRouteDetail() async { + // 재실행 복원: 카드 없이 동기화가 도착하면 상세를 재조회해 카드·해제 버튼· + // 도보 반영 배너까지 복원한다 — "무슨 경로인지 모르는 해제 버튼" 해소. + let departure = fixedNow.addingTimeInterval(42 * 60) + let route = makeWalkRoute(id: "r1", departure: departure, walkSeconds: 120) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT( + alarmUpdates: { stream }, + routeDetail: { routeId in + #expect(routeId == "r1") + return route + } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) + ) + await recorder.waitUntilLast { $0.routeCard != nil } + + #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + #expect(sut.state.alarmButton == .cancel) + // 배너도 상세의 도보 초 반영 기준으로 재시작됐다 (42분 − 2분 도보 − 3분 버퍼 = 37분). + await recorder.waitUntilLast { $0.banner?.text == "출발까지 37분" } + } + + @Test + func alarmSync_detailFails_keepsCancelButtonWithoutCard() async { + // 복원 실패는 현행 폴백 — 카드 없이 해제 버튼·배너만. (기본 routeDetail 스텁이 throw) + let departure = fixedNow.addingTimeInterval(30 * 60) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) + ) + await recorder.waitUntilLast { $0.banner != nil } + for _ in 0..<20 { await Task.yield() } + + #expect(sut.state.routeCard == nil) + #expect(sut.state.alarmButton == .cancel) + } + + @Test + func alarmSync_cardAlreadyVisible_doesNotFetchDetail() async { + // 등록 직후의 동기화 — 카드가 이미 있으면 상세 재조회를 하지 않는다. + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let fetched = FetchFlag() + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT( + alarmUpdates: { stream }, + routeDetail: { _ in + fetched.mark() + throw StubError() + } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + continuation.yield( + AlarmInfo( + lastRouteId: "r1", + departureTime: fixedNow.addingTimeInterval(42 * 60), + updatedAt: nil, + isReal: true + ) + ) + for _ in 0..<20 { await Task.yield() } + + #expect(!fetched.value) + #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + } +} + +/// 상세 재조회 호출 여부 기록용 — 스텁 클로저가 @Sendable이라 클래스 박스로 관찰한다. +private nonisolated final class FetchFlag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return flag + } + + func mark() { + lock.lock() + defer { lock.unlock() } + flag = true + } +} + +/// 첫 구간이 도보인 경로 픽스처 (Phase 14 도보 반영 검증용). +private nonisolated func makeWalkRoute(id: String, departure: Date, walkSeconds: Int) -> LastRoute { + let base = makeRoute(id: id, departure: departure) + let walk = TransportLeg( + mode: .walk, sectionTime: walkSeconds, distance: 150, departureTime: nil, + routeName: nil, lineType: nil, start: nil, end: nil, + subwayFinalStation: nil, subwayDirection: nil, + isExpressSubway: false, isLastSubway: false + ) + return LastRoute( + id: base.id, + departureTime: base.departureTime, + totalTime: base.totalTime, + totalWalkTime: base.totalWalkTime, + transferCount: base.transferCount, + totalDistance: base.totalDistance, + totalWalkDistance: base.totalWalkDistance, + legs: [walk] + base.legs + ) }