From 546e6b3a3211726eca6881e09717837ec19d9a4f Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 23 Aug 2026 02:14:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AtchaV2=20Phase=2011=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=98=EC=98=81=20=ED=94=BC=EA=B8=B0?= =?UTF-8?q?=EB=B0=B1=20(diff=20=ED=8C=90=EC=A0=95=20+=20=EB=B0=A9=ED=96=A5?= =?UTF-8?q?=20=EB=B9=84=EB=8C=80=EC=B9=AD=20+=20=EB=B2=84=ED=8D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: EvaluateAlarmChangeUseCase(AlarmInfo 전/후 비교 — 발화 후 nil 함정 회피, sessionEnded 최우선·1초 미만 오차 흡수·actionable 보수 근사) + 자정 경계 테스트 - AlarmTiming: 버퍼 −3분 균일 적용 (register/refresh 이중 시각 금지, 도보 반영은 TODO #7) - RefreshAlarmUseCase: 기대 발화 시각이 과거면 재스케줄 생략 (AlarmKit 거부로 refresh 전체가 실패하는 경로 차단 — 인지는 훅의 즉시 최후통첩 몫) - AlarmSyncService 훅: 갱신 성공 → 판정 → 채널 분기 (advanced 백그라운드=LA alert +배지 10분 / 포그라운드=조용+changes 스트림 / 알람 후 변경=즉시 최후통첩 / delayed=조용) — 재스케줄 선행 구조 유지, AlarmChangeEvents(replay 없음) 채택 - 행동 중심 문구 한 파일(LastTrainChangeMessages) + 어댑터 문구 주입 경로 - Home: 배너 3단계 긴급도(DSBanner .caution + dangerContainer 토큰) + emphasize 펄스 + "출발까지 N분" 알람 시각 기준 통일 + advanced 토스트("막차가 N분 당겨졌어요") - DEV 디버그 변경 시뮬레이터: 플로팅 버튼 → 앞당김/늦춤/종료 주입(3초 지연) → syncFromPush Co-Authored-By: Claude Fable 5 --- .../Sources/Adapters/DevDemoFallbacks.swift | 90 +++++++++- .../LastTrainLiveActivityAdapter.swift | 57 +++++-- Projects/App/Sources/AlarmSyncService.swift | 158 +++++++++++++++++- Projects/App/Sources/AppDIContainer.swift | 10 +- .../App/Sources/LastTrainChangeMessages.swift | 44 +++++ Projects/App/Sources/SceneDelegate.swift | 92 ++++++++++ .../Sources/Components/DSBanner.swift | 27 ++- .../Sources/Foundation/DSColor.swift | 3 + .../DesignSystem/Tests/DSBannerTests.swift | 19 +++ .../DesignSystem/Tests/DesignTokenTests.swift | 1 + .../Domain/Sources/Entities/AlarmTiming.swift | 13 ++ .../Interfaces/AlarmChangeEvents.swift | 7 + .../UseCases/EvaluateAlarmChangeUseCase.swift | 46 +++++ .../UseCases/ObserveAlarmChangeUseCase.swift | 17 ++ .../UseCases/RefreshAlarmUseCase.swift | 30 +++- .../UseCases/RegisterAlarmUseCase.swift | 4 +- Projects/Domain/Tests/AlarmTimingTests.swift | 16 ++ ...faultEvaluateAlarmChangeUseCaseTests.swift | 119 +++++++++++++ ...efaultObserveAlarmChangeUseCaseTests.swift | 30 ++++ .../DefaultRefreshAlarmUseCaseTests.swift | 37 +++- .../DefaultRegisterAlarmUseCaseTests.swift | 7 +- .../Feature/Home/Example/ExampleApp.swift | 8 + .../Home/Sources/HomeDIContainer.swift | 6 +- .../Home/Sources/HomeViewController.swift | 17 +- .../Feature/Home/Sources/HomeViewModel.swift | 56 ++++++- .../Home/Tests/HomeViewModelTests.swift | 99 +++++++++-- 26 files changed, 949 insertions(+), 64 deletions(-) create mode 100644 Projects/App/Sources/LastTrainChangeMessages.swift create mode 100644 Projects/Domain/Sources/Entities/AlarmTiming.swift create mode 100644 Projects/Domain/Sources/Interfaces/AlarmChangeEvents.swift create mode 100644 Projects/Domain/Sources/UseCases/EvaluateAlarmChangeUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift create mode 100644 Projects/Domain/Tests/AlarmTimingTests.swift create mode 100644 Projects/Domain/Tests/DefaultEvaluateAlarmChangeUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift index d7d104c..2e8108b 100644 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift @@ -48,7 +48,12 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { do { return try await base.searchLastRoutes(start: start, end: end) } catch { print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)") - return [Self.demoRoute(start: start, end: end)] + let route = Self.demoRoute(start: start, end: end) + // 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다. + await DevChangeSimulator.shared.noteKnownSession( + routeId: route.id, departureTime: route.departureTime + ) + return [route] } } @@ -56,10 +61,14 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { do { return try await base.lastRoute(id: id) } catch { print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)") - return Self.demoRoute( + let route = Self.demoRoute( start: Coordinate(latitude: 37.4979, longitude: 127.0276), end: Coordinate(latitude: 37.4853, longitude: 126.9015) ) + await DevChangeSimulator.shared.noteKnownSession( + routeId: route.id, departureTime: route.departureTime + ) + return route } } @@ -110,12 +119,15 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { /// 서버 알람 등록/삭제 실패를 무시해 로컬 스케줄(AlarmKit)까지 진행시킨다. /// refresh는 그대로 실패시킨다 — 홈이 상태를 유지하므로 시연에 지장이 없다. +/// 단, Phase 11 변경 시뮬레이터의 주입이 보류 중이면 서버 대신 변형 AlarmInfo를 반환한다. struct DevDemoTolerantAlarmRepository: AlarmRepository { let base: any AlarmRepository func register(lastRouteId: String) async throws { do { try await base.register(lastRouteId: lastRouteId) } catch { print("⚠️ [DEV 우회] 서버 알람 등록 실패 무시 → 로컬 스케줄 진행: \(error)") } + // 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다. + await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil) } func cancel(lastRouteId: String) async throws { @@ -124,7 +136,79 @@ struct DevDemoTolerantAlarmRepository: AlarmRepository { } func refresh() async throws -> AlarmInfo { - try await base.refresh() + // Phase 11 검수: 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). + if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { + print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") + return injected + } + let info = try await base.refresh() + await DevChangeSimulator.shared.noteKnownSession( + routeId: info.lastRouteId, departureTime: info.departureTime + ) + return info + } +} + +// MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 — 사람 검수의 전제) + +/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단. +/// 실서버가 변경을 내려줄 수 없는 동안 `DevDemoTolerantAlarmRepository.refresh()`가 +/// 보류 중 주입을 소비해, 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다. +@MainActor +final class DevChangeSimulator { + static let shared = DevChangeSimulator() + + enum Injection { + /// 출발 시각을 N초 앞당긴다 → advanced 판정 유도. + case advance(TimeInterval) + /// 출발 시각을 N초 늦춘다 → delayed 판정 유도. + case delay(TimeInterval) + /// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도. + case end + } + + private var pending: Injection? + /// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다. + private var knownRouteId: String? + private var knownDepartureTime: Date? + + private init() {} + + /// 주입 예약 — 다음 refresh() 1회가 소비한다. + func inject(_ injection: Injection) { + pending = injection + print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)") + } + + /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). + func noteKnownSession(routeId: String, departureTime: Date?) { + knownRouteId = routeId + if let departureTime { knownDepartureTime = departureTime } + } + + /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. + /// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다 + /// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수). + func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? { + guard let injection = pending else { return nil } + pending = nil + let routeId = knownRouteId ?? "dev-demo-route" + // 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정. + let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60) + + let departure: Date? + switch injection { + case .advance(let seconds): departure = base.addingTimeInterval(-seconds) + case .delay(let seconds): departure = base.addingTimeInterval(seconds) + case .end: departure = nil + } + knownDepartureTime = departure + return AlarmInfo( + lastRouteId: routeId, + departureTime: departure, + updatedAt: now, + isReal: true + ) } } #endif diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift index 49c05d1..5b067b8 100644 --- a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -3,6 +3,16 @@ import CoreLiveActivity import Domain import Foundation +/// App 내부 확장 포트 — Phase 11 변경 훅이 alert **문구**를 실어 보내는 경로. +/// Domain 포트(`update(state:alert: Bool)`)는 문서 고정 계약이라 그대로 두고, +/// 변경 유형별 문구가 필요한 호출자(AlarmSyncService)는 이 프로토콜로 어댑터를 본다. +/// nonisolated 명시: App의 기본 MainActor 격리가 요구사항에 스미면 actor 어댑터가 +/// 적합성을 만족할 수 없다 — Domain 포트(.nonisolated 모듈)와 같은 조건을 재현한다. +nonisolated protocol LastTrainChangeAlerting: Sendable { + /// alert가 nil이면 조용한 상태 갱신, 값이 있으면 해당 문구의 AlertConfiguration으로 갱신한다. + func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async +} + /// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. /// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체). /// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다. @@ -10,14 +20,10 @@ import Foundation /// actor인 이유: 포트는 nonisolated async 요구사항을 가진 Sendable 프로토콜이라 /// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가). /// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다. -actor LastTrainLiveActivityAdapter: LastTrainActivityPort { +actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting { /// 유저 스와이프 dismiss 기록 키 — 앱 재실행 후에도 남아야 Phase 12 폴백 트리거 재료가 된다. private static let dismissedDefaultsKey = "la.dismissedByUser" - // TODO: Phase 11 — 변경 유형별 알림 문구를 UseCase에서 주입한다. 그 전까지는 범용 문구. - private static let alertTitle: LocalizedStringResource = "막차 정보가 변경됐어요" - private static let alertBody: LocalizedStringResource = "잠금화면에서 최신 막차 시간을 확인하세요" - private let userDefaults: UserDefaults /// 단일 세션 — 단일 알람 정책과 동일. 새 start가 이전 activity를 먼저 내린다. @@ -55,8 +61,8 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort { let departureTime = session.departureTime ?? route.departureTime let initialState = LastTrainActivityState( departureTime: departureTime, - // TODO: Phase 11 — 알람 버퍼(기준 시각 − 버퍼) 도입 전까지 alarmTime = departureTime. - alarmTime: departureTime, + // 로컬 알람 발화 시각 — register/refresh와 동일한 버퍼 반영값(출발 − 3분). + alarmTime: AlarmTiming.alarmFireDate(departureTime: departureTime), urgency: Domain.LastTrainUrgency.forTimeRemaining( departureTime.timeIntervalSinceNow ), @@ -85,16 +91,15 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort { } func update(state: LastTrainActivityState, alert: Bool) async { - guard let activity else { return } - // staleDate도 매 갱신마다 최신 출발 시각으로 재설정한다(앞당겨짐·미뤄짐 반영). - let content = ActivityContent( - state: Self.contentState(from: state), - staleDate: state.departureTime + // 문구 없는 Bool 경로(Domain 포트) — alert=true면 범용 폴백 문구로 위임한다. + // Phase 11 훅은 이 경로 대신 LastTrainChangeAlerting으로 변경 유형별 문구를 싣는다. + await update( + state: state, + alert: alert + ? (title: LastTrainChangeMessages.genericChangeTitle, + body: LastTrainChangeMessages.genericChangeBody) + : nil ) - let alertConfiguration: AlertConfiguration? = alert - ? AlertConfiguration(title: Self.alertTitle, body: Self.alertBody, sound: .default) - : nil - await activity.update(content, alertConfiguration: alertConfiguration) } func end(final state: LastTrainActivityState) async { @@ -115,6 +120,26 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort { var isDismissedByUser: Bool { dismissedByUser } + // MARK: - LastTrainChangeAlerting + + func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async { + guard let activity else { return } + // staleDate도 매 갱신마다 최신 출발 시각으로 재설정한다(앞당겨짐·미뤄짐 반영). + let content = ActivityContent( + state: Self.contentState(from: state), + staleDate: state.departureTime + ) + // LocalizedStringResource의 키로 원문을 그대로 쓴다 — 테이블 미등록 키는 원문 표시. + let alertConfiguration = alert.map { + AlertConfiguration( + title: LocalizedStringResource(stringLiteral: $0.title), + body: LocalizedStringResource(stringLiteral: $0.body), + sound: .default + ) + } + await activity.update(content, alertConfiguration: alertConfiguration) + } + // MARK: - Dismiss 감지 /// 유저가 잠금화면에서 LA를 스와이프로 지우면 `.dismissed`가 도착한다. diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 67296cc..35dd133 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -7,24 +7,43 @@ import os /// 복귀·사일런트 푸시 3경로가 전부 여기의 `RefreshAlarmUseCase` 호출 한 곳으로 /// 모인다 — 시각 변경 시 재스케줄은 UseCase 내부 정책이고, 성공 결과는 /// `AlarmSyncEvents` 스트림으로 구독자(HomeViewModel)에게 전파돼 배너를 갱신한다. -// Sendable 프로토콜(AlarmSyncEvents) 채택이 기본 MainActor 격리를 nonisolated로 +/// +/// Phase 11 피기백: 갱신 성공 뒤 전/후 AlarmInfo를 판정(`EvaluateAlarmChangeUseCase`)해 +/// ① LA 채널(당겨짐 alert / 늦춰짐 조용한 갱신)과 ② 인앱 채널(`AlarmChangeEvents` → +/// 홈 배너 강조·토스트)을 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` 안에서 +/// 이미 끝난 뒤라(반환 = 재스케줄 완료) LA·인앱 표출 실패가 알람을 막을 구조 자체가 없다. +// Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor -final class AlarmSyncService: AlarmSyncEvents { +final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private let refreshAlarmUseCase: any RefreshAlarmUseCase + private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase + /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. + private let liveActivity: any LastTrainChangeAlerting private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") + /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. + private static let changeBadgeDuration: TimeInterval = 600 + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + /// 변경 판정 구독자 — updates()와 달리 **replay 없음**(과거 변경이 재구독 시 재발화 금지). + private var changeSubscribers: [UUID: AsyncStream.Continuation] = [:] /// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와 - /// 거의 동시에 구독하므로 순서에 기대지 않는다. + /// 거의 동시에 구독하므로 순서에 기대지 않는다. 변경 판정의 "이전 값"이기도 하다. private var lastInfo: AlarmInfo? /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. private var inFlight: Task? private var foregroundObserver: (any NSObjectProtocol)? // 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다. - init(refreshAlarmUseCase: any RefreshAlarmUseCase) { + init( + refreshAlarmUseCase: any RefreshAlarmUseCase, + evaluateChangeUseCase: any EvaluateAlarmChangeUseCase, + liveActivity: any LastTrainChangeAlerting + ) { self.refreshAlarmUseCase = refreshAlarmUseCase + self.evaluateChangeUseCase = evaluateChangeUseCase + self.liveActivity = liveActivity } /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 @@ -68,14 +87,127 @@ final class AlarmSyncService: AlarmSyncEvents { inFlight = nil if let info { Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") + let previous = lastInfo lastInfo = info for continuation in subscribers.values { continuation.yield(info) } + // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 + // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. + await propagateChange(previous: previous, latest: info) } return info } + // MARK: - Phase 11 변경 표출 (판정 → LA/인앱 채널) + + /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. + private func propagateChange(previous: AlarmInfo?, latest: AlarmInfo) async { + let now = Date() + // 첫 수신(이전 값 없음)은 비교 대상이 없다 — unchanged 취급. + let verdict = previous.map { + evaluateChangeUseCase.execute(previous: $0, latest: latest, now: now) + } ?? AlarmChangeVerdict.unchanged + + switch verdict { + case .unchanged: + // 변경 없음 — 조용한 상태 갱신 1회만(긴급도 색 재평가 목적). 배지·alert·yield 없음. + if let state = activityState(for: latest, now: now) { + await liveActivity.update(state: state, alert: nil) + } + + case .delayed: + // 늦춰짐 — 새 시각·재평가 긴급도로 조용한 업데이트. 배지 없음. + if let state = activityState(for: latest, now: now) { + await liveActivity.update(state: state, alert: nil) + } + yieldChange(verdict) + + case let .advanced(by: delta, actionable: true): + await presentAdvanced(previous: previous, latest: latest, delta: delta, now: now) + yieldChange(verdict) + + case .advanced(by: _, actionable: false), .sessionEnded: + // TODO(Phase 12): 못 탐/운행 종료 표출(missed·serviceEnded 전환, 로컬 노티 폴백)은 + // 다음 페이즈 산출물 — 그 전까지 조용한 LA 갱신만 한다 + // (sessionEnded는 departureTime이 없어 사실상 no-op). + if let state = activityState(for: latest, now: now) { + await liveActivity.update(state: state, alert: nil) + } + yieldChange(verdict) + } + } + + /// 앞당겨짐(actionable) 표출 — 새 알람 시각과 앱 상태로 alert 채널을 고른다. + private func presentAdvanced( + previous: AlarmInfo?, + latest: AlarmInfo, + delta: TimeInterval, + now: Date + ) async { + guard let departure = latest.departureTime else { return } + let alarmTime = AlarmTiming.alarmFireDate(departureTime: departure) + let badgeExpiry = now.addingTimeInterval(Self.changeBadgeDuration) + + guard alarmTime > now else { + // 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람 + // 시점이 지나 있으므로 조용한 채널로는 늦다: 포그라운드 여부와 무관하게 즉시 최후통첩. + let state = LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: .imminent, + changeBadgeExpiry: badgeExpiry, + phase: .active + ) + await liveActivity.update(state: state, alert: ( + title: LastTrainChangeMessages.ultimatumTitle, + body: LastTrainChangeMessages.ultimatumBody(latestDeparture: departure) + )) + return + } + + let state = LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), + changeBadgeExpiry: badgeExpiry, + phase: .active + ) + if UIApplication.shared.applicationState == .active { + // 포그라운드 — LA alert 생략(조용한 업데이트 + 배지). 사용자 주의는 인앱 채널 + // (changes 스트림 → 홈 배너 강조 + 토스트)이 맡는다. 이중 알림 방지. + await liveActivity.update(state: state, alert: nil) + } else { + // 백그라운드 — 잠금화면 alert + 행동 중심 문구 + "당겨짐" 배지(만료 now+10분). + let minutesEarlier = max(1, Int((delta / 60).rounded(.up))) + // advanced(by:)의 delta = 이전 출발 − 새 출발. 이전 값이 비어 있으면 새 시각 + delta로 복원. + let previousDeparture = previous?.departureTime ?? departure.addingTimeInterval(delta) + await liveActivity.update(state: state, alert: ( + title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier), + body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure) + )) + } + } + + /// 갱신된 AlarmInfo → LA 상태. departureTime이 없으면(세션 종료 등) 만들 수 없다. + private func activityState(for info: AlarmInfo, now: Date) -> LastTrainActivityState? { + guard let departure = info.departureTime else { return nil } + let alarmTime = AlarmTiming.alarmFireDate(departureTime: departure) + return LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), + changeBadgeExpiry: nil, + phase: .active + ) + } + + private func yieldChange(_ verdict: AlarmChangeVerdict) { + for continuation in changeSubscribers.values { + continuation.yield(verdict) + } + } + // MARK: - AlarmSyncEvents nonisolated func updates() -> AsyncStream { @@ -94,4 +226,22 @@ final class AlarmSyncService: AlarmSyncEvents { } } } + + // MARK: - AlarmChangeEvents + + /// updates()와 달리 replay 없음 — 변경 알림은 상태가 아니라 사건이라, + /// 재구독 시 과거 판정이 다시 발화하면(배너 강조·토스트 반복) 안 된다. + nonisolated func changes() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { @MainActor in + self.changeSubscribers[id] = continuation + } + continuation.onTermination = { _ in + Task { @MainActor in + self.changeSubscribers.removeValue(forKey: id) + } + } + } + } } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 139fd6f..486a58c 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -79,12 +79,17 @@ final class AppDIContainer { // 디바이스 포트 어댑터 — CoreLocation/AlarmKit/ActivityKit을 아는 곳은 App의 어댑터뿐. let alarmScheduler = CoreAlarmSchedulerAdapter() self.alarmScheduler = alarmScheduler - self.liveActivityPort = LastTrainLiveActivityAdapter() + // 구체 어댑터로 들고 있다가 두 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase)와 + // App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅). + let liveActivityAdapter = LastTrainLiveActivityAdapter() + self.liveActivityPort = liveActivityAdapter self.alarmSyncService = AlarmSyncService( refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler - ) + ), + evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), + liveActivity: liveActivityAdapter ) } @@ -115,6 +120,7 @@ final class AppDIContainer { activityPort: liveActivityPort ), observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), + observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmSyncService), searchCoordinatorBuildable: searchContainer ) } diff --git a/Projects/App/Sources/LastTrainChangeMessages.swift b/Projects/App/Sources/LastTrainChangeMessages.swift new file mode 100644 index 0000000..f01fc79 --- /dev/null +++ b/Projects/App/Sources/LastTrainChangeMessages.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Phase 11 — 막차 변경 인지 계층의 사용자 대면 문구 단일 소스(정책: 하드코딩 상수, 한 파일). +/// Live Activity alert(잠금화면)와 추후 Phase 12 로컬 노티 폴백이 같은 문구를 공유한다. +/// nonisolated: 발신처가 액터 경계를 넘나든다(@MainActor AlarmSyncService·actor LA 어댑터· +/// Phase 12 노티 빌더) — 어디서든 동기 접근 가능해야 한다. +nonisolated enum LastTrainChangeMessages { + + // MARK: - 당겨짐 (advanced, actionable) — 행동 중심 문구 + + /// LA alert 제목: "N분 일찍 나가야 해요" + static func advancedAlertTitle(minutesEarlier: Int) -> String { + "\(minutesEarlier)분 일찍 나가야 해요" + } + + /// LA alert 본문: "막차가 HH:mm → HH:mm로 당겨졌어요" + static func advancedAlertBody(from previousDeparture: Date, to latestDeparture: Date) -> String { + "막차가 \(timeText(previousDeparture)) → \(timeText(latestDeparture))로 당겨졌어요" + } + + // MARK: - 최후통첩 — 당겨진 새 알람 시각이 이미 과거(출발은 미래)인 마지노선 침범 + + static let ultimatumTitle = "지금 안 나가면 못 타요" + + static func ultimatumBody(latestDeparture: Date) -> String { + "막차가 \(timeText(latestDeparture)) 출발로 당겨졌어요. 바로 출발하세요" + } + + // MARK: - 범용 폴백 — 문구를 싣지 않는 Domain 포트 경로(update(state:alert: true)) 전용 + + static let genericChangeTitle = "막차 정보가 변경됐어요" + static let genericChangeBody = "잠금화면에서 최신 막차 시간을 확인하세요" + + // MARK: - 시각 표기 + + /// HH:mm (24시간 고정, ko_KR) — 사용자 로캘의 12시간제 설정과 무관하게 "23:40" 형태를 보장한다. + /// 호출 빈도가 낮아(변경 이벤트 시에만) 포매터를 매번 생성한다 — 격리 걱정 없는 가장 단순한 형태. + static func timeText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 181fe50..5091955 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -25,5 +25,97 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window appCoordinator = coordinator coordinator.start() + + #if DEV + installDevChangeButton(in: window, container: container) + #endif + } + + #if DEV + // MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 플로팅 버튼) + + /// 실서버가 변경을 내려줄 수 없는 동안 "막차 변경 → 알람 재스케줄 → LA/배너 표출"을 + /// 사람이 검수할 진입점. 주입 설정 후 3초 지연 뒤 사일런트 푸시 도착을 시뮬레이션한다 — + /// 3초는 홈 버튼으로 백그라운드 전환할 시간(잠금화면 LA alert 경로 검수용). + private func installDevChangeButton(in window: UIWindow, container: AppDIContainer) { + var configuration = UIButton.Configuration.filled() + configuration.title = "변경" + configuration.baseBackgroundColor = UIColor.systemIndigo.withAlphaComponent(0.75) + configuration.baseForegroundColor = .white + configuration.cornerStyle = .capsule + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 8, leading: 12, bottom: 8, trailing: 12 + ) + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.addAction( + UIAction { [weak self, weak button] _ in + self?.presentDevChangeMenu(container: container, sourceView: button) + }, + for: .touchUpInside + ) + // rootViewController 위 window 직속 — 네비게이션 푸시와 무관하게 항상 떠 있는다. + window.addSubview(button) + NSLayoutConstraint.activate([ + button.trailingAnchor.constraint( + equalTo: window.safeAreaLayoutGuide.trailingAnchor, constant: -16 + ), + button.bottomAnchor.constraint( + equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -96 + ), + ]) + } + + private func presentDevChangeMenu(container: AppDIContainer, sourceView: UIView?) { + guard let window, var top = window.rootViewController else { return } + while let presented = top.presentedViewController { top = presented } + + let sheet = UIAlertController( + title: "변경 시뮬레이터 (DEV)", + message: "선택 후 3초 안에 백그라운드로 전환하면 잠금화면 LA alert 경로, " + + "그대로 두면 인앱(배너 강조+토스트) 경로를 검수합니다.", + preferredStyle: .actionSheet + ) + func add( + _ title: String, + injection: DevChangeSimulator.Injection?, + delay: TimeInterval, + style: UIAlertAction.Style = .default + ) { + sheet.addAction(UIAlertAction(title: title, style: style) { _ in + Self.simulateChange(injection, after: delay, container: container) + }) + } + add("5분 앞당김 (3초 후)", injection: .advance(5 * 60), delay: 3) + add("15분 앞당김 (3초 후)", injection: .advance(15 * 60), delay: 3) + add("10분 늦춤 (3초 후)", injection: .delay(10 * 60), delay: 3) + add("운행 종료 (3초 후)", injection: .end, delay: 3, style: .destructive) + add("즉시 refresh", injection: nil, delay: 0) + sheet.addAction(UIAlertAction(title: "취소", style: .cancel)) + if let popover = sheet.popoverPresentationController { + popover.sourceView = sourceView ?? window + popover.sourceRect = sourceView?.bounds + ?? CGRect(origin: window.center, size: .zero) + } + top.present(sheet, animated: true) + } + + /// 주입 설정 → 지연 → 사일런트 푸시 수신과 동일 경로(syncFromPush) 호출. + /// injection이 nil이면 주입 없이 즉시 refresh만 한다(unchanged 경로·실서버 검수). + private static func simulateChange( + _ injection: DevChangeSimulator.Injection?, + after delay: TimeInterval, + container: AppDIContainer + ) { + Task { @MainActor in + if let injection { + DevChangeSimulator.shared.inject(injection) + } + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } + _ = await container.alarmSyncService.syncFromPush() + } } + #endif } diff --git a/Projects/DesignSystem/Sources/Components/DSBanner.swift b/Projects/DesignSystem/Sources/Components/DSBanner.swift index 8c68a5a..0e32833 100644 --- a/Projects/DesignSystem/Sources/Components/DSBanner.swift +++ b/Projects/DesignSystem/Sources/Components/DSBanner.swift @@ -1,10 +1,13 @@ import UIKit -// Countdown banner ("막차 출발까지 N분"). Rendering only — the 1-minute tick +// Countdown banner ("출발까지 N분"). Rendering only — the 1-minute tick // lives in the owning ViewModel, which calls configure on each update. public final class DSBanner: UIView { + /// 긴급도 3단계에 대응한다: 여유(normal) → 주의(caution) → 임박(urgent). + /// 시각 위계도 같은 순서로 상승한다 — 라임 컨테이너 → 레드 컨테이너 → 레드 솔리드. public enum Style { case normal + case caution case urgent } @@ -35,12 +38,34 @@ public final class DSBanner: UIView { case .normal: backgroundColor = DSColor.Accent.container label.textColor = DSColor.Accent.default + case .caution: + backgroundColor = DSColor.State.dangerContainer + label.textColor = DSColor.State.danger case .urgent: backgroundColor = DSColor.State.urgent label.textColor = DSColor.Text.primary } } + /// 갱신 강조 — 값이 바뀐 순간 1회 펄스로 시선을 끈다("막차가 당겨졌어요" 등). + /// 텍스트·스타일은 건드리지 않으므로 configure와 어느 순서로 불러도 안전하다. + public func emphasize() { + UIView.animate( + withDuration: 0.15, delay: 0, + options: [.curveEaseOut, .beginFromCurrentState] + ) { + self.transform = CGAffineTransform(scaleX: 1.03, y: 1.03) + } completion: { _ in + UIView.animate( + withDuration: 0.4, delay: 0, + usingSpringWithDamping: 0.5, initialSpringVelocity: 0, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.transform = .identity + } + } + } + @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") diff --git a/Projects/DesignSystem/Sources/Foundation/DSColor.swift b/Projects/DesignSystem/Sources/Foundation/DSColor.swift index 49f68b8..87e9e9f 100644 --- a/Projects/DesignSystem/Sources/Foundation/DSColor.swift +++ b/Projects/DesignSystem/Sources/Foundation/DSColor.swift @@ -41,6 +41,9 @@ public enum DSColor { public enum State { public static var danger: UIColor { DSPalette.red400 } + /// danger의 컨테이너 톤 — "주의" 단계 배경(DSBanner.caution 등). 어두운 베이스 위에 + /// red400을 저알파로 깔아 Accent.container와 같은 "은은한 채움" 위계를 만든다. + public static var dangerContainer: UIColor { DSPalette.red400.withAlphaComponent(0.16) } public static var urgent: UIColor { DSPalette.red600 } } } diff --git a/Projects/DesignSystem/Tests/DSBannerTests.swift b/Projects/DesignSystem/Tests/DSBannerTests.swift index dabb6e1..77cf514 100644 --- a/Projects/DesignSystem/Tests/DSBannerTests.swift +++ b/Projects/DesignSystem/Tests/DSBannerTests.swift @@ -19,6 +19,14 @@ struct DSBannerTests { } == true) } + @Test + func cautionStyleUsesDangerContainer() { + let banner = DSBanner(text: "출발까지 25분", style: .caution) + #expect(banner.backgroundColor.map { + colorsMatch($0, DSColor.State.dangerContainer) + } == true) + } + @Test func urgentStyleUsesUrgentState() { let banner = DSBanner(text: "막차 출발까지 5분", style: .urgent) @@ -26,4 +34,15 @@ struct DSBannerTests { colorsMatch($0, DSColor.State.urgent) } == true) } + + @Test + func emphasizeKeepsConfiguredContent() { + // 강조는 1회 펄스일 뿐 — 텍스트·스타일 상태를 바꾸지 않는다. + let banner = DSBanner(text: "출발까지 25분", style: .caution) + banner.emphasize() + #expect(renderedTexts(in: banner).contains("출발까지 25분")) + #expect(banner.backgroundColor.map { + colorsMatch($0, DSColor.State.dangerContainer) + } == true) + } } diff --git a/Projects/DesignSystem/Tests/DesignTokenTests.swift b/Projects/DesignSystem/Tests/DesignTokenTests.swift index fe695b7..3f0b88b 100644 --- a/Projects/DesignSystem/Tests/DesignTokenTests.swift +++ b/Projects/DesignSystem/Tests/DesignTokenTests.swift @@ -84,6 +84,7 @@ struct DesignTokenTests { #expect(colorsMatch(DSColor.Accent.container, DSPalette.lime900)) #expect(colorsMatch(DSColor.Accent.tint, DSPalette.lime200)) #expect(colorsMatch(DSColor.State.danger, DSPalette.red400)) + #expect(colorsMatch(DSColor.State.dangerContainer, DSPalette.red400.withAlphaComponent(0.16))) #expect(colorsMatch(DSColor.State.urgent, DSPalette.red600)) } } diff --git a/Projects/Domain/Sources/Entities/AlarmTiming.swift b/Projects/Domain/Sources/Entities/AlarmTiming.swift new file mode 100644 index 0000000..053c67c --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmTiming.swift @@ -0,0 +1,13 @@ +import Foundation + +/// 버퍼 = 스누즈 예산 (정책 5). 알람은 기준 시각 − 버퍼에 울린다. 버퍼 고정 3분, 설정 없음(v1). +/// TODO(미확정 #7): 기준 시각 = departureTime − 첫 도보 구간 시간이 원칙이나, refresh 응답에 +/// 도보 정보가 없어 register/refresh 이중 시각이 생기므로 확정 전까지 버퍼만 균일 적용한다. +public enum AlarmTiming { + public static let bufferSeconds: TimeInterval = 180 + + /// 로컬 알람 발화 시각 = departureTime − bufferSeconds. + public static func alarmFireDate(departureTime: Date) -> Date { + departureTime.addingTimeInterval(-bufferSeconds) + } +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmChangeEvents.swift b/Projects/Domain/Sources/Interfaces/AlarmChangeEvents.swift new file mode 100644 index 0000000..c00ec97 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmChangeEvents.swift @@ -0,0 +1,7 @@ +import Foundation + +/// 변경 판정 스트림. AlarmSyncEvents(성공 정보 replay-1)와 달리 **replay 없음** — +/// 과거 변경 알림이 재구독 시 다시 발화하면 안 된다. +public protocol AlarmChangeEvents: Sendable { + func changes() -> AsyncStream +} diff --git a/Projects/Domain/Sources/UseCases/EvaluateAlarmChangeUseCase.swift b/Projects/Domain/Sources/UseCases/EvaluateAlarmChangeUseCase.swift new file mode 100644 index 0000000..587aa04 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/EvaluateAlarmChangeUseCase.swift @@ -0,0 +1,46 @@ +import Foundation + +/// 막차 변경 판정 결과 — 표출(조용한 업데이트 vs alert)은 어댑터의 몫, 판정만 Domain이 한다. +public enum AlarmChangeVerdict: Sendable, Equatable { + case unchanged + /// 늦춰짐 → 조용한 업데이트 + case delayed(by: TimeInterval) + /// 앞당겨짐 → alert. actionable=false면 이미 못 탐(latest가 과거). + case advanced(by: TimeInterval, actionable: Bool) + /// 운행 종료·경로 소멸 + case sessionEnded +} + +/// 갱신 전/후 AlarmInfo를 비교해 변경을 판정한다. +/// 스케줄러 상태(scheduledFireDate)가 아니라 **AlarmInfo 전/후 비교**인 이유: +/// 알람 발화 후 scheduledFireDate가 nil이 되어 "변경 없음"이 "변경"으로 오판되기 때문. +public protocol EvaluateAlarmChangeUseCase: Sendable { + func execute(previous: AlarmInfo, latest: AlarmInfo, now: Date) -> AlarmChangeVerdict +} + +public struct DefaultEvaluateAlarmChangeUseCase: EvaluateAlarmChangeUseCase { + public init() {} + + public func execute(previous: AlarmInfo, latest: AlarmInfo, now: Date) -> AlarmChangeVerdict { + // 운행 종료·경로 소멸: 서버 재계산 결과에 출발 시각이 없다. + guard let latestDeparture = latest.departureTime else { + return .sessionEnded + } + // TODO: 첫 수신(이전 값 없음)은 비교 불가 — 변경으로 취급하지 않는다. + guard let previousDeparture = previous.departureTime else { + return .unchanged + } + + let difference = latestDeparture.timeIntervalSince(previousDeparture) + // 초 단위 동일(서브초 오차 포함)은 변경으로 보지 않는다. + if abs(difference) < 1 { + return .unchanged + } + if difference < 0 { + // TODO: [미확정 #7] 원칙은 "now + 첫 도보 구간 시간 < latest"지만 refresh 응답에 + // 이동시간 데이터가 없어, 출발 시각이 아직 미래인지로 보수적으로 근사한다. + return .advanced(by: -difference, actionable: latestDeparture > now) + } + return .delayed(by: difference) + } +} diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift new file mode 100644 index 0000000..1a89f31 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +public protocol ObserveAlarmChangeUseCase: Sendable { + func execute() -> AsyncStream +} + +public struct DefaultObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { + private let events: any AlarmChangeEvents + + public init(events: any AlarmChangeEvents) { + self.events = events + } + + public func execute() -> AsyncStream { + events.changes() + } +} diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift index b80ee54..2c76034 100644 --- a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -7,22 +7,36 @@ public protocol RefreshAlarmUseCase: Sendable { public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { private let repository: any AlarmRepository private let scheduler: any AlarmScheduler + private let now: @Sendable () -> Date - public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + public init( + repository: any AlarmRepository, + scheduler: any AlarmScheduler, + now: @escaping @Sendable () -> Date = { Date() } + ) { self.repository = repository self.scheduler = scheduler + self.now = now } public func execute() async throws -> AlarmInfo { let info = try await repository.refresh() // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. - if let departure = info.departureTime, - await scheduler.scheduledFireDate() != departure { - try await scheduler.replaceAlarm( - id: info.lastRouteId, - fireDate: departure, - title: AlarmSchedulingDefaults.title - ) + // 기대 발화 시각은 버퍼 반영값(AlarmTiming) — register와 동일한 기준으로 비교해야 + // 변경이 없어도 매 refresh마다 재스케줄되는 헛돎을 막는다. + if let departure = info.departureTime { + let expectedFireDate = AlarmTiming.alarmFireDate(departureTime: departure) + // 새 알람 시각이 이미 과거면 재스케줄하지 않는다 — 과거 fixed 스케줄은 AlarmKit이 + // 거부해 refresh 전체를 실패시킬 수 있다. 이 경우의 인지는 갱신 성공 이후 + // 훅의 즉시 최후통첩이 담당한다(정책 4 "알람 발화 후 변경"). + if expectedFireDate > now(), + await scheduler.scheduledFireDate() != expectedFireDate { + try await scheduler.replaceAlarm( + id: info.lastRouteId, + fireDate: expectedFireDate, + title: AlarmSchedulingDefaults.title + ) + } } return info } diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index a47626c..0f1b1eb 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -30,10 +30,10 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { } try await repository.register(lastRouteId: route.id) // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. - // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각으로 스케줄한다. + // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각 − 버퍼로 스케줄한다. try await scheduler.replaceAlarm( id: route.id, - fireDate: route.departureTime, + fireDate: AlarmTiming.alarmFireDate(departureTime: route.departureTime), title: AlarmSchedulingDefaults.title ) // 수명 정책: 알람 등록(서버+로컬)이 전부 성공한 뒤에만 LA를 시작한다. diff --git a/Projects/Domain/Tests/AlarmTimingTests.swift b/Projects/Domain/Tests/AlarmTimingTests.swift new file mode 100644 index 0000000..ec08428 --- /dev/null +++ b/Projects/Domain/Tests/AlarmTimingTests.swift @@ -0,0 +1,16 @@ +@testable import Domain +import Foundation +import Testing + +struct AlarmTimingTests { + @Test + func alarmFireDate_isDepartureMinusBuffer() { + let departure = Date(timeIntervalSince1970: 1_000) + #expect(AlarmTiming.alarmFireDate(departureTime: departure) == Date(timeIntervalSince1970: 820)) + } + + @Test + func buffer_isFixedThreeMinutes() { + #expect(AlarmTiming.bufferSeconds == 180) + } +} diff --git a/Projects/Domain/Tests/DefaultEvaluateAlarmChangeUseCaseTests.swift b/Projects/Domain/Tests/DefaultEvaluateAlarmChangeUseCaseTests.swift new file mode 100644 index 0000000..91ad633 --- /dev/null +++ b/Projects/Domain/Tests/DefaultEvaluateAlarmChangeUseCaseTests.swift @@ -0,0 +1,119 @@ +@testable import Domain +import Foundation +import Testing + +private func makeInfo(departureTime: Date?) -> AlarmInfo { + AlarmInfo(lastRouteId: "route-1", departureTime: departureTime, updatedAt: nil, isReal: true) +} + +/// KST 절대 시각 헬퍼 — 자정 경계 테스트가 벽시계가 아닌 절대 Date 연산임을 보장한다. +private func kst(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute) + return calendar.date(from: components)! +} + +struct DefaultEvaluateAlarmChangeUseCaseTests { + private let sut = DefaultEvaluateAlarmChangeUseCase() + private let now = Date(timeIntervalSince1970: 10_000) + + @Test + func execute_sameDeparture_unchanged() { + let departure = Date(timeIntervalSince1970: 10_600) + let verdict = sut.execute( + previous: makeInfo(departureTime: departure), + latest: makeInfo(departureTime: departure), + now: now + ) + #expect(verdict == .unchanged) + + // 초 단위 동일(서브초 오차)도 변경이 아니다. + let subSecond = sut.execute( + previous: makeInfo(departureTime: departure), + latest: makeInfo(departureTime: departure.addingTimeInterval(0.4)), + now: now + ) + #expect(subSecond == .unchanged) + } + + @Test + func execute_advancedFiveMinutes_latestStillFuture_actionableAlert() { + let verdict = sut.execute( + previous: makeInfo(departureTime: Date(timeIntervalSince1970: 10_900)), + latest: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + now: now + ) + #expect(verdict == .advanced(by: 300, actionable: true)) + } + + @Test + func execute_advanced_latestAlreadyPast_notActionable() { + let verdict = sut.execute( + previous: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + latest: makeInfo(departureTime: Date(timeIntervalSince1970: 9_700)), + now: now + ) + #expect(verdict == .advanced(by: 900, actionable: false)) + + // 경계: latest == now도 이미 못 탄 것으로 본다 (미래여야 actionable). + let boundary = sut.execute( + previous: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + latest: makeInfo(departureTime: now), + now: now + ) + #expect(boundary == .advanced(by: 600, actionable: false)) + } + + @Test + func execute_delayedTenMinutes_delayed() { + let verdict = sut.execute( + previous: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + latest: makeInfo(departureTime: Date(timeIntervalSince1970: 11_200)), + now: now + ) + #expect(verdict == .delayed(by: 600)) + } + + @Test + func execute_latestDepartureNil_sessionEnded() { + let verdict = sut.execute( + previous: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + latest: makeInfo(departureTime: nil), + now: now + ) + #expect(verdict == .sessionEnded) + } + + @Test + func execute_previousDepartureNil_firstObservation_unchanged() { + let verdict = sut.execute( + previous: makeInfo(departureTime: nil), + latest: makeInfo(departureTime: Date(timeIntervalSince1970: 10_600)), + now: now + ) + #expect(verdict == .unchanged) + } + + @Test + func execute_delayedAcrossMidnight_absoluteDateArithmetic() { + // 23:40 → 익일 00:10: 벽시계로는 "이른 시각"이지만 절대 시간으로는 30분 늦춰짐. + let verdict = sut.execute( + previous: makeInfo(departureTime: kst(2026, 8, 22, 23, 40)), + latest: makeInfo(departureTime: kst(2026, 8, 23, 0, 10)), + now: kst(2026, 8, 22, 23, 0) + ) + #expect(verdict == .delayed(by: 1_800)) + } + + @Test + func execute_advancedAcrossMidnight_absoluteDateArithmetic() { + // 익일 00:10 → 당일 23:40: 절대 시간으로 30분 당겨짐, 아직 미래라 actionable. + let verdict = sut.execute( + previous: makeInfo(departureTime: kst(2026, 8, 23, 0, 10)), + latest: makeInfo(departureTime: kst(2026, 8, 22, 23, 40)), + now: kst(2026, 8, 22, 23, 0) + ) + #expect(verdict == .advanced(by: 1_800, actionable: true)) + } +} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift new file mode 100644 index 0000000..ebf36cd --- /dev/null +++ b/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift @@ -0,0 +1,30 @@ +@testable import Domain +import Foundation +import Testing + +private struct StubAlarmChangeEvents: AlarmChangeEvents { + let handler: @Sendable () -> AsyncStream + func changes() -> AsyncStream { handler() } +} + +struct DefaultObserveAlarmChangeUseCaseTests { + @Test + func execute_forwardsPortStream() async { + let sut = DefaultObserveAlarmChangeUseCase( + events: StubAlarmChangeEvents { + AsyncStream { continuation in + continuation.yield(.delayed(by: 600)) + continuation.yield(.sessionEnded) + continuation.finish() + } + } + ) + + var received: [AlarmChangeVerdict] = [] + for await verdict in sut.execute() { + received.append(verdict) + } + + #expect(received == [.delayed(by: 600), .sessionEnded]) + } +} diff --git a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift index 6c8db6b..ec6243d 100644 --- a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift @@ -51,6 +51,10 @@ private func makeInfo(departureTime: Date?) -> AlarmInfo { AlarmInfo(lastRouteId: "route-1", departureTime: departureTime, updatedAt: nil, isReal: true) } +/// 픽스처가 1970 부근의 작은 epoch를 쓰므로, "기대 발화 시각이 미래일 때만 재스케줄" 가드를 +/// 통과시키려면 now도 그보다 이른 고정값으로 주입한다. +private let fixedNow: @Sendable () -> Date = { Date(timeIntervalSince1970: 0) } + struct DefaultRefreshAlarmUseCaseTests { @Test func execute_departureChanged_reschedules() async throws { @@ -58,11 +62,13 @@ struct DefaultRefreshAlarmUseCaseTests { let newDeparture = Date(timeIntervalSince1970: 2_000) let sut = DefaultRefreshAlarmUseCase( repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: newDeparture))), - scheduler: SpyAlarmScheduler(log: log, scheduledDate: Date(timeIntervalSince1970: 1_000)) + scheduler: SpyAlarmScheduler(log: log, scheduledDate: Date(timeIntervalSince1970: 1_000)), + now: fixedNow ) let info = try await sut.execute() #expect(info.departureTime == newDeparture) - #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@2000"]) + // 스케줄 시각은 버퍼 반영값: 2000 − 180 = 1820 + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1820"]) } @Test @@ -71,7 +77,9 @@ struct DefaultRefreshAlarmUseCaseTests { let departure = Date(timeIntervalSince1970: 1_000) let sut = DefaultRefreshAlarmUseCase( repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: departure))), - scheduler: SpyAlarmScheduler(log: log, scheduledDate: departure) + // 로컬 알람은 버퍼 반영값으로 스케줄돼 있으므로, 같은 기준으로 비교해야 헛재스케줄이 없다. + scheduler: SpyAlarmScheduler(log: log, scheduledDate: AlarmTiming.alarmFireDate(departureTime: departure)), + now: fixedNow ) _ = try await sut.execute() #expect(await log.events == ["refresh", "scheduledFireDate"]) @@ -83,10 +91,29 @@ struct DefaultRefreshAlarmUseCaseTests { let departure = Date(timeIntervalSince1970: 3_000) let sut = DefaultRefreshAlarmUseCase( repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: departure))), - scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil) + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), + now: fixedNow ) _ = try await sut.execute() - #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@3000"]) + // 스케줄 시각은 버퍼 반영값: 3000 − 180 = 2820 + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@2820"]) + } + + @Test + func execute_expectedFireDateInPast_doesNotReschedule() async throws { + let log = CallLog() + // 기대 발화 시각(2000 − 180 = 1820)이 now(1900)보다 과거 — 재스케줄하면 AlarmKit이 + // 거부해 refresh 전체가 실패할 수 있다. 인지는 훅의 즉시 최후통첩 몫(정책 4). + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository( + log: log, + refreshResult: .success(makeInfo(departureTime: Date(timeIntervalSince1970: 2_000))) + ), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), + now: { Date(timeIntervalSince1970: 1_900) } + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh"]) } @Test diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index 17407d7..a313622 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -40,7 +40,7 @@ private struct SpyAlarmScheduler: AlarmScheduler { } func replaceAlarm(id: String, fireDate: Date, title: String) async throws { - await log.append("replaceAlarm:\(id)") + await log.append("replaceAlarm:\(id)@\(Int(fireDate.timeIntervalSince1970))") } func cancelAlarm() async { @@ -75,7 +75,8 @@ struct DefaultRegisterAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log) ) try await sut.execute(route: .fixture(id: "new")) - #expect(await log.events == ["auth:true", "refresh", "cancel:old", "register:new", "replaceAlarm:new"]) + // 스케줄 시각은 버퍼 반영값: 출발 1000 − 180 = 820 + #expect(await log.events == ["auth:true", "refresh", "cancel:old", "register:new", "replaceAlarm:new@820"]) } @Test @@ -86,7 +87,7 @@ struct DefaultRegisterAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log) ) try await sut.execute(route: .fixture(id: "new")) - #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new"]) + #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new@820"]) } @Test diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index afdd7e0..3102c72 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -40,6 +40,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { registerAlarmUseCase: PreviewRegisterAlarmUseCase(), cancelAlarmUseCase: PreviewCancelAlarmUseCase(), observeAlarmUseCase: PreviewObserveAlarmUseCase(), + observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -88,6 +89,13 @@ struct PreviewObserveAlarmUseCase: ObserveAlarmUseCase { } } +/// 막차 변경 판정이 없는 상태를 흉내 낸다 — 토스트·배너 강조는 발생하지 않는다. +struct PreviewObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { + func execute() -> AsyncStream { + AsyncStream { _ in } + } +} + /// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다. /// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다. struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 1bfb2e1..4d86076 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -13,6 +13,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase + private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable public init( @@ -21,6 +22,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, + observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase @@ -28,6 +30,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase + self.observeAlarmChangeUseCase = observeAlarmChangeUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -43,7 +46,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { reverseGeocodeUseCase: reverseGeocodeUseCase, registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, - observeAlarmUseCase: observeAlarmUseCase + observeAlarmUseCase: observeAlarmUseCase, + observeAlarmChangeUseCase: observeAlarmChangeUseCase ) viewModel.onSearchRequested = onSearchRequested return HomeViewController(viewModel: viewModel) diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 2b128d5..1c2d1d7 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -1,4 +1,5 @@ import DesignSystem +import Domain import SnapKit import UIKit @@ -181,13 +182,22 @@ final class HomeViewController: UIViewController { cancelButton.isEnabled = !state.isAlarmBusy if let bannerData = state.banner { - banner.configure(text: bannerData.text, style: bannerData.isUrgent ? .urgent : .normal) + banner.configure(text: bannerData.text, style: Self.bannerStyle(for: bannerData.urgency)) banner.isHidden = false } else { banner.isHidden = true } } + /// 긴급도 3단계 ↔ DSBanner.Style 매핑 — LA와 같은 척도(여유/주의/임박)를 그대로 쓴다. + private static func bannerStyle(for urgency: LastTrainUrgency) -> DSBanner.Style { + switch urgency { + case .relaxed: .normal + case .caution: .caution + case .imminent: .urgent + } + } + private func showToast(for event: HomeViewModel.ToastEvent) { switch event { case .locationPermissionNeeded: @@ -212,6 +222,11 @@ final class HomeViewController: UIViewController { DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: view) case .alarmCancelFailed: DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: view) + case let .lastTrainAdvanced(minutes): + // 배너 강조는 이 원샷 이벤트에 부수하는 시각 효과 — 별도 채널을 만들지 않는다. + // 배너의 시각·긴급도 값 자체는 info 스트림이 State로 이미 갱신한다. + DSToast.show("막차가 \(minutes)분 당겨졌어요", in: view) + banner.emphasize() } } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 902906b..f2bdac6 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -14,7 +14,9 @@ final class HomeViewModel { nonisolated struct BannerViewData: Equatable { let text: String - let isUrgent: Bool + /// 긴급도 3단계(여유/주의/임박) — Domain 기준을 그대로 쓴다. LA와 같은 척도여야 + /// 배너·잠금화면이 다른 색을 보여주는 일이 없다. + let urgency: LastTrainUrgency } /// 알람 버튼 슬롯의 표출 모드 — 선택 경로·등록 상태의 순수 함수로 계산한다. @@ -38,6 +40,8 @@ final class HomeViewModel { case alarmPermissionNeeded case alarmRegisterFailed case alarmCancelFailed + /// 포그라운드 인앱 채널: 막차가 당겨졌다는 판정 — VC가 토스트 + 배너 강조로 표출한다. + case lastTrainAdvanced(minutes: Int) } /// Set by the ViewController; always invoked on the main actor. @@ -55,6 +59,7 @@ final class HomeViewModel { private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase + private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration @@ -64,6 +69,7 @@ final class HomeViewModel { private var locationTask: Task? private var alarmTask: Task? private var observeTask: Task? + private var changeTask: Task? private var bannerTask: Task? init( @@ -72,6 +78,7 @@ final class HomeViewModel { registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, + observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) ) { @@ -80,6 +87,7 @@ final class HomeViewModel { self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase + self.observeAlarmChangeUseCase = observeAlarmChangeUseCase self.now = now self.bannerTickInterval = bannerTickInterval } @@ -88,6 +96,7 @@ final class HomeViewModel { locationTask?.cancel() alarmTask?.cancel() observeTask?.cancel() + changeTask?.cancel() bannerTask?.cancel() } @@ -96,6 +105,7 @@ final class HomeViewModel { func viewDidLoad() { loadCurrentLocation() observeAlarmUpdates() + observeAlarmChanges() } /// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다. @@ -198,6 +208,38 @@ final class HomeViewModel { } } + /// 포그라운드 인앱 채널: 앱이 떠 있을 때의 변경 판정은 LA alert 대신 여기서 소비한다. + /// 배너의 시각·긴급도 자체는 info 스트림(alarmSynced)이 이미 갱신하므로, + /// 이 스트림은 "당겨짐" 원샷 안내만 담당한다. + private func observeAlarmChanges() { + changeTask?.cancel() + changeTask = Task { [weak self] in + guard let stream = self?.observeAlarmChangeUseCase.execute() else { return } + for await verdict in stream { + guard !Task.isCancelled else { return } + self?.alarmChanged(verdict) + } + } + } + + private func alarmChanged(_ verdict: AlarmChangeVerdict) { + switch verdict { + case let .advanced(by: interval, actionable: true): + // 반올림하되 최소 1분 — "0분 당겨졌어요"는 말이 안 된다. + let minutes = max(1, Int((interval / 60).rounded())) + onToast?(.lastTrainAdvanced(minutes: minutes)) + case .advanced(by: _, actionable: false): + // TODO(Phase 12): 이미 못 타는 앞당김 — 막차 놓침(세션 종료) UX로 처리한다. + break + case .sessionEnded: + // TODO(Phase 12): 운행 종료·경로 소멸 UX. + break + case .delayed, .unchanged: + // 정책: 늦춰짐은 조용한 업데이트 — 배너는 info 스트림이 갱신하고 토스트는 없다. + break + } + } + private func loadCurrentLocation() { locationTask?.cancel() state.departure = .loading @@ -261,9 +303,15 @@ final class HomeViewModel { max(0, Int(ceil(departure.timeIntervalSince(now) / 60))) } + /// 카운트다운·긴급도 모두 **버퍼 포함 알람 발화 시각**(AlarmTiming) 기준 — LA와 기준을 + /// 통일한다(이중 시각 금지). 그래서 문구도 "막차 출발까지"가 아니라 사용자가 출발해야 + /// 할 시각 기준의 "출발까지"다. nonisolated static func makeBanner(departure: Date, now: Date) -> BannerViewData { - let minutes = minutesUntil(departure: departure, now: now) - // 긴박 기준 10분은 디자이너 확정 전 제안값. - return BannerViewData(text: "막차 출발까지 \(minutes)분", isUrgent: minutes <= 10) + let alarmDate = AlarmTiming.alarmFireDate(departureTime: departure) + let minutes = minutesUntil(departure: alarmDate, now: now) + return BannerViewData( + text: "출발까지 \(minutes)분", + urgency: LastTrainUrgency.forTimeRemaining(alarmDate.timeIntervalSince(now)) + ) } } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index a4d44e1..ff9f7af 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -30,6 +30,11 @@ private struct StubObserveAlarmUseCase: ObserveAlarmUseCase { func execute() -> AsyncStream { handler() } } +private struct StubObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { + let handler: @Sendable () -> AsyncStream + func execute() -> AsyncStream { handler() } +} + /// 테스트 도중 `now()`를 전진시키기 위한 가변 시계. private nonisolated final class NowBox: @unchecked Sendable { private let lock = NSLock() @@ -113,6 +118,9 @@ private func makeSUT( alarmUpdates: @escaping @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } }, + alarmChanges: @escaping @Sendable () -> AsyncStream = { + AsyncStream { $0.finish() } + }, now: @escaping @Sendable () -> Date = { fixedNow }, bannerTickInterval: Duration = .seconds(60) ) -> HomeViewModel { @@ -122,6 +130,7 @@ private func makeSUT( registerAlarmUseCase: StubRegisterAlarmUseCase(handler: register), cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates), + observeAlarmChangeUseCase: StubObserveAlarmChangeUseCase(handler: alarmChanges), now: now, bannerTickInterval: bannerTickInterval ) @@ -194,7 +203,8 @@ struct HomeViewModelTests { #expect(sut.state.isAlarmBusy) await recorder.waitUntilLast { $0.banner != nil } - #expect(sut.state.banner == .init(text: "막차 출발까지 42분", isUrgent: false)) + // 카운트다운은 버퍼(3분) 포함 알람 발화 시각 기준 — 42분 출발이면 39분. + #expect(sut.state.banner == .init(text: "출발까지 39분", urgency: .relaxed)) #expect(!sut.state.isAlarmBusy) // 등록 성공 후 같은 자리 버튼이 해제로 토글된다. #expect(sut.state.alarmButton == .cancel) @@ -244,16 +254,31 @@ struct HomeViewModelTests { #expect(HomeViewModel.minutesUntil( departure: fixedNow.addingTimeInterval(-30), now: fixedNow ) == 0) + } + @Test + func makeBanner_urgencyBoundaries_useAlarmFireDate() { + // 카운트다운·긴급도 모두 알람 발화 시각(출발 − 3분 버퍼) 기준 — 이중 시각 금지. + // 출발까지 13분 = 알람까지 정확히 600초: imminent 경계. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(13 * 60), now: fixedNow + ) == .init(text: "출발까지 10분", urgency: .imminent)) + // 알람까지 601초: caution으로 넘어간다. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(13 * 60 + 1), now: fixedNow + ).urgency == .caution) + // 출발까지 33분 = 알람까지 정확히 1800초: caution 경계. #expect(HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(10 * 60), now: fixedNow - ).isUrgent) - #expect(!HomeViewModel.makeBanner( - departure: fixedNow.addingTimeInterval(11 * 60), now: fixedNow - ).isUrgent) + departure: fixedNow.addingTimeInterval(33 * 60), now: fixedNow + ) == .init(text: "출발까지 30분", urgency: .caution)) + // 알람까지 1801초: relaxed. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(33 * 60 + 1), now: fixedNow + ).urgency == .relaxed) + // 알람 시각이 이미 지났다: 0분 클램프 + imminent. #expect(HomeViewModel.makeBanner( departure: fixedNow, now: fixedNow - ) == .init(text: "막차 출발까지 0분", isUrgent: true)) + ) == .init(text: "출발까지 0분", urgency: .imminent)) } @Test @@ -266,12 +291,12 @@ struct HomeViewModelTests { sut.routeSelected(route) sut.registerAlarmTapped() - await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" } + await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } - clock.set(fixedNow.addingTimeInterval(40 * 60)) - await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 2분" } + clock.set(fixedNow.addingTimeInterval(37 * 60)) + await recorder.waitUntilLast { $0.banner?.text == "출발까지 2분" } - #expect(sut.state.banner?.isUrgent == true) + #expect(sut.state.banner?.urgency == .imminent) } @Test @@ -344,7 +369,8 @@ struct HomeViewModelTests { ) await recorder.waitUntilLast { $0.banner != nil } - #expect(sut.state.banner == .init(text: "막차 출발까지 30분", isUrgent: false)) + // 30분 출발 → 알람까지 27분(1620초) — caution 구간. + #expect(sut.state.banner == .init(text: "출발까지 27분", urgency: .caution)) #expect(sut.state.alarmButton == .cancel) #expect(sut.state.routeCard == nil) } @@ -359,7 +385,7 @@ struct HomeViewModelTests { sut.viewDidLoad() sut.routeSelected(route) sut.registerAlarmTapped() - await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" } + await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } // 서버가 15분 당긴 시각을 돌려준다 (포그라운드 복귀·푸시 동기화 공용 경로). continuation.yield( @@ -370,7 +396,7 @@ struct HomeViewModelTests { isReal: true ) ) - await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 27분" } + await recorder.waitUntilLast { $0.banner?.text == "출발까지 24분" } #expect(sut.state.alarmButton == .cancel) } @@ -389,4 +415,49 @@ struct HomeViewModelTests { #expect(sut.state.alarmButton == .hidden) #expect(recorder.toasts.isEmpty) } + + @Test + func changeStream_advancedActionable_emitsAdvancedToast() async { + // 포그라운드 인앱 채널: 앞당겨짐(아직 탈 수 있음) → 원샷 토스트 이벤트. + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmChanges: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield(.advanced(by: 15 * 60, actionable: true)) + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.lastTrainAdvanced(minutes: 15)]) + // 배너 시각·긴급도 갱신은 info 스트림 몫 — verdict만으로 State는 바뀌지 않는다. + #expect(sut.state.banner == nil) + + // 1분 미만 앞당김은 최소 1분으로 클램프한다. + continuation.yield(.advanced(by: 20, actionable: true)) + while recorder.toasts.count < 2 { await Task.yield() } + + #expect(recorder.toasts == [ + .lastTrainAdvanced(minutes: 15), + .lastTrainAdvanced(minutes: 1), + ]) + } + + @Test + func changeStream_quietVerdicts_emitNoToast() async { + // 정책: 늦춰짐은 조용한 업데이트. actionable=false·sessionEnded는 Phase 12 산출물. + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmChanges: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield(.delayed(by: 10 * 60)) + continuation.yield(.unchanged) + continuation.yield(.advanced(by: 5 * 60, actionable: false)) + continuation.yield(.sessionEnded) + for _ in 0..<20 { await Task.yield() } + + #expect(recorder.toasts.isEmpty) + #expect(sut.state.banner == nil) + } }