From 44de94d9c880869c25a87fc4f99625f013face09 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 20:59:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20AlarmSyncService=EB=A5=BC=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20Store=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20(Phase=20B3c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AlarmSessionSnapshot`을 완전히 걷어내고 `AlarmSession`으로 통일한다. 세션을 소유하는 곳이 하나가 되면서 만료 판정이 진짜로 한 곳에 모였다. ## 숫자 AlarmSyncService 575줄 → 449줄 세션 상태 필드 5개 + 딕셔너리 2개 → 1개 + 딕셔너리 1개 만료 판정 주체 3곳 → 1곳(순수 함수) firstWalkSeconds 참조 4곳 → 1곳(AlarmSession.fireDate) `sync()`가 네 단계로 정리됐다: Store.bootstrap() → refresh(current:) → Reconciler.reconcile() → Store.apply() 기존의 "시딩 게이트 → 선행 만료 후보 → refresh → 3분기 서버 우선 판정 → 톰스톤 기록 → 스냅샷 병합 저장"이 한 번의 판정으로 모이면서, 코드가 스스로 붙여둔 "1차 방어 / 2차 방어" 주석이 사라졌다. 남은 `previousServerInfo` 하나는 세션이 아니라 **diff 한 번에만 쓰이는 지역 기억**이라 여기 있는 게 맞다. ## 회귀 1건을 만들었다가 잡았다 만료와 missed("막차가 지나갔어요")를 가르는 경계가 코드 어디에도 적혀 있지 않았다. `expireLocallyIfNeeded`가 "진입 시점 lastInfo만 본다"는 구현 세부가 사실상 그 규칙이었는데, Reconciler로 옮기며 병합된 최신 정보로 판정하도록 바꿔 버렸다. 결과는 **막차를 놓친 순간 알림이 나가지 않는 것** — 빌드도 되고 대부분의 테스트도 통과하는 종류의 회귀다. 기존 통합 테스트가 잡아줬다. 이제 그 규칙이 세 곳에 명시돼 있다: Reconciler 주석 / Reconciler 순수 함수 테스트 / 기존 통합 테스트. ## 스트림 소유권과 행(hang) `AlarmSyncEvents`를 `AlarmSessionStore`가 직접 구현한다. 다른 타입이 중계하면 끝나지 않는 스트림을 구독하는 Task가 해제되지 않는다. 더 큰 문제는 **등록이 비동기였던 것**이다. `updates()`는 동기 메서드인데 `@MainActor` 상태를 만지려고 `Task`를 썼더니, 등록 전에 일어난 방출을 구독자가 통째로 놓치고 오지 않을 값을 영원히 기다렸다(테스트 25개가 실행조차 되지 않고 47분 행). `Mutex`로 등록과 replay를 동기화했다 — 같은 모듈 `KeychainStore`가 쓰는 패턴이다. 스트림이 둘로 정리됐다: - `updates()` → AlarmSyncUpdate (홈 계약, **끝난 세션 제외**) - `sessionUpdates()` → AlarmSession? (App 내부용) ## 테스트 스냅샷 스텁을 걷어내고 **실제 `AlarmSessionStore`**를 쓴다. 스텁으로 뒀다면 이번에 Store로 옮겨온 부트스트랩·톰스톤·replay 규칙이 검증에서 빠졌을 것이다. `AlarmSessionTests`에 저장 포맷 계약을 고정하는 테스트를 추가했다 — `lifecycle`이 rawValue 문자열로 저장되므로 케이스 이름을 바꾸면 기존 기록을 못 읽는다. 검증: Domain 119 / AtchaData 53 / AtchaV2 36 / Home 66 / CoreStorage 33 Co-Authored-By: Claude Opus 5 --- .../AlarmSessionSnapshotStoreAdapter.swift | 29 -- .../AlarmSessionTeardownAdapter.swift | 10 +- .../LastTrainLiveActivityAdapter.swift | 43 ++- .../AlarmSessionLifecycleService.swift | 10 +- Projects/App/Sources/AlarmSessionStore.swift | 73 +++- Projects/App/Sources/AlarmSyncService.swift | 319 +++++------------- Projects/App/Sources/AppDIContainer.swift | 32 +- .../App/Tests/AlarmSessionStoreTests.swift | 35 +- .../App/Tests/AlarmSyncServiceTests.swift | 99 +++--- .../Sources/AlarmSessionReconciler.swift | 6 + .../Sources/Entities/AlarmSession.swift | 8 + .../Entities/AlarmSessionSnapshot.swift | 70 ---- .../Sources/UseCases/CancelAlarmUseCase.swift | 12 +- .../UseCases/RefreshAlarmUseCase.swift | 55 ++- .../UseCases/RegisterAlarmUseCase.swift | 23 +- .../Tests/AlarmSessionReconcilerTests.swift | 20 ++ .../Tests/AlarmSessionSnapshotTests.swift | 101 ------ Projects/Domain/Tests/AlarmSessionTests.swift | 134 ++++++++ .../DefaultCancelAlarmUseCaseTests.swift | 16 +- .../DefaultRefreshAlarmUseCaseTests.swift | 65 ++-- .../DefaultRegisterAlarmUseCaseTests.swift | 49 ++- 21 files changed, 560 insertions(+), 649 deletions(-) delete mode 100644 Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift delete mode 100644 Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift delete mode 100644 Projects/Domain/Tests/AlarmSessionSnapshotTests.swift create mode 100644 Projects/Domain/Tests/AlarmSessionTests.swift diff --git a/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift b/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift deleted file mode 100644 index 38263179..00000000 --- a/Projects/App/Sources/Adapters/AlarmSessionSnapshotStoreAdapter.swift +++ /dev/null @@ -1,29 +0,0 @@ -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/AlarmSessionTeardownAdapter.swift b/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift index 85eb2e58..fc801a8f 100644 --- a/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift +++ b/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift @@ -2,13 +2,13 @@ import Domain import Foundation import os -/// 계정 세션 종료 시 알람 세션 정리 — 조합 루트가 공유 인스턴스(스케줄러·LA·스냅샷·동기화)를 +/// 계정 세션 종료 시 알람 세션 정리 — 조합 루트가 공유 인스턴스(스케줄러·LA·세션·동기화)를 /// 그대로 넘긴다. 등록/취소 UseCase와 다른 인스턴스를 보면 정리가 헛돈다. struct AlarmSessionTeardownAdapter: AlarmSessionTeardown { let alarmRepository: any AlarmRepository let scheduler: any AlarmScheduler let activityPort: any LastTrainActivityPort - let snapshotStore: any AlarmSessionSnapshotStore + let sessionStore: AlarmSessionStore let syncService: AlarmSyncService private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmTeardown") @@ -24,7 +24,6 @@ struct AlarmSessionTeardownAdapter: AlarmSessionTeardown { } // 더는 울리면 안 되는 것이 핵심 — 표출 정리(LA)보다 알람 취소를 먼저 한다. await scheduler.cancelAlarm() - await snapshotStore.clear() let now = Date() await activityPort.end(final: LastTrainActivityState( departureTime: now, @@ -33,13 +32,14 @@ struct AlarmSessionTeardownAdapter: AlarmSessionTeardown { changeBadgeExpiry: nil, phase: .active )) + // 세션 기록 정리는 resetForSignOut → Store.reset이 담당한다(중복 clear 제거). await syncService.resetForSignOut() } /// 만료 톰스톤은 이미 죽은 세션이라 서버 취소 대상이 아니다. private func activeRouteId() async -> String? { - if let snapshot = await snapshotStore.load(), !snapshot.expired { - return snapshot.info.lastRouteId + if let session = await sessionStore.loadSession(), !session.isEnded { + return session.server.lastRouteId } return await syncService.currentSession?.lastRouteId } diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift index 89ddfe31..643dcde7 100644 --- a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -40,12 +40,12 @@ nonisolated protocol LastTrainSessionRestoring: Sendable { /// `Activity.activities`를 스캔해, 스냅샷과 routeId가 일치하고 미만료인 세션은 /// **adopt**(보관 + 상태 관찰 재개 — 이후 update/end가 정상 동작)하고, /// 불일치·만료·스냅샷 없음은 즉시 정리한다. - func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async + func reattachOrphans(session: AlarmSession?, now: Date) async /// sync 성공 후 — 스냅샷은 살아 있는데(미만료·미확인) 활성 activity가 없고 dismiss /// 기록도 없으면 LA를 로컬 재시작한다. 8시간 한도로 시스템이 내린 세션·시작 실패 /// 세션 커버 — push-to-start 금지 정책과 무관(그 정책은 유저가 지운 LA의 재생성 금지, /// dismiss 기록이 있으면 여기서도 재시작하지 않는다). - func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async + func restartIfNeeded(session: AlarmSession, now: Date) async } /// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. @@ -140,7 +140,7 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti // MARK: - LastTrainSessionRestoring (Phase 14) - func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async { + func reattachOrphans(session: AlarmSession?, now: Date) async { // 살아 있는 세션을 이미 보관 중이면(이론상 재부착 전 start 경합) 손대지 않는다. var adopted = activity != nil for orphan in Activity.activities { @@ -149,38 +149,34 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti // 어떤 경로로도 되살리지 않는 정책과 한 몸(정상 흐름에선 지워진 LA가 // 목록에 없지만, 기록·상태가 어긋난 경우에도 지운 의사가 이긴다). !dismissedByUser, - let snapshot, !snapshot.expired, - orphan.attributes.routeId == snapshot.info.lastRouteId, - let departure = snapshot.info.departureTime, - !AlarmTiming.isSessionExpired(departureTime: departure, now: now), + let session, !session.isEnded, + orphan.attributes.routeId == session.server.lastRouteId, + !session.hasPassedDeparture(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 { + func restartIfNeeded(session: AlarmSession, now: Date) async { // dismiss 존중(유저가 지운 LA 재생성 금지)·확인된 세션(departed 소멸 예약 완료) // 재시작 금지. 활성 activity가 있으면 당연히 재시작하지 않는다 — adopt된 세션 포함. + // `allowsActivityRestart`가 수명 게이트를 담당한다(이전에는 expired·acknowledged + // 두 Bool을 여기서 각각 검사했다). guard activity == nil, !dismissedByUser, - !snapshot.expired, - !snapshot.acknowledged, - let departure = snapshot.info.departureTime, - !AlarmTiming.isSessionExpired(departureTime: departure, now: now), - ActivityAuthorizationInfo().areActivitiesEnabled + session.allowsActivityRestart, + let departure = session.server.departureTime, + !session.hasPassedDeparture(now: now), + ActivityAuthorizationInfo().areActivitiesEnabled, + let alarmTime = session.fireDate else { return } - - let alarmTime = AlarmTiming.alarmFireDate( - departureTime: departure, - firstWalkSeconds: snapshot.firstWalkSeconds - ) let state = LastTrainActivityState( departureTime: departure, alarmTime: alarmTime, @@ -191,10 +187,11 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti 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 + routeId: session.server.lastRouteId, + routeName: session.local.routeDisplayName.isEmpty + ? "막차" : session.local.routeDisplayName, + transportKind: Self.transportKind(from: session.local.transportMode), + firstWalkSeconds: session.local.firstWalkSeconds ), content: ActivityContent( state: Self.contentState(from: state), diff --git a/Projects/App/Sources/AlarmSessionLifecycleService.swift b/Projects/App/Sources/AlarmSessionLifecycleService.swift index d13be022..3afc3f0e 100644 --- a/Projects/App/Sources/AlarmSessionLifecycleService.swift +++ b/Projects/App/Sources/AlarmSessionLifecycleService.swift @@ -11,7 +11,7 @@ final class AlarmSessionLifecycleService { private let liveActivity: any LastTrainDepartureEnding /// 확인 기록 영속화 (Phase 14) — 강제 종료·재실행 후에도 남아 재시작 판정 /// (확인된 세션 재시작 금지)의 재료가 된다. - private let snapshotStore: any AlarmSessionSnapshotStore + private let sessionStore: AlarmSessionStore private static let logger = Logger( subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle" ) @@ -21,10 +21,10 @@ final class AlarmSessionLifecycleService { init( liveActivity: any LastTrainDepartureEnding, - snapshotStore: any AlarmSessionSnapshotStore + sessionStore: AlarmSessionStore ) { self.liveActivity = liveActivity - self.snapshotStore = snapshotStore + self.sessionStore = sessionStore } /// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록(스냅샷 영속화) ② LA departed 전환 @@ -37,9 +37,7 @@ final class AlarmSessionLifecycleService { isAcknowledged = true // 세션 스냅샷이 있을 때만 기록한다 — 스냅샷 없는 확인(이론상 경합)은 남길 곳이 없고, // 그 경우의 정리는 wake 시점 리컨실이 맡는다. - if let snapshot = await snapshotStore.load() { - await snapshotStore.save(snapshot.updating(acknowledged: true)) - } + await sessionStore.acknowledge() await liveActivity.endAsDeparted() } } diff --git a/Projects/App/Sources/AlarmSessionStore.swift b/Projects/App/Sources/AlarmSessionStore.swift index 142c10b1..80785205 100644 --- a/Projects/App/Sources/AlarmSessionStore.swift +++ b/Projects/App/Sources/AlarmSessionStore.swift @@ -1,6 +1,7 @@ import CoreStorage import Domain import Foundation +import Synchronization import os /// 알람 세션을 소유하는 **유일한 지점**. 읽기도 쓰기도 여기를 통한다. @@ -16,15 +17,25 @@ import os /// 경로가 이미 `inFlight` 합류로 직렬화되므로, 메인 액터 격리만으로 단일 writer가 /// 보장된다. @MainActor -final class AlarmSessionStore { +final class AlarmSessionStore: AlarmSessionStoring, AlarmSyncEvents { private static let logger = Logger(subsystem: "com.atcha.alarm", category: "session") /// 새 키다 — 기존 `alarm.sessionSnapshot`과 스키마가 다르다. V2가 미출시이므로 /// 마이그레이션을 두지 않고, 구 키는 전환이 끝난 뒤 정리한다. - static let storageKey = "alarm.session" + nonisolated static let storageKey = "alarm.session" private let document: DocumentStore private var subscribers: [UUID: AsyncStream.Continuation] = [:] + /// `AlarmSyncEvents` 구독자 — 세션을 `AlarmSyncUpdate`로 매핑해 흘린다. + /// + /// `Mutex`인 이유: 프로토콜의 `updates()`가 **동기** 메서드라 `@MainActor` 상태를 + /// 직접 만질 수 없다. `Task`로 등록하면 등록이 비동기가 되어 그 사이에 일어난 + /// 방출을 구독자가 통째로 놓치고, 오지 않을 값을 영원히 기다린다(실제로 테스트가 + /// 행에 걸렸다). 등록과 replay 값 읽기는 반드시 동기여야 한다. + private let updateSubscribers = + Mutex<[UUID: AsyncStream.Continuation]>([:]) + /// replay-1 버퍼 — 구독 시점에 동기로 읽어야 하므로 메인 액터 밖에 둔다. + private let lastUpdate = Mutex(nil) /// 메모리 캐시 = 동기 읽기의 근거. 디스크 로드는 `bootstrap()`에서 1회만 한다. private(set) var current: AlarmSession? @@ -98,6 +109,29 @@ final class AlarmSessionStore { broadcast() } + // MARK: - AlarmSessionStoring (Domain 포트) + // + // 등록/취소 UseCase가 쓰는 경로. 프로토콜 요구사항이 nonisolated라 격리를 명시해야 + // 한다 — 그러지 않으면 Swift가 적합성에 맞춰 nonisolated로 추론해 `current`에 + // 접근할 수 없다. 요구사항이 `async`이므로 호출자는 await로 메인 액터에 진입하고, + // 쓰기 지점이 하나로 유지된다. + + @MainActor + func loadSession() async -> AlarmSession? { + await bootstrap() + return current + } + + @MainActor + func saveSession(_ session: AlarmSession) async { + await persist(session) + } + + @MainActor + func clearSession() async { + await clear() + } + /// 로그아웃·탈퇴 — 로컬 기록을 비우고 구독자에게도 알린다. func reset() async { await clear() @@ -105,9 +139,31 @@ final class AlarmSessionStore { // MARK: - 구독 - /// 구독자마다 독립 스트림. **replay-1** — 구독 전에 끝난 부트스트랩·동기화를 - /// 놓치지 않아야 한다. (변경 *사건*은 replay하지 않는 별도 채널이 맡는다.) - func updates() -> AsyncStream { + /// `AlarmSyncEvents` — 구독자(홈)가 보는 계약. 세션 스트림을 그대로 매핑한다. + /// + /// **Store가 직접 구현하는 이유**: 다른 타입이 이 스트림을 중계하면 끝나지 않는 + /// 스트림을 `for await`로 구독하는 Task가 생기고, 그 Task가 해제되지 않아 + /// 테스트 프로세스가 종료되지 못한다(실제로 행이 걸렸다). 소유자가 계약도 갖는다. + /// + /// **끝난 세션은 흘리지 않는다** — 죽은 세션으로 배너·해제 버튼이 복원되면 안 된다. + nonisolated func updates() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + // 등록을 먼저, 동기로 — 이후의 모든 방출을 받는다. + updateSubscribers.withLock { $0[id] = continuation } + if let last = lastUpdate.withLock({ $0 }) { + continuation.yield(last) + } + // Mutex는 non-copyable이라 캡처 리스트에 담을 수 없다 — self를 캡처한다. + // Store는 앱 수명 객체라 순환 참조 문제가 없다. + continuation.onTermination = { _ in + self.updateSubscribers.withLock { $0[id] = nil } + } + } + } + + /// 세션 그대로를 보는 내부 스트림 — 홈이 아닌 App 내부 소비자용. + func sessionUpdates() -> AsyncStream { let id = UUID() return AsyncStream { continuation in subscribers[id] = continuation @@ -132,5 +188,12 @@ final class AlarmSessionStore { for continuation in subscribers.values { continuation.yield(current) } + // 끝난 세션은 홈 계약으로 흘리지 않는다(위 updates() 주석 참조). + guard let session = current, !session.isEnded else { return } + let update = AlarmSyncUpdate(info: session.server, checkedAt: session.syncedAt) + lastUpdate.withLock { $0 = update } + for continuation in updateSubscribers.withLock({ Array($0.values) }) { + continuation.yield(update) + } } } diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index bb29f05e..4831c25d 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -3,45 +3,34 @@ import Foundation import UIKit import os -/// 서버발 알람 시각 갱신의 단일 진입점. 앱 시작(인증 부트스트랩 직후)·포그라운드 -/// 복귀·사일런트 푸시 3경로가 전부 여기의 `RefreshAlarmUseCase` 호출 한 곳으로 -/// 모인다 — 시각 변경 시 재스케줄은 UseCase 내부 정책이고, 성공 결과는 -/// `AlarmSyncEvents` 스트림으로 구독자(HomeViewModel)에게 전파돼 배너를 갱신한다. +/// 서버발 알람 시각 갱신의 단일 진입점. 앱 시작·포그라운드 복귀·사일런트 푸시·홈 +/// pull-to-refresh 4경로가 전부 여기의 `RefreshAlarmUseCase` 호출 한 곳으로 모인다 +/// (`inFlight` 합류가 있어 트리거가 겹쳐도 refresh는 1회). /// -/// Phase 11 피기백: 갱신 성공 뒤 전/후 AlarmInfo를 판정(`EvaluateAlarmChangeUseCase`)해 +/// **세션 상태는 이 타입이 들지 않는다** — `AlarmSessionStore`가 소유하고, 여기는 +/// 트리거를 모으고 판정 결과를 표출 채널로 흘리는 일만 한다. 만료·서버 우선·톰스톤 +/// 메아리 판정은 `AlarmSessionReconciler`(순수 함수)가 한 번에 내린다. +/// +/// 변경 표출: 갱신 성공 뒤 전/후 `AlarmInfo`를 판정(`EvaluateAlarmChangeUseCase`)해 /// ① LA 채널(당겨짐 alert / 늦춰짐 조용한 갱신)과 ② 인앱 채널(`AlarmChangeEvents` → -/// 홈 배너 강조·토스트)을 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` 안에서 -/// 이미 끝난 뒤라(반환 = 재스케줄 완료) LA·인앱 표출 실패가 알람을 막을 구조 자체가 없다. +/// 홈 배너 강조·토스트)에 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` +/// 안에서 이미 끝난 뒤라(반환 = 재스케줄 완료) 표출 실패가 알람을 막을 구조가 없다. /// -/// Phase 12 폴백·종료(15에서 조건 확대): ③ LA alert가 도달 불가한 상태(dismissed ∨ 활성 -/// activity 없음 ∨ LA 비활성 — 어댑터의 `isAlertReachable` 단일 판정)면 백그라운드 alert -/// 채널을 로컬 노티(같은 문구, time-sensitive)로 갈아탄다 — 피기백 시점엔 앱이 깨어 있으므로 -/// 서버 무관여로 가능하고, push-to-start 재생성은 하지 않는다(지운 의사 존중). -/// ④ advanced(actionable: false)는 LA를 missed 상태로, sessionEnded는 serviceEnded 최종 +/// 폴백·종료: ③ LA alert가 도달 불가(dismissed ∨ 활성 activity 없음 ∨ LA 비활성 — +/// 어댑터의 `isAlertReachable` 단일 판정)면 백그라운드 alert를 로컬 노티(같은 문구, +/// time-sensitive)로 갈아탄다. push-to-start 재생성은 하지 않는다(지운 의사 존중). +/// ④ advanced(actionable: false)는 LA를 missed로, sessionEnded는 serviceEnded 최종 /// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫. /// -/// Phase 13 클라 자체 만료: sync 진입 시 보유 세션이 유예(출발+60초)를 넘겼으면 만료 -/// 후보로 잡고, refresh 결과와 무관하게 로컬 sessionEnded 처리한다 — 단 refresh가 -/// 성공해 **미래 출발 시각**을 반환하면 서버 우선(만료 취소, 정상 갱신 경로). -/// 만료 확정 세션은 기록해 이후 refresh가 같은 과거 세션으로 배너를 되살리지 못하게 -/// 한다(홈의 미래 시각 가드가 1차 방어, 이 기록이 2차). +/// 세션 스트림(`AlarmSyncEvents`)은 이 타입이 제공하지 않는다 — 소유자인 +/// `AlarmSessionStore`가 직접 구현한다. 중계하면 끝나지 않는 스트림을 구독하는 Task가 +/// 해제되지 않아 테스트 프로세스가 종료되지 못한다(실측). /// -/// Phase 14 재실행 정합성: 첫 sync 진입 시 스냅샷을 시딩해 ⑤ `lastInfo`(diff의 "이전 -/// 값")가 재실행 후에도 살아 종료 중 발생한 변경을 실제로 판정하고, 만료 판정도 프로세스 -/// 수명과 무관하게 성립한다(expired 톰스톤은 2차 방어 복원). ⑥ sync 성공 시 스냅샷을 -/// 병합 저장하고, 스냅샷은 살아 있는데 활성 LA가 없는 죽은 세션은 로컬 재시작을 -/// 위임한다(dismiss·확인 기록 존중은 어댑터 몫). 도보 초는 스냅샷에서 읽어 LA 알람 -/// 시각 계산이 등록/refresh와 같은 기준을 탄다(이중 시각 금지). -/// Phase 16 갱신 신뢰성: ⑦ 홈 pull-to-refresh가 4번째 트리거(`AlarmSyncRequesting`)로 -/// 합류한다 — inFlight 합류가 있어 당김·포그라운드 복귀가 겹쳐도 refresh는 1회. -/// ⑧ sync 성공마다 확인 시각(checkedAt)을 방출·스냅샷(syncedAt)에 영속화해 신선도 -/// 스탬프("HH:mm 확인 기준")의 원천이 된다 — 시딩 방출은 직전 세션의 마지막 확인 -/// 시각을 나른다(실패 무음의 정직한 표면화, 원칙 3). ⑨ `UIApplication`·`Date()` 직접 -/// 참조는 주입(isAppActive·now)으로 교체 — App 테스트 타겟의 회귀 방어 대상이 됐다. +/// 표출 채널 분기용 `isAppActive`와 `now`는 주입이다 — App 테스트 타겟의 회귀 방어 대상. // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor -final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncRequesting { +final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { private let refreshAlarmUseCase: any RefreshAlarmUseCase private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. @@ -51,9 +40,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque private let localNotification: any LocalNotificationPort /// sessionEnded 시 로컬 알람 취소용(Phase 12) — 등록/갱신 UseCase와 같은 스케줄러를 공유한다. private let alarmScheduler: any AlarmScheduler - /// 세션 스냅샷(Phase 14) — 재실행 브리지. sync 성공 시 병합 저장, 만료 확정 시 톰스톤, - /// 서버 sessionEnded 시 clear. - private let snapshotStore: any AlarmSessionSnapshotStore + /// 세션의 단일 소유자 — 읽기·쓰기·구독이 전부 여기를 통한다. + /// 이 서비스는 더 이상 세션 상태를 필드로 들지 않는다. + private let sessionStore: AlarmSessionStore /// 죽은 세션 LA 재시작 경로(Phase 14) — dismiss·확인 기록 판정은 어댑터가 한다. private let sessionRestorer: any LastTrainSessionRestoring /// 표출 채널 분기용 앱 활성 판정(Phase 16) — UIApplication 직접 참조를 걷어내 @@ -66,24 +55,12 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. private static let changeBadgeDuration: TimeInterval = 600 - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - /// 변경 판정 구독자 — updates()와 달리 **replay 없음**(과거 변경이 재구독 시 재발화 금지). + /// 변경 판정 구독자 — 세션 스트림과 달리 **replay 없음**(과거 변경이 재구독 시 재발화 금지). + /// "상태는 replay-1, 사건은 replay 없음"의 사건 쪽이다. private var changeSubscribers: [UUID: AsyncStream.Continuation] = [:] - /// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와 - /// 거의 동시에 구독하므로 순서에 기대지 않는다. 변경 판정의 "이전 값"이기도 하다. - private var lastInfo: AlarmInfo? - /// lastInfo가 마지막으로 서버로 확인된 시각(Phase 16) — 신선도 스탬프의 원천. - /// sync 성공 시 now(), 시딩 복원 시 스냅샷의 syncedAt. 수신·방출 시각이 아니다. - private var lastCheckedAt: Date? - /// 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? + /// 변경 판정의 "이전 값" — 세션 자체가 아니라 **직전 sync의 서버 값**이라 여기 둔다. + /// 세션은 Store가 소유하고, 이건 diff 한 번에만 쓰이는 지역 기억이다. + private var previousServerInfo: AlarmInfo? /// "⚠ 당겨짐" 배지의 현재 만료 시각 — 조용한 갱신(unchanged/delayed)이 배지를 10분 /// 정책보다 일찍 지우지 않도록 보존한다(Phase 14 정합). private var changeBadgeExpiry: Date? @@ -98,7 +75,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque liveActivity: any LastTrainChangeAlerting, localNotification: any LocalNotificationPort, alarmScheduler: any AlarmScheduler, - snapshotStore: any AlarmSessionSnapshotStore, + sessionStore: AlarmSessionStore, sessionRestorer: any LastTrainSessionRestoring, isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active @@ -110,7 +87,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque self.liveActivity = liveActivity self.localNotification = localNotification self.alarmScheduler = alarmScheduler - self.snapshotStore = snapshotStore + self.sessionStore = sessionStore self.sessionRestorer = sessionRestorer self.isAppActive = isAppActive self.now = now @@ -138,20 +115,19 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque // MARK: - 계정 세션 종료 (로그아웃·탈퇴·만료) - /// 현재 보유 세션 — 로그아웃 시 서버 취소 대상 routeId의 출처(스냅샷 부재 대비). - var currentSession: AlarmInfo? { lastInfo } + /// 현재 보유 세션 — 로그아웃 시 서버 취소 대상 routeId의 출처. + var currentSession: AlarmInfo? { sessionStore.current?.server } /// 계정이 바뀌면 이전 계정의 세션 기억이 새 계정의 diff·만료 판정을 오염시킨다 — /// 메모리 상태를 전부 비우고, 다음 로그인의 첫 sync가 (이미 비워진) 스냅샷부터 다시 시딩한다. - func resetForSignOut() { + func resetForSignOut() async { inFlight?.cancel() inFlight = nil - lastInfo = nil - lastCheckedAt = nil - locallyExpiredSession = nil - sessionWalkSeconds = nil + previousServerInfo = nil changeBadgeExpiry = nil - isSeededFromSnapshot = false + // 세션 기록 자체는 Store가 지운다 — 이전 계정의 세션이 새 계정의 diff·만료 + // 판정을 오염시키면 안 된다. + await sessionStore.reset() } // MARK: - AlarmSyncRequesting (Phase 16 — 홈 pull-to-refresh) @@ -171,14 +147,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque return await inFlight.value } Self.logger.info("알람 동기화 시작") - // Phase 14 시딩 — 첫 진입에서 스냅샷을 lastInfo(diff 이전 값)·만료 기록으로 복원. - // 만료 선행 판정보다 먼저여야 재실행 직후의 과거 세션도 리컨실에 걸린다. - await seedFromSnapshotIfNeeded() - // Phase 13 선행 판정 — 확정은 refresh 결과를 본 뒤(미래 출발이면 서버 우선 취소). - let expiryCandidate = expireLocallyIfNeeded(now: now()) - let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in + // 재실행 브리지 복원 — 세션을 디스크에서 되살린다. Store가 1회만 수행한다. + await sessionStore.bootstrap() + let current = sessionStore.current + // diff의 "이전 값"은 이번 갱신 **전**의 서버 값이다. + previousServerInfo = current?.server + + let task = Task { [refreshAlarmUseCase, current] () -> AlarmInfo? in do { - return try await refreshAlarmUseCase.execute() + return try await refreshAlarmUseCase.execute(current: current) } catch { Self.logger.info("알람 동기화 실패(상태 유지): \(error)") return nil @@ -188,144 +165,53 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque let info = await task.value inFlight = nil - guard let info else { - // refresh 실패여도 만료는 확정한다 — "refresh 결과와 무관하게"가 정책이다. - if let expiryCandidate { - await finalizeLocalExpiry(of: expiryCandidate) - } - return nil - } - - if let departure = info.departureTime, departure > now() { - // 서버 우선 — 미래 출발 시각이 오면 만료 후보·확정 기록 모두 해제하고 정상 경로. - locallyExpiredSession = nil - } else if let expiryCandidate { - // 성공했지만 여전히 과거 세션(또는 출발 시각 없음) — 만료 확정. - // 이 결과는 구독자에게 흘리지 않는다(죽은 세션으로 배너·버튼 복원 금지). - await finalizeLocalExpiry(of: expiryCandidate) - return info - } else if let expired = locallyExpiredSession, expired.lastRouteId == info.lastRouteId { - // 만료 확정 후 같은 과거 세션의 재수신 — 무시(2차 방어). - Self.logger.info("만료 확정 세션 재수신 → 무시: route=\(info.lastRouteId, privacy: .public)") - return info - } - - Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") - let previous = lastInfo - lastInfo = info - // Phase 16 — 서버가 방금 이 값을 확인해줬다. 이 시각이 스탬프의 유일한 원천. - let checkedAt = now() - lastCheckedAt = checkedAt - // Phase 14 — sync 성공은 스냅샷 저장 시점. 도보·표시명은 같은 세션이면 보존한다. - let snapshot = await persistSyncedSnapshot(for: info, checkedAt: checkedAt) - for continuation in subscribers.values { - continuation.yield(AlarmSyncUpdate(info: info, checkedAt: checkedAt)) - } - // Phase 14 죽은 세션 재시작 — 스냅샷은 살아 있는데 활성 LA가 없고 dismiss·확인 - // 기록도 없으면 로컬 재시작(판정은 어댑터). 8시간 한도·시작 실패 세션 커버. - await sessionRestorer.restartIfNeeded(snapshot: snapshot, now: now()) - // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 - // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. - await propagateChange(previous: previous, latest: info) - return info - } + // 만료·서버 우선·톰스톤 메아리 판정이 전부 여기 한 번에 일어난다. + // 이전에는 이 판단이 선행 만료 후보 → refresh → 3분기 서버 우선 → 톰스톤 기록으로 + // 흩어져 있었다(그래서 "1차 방어 / 2차 방어" 주석이 붙었다). + let outcome = AlarmSessionReconciler.reconcile( + current: current, server: info, now: now() + ) + await sessionStore.apply(outcome) - // 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 - // Phase 16 — 시딩 복원값의 확인 시각은 "지금"이 아니라 스냅샷에 영속화된 - // 마지막 확인 시각이다. 직후 refresh가 실패해도(오프라인) 스탬프는 이 - // 낡은 시각을 정직하게 유지한다. - lastCheckedAt = snapshot.syncedAt + switch outcome { + case let .refreshed(session): Self.logger.info( - "스냅샷 시딩: route=\(snapshot.info.lastRouteId, privacy: .public)" + "알람 동기화 성공: route=\(session.server.lastRouteId, privacy: .public)" ) - for continuation in subscribers.values { - continuation.yield(AlarmSyncUpdate(info: snapshot.info, checkedAt: snapshot.syncedAt)) + // 죽은 세션 재시작 — 세션은 살아 있는데 활성 LA가 없고 dismiss·확인 기록도 + // 없으면 로컬 재시작(판정은 어댑터). 8시간 한도·시작 실패 세션 커버. + await sessionRestorer.restartIfNeeded(session: session, now: now()) + // 알람 재스케줄은 RefreshAlarmUseCase 안에서 이미 끝났다(반환 = 재스케줄 완료) — + // 표출은 그 뒤에만 덧붙으므로 LA·노티 실패가 알람을 막을 구조가 없다. + await propagateChange(previous: previousServerInfo, latest: session.server) + return session.server + + case let .expired(session): + // 로컬 sessionEnded 처리. 톰스톤 저장은 Store가 이미 했다. + await presentSessionEnded(previous: session.server, now: now()) + yieldChange(.sessionEnded) + return info + + case .ended: + // 서버가 세션 종료를 확정 — 표출은 propagateChange의 sessionEnded 분기가 맡는다. + if let info { + await propagateChange(previous: previousServerInfo, latest: info) } - } - } + return info - /// sync 성공 시 스냅샷 병합 저장 — 같은 세션(routeId 일치)이면 등록 시점 사실 - /// (도보·표시명·수단·확인 기록)을 보존하고 info만 갱신, 다른 세션이면 아는 것만 담는다 - /// (표시명 공백은 LA 재시작 시 "막차" 폴백, 카드는 상세 재조회가 채운다). - /// syncedAt도 함께 기록한다(Phase 16) — 재실행 시딩이 이 시각으로 스탬프를 복원한다. - private func persistSyncedSnapshot( - for info: AlarmInfo, checkedAt: Date - ) 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, syncedAt: checkedAt) - } else { - snapshot = AlarmSessionSnapshot( - info: info, - firstWalkSeconds: nil, - routeDisplayName: "", - transportMode: nil, - acknowledged: false, - expired: false, - syncedAt: checkedAt - ) + case .ignoredStaleEcho: + Self.logger.info("만료 확정 세션 재수신 → 무시") + return info } - await snapshotStore.save(snapshot) - sessionWalkSeconds = snapshot.firstWalkSeconds - return snapshot } - // MARK: - Phase 13 클라 자체 만료 (wake 시점 판정) - - /// sync 진입 선행 판정 — 보유 세션이 만료 유예(출발+60초, AlarmTiming 단일 기준)를 - /// 넘겼으면 만료 후보를 반환한다. 판정 자체는 Domain 순수 함수(시각 주입 테스트 대상). - private func expireLocallyIfNeeded(now: Date) -> AlarmInfo? { - guard let lastInfo, - let departure = lastInfo.departureTime, - AlarmTiming.isSessionExpired(departureTime: departure, now: now) - else { return nil } - return lastInfo - } - - /// 만료 확정 = 로컬 sessionEnded 처리: 알람 레코드 정리 → LA 최종 종료 → - /// changes yield(홈 정리는 기존 sessionEnded 소비 경로 재사용). 서버 계약 무관여. - private func finalizeLocalExpiry(of session: AlarmInfo) async { - Self.logger.info( - "클라 자체 만료 확정(로컬 sessionEnded): route=\(session.lastRouteId, privacy: .public)" + /// 세션이 아는 도보 초로 알람 시각을 계산한다. **도보 초를 읽는 유일한 경로**가 + /// `AlarmSession.fireDate`이므로, 세션이 없을 때만 버퍼 폴백을 쓴다. + private func alarmFireDate(departureTime: Date) -> Date { + AlarmTiming.alarmFireDate( + departureTime: departureTime, + firstWalkSeconds: sessionStore.current?.local.firstWalkSeconds ) - 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, - syncedAt: lastCheckedAt - )) - } - await presentSessionEnded(previous: session, now: now()) - yieldChange(.sessionEnded) } // MARK: - Phase 11·12 변경 표출 (판정 → LA/로컬 노티/인앱 채널) @@ -364,9 +250,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque case .sessionEnded: // 운행 종료·경로 소멸 — LA 최종 상태 종료 + 로컬 알람 취소. // 배너 정리는 changes yield를 받은 홈의 몫. - // 서버가 세션 종료를 확정했으므로 재실행 브리지(스냅샷)도 지운다(Phase 14). - await snapshotStore.clear() - sessionWalkSeconds = nil + // 로컬 기록 정리는 Store가 `.ended` outcome에서 이미 했다. await presentSessionEnded(previous: previous, now: now) yieldChange(verdict) } @@ -380,9 +264,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque now: Date ) async { guard let departure = latest.departureTime else { return } - let alarmTime = AlarmTiming.alarmFireDate( - departureTime: departure, firstWalkSeconds: sessionWalkSeconds - ) + let alarmTime = alarmFireDate(departureTime: departure) let badgeExpiry = now.addingTimeInterval(Self.changeBadgeDuration) // 조용한 후속 갱신(unchanged/delayed)이 배지를 10분보다 일찍 지우지 않도록 보존한다. changeBadgeExpiry = badgeExpiry @@ -461,9 +343,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque changeBadgeExpiry = nil let state = LastTrainActivityState( departureTime: departure, - alarmTime: AlarmTiming.alarmFireDate( - departureTime: departure, firstWalkSeconds: sessionWalkSeconds - ), + alarmTime: alarmFireDate(departureTime: departure), urgency: .imminent, changeBadgeExpiry: nil, phase: .missed @@ -495,15 +375,13 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque changeBadgeExpiry = nil // sessionEnded 응답에는 departureTime이 없다 — 종료 시각 정보용으로 직전 스냅샷 - // (previous = 갱신 전 lastInfo)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. + // (previous = 갱신 전 서버 값)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. let departure = previous?.departureTime ?? now // 유저가 이미 LA를 지웠으면 end는 no-op — 종료는 행동을 요구하지 않으므로 // 로컬 노티 폴백도 없다(배너 정리는 changes 스트림을 받은 홈이 한다). await liveActivity.end(final: LastTrainActivityState( departureTime: departure, - alarmTime: AlarmTiming.alarmFireDate( - departureTime: departure, firstWalkSeconds: sessionWalkSeconds - ), + alarmTime: alarmFireDate(departureTime: departure), // 위젯은 serviceEnded phase 키로 그린다 — urgency는 종료 화면에선 의미 없는 방어값. urgency: .imminent, changeBadgeExpiry: nil, @@ -515,9 +393,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque /// 조용한 갱신도 살아 있는 "당겨짐" 배지는 그대로 싣는다 — 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, firstWalkSeconds: sessionWalkSeconds - ) + let alarmTime = alarmFireDate(departureTime: departure) if let badgeExpiry = changeBadgeExpiry, badgeExpiry <= now { changeBadgeExpiry = nil // 만료된 배지 기록은 정리한다. } @@ -536,25 +412,6 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque } } - // MARK: - AlarmSyncEvents - - nonisolated func updates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - Task { @MainActor in - if let last = self.lastInfo { - continuation.yield(AlarmSyncUpdate(info: last, checkedAt: self.lastCheckedAt)) - } - self.subscribers[id] = continuation - } - continuation.onTermination = { _ in - Task { @MainActor in - self.subscribers.removeValue(forKey: id) - } - } - } - } - // MARK: - AlarmChangeEvents /// updates()와 달리 replay 없음 — 변경 알림은 상태가 아니라 사건이라, diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 642c96be..4b4adcfd 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -36,7 +36,9 @@ final class AppDIContainer { private let liveActivityAdapter: LastTrainLiveActivityAdapter /// 세션 스냅샷(Phase 14 재실행 브리지) — 등록/취소/refresh UseCase·수명 서비스· /// 동기화 서비스가 같은 저장소를 봐야 한다(1회 생성 공유). - private let alarmSessionSnapshotStore: any AlarmSessionSnapshotStore + /// 알람 세션의 단일 소유자 — 동기화·수명·정리·등록/취소 UseCase가 같은 + /// 인스턴스를 봐야 한다(actor 격리는 인스턴스 단위). + let alarmSessionStore: AlarmSessionStore let alarmSyncService: AlarmSyncService /// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를 /// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다. @@ -112,36 +114,35 @@ final class AppDIContainer { let liveActivityAdapter = LastTrainLiveActivityAdapter() self.liveActivityAdapter = liveActivityAdapter self.liveActivityPort = liveActivityAdapter - let snapshotStore = AlarmSessionSnapshotStoreAdapter() - self.alarmSessionSnapshotStore = snapshotStore + let sessionStore = AlarmSessionStore(store: UserDefaultsKeyValueStore()) + self.alarmSessionStore = sessionStore self.alarmSessionLifecycle = AlarmSessionLifecycleService( liveActivity: liveActivityAdapter, - snapshotStore: snapshotStore + sessionStore: sessionStore ) let localNotificationAdapter = LocalNotificationAdapter() self.localNotificationPort = localNotificationAdapter self.alarmSyncService = AlarmSyncService( + // 도보 초는 호출자가 세션으로 넘긴다 — refresh 응답에 도보 정보가 없다. refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, - scheduler: alarmScheduler, - // refresh 응답에는 도보 정보가 없다 — 등록 시점 스냅샷이 도보 초의 출처. - snapshotStore: snapshotStore + scheduler: alarmScheduler ), evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), liveActivity: liveActivityAdapter, localNotification: localNotificationAdapter, alarmScheduler: alarmScheduler, - snapshotStore: snapshotStore, + sessionStore: sessionStore, sessionRestorer: liveActivityAdapter ) } /// 부트스트랩 직후 1회(AppDelegate) — 프로세스가 죽는 사이 잠금화면에 남은 고아 LA를 - /// 스냅샷과 대조해 재부착하거나 정리한다(Phase 14). 인증·네트워크와 무관한 로컬 + /// 세션 기록과 대조해 재부착하거나 정리한다(Phase 14). 인증·네트워크와 무관한 로컬 /// 리컨실이라 앱 시작 최전선에서 수행한다(부트스트랩 실패로 고아가 방치되지 않게). func reattachOrphanLiveActivities() async { - let snapshot = await alarmSessionSnapshotStore.load() - await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) + let session = await alarmSessionStore.loadSession() + await liveActivityAdapter.reattachOrphans(session: session, now: Date()) } /// 게스트 부트스트랩(서버 계약: POST /auth/guest) — 앱 시작 시 토큰이 없으면 이걸로 받는다. @@ -171,7 +172,7 @@ final class AppDIContainer { alarmRepository: alarmRepository, scheduler: alarmScheduler, activityPort: liveActivityPort, - snapshotStore: alarmSessionSnapshotStore, + sessionStore: alarmSessionStore, syncService: alarmSyncService ) } @@ -259,15 +260,16 @@ final class AppDIContainer { // 알림 권한 요청의 유일한 시점(등록 성공 직후) — UseCase 내부 훅이 호출한다. notificationPort: localNotificationPort, // 등록 성공 = 스냅샷 저장 시점(Phase 14). - snapshotStore: alarmSessionSnapshotStore + sessionStore: alarmSessionStore ), cancelAlarmUseCase: DefaultCancelAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler, activityPort: liveActivityPort, - snapshotStore: alarmSessionSnapshotStore + sessionStore: alarmSessionStore ), - observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), + // 세션 스트림은 소유자(Store)가 제공한다 — AlarmSyncService는 사건 채널만. + observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSessionStore), observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmSyncService), // 홈 pull-to-refresh(Phase 16) — 4번째 트리거도 같은 동기화 한 곳으로 합류한다. requestAlarmSyncUseCase: DefaultRequestAlarmSyncUseCase(requesting: alarmSyncService), diff --git a/Projects/App/Tests/AlarmSessionStoreTests.swift b/Projects/App/Tests/AlarmSessionStoreTests.swift index 89b77d5b..4d4ca788 100644 --- a/Projects/App/Tests/AlarmSessionStoreTests.swift +++ b/Projects/App/Tests/AlarmSessionStoreTests.swift @@ -184,6 +184,9 @@ struct AlarmSessionStoreTests { } // MARK: - 구독 (replay-1) + // + // 세션 그대로를 보는 내부 스트림. 홈이 보는 `AlarmSyncEvents.updates()`는 이 위에 + // 얹힌 매핑이고, 끝난 세션을 흘리지 않는다(아래 별도 검증). /// 구독 전에 끝난 부트스트랩을 놓치지 않아야 한다. @Test @@ -192,7 +195,7 @@ struct AlarmSessionStoreTests { let existing = session() await sut.register(session: existing) - var iterator = sut.updates().makeAsyncIterator() + var iterator = sut.sessionUpdates().makeAsyncIterator() let first = await iterator.next() #expect(first == existing) @@ -202,7 +205,7 @@ struct AlarmSessionStoreTests { func updates_emitsOnApply() async { let sut = makeSUT() await sut.bootstrap() - var iterator = sut.updates().makeAsyncIterator() + var iterator = sut.sessionUpdates().makeAsyncIterator() _ = await iterator.next() // replay(nil) let next = session() @@ -211,11 +214,37 @@ struct AlarmSessionStoreTests { #expect(await iterator.next() == next) } + /// 홈 계약(`AlarmSyncEvents`)은 **끝난 세션을 흘리지 않는다** — 죽은 세션으로 + /// 배너·해제 버튼이 복원되면 안 된다. @Test - func updates_emitsNilOnClear() async { + func syncEventsUpdates_skipsEndedSession() async { + let sut = makeSUT() + await sut.register(session: session(lifecycle: .ended)) + + var iterator = sut.updates().makeAsyncIterator() + // replay에 끝난 세션이 실리지 않으므로, 살아 있는 세션을 넣어야 값이 온다. + await sut.apply(.refreshed(session(lifecycle: .active))) + + let update = await iterator.next() + #expect(update?.info.lastRouteId == "R1") + } + + @Test + func syncEventsUpdates_carriesSyncedAtAsCheckedAt() async { let sut = makeSUT() await sut.register(session: session()) + var iterator = sut.updates().makeAsyncIterator() + + // 스탬프의 원천은 세션의 syncedAt이다 — 수신 시각이 아니다. + #expect(await iterator.next()?.checkedAt == now) + } + + @Test + func updates_emitsNilOnClear() async { + let sut = makeSUT() + await sut.register(session: session()) + var iterator = sut.sessionUpdates().makeAsyncIterator() _ = await iterator.next() // replay(session) await sut.clear() diff --git a/Projects/App/Tests/AlarmSyncServiceTests.swift b/Projects/App/Tests/AlarmSyncServiceTests.swift index ff1fec14..f8d89f31 100644 --- a/Projects/App/Tests/AlarmSyncServiceTests.swift +++ b/Projects/App/Tests/AlarmSyncServiceTests.swift @@ -1,4 +1,5 @@ @testable import AtchaV2 +import CoreStorage import Domain import Foundation import Testing @@ -47,7 +48,7 @@ private final class RefreshStub: RefreshAlarmUseCase, @unchecked Sendable { defer { lock.unlock() } return queue.isEmpty ? nil : queue.removeFirst() } - func execute() async throws -> AlarmInfo { + func execute(current: AlarmSession?) async throws -> AlarmInfo { guard let next = dequeue() else { throw StubError() } return try next.get() } @@ -104,18 +105,38 @@ private actor SchedulerSpy: AlarmScheduler { func scheduledFireDate() async -> Date? { nil } } -private actor StoreStub: AlarmSessionSnapshotStore { - private(set) var snapshot: AlarmSessionSnapshot? - init(_ snapshot: AlarmSessionSnapshot?) { self.snapshot = snapshot } - func load() async -> AlarmSessionSnapshot? { snapshot } - func save(_ snapshot: AlarmSessionSnapshot) async { self.snapshot = snapshot } - func clear() async { snapshot = nil } +/// 세션은 실제 `AlarmSessionStore`를 쓴다 — 스텁으로 대체하면 Store가 담당하는 +/// 부트스트랩·톰스톤·replay 규칙이 검증에서 빠진다. +private final class MemoryKeyValueStore: KeyValueStore, @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Data] = [:] + + init(seeded: AlarmSession? = nil) { + if let seeded, let data = try? JSONEncoder().encode(seeded) { + storage[AlarmSessionStore.storageKey] = data + } + } + + func data(forKey key: String) throws -> Data? { + lock.lock(); defer { lock.unlock() } + return storage[key] + } + + func set(_ data: Data, forKey key: String) throws { + lock.lock(); defer { lock.unlock() } + storage[key] = data + } + + func removeValue(forKey key: String) throws { + lock.lock(); defer { lock.unlock() } + storage[key] = nil + } } private actor RestorerSpy: LastTrainSessionRestoring { private(set) var restartCount = 0 - func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async {} - func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async { + func reattachOrphans(session: AlarmSession?, now: Date) async {} + func restartIfNeeded(session: AlarmSession, now: Date) async { restartCount += 1 } } @@ -128,7 +149,7 @@ private struct Harness { let activity: ActivitySpy let noti: NotiSpy let scheduler: SchedulerSpy - let store: StoreStub + let store: AlarmSessionStore let active: ValueBox /// 이전 값(diff 기준)을 심는 선행 동기화 — unchanged 판정으로 조용히 지나간다. @@ -140,7 +161,7 @@ private struct Harness { @MainActor private func makeHarness( - seeded: AlarmSessionSnapshot? = nil, + seeded: AlarmSession? = nil, isAppActive: Bool = false ) -> Harness { let refresh = RefreshStub([]) @@ -148,7 +169,7 @@ private func makeHarness( let activity = ActivitySpy() let noti = NotiSpy() let scheduler = SchedulerSpy() - let store = StoreStub(seeded) + let store = AlarmSessionStore(store: MemoryKeyValueStore(seeded: seeded)) let restorer = RestorerSpy() let active = ValueBox(isAppActive) let sut = AlarmSyncService( @@ -157,7 +178,7 @@ private func makeHarness( liveActivity: activity, localNotification: noti, alarmScheduler: scheduler, - snapshotStore: store, + sessionStore: store, sessionRestorer: restorer, isAppActive: { active.get() }, now: { fixedNow } @@ -181,13 +202,13 @@ struct AlarmSyncServiceTests { await harness.sut.syncNow() // replay-1이 확인 시각을 함께 나른다 — 스탬프의 원천은 sync 성공 시각(주입 now)뿐. - var iterator = harness.sut.updates().makeAsyncIterator() + var iterator = harness.store.updates().makeAsyncIterator() let replayed = await iterator.next() #expect(replayed == AlarmSyncUpdate( info: info(route: "r1", departure: departure), checkedAt: fixedNow )) // 재실행 브리지에도 같은 시각이 영속화된다. - #expect(await harness.store.snapshot?.syncedAt == fixedNow) + #expect(harness.store.current?.syncedAt == fixedNow) } @Test @@ -196,19 +217,19 @@ struct AlarmSyncServiceTests { // 마지막 확인 시각이어야 한다 — 낡음을 숨기지 않는다. let seededCheckedAt = fixedNow.addingTimeInterval(-2400) let departure = fixedNow.addingTimeInterval(1800) - let harness = makeHarness(seeded: AlarmSessionSnapshot( - info: info(route: "r1", departure: departure), - firstWalkSeconds: nil, - routeDisplayName: "6411번 버스", - transportMode: .bus, - acknowledged: false, - expired: false, + let harness = makeHarness(seeded: AlarmSession( + server: info(route: "r1", departure: departure), + local: .init( + firstWalkSeconds: nil, + routeDisplayName: "6411번 버스", + transportMode: .bus + ), syncedAt: seededCheckedAt )) await harness.sut.syncNow() // refresh 큐 비어 있음 → 실패(무음) - var iterator = harness.sut.updates().makeAsyncIterator() + var iterator = harness.store.updates().makeAsyncIterator() let replayed = await iterator.next() #expect(replayed?.info.lastRouteId == "r1") #expect(replayed?.checkedAt == seededCheckedAt) @@ -311,14 +332,14 @@ struct AlarmSyncServiceTests { func sessionEnded_cancelsAlarmClearsSnapshotAndEndsActivity() async { let harness = makeHarness(isAppActive: false) await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) - #expect(await harness.store.snapshot != nil) + #expect(harness.store.current != nil) harness.evaluate.fix(.sessionEnded) harness.refresh.enqueue(.success(info(route: "r1", departure: nil))) await harness.sut.syncNow() #expect(await harness.scheduler.cancelCount == 1) - #expect(await harness.store.snapshot == nil) + #expect(harness.store.current == nil) #expect(await harness.activity.finals.last?.phase == .serviceEnded) #expect(await harness.noti.posted.isEmpty) // 종료는 행동을 요구하지 않는다 — 폴백 없음. } @@ -329,13 +350,9 @@ struct AlarmSyncServiceTests { func localExpiry_refreshFailure_finalizesTombstoneAndCancelsAlarm() async { // 시딩된 과거 세션(출발+유예 경과) + refresh 실패 → 로컬 sessionEnded 확정. let pastDeparture = fixedNow.addingTimeInterval(-120) - let harness = makeHarness(seeded: AlarmSessionSnapshot( - info: info(route: "r1", departure: pastDeparture), - firstWalkSeconds: nil, - routeDisplayName: "", - transportMode: nil, - acknowledged: false, - expired: false, + let harness = makeHarness(seeded: AlarmSession( + server: info(route: "r1", departure: pastDeparture), + local: .empty, syncedAt: fixedNow.addingTimeInterval(-3600) )) var changeIterator = harness.sut.changes().makeAsyncIterator() @@ -344,7 +361,7 @@ struct AlarmSyncServiceTests { await harness.sut.syncNow() // refresh 큐 비어 있음 → 실패여도 만료는 확정된다. #expect(await harness.scheduler.cancelCount == 1) - #expect(await harness.store.snapshot?.expired == true) + #expect(harness.store.current?.lifecycle == .ended) #expect(await harness.activity.finals.last?.phase == .serviceEnded) let verdict = await changeIterator.next() #expect(verdict == .sessionEnded) @@ -353,13 +370,9 @@ struct AlarmSyncServiceTests { @Test func localExpiry_serverReturnsFutureDeparture_serverWins() async { // 만료 후보 상태에서 refresh가 미래 출발을 주면 만료를 취소한다(서버 우선). - let harness = makeHarness(seeded: AlarmSessionSnapshot( - info: info(route: "r1", departure: fixedNow.addingTimeInterval(-120)), - firstWalkSeconds: nil, - routeDisplayName: "", - transportMode: nil, - acknowledged: false, - expired: false + let harness = makeHarness(seeded: AlarmSession( + server: info(route: "r1", departure: fixedNow.addingTimeInterval(-120)), + local: .empty )) let future = fixedNow.addingTimeInterval(1800) harness.refresh.enqueue(.success(info(route: "r1", departure: future))) @@ -367,8 +380,8 @@ struct AlarmSyncServiceTests { await harness.sut.syncNow() #expect(await harness.scheduler.cancelCount == 0) - #expect(await harness.store.snapshot?.expired == false) - #expect(await harness.store.snapshot?.info.departureTime == future) + #expect(harness.store.current?.lifecycle != .ended) + #expect(harness.store.current?.server.departureTime == future) } // MARK: - 수동 갱신 합류 (Phase 16) @@ -384,7 +397,7 @@ struct AlarmSyncServiceTests { async let second: Void = harness.sut.syncNow() _ = await (first, second) - var iterator = harness.sut.updates().makeAsyncIterator() + var iterator = harness.store.updates().makeAsyncIterator() let replayed = await iterator.next() #expect(replayed?.info.lastRouteId == "r1") } diff --git a/Projects/Domain/Sources/AlarmSessionReconciler.swift b/Projects/Domain/Sources/AlarmSessionReconciler.swift index 45faa86b..df3b96f5 100644 --- a/Projects/Domain/Sources/AlarmSessionReconciler.swift +++ b/Projects/Domain/Sources/AlarmSessionReconciler.swift @@ -78,6 +78,12 @@ public enum AlarmSessionReconciler { // 서버가 출발 시각을 안 줬다 — 세션이 서버에서 사라진 것으로 본다. guard info.departureTime != nil else { return .ended } + // **만료 판정은 진입 시점 세션(`session`)으로 한다.** 서버가 방금 준 시각이 + // 과거라는 사실은 만료가 아니라 "막차가 지나갔다"는 **변경 판정(missed)의 + // 재료**다 — 조용히 세션을 끝내면 LA가 종료되고 '막차가 지나갔어요' 알림이 + // 나가지 않아 사용자가 인지 기회를 잃는다. + guard session.hasPassedDeparture(now: now) else { return .refreshed(merged) } + // 보유 세션이 만료 상태였다면, 서버가 미래 시각을 줄 때만 되살린다(서버 우선). return expiryOutcome(for: merged, now: now) ?? .refreshed(merged) } diff --git a/Projects/Domain/Sources/Entities/AlarmSession.swift b/Projects/Domain/Sources/Entities/AlarmSession.swift index c02f126c..94722cd5 100644 --- a/Projects/Domain/Sources/Entities/AlarmSession.swift +++ b/Projects/Domain/Sources/Entities/AlarmSession.swift @@ -138,3 +138,11 @@ public extension AlarmSession.LocalFacts { .init(firstWalkSeconds: nil, routeDisplayName: "", transportMode: nil) } } + +/// 세션 영속화 포트 — 구현(저장 백엔드)은 App에 둔다. +/// 디코딩 실패는 구현이 nil로 무해화한다(자가치유 — 다음 save가 덮어쓴다). +public protocol AlarmSessionStoring: Sendable { + func loadSession() async -> AlarmSession? + func saveSession(_ session: AlarmSession) async + func clearSession() async +} diff --git a/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift b/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift deleted file mode 100644 index 91a845ad..00000000 --- a/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift +++ /dev/null @@ -1,70 +0,0 @@ -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 - /// 이 세션 값이 마지막으로 서버로 확인된 시각(Phase 16) — 등록 성공·sync 성공 시 - /// 갱신된다. 재실행 시딩이 이 값을 날라 신선도 스탬프("HH:mm 확인 기준")가 재실행· - /// 오프라인에서도 마지막 확인 시각을 정직하게 유지한다. 구 스냅샷은 nil로 디코딩된다. - public let syncedAt: Date? - - public init( - info: AlarmInfo, - firstWalkSeconds: Int?, - routeDisplayName: String, - transportMode: TransportMode?, - acknowledged: Bool, - expired: Bool, - syncedAt: Date? = nil - ) { - self.info = info - self.firstWalkSeconds = firstWalkSeconds - self.routeDisplayName = routeDisplayName - self.transportMode = transportMode - self.acknowledged = acknowledged - self.expired = expired - self.syncedAt = syncedAt - } - - /// 세션 사실(도보·표시명·수단)은 유지하고 기록 필드만 바꾼 사본. - /// syncedAt은 명시할 때만 갱신된다 — 만료 톰스톤 전환 등은 마지막 확인 시각을 보존한다. - public func updating( - info: AlarmInfo? = nil, - acknowledged: Bool? = nil, - expired: Bool? = nil, - syncedAt: Date? = nil - ) -> AlarmSessionSnapshot { - AlarmSessionSnapshot( - info: info ?? self.info, - firstWalkSeconds: firstWalkSeconds, - routeDisplayName: routeDisplayName, - transportMode: transportMode, - acknowledged: acknowledged ?? self.acknowledged, - expired: expired ?? self.expired, - syncedAt: syncedAt ?? self.syncedAt - ) - } -} - -/// 스냅샷 영속화 포트 — 어댑터(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/UseCases/CancelAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift index 2c6ac943..91951cdd 100644 --- a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift @@ -9,26 +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)? + /// 세션 영속화 — 취소는 clear 시점이다. nil이면 저장 없이 동작한다. + private let sessionStore: (any AlarmSessionStoring)? public init( repository: any AlarmRepository, scheduler: any AlarmScheduler, activityPort: (any LastTrainActivityPort)? = nil, - snapshotStore: (any AlarmSessionSnapshotStore)? = nil + sessionStore: (any AlarmSessionStoring)? = nil ) { self.repository = repository self.scheduler = scheduler self.activityPort = activityPort - self.snapshotStore = snapshotStore + self.sessionStore = sessionStore } public func execute(lastRouteId: String) async throws { try await repository.cancel(lastRouteId: lastRouteId) await scheduler.cancelAlarm() - // 세션이 유저 의사로 끝났다 — 재실행 브리지(스냅샷)도 함께 지운다. - await snapshotStore?.clear() + // 세션이 유저 의사로 끝났다 — 재실행 브리지(영속 기록)도 함께 지운다. + await sessionStore?.clearSession() // 수명 정책: 알람 세션이 끝나면 LA도 끝낸다. 유저 취소는 실패 상태가 아니므로 // phase는 .active로 종료한다. end는 즉시 닫는 경로라 시각 값은 표시에 쓰이지 않는다. if let activityPort { diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift index 2a604c50..145e7641 100644 --- a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -1,56 +1,49 @@ import Foundation public protocol RefreshAlarmUseCase: Sendable { - func execute() async throws -> AlarmInfo + /// - Parameter current: 로컬이 아는 세션. 도보 초의 출처이며, 재스케줄 시각 계산에 + /// 쓰인다. nil이면 버퍼만 적용된다(등록 경로와 같은 폴백). + func execute(current: AlarmSession?) async throws -> AlarmInfo } 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 { + public func execute(current: AlarmSession?) async throws -> AlarmInfo { let info = try await repository.refresh() - // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. - // 기대 발화 시각은 register와 같은 기준(출발 − 도보 − 버퍼)으로 비교해야 - // 변경이 없어도 매 refresh마다 재스케줄되는 헛돎이 없다(이중 시각 금지). - if let departure = info.departureTime { - let snapshot = await snapshotStore?.load() - // 스냅샷의 도보 초는 같은 세션(routeId 일치)일 때만 유효하다 — - // 서버가 다른 경로로 갈아탔으면 그 경로의 도보를 모른다(버퍼만 적용). - let firstWalkSeconds = snapshot?.info.lastRouteId == info.lastRouteId - ? snapshot?.firstWalkSeconds - : nil - let expectedFireDate = AlarmTiming.alarmFireDate( - departureTime: departure, - firstWalkSeconds: firstWalkSeconds + // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. 기대 발화 시각은 + // register와 같은 기준(출발 − 도보 − 버퍼)으로 비교해야 변경이 없어도 매 + // refresh마다 재스케줄되는 헛돎이 없다(이중 시각 금지). + // + // 도보 초를 직접 다루지 않는다 — `merging`이 routeId 일치까지 판단해 같은 + // 경로일 때만 보존하고, `fireDate`가 계산한다. 이전에는 이 파일이 스냅샷을 + // 직접 로드해 routeId를 비교했고, 같은 규칙이 AlarmSyncService에도 있었다. + let expected = current?.merging(server: info, syncedAt: nil) + ?? AlarmSession(server: info, local: .empty) + guard let expectedFireDate = expected.fireDate else { return info } + + // 새 알람 시각이 이미 과거면 재스케줄하지 않는다 — 과거 fixed 스케줄은 AlarmKit이 + // 거부해 refresh 전체를 실패시킬 수 있다. 이 경우의 인지는 갱신 성공 이후 + // 훅의 즉시 최후통첩이 담당한다(정책 4 "알람 발화 후 변경"). + if expectedFireDate > now(), + await scheduler.scheduledFireDate() != expectedFireDate { + try await scheduler.replaceAlarm( + id: info.lastRouteId, + fireDate: expectedFireDate, + title: AlarmSchedulingDefaults.title ) - // 새 알람 시각이 이미 과거면 재스케줄하지 않는다 — 과거 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 af0aa192..8a4327c1 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -14,8 +14,8 @@ 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)? + /// 세션 영속화 — nil이면 저장 없이 동작한다(Example 호환). + private let sessionStore: (any AlarmSessionStoring)? private let now: @Sendable () -> Date public init( @@ -23,14 +23,14 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { scheduler: any AlarmScheduler, activityPort: (any LastTrainActivityPort)? = nil, notificationPort: (any LocalNotificationPort)? = nil, - snapshotStore: (any AlarmSessionSnapshotStore)? = nil, + sessionStore: (any AlarmSessionStoring)? = nil, now: @escaping @Sendable () -> Date = { Date() } ) { self.repository = repository self.scheduler = scheduler self.activityPort = activityPort self.notificationPort = notificationPort - self.snapshotStore = snapshotStore + self.sessionStore = sessionStore self.now = now } @@ -69,13 +69,14 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { // **알람이 도보 시간만큼 늦게 울린다**(도보 3분이면 3분 늦음). // 저장은 throws가 아니라 실패를 흡수하므로 이 순서가 등록을 막지 않는다. // syncedAt = 등록 시각(Phase 16) — 서버가 방금 이 값을 받아들였으므로 확인이다. - await snapshotStore?.save(AlarmSessionSnapshot( - info: session, - firstWalkSeconds: route.firstWalkSectionSeconds, - routeDisplayName: route.sessionDisplayName, - transportMode: route.boardingLeg?.mode, - acknowledged: false, - expired: false, + await sessionStore?.saveSession(AlarmSession( + server: session, + local: .init( + firstWalkSeconds: route.firstWalkSectionSeconds, + routeDisplayName: route.sessionDisplayName, + transportMode: route.boardingLeg?.mode + ), + lifecycle: .active, syncedAt: now() )) // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. diff --git a/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift index 50b7a157..ac55da3e 100644 --- a/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift +++ b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift @@ -99,6 +99,26 @@ struct AlarmSessionReconcilerTests { #expect(session.lifecycle == .active) } + /// 회귀: **살아 있던 세션**에 서버가 방금 과거 시각을 주는 경우. + /// + /// 이건 만료가 아니라 "막차가 지나갔다"는 변경 판정(missed)의 재료다. 만료로 + /// 처리하면 LA가 조용히 종료되고 '막차가 지나갔어요' 알림이 나가지 않아 사용자가 + /// 인지 기회를 잃는다. 만료 판정은 **진입 시점 세션**으로만 한다. + @Test + func liveSession_serverReturnsPastDeparture_refreshesForMissedVerdict() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: 3600), // 아직 살아 있다 + server: info(departureOffset: -100), // 서버가 방금 지나갔다고 알림 + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .active) + #expect(session.server.departureTime == now.addingTimeInterval(-100)) + } + @Test func pastDeparture_serverConfirmsSamePast_expires() { let outcome = AlarmSessionReconciler.reconcile( diff --git a/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift b/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift deleted file mode 100644 index a81b70cd..00000000 --- a/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -@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, - syncedAt: Date(timeIntervalSince1970: 1_755_995_000) - ) - - @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) - } - - // MARK: - syncedAt (Phase 16 신선도 스탬프) - - @Test - func updating_preservesSyncedAtUnlessGiven() { - // 명시하지 않으면 마지막 확인 시각을 보존한다 — 톰스톤 전환이 스탬프를 지우면 안 된다. - let tombstone = snapshot.updating(expired: true) - #expect(tombstone.syncedAt == snapshot.syncedAt) - - let refreshed = snapshot.updating(syncedAt: Date(timeIntervalSince1970: 1_756_000_100)) - #expect(refreshed.syncedAt == Date(timeIntervalSince1970: 1_756_000_100)) - #expect(refreshed.info == snapshot.info) - } - - @Test - func decoding_snapshotWithoutSyncedAtKey_defaultsToNil() throws { - // Phase 16 이전에 저장된 스냅샷(구 포맷) — syncedAt 키 부재는 nil로 디코딩돼야 - // 재실행 브리지가 깨지지 않는다(하위호환). - let legacy = AlarmSessionSnapshot( - info: snapshot.info, - firstWalkSeconds: 120, - routeDisplayName: "6411번 버스", - transportMode: .bus, - acknowledged: false, - expired: false - ) - var object = try #require( - try JSONSerialization.jsonObject(with: JSONEncoder().encode(legacy)) as? [String: Any] - ) - object.removeValue(forKey: "syncedAt") - let data = try JSONSerialization.data(withJSONObject: object) - let decoded = try JSONDecoder().decode(AlarmSessionSnapshot.self, from: data) - #expect(decoded.syncedAt == nil) - #expect(decoded.info == snapshot.info) - } -} diff --git a/Projects/Domain/Tests/AlarmSessionTests.swift b/Projects/Domain/Tests/AlarmSessionTests.swift new file mode 100644 index 00000000..64927925 --- /dev/null +++ b/Projects/Domain/Tests/AlarmSessionTests.swift @@ -0,0 +1,134 @@ +@testable import Domain +import Foundation +import Testing + +/// `AlarmSessionSnapshotTests`가 검증하던 성질(Codable 왕복·부분 갱신·`syncedAt` 보존· +/// 하위호환)을 새 타입에서 이어간다. 영속 타입이라 디코딩이 깨지면 재실행 브리지가 +/// 통째로 사라지므로 계약을 고정해 둔다. +struct AlarmSessionTests { + private let session = AlarmSession( + server: AlarmInfo( + lastRouteId: "route-1", + departureTime: Date(timeIntervalSince1970: 1_756_000_000), + updatedAt: Date(timeIntervalSince1970: 1_755_990_000), + isReal: true + ), + local: .init( + firstWalkSeconds: 120, + routeDisplayName: "6411번 버스", + transportMode: .bus + ), + lifecycle: .active, + syncedAt: Date(timeIntervalSince1970: 1_755_995_000) + ) + + // MARK: - Codable + + @Test + func roundTripsCodable() throws { + let data = try JSONEncoder().encode(session) + let decoded = try JSONDecoder().decode(AlarmSession.self, from: data) + #expect(decoded == session) + } + + @Test + func nilOptionalFields_roundTripCodable() throws { + let sparse = AlarmSession( + server: AlarmInfo(lastRouteId: "r", departureTime: nil, updatedAt: nil, isReal: false), + local: .empty, + lifecycle: .ended + ) + let data = try JSONEncoder().encode(sparse) + let decoded = try JSONDecoder().decode(AlarmSession.self, from: data) + + #expect(decoded == sparse) + #expect(decoded.local.firstWalkSeconds == nil) + #expect(decoded.local.transportMode == nil) + #expect(decoded.syncedAt == nil) + } + + /// 수명은 rawValue 문자열로 저장된다 — 케이스 이름을 바꾸면 기존 기록을 못 읽는다. + @Test + func lifecycle_encodesAsStableRawValue() throws { + for (lifecycle, expected) in [ + (AlarmSession.Lifecycle.active, "active"), + (.acknowledged, "acknowledged"), + (.ended, "ended"), + ] { + let data = try JSONEncoder().encode(session.with(lifecycle: lifecycle)) + let object = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #expect(object["lifecycle"] as? String == expected) + } + } + + // MARK: - 부분 갱신 + + @Test + func mergingSameRoute_replacesServerFactsAndKeepsLocalOnes() { + let newServer = AlarmInfo( + lastRouteId: "route-1", + departureTime: Date(timeIntervalSince1970: 1_756_000_600), + updatedAt: nil, + isReal: true + ) + + let merged = session.merging(server: newServer, syncedAt: nil) + + #expect(merged.server == newServer) + // 로컬 사실은 서버가 주지 않는 값이라 같은 경로면 반드시 보존돼야 한다. + #expect(merged.local.firstWalkSeconds == 120) + #expect(merged.local.routeDisplayName == "6411번 버스") + #expect(merged.local.transportMode == .bus) + #expect(merged.lifecycle == .active) + } + + @Test + func with_lifecycle_keepsEverythingElse() { + let tombstone = session.with(lifecycle: .ended) + + #expect(tombstone.lifecycle == .ended) + #expect(tombstone.server == session.server) + #expect(tombstone.local == session.local) + } + + // MARK: - syncedAt (신선도 스탬프) + + /// 톰스톤 전환이 스탬프를 지우거나 "지금"으로 바꾸면 사용자에게 거짓말이 된다. + @Test + func with_lifecycle_preservesSyncedAt() { + #expect(session.with(lifecycle: .ended).syncedAt == session.syncedAt) + } + + @Test + func merging_withoutSyncedAt_preservesPrevious() { + let merged = session.merging(server: session.server, syncedAt: nil) + #expect(merged.syncedAt == session.syncedAt) + } + + @Test + func merging_withSyncedAt_updatesIt() { + let checkedAt = Date(timeIntervalSince1970: 1_756_000_100) + let merged = session.merging(server: session.server, syncedAt: checkedAt) + #expect(merged.syncedAt == checkedAt) + } + + /// 저장 포맷에서 키가 빠져도 디코딩이 깨지면 안 된다 — 재실행 브리지가 통째로 + /// 사라지는 것보다 스탬프를 잃는 쪽이 낫다. + @Test + func decoding_withoutSyncedAtKey_defaultsToNil() throws { + var object = try #require( + try JSONSerialization.jsonObject(with: JSONEncoder().encode(session)) + as? [String: Any] + ) + object.removeValue(forKey: "syncedAt") + let data = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(AlarmSession.self, from: data) + + #expect(decoded.syncedAt == nil) + #expect(decoded.server == session.server) + #expect(decoded.local == session.local) + } +} diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift index ad534438..d832558f 100644 --- a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -44,12 +44,12 @@ private struct SpyAlarmScheduler: AlarmScheduler { func scheduledFireDate() async -> Date? { nil } } -private actor SpySnapshotStore: AlarmSessionSnapshotStore { +private actor SpySessionStore: AlarmSessionStoring { private(set) var clearCount = 0 - func load() async -> AlarmSessionSnapshot? { nil } - func save(_ snapshot: AlarmSessionSnapshot) async {} - func clear() async { clearCount += 1 } + func loadSession() async -> AlarmSession? { nil } + func saveSession(_ session: AlarmSession) async {} + func clearSession() async { clearCount += 1 } } struct DefaultCancelAlarmUseCaseTests { @@ -82,11 +82,11 @@ struct DefaultCancelAlarmUseCaseTests { @Test func execute_success_clearsSnapshot() async throws { let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() let sut = DefaultCancelAlarmUseCase( repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log), - snapshotStore: store + sessionStore: store ) try await sut.execute(lastRouteId: "route-1") #expect(await store.clearCount == 1) @@ -96,11 +96,11 @@ struct DefaultCancelAlarmUseCaseTests { func execute_serverCancelFails_keepsSnapshot() async { // 세션은 아직 살아 있다 — 실패한 취소가 재실행 브리지를 지우면 안 된다. let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() let sut = DefaultCancelAlarmUseCase( repository: SpyAlarmRepository(log: log, cancelError: StubError()), scheduler: SpyAlarmScheduler(log: log), - snapshotStore: store + sessionStore: store ) await #expect(throws: StubError.self) { try await sut.execute(lastRouteId: "route-1") diff --git a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift index dd8979a8..c852e5b3 100644 --- a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift @@ -51,22 +51,16 @@ 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 +/// 도보 초의 출처. 이전에는 UseCase가 저장소를 직접 로드했지만, 이제 호출자가 +/// 세션을 넘기고 `AlarmSession.merging`이 routeId 일치까지 판단한다. +private func makeSession(routeId: String, firstWalkSeconds: Int?) -> AlarmSession { + AlarmSession( + server: AlarmInfo(lastRouteId: routeId, departureTime: nil, updatedAt: nil, isReal: true), + local: .init( + firstWalkSeconds: firstWalkSeconds, + routeDisplayName: "6411번 버스", + transportMode: .bus + ) ) } @@ -84,7 +78,7 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log, scheduledDate: Date(timeIntervalSince1970: 1_000)), now: fixedNow ) - let info = try await sut.execute() + let info = try await sut.execute(current: nil) #expect(info.departureTime == newDeparture) // 스케줄 시각은 버퍼 반영값: 2000 − 180 = 1820 #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1820"]) @@ -105,7 +99,7 @@ struct DefaultRefreshAlarmUseCaseTests { ), now: fixedNow ) - _ = try await sut.execute() + _ = try await sut.execute(current: nil) #expect(await log.events == ["refresh", "scheduledFireDate"]) } @@ -118,7 +112,7 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), now: fixedNow ) - _ = try await sut.execute() + _ = try await sut.execute(current: nil) // 스케줄 시각은 버퍼 반영값: 3000 − 180 = 2820 #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@2820"]) } @@ -136,7 +130,7 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), now: { Date(timeIntervalSince1970: 1_900) } ) - _ = try await sut.execute() + _ = try await sut.execute(current: nil) #expect(await log.events == ["refresh"]) } @@ -147,14 +141,14 @@ struct DefaultRefreshAlarmUseCaseTests { repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: nil))), scheduler: SpyAlarmScheduler(log: log) ) - _ = try await sut.execute() + _ = try await sut.execute(current: nil) #expect(await log.events == ["refresh"]) } // MARK: - 도보 반영 (Phase 14 — 스냅샷이 도보 초의 출처) @Test - func execute_snapshotWalkSeconds_rescheduleUsesWalkAwareFireDate() async throws { + func execute_sessionWalkSeconds_rescheduleUsesWalkAwareFireDate() async throws { let log = CallLog() let departure = Date(timeIntervalSince1970: 2_000) let sut = DefaultRefreshAlarmUseCase( @@ -162,18 +156,17 @@ struct DefaultRefreshAlarmUseCaseTests { 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() + _ = try await sut.execute( + current: makeSession(routeId: "route-1", firstWalkSeconds: 120) + ) // 기대 발화 시각 = 2000 − 120(도보) − 180(버퍼) = 1700 — 등록 경로와 같은 기준. #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1700"]) } @Test - func execute_snapshotWalkMatchingSchedule_doesNotReschedule() async throws { + func execute_sessionWalkMatchingSchedule_doesNotReschedule() async throws { // 로컬 알람이 이미 도보 반영값으로 걸려 있으면 재스케줄하지 않는다(헛돎 금지). let log = CallLog() let departure = Date(timeIntervalSince1970: 2_000) @@ -184,17 +177,16 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler( log: log, scheduledDate: Date(timeIntervalSince1970: 1_700) ), - snapshotStore: StubSnapshotStore( - snapshot: makeSnapshot(routeId: "route-1", firstWalkSeconds: 120) - ), now: fixedNow ) - _ = try await sut.execute() + _ = try await sut.execute( + current: makeSession(routeId: "route-1", firstWalkSeconds: 120) + ) #expect(await log.events == ["refresh", "scheduledFireDate"]) } @Test - func execute_snapshotForDifferentRoute_ignoresItsWalkSeconds() async throws { + func execute_sessionForDifferentRoute_ignoresItsWalkSeconds() async throws { // 서버가 다른 경로로 갈아탔으면 옛 경로의 도보 초는 무효 — 버퍼만 적용한다. let log = CallLog() let departure = Date(timeIntervalSince1970: 2_000) @@ -203,12 +195,11 @@ struct DefaultRefreshAlarmUseCaseTests { 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() + _ = try await sut.execute( + current: makeSession(routeId: "other-route", firstWalkSeconds: 120) + ) #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1820"]) } @@ -220,7 +211,7 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log) ) await #expect(throws: StubError.self) { - _ = try await sut.execute() + _ = try await sut.execute(current: nil) } #expect(await log.events == ["refresh"]) } diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index c5ede2a8..ce50b005 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -68,19 +68,19 @@ private struct StubLocalNotificationPort: LocalNotificationPort { } } -private actor SpySnapshotStore: AlarmSessionSnapshotStore { - private(set) var saved: [AlarmSessionSnapshot] = [] +private actor SpySessionStore: AlarmSessionStoring { + private(set) var saved: [AlarmSession] = [] private(set) var clearCount = 0 - var stored: AlarmSessionSnapshot? + var stored: AlarmSession? - func load() async -> AlarmSessionSnapshot? { stored } + func loadSession() async -> AlarmSession? { stored } - func save(_ snapshot: AlarmSessionSnapshot) async { - saved.append(snapshot) - stored = snapshot + func saveSession(_ session: AlarmSession) async { + saved.append(session) + stored = session } - func clear() async { + func clearSession() async { clearCount += 1 stored = nil } @@ -229,12 +229,12 @@ struct DefaultRegisterAlarmUseCaseTests { @Test func execute_fireDateAlreadyPast_throwsTooLateBeforeAnySideEffect() async { let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() // 발화 시각 820 ≤ now 820 — 경계 포함 과거로 본다. let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log), - snapshotStore: store, + sessionStore: store, now: { Date(timeIntervalSince1970: 820) } ) await #expect(throws: AlarmError.tooLate) { @@ -265,27 +265,26 @@ struct DefaultRegisterAlarmUseCaseTests { @Test func execute_success_savesSnapshotWithRouteFacts() async throws { let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() 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, + sessionStore: 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) + #expect(saved.first?.server.lastRouteId == "new") + #expect(saved.first?.server.departureTime == route.departureTime) + #expect(saved.first?.local.firstWalkSeconds == 120) + #expect(saved.first?.local.routeDisplayName == "6411번 버스") + #expect(saved.first?.local.transportMode == .bus) + #expect(saved.first?.lifecycle == .active) // 등록 성공 = 서버 확인 — 신선도 스탬프의 원천이 등록 시각으로 기록된다(Phase 16). #expect(saved.first?.syncedAt == fixedNow()) } @@ -293,11 +292,11 @@ struct DefaultRegisterAlarmUseCaseTests { @Test func execute_serverRegisterFails_doesNotSaveSnapshot() async { let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log, registerError: StubError()), scheduler: SpyAlarmScheduler(log: log), - snapshotStore: store, + sessionStore: store, now: fixedNow ) await #expect(throws: StubError.self) { @@ -315,11 +314,11 @@ struct DefaultRegisterAlarmUseCaseTests { @Test func execute_localScheduleFails_stillPersistsLocalFacts() async { let log = CallLog() - let store = SpySnapshotStore() + let store = SpySessionStore() let sut = DefaultRegisterAlarmUseCase( repository: SpyAlarmRepository(log: log), scheduler: SpyAlarmScheduler(log: log, replaceError: StubError()), - snapshotStore: store, + sessionStore: store, now: fixedNow ) @@ -334,8 +333,8 @@ struct DefaultRegisterAlarmUseCaseTests { let saved = await store.saved #expect(saved.count == 1) // 도보 초가 보존돼야 한다 — 이게 없으면 복구 시 알람이 늦는다. - #expect(saved.first?.firstWalkSeconds == 300) - #expect(saved.first?.info.lastRouteId == "new") + #expect(saved.first?.local.firstWalkSeconds == 300) + #expect(saved.first?.server.lastRouteId == "new") // 로컬 스케줄은 실제로 시도됐고 실패했다(저장이 스케줄을 건너뛴 게 아니다). #expect(await log.events.contains { $0.hasPrefix("replaceAlarm:new") }) } From f4a3be75ea89dfdfba9f0bc711e51c74d37c5415 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 22:14:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=84=B8=EC=85=98=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=C2=B7=ED=8B=B1=C2=B7=ED=91=9C=EC=B6=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(Phase=20C=C2=B7D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Phase C — 화면이 세션을 끝내지 않게 이전에는 홈의 배너 타이머가 유예 경과를 감지해 `sessionExpired()`로 세션을 끝냈다. 만료 판정 주체가 하나 더 있던 셈이고, **홈이 떠 있지 않으면 감지되지도 않았다.** 틱과 판정을 세션 소유자(Store → Reconciler)로 옮겼다. 홈은 받은 값을 그리기만 한다. `bannerTickInterval` 주입이 프로젝트 전체에서 사라졌다. 부수적 개선: 도보 초를 스트림이 싣고 온다(`AlarmSyncUpdate.firstWalkSeconds`). 기존에는 홈이 화면에 떠 있는 경로에서 추측했고, **재실행 복원처럼 카드가 없으면 버퍼만 적용된 배너**를 그렸다. 세션이 유일한 출처이므로 이제 정확하다. ### 종료 경로를 구분해야 했다 `.sessionEnded`(서버 운행 종료)와 유예 경과 만료는 의미가 다르다 — 전자는 카드를 그대로 두고, 후자만 "지난 막차"로 바꾼다. 둘 다 같은 채널로 오게 되면서 홈이 출발 시각으로 구분하도록 했다. 지나간 것과 사라진 것은 다르고, 사라진 경로에 "지난 막차"를 붙이면 오정보다. 지난 막차일 때는 등록 버튼도 숨긴다 — 누르면 `tooLate`로 실패할 뿐이다. ## Phase D — 트리거와 표출 분리 AlarmSyncService 575줄 / 프로토콜 3개 → 183줄 / 1개 AlarmChangePresenter 신규 292줄 / 1개 셋이 한 타입에 있던 이유는 "같은 세션을 필요로 해서"였다. Store가 세션을 소유하게 되자 서로를 알 필요가 없어졌다. `changeBadgeExpiry`와 `reset()`도 Presenter로 옮겼다 — **세션 상태가 아니라 표출 상태**다. ## 타입당 프로토콜 1개 AlarmSyncService 183줄 AlarmSyncRequesting 트리거 4경로 + inFlight AlarmChangePresenter 292줄 AlarmChangeEvents 판정 → 3채널 표출 AlarmSessionStore 210줄 AlarmSyncEvents + 세션 소유 + 시계 틱 AlarmSessionStoring AlarmSessionReconciler 105줄 (순수 함수) 만료·서버우선·메아리 ## 테스트 삭제 0개. 소유자가 바뀐 2개는 그에 맞춰 재작성했다: - `bannerTimer_graceElapsed_...` → `sessionEnded_transitionsToPastTrainState` (틱 감지 → 종료 **이벤트**) - `bannerTimer_ticksRecomputeMinutes` → `syncUpdate_recomputesBannerMinutes` (홈 틱 → 스트림 **재방출**) Store 틱 테스트 3개를 추가했다. 그중 "이미 끝난 세션을 다시 통지하지 않는지"가 중요하다 — 톰스톤을 걸러내지 않으면 매 틱마다 `.sessionEnded`가 방출되어 LA 종료·알람 취소가 반복된다. 검증: Domain 119 / AtchaV2 39 / HomeFeature 66 / Debug 빌드 Co-Authored-By: Claude Opus 5 --- .../App/Sources/AlarmChangePresenter.swift | 297 +++++++++++++++++ Projects/App/Sources/AlarmSessionStore.swift | 44 ++- Projects/App/Sources/AlarmSyncService.swift | 305 ++---------------- Projects/App/Sources/AppDIContainer.swift | 18 +- .../App/Tests/AlarmSessionStoreTests.swift | 69 +++- .../App/Tests/AlarmSyncServiceTests.swift | 20 +- .../Sources/AlarmSessionReconciler.swift | 11 + .../Sources/Interfaces/AlarmSyncEvents.swift | 8 +- .../Feature/Home/Sources/HomeViewModel.swift | 86 ++--- .../Home/Tests/HomeViewModelTests.swift | 61 ++-- 10 files changed, 544 insertions(+), 375 deletions(-) create mode 100644 Projects/App/Sources/AlarmChangePresenter.swift diff --git a/Projects/App/Sources/AlarmChangePresenter.swift b/Projects/App/Sources/AlarmChangePresenter.swift new file mode 100644 index 00000000..2f5699a9 --- /dev/null +++ b/Projects/App/Sources/AlarmChangePresenter.swift @@ -0,0 +1,297 @@ +import Domain +import Foundation +import UIKit +import os + +/// 알람 시각 **변경을 사용자에게 알리는** 일만 한다 — 판정(`EvaluateAlarmChangeUseCase`) +/// 결과를 받아 세 채널로 분기한다: ① Live Activity ② 로컬 노티(LA 도달 불가 폴백) +/// ③ 인앱(`AlarmChangeEvents` → 홈 배너 강조·토스트). +/// +/// 이전에는 동기화 트리거와 한 타입에 있었다(575줄, 프로토콜 3개 동시 구현). 트리거를 +/// 모으는 일과 변경을 표출하는 일은 같은 세션을 필요로 했을 뿐 서로를 알 필요가 없다 — +/// 세션을 `AlarmSessionStore`가 소유하게 되자 분리가 가능해졌다. +/// +/// 이 타입의 모든 표출은 **실패 무해**다. LA·노티 포트가 non-throwing이라 알람 자체에 +/// 영향을 줄 구조가 없다(알람 재스케줄은 표출보다 앞서 끝나 있다). +@MainActor +final class AlarmChangePresenter: AlarmChangeEvents { + private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase + /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. + private let liveActivity: any LastTrainChangeAlerting + /// dismiss 폴백 채널 — 유저가 LA를 지운 뒤의 변경 alert를 로컬 노티로 대신한다. + /// 발송도 non-throwing(권한 없으면 조용히 no-op) — 여기서 권한을 요청하는 일은 절대 없다. + private let localNotification: any LocalNotificationPort + /// sessionEnded 시 로컬 알람 취소용 — 등록/갱신 UseCase와 같은 스케줄러를 공유한다. + private let alarmScheduler: any AlarmScheduler + /// 도보 초의 출처 — 알람 시각 계산이 등록/refresh와 같은 기준을 타야 한다(이중 시각 금지). + private let sessionStore: AlarmSessionStore + /// 표출 채널 분기용 앱 활성 판정 — UIApplication 직접 참조를 걷어내 테스트가 상태를 + /// 주입한다. 분기 의미(포그라운드 = 인앱 채널 단독)는 불변. + private let isAppActive: @MainActor () -> Bool + private let now: @Sendable () -> Date + private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmChange") + + /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. + private static let changeBadgeDuration: TimeInterval = 600 + + /// 변경 판정 구독자 — 세션 스트림과 달리 **replay 없음**(과거 변경이 재구독 시 재발화 금지). + /// "상태는 replay-1, 사건은 replay 없음"의 사건 쪽이다. + private var changeSubscribers: [UUID: AsyncStream.Continuation] = [:] + /// "⚠ 당겨짐" 배지의 현재 만료 시각 — 조용한 갱신(unchanged/delayed)이 배지를 10분 + /// 정책보다 일찍 지우지 않도록 보존한다. **세션 상태가 아니라 표출 상태**라 여기 둔다. + private var changeBadgeExpiry: Date? + + init( + evaluateChangeUseCase: any EvaluateAlarmChangeUseCase, + liveActivity: any LastTrainChangeAlerting, + localNotification: any LocalNotificationPort, + alarmScheduler: any AlarmScheduler, + sessionStore: AlarmSessionStore, + isAppActive: @escaping @MainActor () -> Bool = { + UIApplication.shared.applicationState == .active + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.evaluateChangeUseCase = evaluateChangeUseCase + self.liveActivity = liveActivity + self.localNotification = localNotification + self.alarmScheduler = alarmScheduler + self.sessionStore = sessionStore + self.isAppActive = isAppActive + self.now = now + } + + /// 세션이 아는 도보 초로 알람 시각을 계산한다 — `AlarmSession.fireDate`가 도보 초를 + /// 읽는 유일한 경로이므로, 세션이 없을 때만 버퍼 폴백을 쓴다. + private func alarmFireDate(departureTime: Date) -> Date { + AlarmTiming.alarmFireDate( + departureTime: departureTime, + firstWalkSeconds: sessionStore.current?.local.firstWalkSeconds + ) + } + + /// 계정이 바뀌면 이전 계정의 표출 상태가 새 계정에 남으면 안 된다. + func reset() { + changeBadgeExpiry = nil + } + + /// 로컬 만료 확정 시 호출 — 표출만 하고 세션 기록은 손대지 않는다(Store가 이미 했다). + func presentLocalExpiry(of session: AlarmSession) async { + await presentSessionEnded(previous: session.server, now: now()) + yieldChange(.sessionEnded) + } + + /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. + func propagateChange(previous: AlarmInfo?, latest: AlarmInfo) async { + let now = self.now() + // 첫 수신(이전 값 없음)은 비교 대상이 없다 — 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): + // 못 타게 됨 — LA를 실패 상태(missed)로 전환하고 '막차가 지나갔어요'를 알린다. + await presentMissed(latest: latest, now: now) + yieldChange(verdict) + + case .sessionEnded: + // 운행 종료·경로 소멸 — LA 최종 상태 종료 + 로컬 알람 취소. + // 배너 정리는 changes yield를 받은 홈의 몫. + // 로컬 기록 정리는 Store가 `.ended` outcome에서 이미 했다. + await presentSessionEnded(previous: previous, now: now) + 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 = alarmFireDate(departureTime: departure) + let badgeExpiry = now.addingTimeInterval(Self.changeBadgeDuration) + // 조용한 후속 갱신(unchanged/delayed)이 배지를 10분보다 일찍 지우지 않도록 보존한다. + changeBadgeExpiry = badgeExpiry + + guard alarmTime > now else { + // 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람 + // 시점이 지나 있으므로 조용한 채널로는 늦다: 백그라운드면 즉시 최후통첩. + let ultimatumState = LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: .imminent, + changeBadgeExpiry: badgeExpiry, + phase: .active + ) + if isAppActive() { + // 포그라운드 — LA alert 소리·로컬 노티 없이 조용한 상태 갱신만(Phase 15 + // 이중 알림 제거). 사용자 주의는 인앱 채널(changes 스트림 → 배너 강조 + + // 토스트)이 단독으로 맡는다 — 일반 advanced·missed 분기와 동일 구조. + await liveActivity.update(state: ultimatumState, alert: nil) + return + } + let alert = ( + title: LastTrainChangeMessages.ultimatumTitle, + body: LastTrainChangeMessages.ultimatumBody(latestDeparture: departure) + ) + if await liveActivity.isAlertReachable { + await liveActivity.update(state: ultimatumState, alert: alert) + } else { + // 도달 불가 폴백(Phase 12→15 확대) — dismissed·activity 없음·LA 비활성 + // 전부 로컬 노티로 갈아탄다. push-to-start 재생성은 하지 않는다(정책 불변). + // 피기백 시점엔 앱이 깨어 있으므로 서버 무관여 로컬 노티로 같은 문구를 보낸다. + await localNotification.post(title: alert.title, body: alert.body) + } + return + } + + let state = LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), + changeBadgeExpiry: badgeExpiry, + phase: .active + ) + if isAppActive() { + // 포그라운드 — LA alert 생략(조용한 업데이트 + 배지). 사용자 주의는 인앱 채널 + // (changes 스트림 → 홈 배너 강조 + 토스트)이 맡는다. 이중 알림 방지. + await liveActivity.update(state: state, alert: nil) + return + } + + // 백그라운드 — 행동 중심 문구를 잠금화면에 싣는다. + let minutesEarlier = max(1, Int((delta / 60).rounded(.up))) + // advanced(by:)의 delta = 이전 출발 − 새 출발. 이전 값이 비어 있으면 새 시각 + delta로 복원. + let previousDeparture = previous?.departureTime ?? departure.addingTimeInterval(delta) + let alert = ( + title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier), + body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure) + ) + if await liveActivity.isAlertReachable { + // 잠금화면 alert + "당겨짐" 배지(만료 now+10분). + await liveActivity.update(state: state, alert: alert) + } else { + // 도달 불가 폴백(Phase 12→15 확대) — LA alert 대신 같은 행동 중심 문구의 + // 로컬 노티. push-to-start 재생성 금지(정책) — 지워진 LA를 되살리지 않는다. + await localNotification.post(title: alert.title, body: alert.body) + } + } + + /// 못 탐(advanced, actionable: false) 표출 — LA를 실패 상태(missed)로 전환한다. + /// 알람은 손대지 않는다: 과거 fireDate 재스케줄은 RefreshAlarmUseCase가 이미 걸렀고, + /// 이미 울렸거나 임박한 알람을 지우는 것은 인지 기회만 줄인다. + 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: alarmFireDate(departureTime: departure), + urgency: .imminent, + changeBadgeExpiry: nil, + phase: .missed + ) + // TODO(#9 임시 — 문구만): 대안 제시 데이터(심야버스·첫차 등) 확보 시 본문에 대안 안내를 싣는다. + let alert = ( + title: LastTrainChangeMessages.missedTitle, + body: LastTrainChangeMessages.missedBody(latestDeparture: departure) + ) + if isAppActive() { + // 포그라운드 — 상태 전환만 조용히. 사용자 주의는 인앱 채널이 맡는다(이중 알림 방지). + await liveActivity.update(state: state, alert: nil) + } else if await liveActivity.isAlertReachable { + await liveActivity.update(state: state, alert: alert) + } else { + // 도달 불가 폴백(Phase 12→15 확대) — push-to-start 재생성 금지, 같은 문구의 + // 로컬 노티로 대신한다. + await localNotification.post(title: alert.title, body: alert.body) + } + } + + /// 운행 종료·경로 소멸(sessionEnded) 표출 — LA를 최종 상태(serviceEnded)로 내리고 + /// 로컬 알람을 취소한다. 서버 측 알람 취소는 부르지 않는다 — 경로 소멸은 서버 재계산 + /// 결과 그 자체라 이미 반영돼 있다. + /// TODO: [미확정] 서버가 종료 후에도 세션을 남겨 두는 스펙으로 확정되면 취소 API 연동을 재검토한다. + private func presentSessionEnded(previous: AlarmInfo?, now: Date) async { + // 더는 울리면 안 되는 것이 정책의 핵심 — 표출(LA 종료)보다 알람 취소를 먼저 한다. + await alarmScheduler.cancelAlarm() + changeBadgeExpiry = nil + + // sessionEnded 응답에는 departureTime이 없다 — 종료 시각 정보용으로 직전 스냅샷 + // (previous = 갱신 전 서버 값)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. + let departure = previous?.departureTime ?? now + // 유저가 이미 LA를 지웠으면 end는 no-op — 종료는 행동을 요구하지 않으므로 + // 로컬 노티 폴백도 없다(배너 정리는 changes 스트림을 받은 홈이 한다). + await liveActivity.end(final: LastTrainActivityState( + departureTime: departure, + alarmTime: alarmFireDate(departureTime: departure), + // 위젯은 serviceEnded phase 키로 그린다 — urgency는 종료 화면에선 의미 없는 방어값. + urgency: .imminent, + changeBadgeExpiry: nil, + phase: .serviceEnded + )) + } + + /// 갱신된 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 = alarmFireDate(departureTime: departure) + if let badgeExpiry = changeBadgeExpiry, badgeExpiry <= now { + changeBadgeExpiry = nil // 만료된 배지 기록은 정리한다. + } + return LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), + changeBadgeExpiry: changeBadgeExpiry, + phase: .active + ) + } + + private func yieldChange(_ verdict: AlarmChangeVerdict) { + for continuation in changeSubscribers.values { + continuation.yield(verdict) + } + } + + // 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/AlarmSessionStore.swift b/Projects/App/Sources/AlarmSessionStore.swift index 80785205..92953ece 100644 --- a/Projects/App/Sources/AlarmSessionStore.swift +++ b/Projects/App/Sources/AlarmSessionStore.swift @@ -40,9 +40,12 @@ final class AlarmSessionStore: AlarmSessionStoring, AlarmSyncEvents { /// 메모리 캐시 = 동기 읽기의 근거. 디스크 로드는 `bootstrap()`에서 1회만 한다. private(set) var current: AlarmSession? private var didBootstrap = false + private var tickTask: Task? + private let now: @Sendable () -> Date - init(store: any KeyValueStore) { + init(store: any KeyValueStore, now: @escaping @Sendable () -> Date = { Date() }) { document = DocumentStore(store: store, key: Self.storageKey) + self.now = now } /// 앱 시작 직후 1회. **sync보다 먼저 불러야 한다** — 그래야 오프라인 콜드스타트에서도 @@ -137,6 +140,38 @@ final class AlarmSessionStore: AlarmSessionStoring, AlarmSyncEvents { await clear() } + // MARK: - 시계 틱 + + /// 앱이 켜져 있는 동안 **시간 경과만으로** 세션이 죽는 것을 감지한다. + /// + /// 이전에는 홈 ViewModel의 배너 타이머가 이 역할을 겸했다 — 화면이 세션을 끝내는 + /// 구조라 만료 판정이 또 하나의 주체를 갖게 됐고, 홈이 떠 있지 않으면 감지도 되지 + /// 않았다. 세션 소유자가 틱도 갖는 게 맞다. + /// + /// 만료가 아니어도 매 틱 재방출한다 — 구독자(홈)가 "출발까지 N분"을 다시 계산해야 + /// 하기 때문이다. 값이 같으면 구독자 쪽 동등성 게이트가 걸러낸다. + func startTicking(interval: Duration, onExpiry: @escaping @MainActor (AlarmSession) async -> Void) { + tickTask?.cancel() + tickTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: interval) + guard let self, !Task.isCancelled else { return } + let outcome = AlarmSessionReconciler.tick(current: current, now: now()) + if case let .expired(session) = outcome { + await apply(outcome) + await onExpiry(session) + } else if case .refreshed = outcome { + broadcast() + } + } + } + } + + func stopTicking() { + tickTask?.cancel() + tickTask = nil + } + // MARK: - 구독 /// `AlarmSyncEvents` — 구독자(홈)가 보는 계약. 세션 스트림을 그대로 매핑한다. @@ -190,7 +225,12 @@ final class AlarmSessionStore: AlarmSessionStoring, AlarmSyncEvents { } // 끝난 세션은 홈 계약으로 흘리지 않는다(위 updates() 주석 참조). guard let session = current, !session.isEnded else { return } - let update = AlarmSyncUpdate(info: session.server, checkedAt: session.syncedAt) + let update = AlarmSyncUpdate( + info: session.server, + checkedAt: session.syncedAt, + // 배너 시각 계산의 재료 — 세션이 유일한 출처다. + firstWalkSeconds: session.local.firstWalkSeconds + ) lastUpdate.withLock { $0 = update } for continuation in updateSubscribers.withLock({ Array($0.values) }) { continuation.yield(update) diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 4831c25d..9b6421e5 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -11,59 +11,37 @@ import os /// 트리거를 모으고 판정 결과를 표출 채널로 흘리는 일만 한다. 만료·서버 우선·톰스톤 /// 메아리 판정은 `AlarmSessionReconciler`(순수 함수)가 한 번에 내린다. /// -/// 변경 표출: 갱신 성공 뒤 전/후 `AlarmInfo`를 판정(`EvaluateAlarmChangeUseCase`)해 -/// ① LA 채널(당겨짐 alert / 늦춰짐 조용한 갱신)과 ② 인앱 채널(`AlarmChangeEvents` → -/// 홈 배너 강조·토스트)에 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` -/// 안에서 이미 끝난 뒤라(반환 = 재스케줄 완료) 표출 실패가 알람을 막을 구조가 없다. +/// **이 타입이 하는 일은 트리거를 모으는 것뿐이다.** 변경 표출은 +/// `AlarmChangePresenter`, 세션 소유는 `AlarmSessionStore`, 만료·서버우선 판정은 +/// `AlarmSessionReconciler`(순수 함수)가 맡는다. /// -/// 폴백·종료: ③ LA alert가 도달 불가(dismissed ∨ 활성 activity 없음 ∨ LA 비활성 — -/// 어댑터의 `isAlertReachable` 단일 판정)면 백그라운드 alert를 로컬 노티(같은 문구, -/// time-sensitive)로 갈아탄다. push-to-start 재생성은 하지 않는다(지운 의사 존중). -/// ④ advanced(actionable: false)는 LA를 missed로, sessionEnded는 serviceEnded 최종 -/// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫. +/// 이전에는 한 타입이 프로토콜 3개를 동시 구현하며 575줄이었다 — 셋 다 "같은 세션"을 +/// 필요로 해서였는데, 세션을 Store가 소유하게 되자 서로를 알 필요가 없어졌다. /// -/// 세션 스트림(`AlarmSyncEvents`)은 이 타입이 제공하지 않는다 — 소유자인 -/// `AlarmSessionStore`가 직접 구현한다. 중계하면 끝나지 않는 스트림을 구독하는 Task가 -/// 해제되지 않아 테스트 프로세스가 종료되지 못한다(실측). -/// -/// 표출 채널 분기용 `isAppActive`와 `now`는 주입이다 — App 테스트 타겟의 회귀 방어 대상. +/// 세션 스트림(`AlarmSyncEvents`)도 이 타입이 제공하지 않는다 — 소유자인 Store가 +/// 직접 구현한다. 중계하면 끝나지 않는 스트림을 구독하는 Task가 해제되지 않아 +/// 테스트 프로세스가 종료되지 못한다(실측). // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor -final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { +final class AlarmSyncService: AlarmSyncRequesting { private let refreshAlarmUseCase: any RefreshAlarmUseCase - private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase - /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. - private let liveActivity: any LastTrainChangeAlerting - /// dismiss 폴백 채널(Phase 12) — 유저가 LA를 지운 뒤의 변경 alert를 로컬 노티로 대신한다. - /// 발송도 non-throwing(권한 없으면 조용히 no-op) — 여기서 권한을 요청하는 일은 절대 없다. - private let localNotification: any LocalNotificationPort - /// sessionEnded 시 로컬 알람 취소용(Phase 12) — 등록/갱신 UseCase와 같은 스케줄러를 공유한다. - private let alarmScheduler: any AlarmScheduler + /// 변경 표출 전담 — 판정·LA·노티·인앱 채널은 전부 저쪽 책임이다. + private let presenter: AlarmChangePresenter /// 세션의 단일 소유자 — 읽기·쓰기·구독이 전부 여기를 통한다. /// 이 서비스는 더 이상 세션 상태를 필드로 들지 않는다. private let sessionStore: AlarmSessionStore /// 죽은 세션 LA 재시작 경로(Phase 14) — dismiss·확인 기록 판정은 어댑터가 한다. private let sessionRestorer: any LastTrainSessionRestoring - /// 표출 채널 분기용 앱 활성 판정(Phase 16) — UIApplication 직접 참조를 걷어내 - /// 테스트가 상태를 주입한다. 분기 의미(포그라운드 = 인앱 채널 단독)는 불변. - private let isAppActive: @MainActor () -> Bool /// 만료·판정·스탬프의 시각 주입(Phase 16) — 실 Date() 직접 호출 제거(기존 관례). private let now: @Sendable () -> Date + /// 시계 틱 간격 — 테스트가 줄여 주입한다(기본 1분). + private let tickInterval: Duration private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") - /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. - private static let changeBadgeDuration: TimeInterval = 600 - - /// 변경 판정 구독자 — 세션 스트림과 달리 **replay 없음**(과거 변경이 재구독 시 재발화 금지). - /// "상태는 replay-1, 사건은 replay 없음"의 사건 쪽이다. - private var changeSubscribers: [UUID: AsyncStream.Continuation] = [:] /// 변경 판정의 "이전 값" — 세션 자체가 아니라 **직전 sync의 서버 값**이라 여기 둔다. /// 세션은 Store가 소유하고, 이건 diff 한 번에만 쓰이는 지역 기억이다. private var previousServerInfo: AlarmInfo? - /// "⚠ 당겨짐" 배지의 현재 만료 시각 — 조용한 갱신(unchanged/delayed)이 배지를 10분 - /// 정책보다 일찍 지우지 않도록 보존한다(Phase 14 정합). - private var changeBadgeExpiry: Date? /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. private var inFlight: Task? private var foregroundObserver: (any NSObjectProtocol)? @@ -71,26 +49,18 @@ final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { // 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다. init( refreshAlarmUseCase: any RefreshAlarmUseCase, - evaluateChangeUseCase: any EvaluateAlarmChangeUseCase, - liveActivity: any LastTrainChangeAlerting, - localNotification: any LocalNotificationPort, - alarmScheduler: any AlarmScheduler, + presenter: AlarmChangePresenter, sessionStore: AlarmSessionStore, sessionRestorer: any LastTrainSessionRestoring, - isAppActive: @escaping @MainActor () -> Bool = { - UIApplication.shared.applicationState == .active - }, - now: @escaping @Sendable () -> Date = { Date() } + now: @escaping @Sendable () -> Date = { Date() }, + tickInterval: Duration = .seconds(60) ) { self.refreshAlarmUseCase = refreshAlarmUseCase - self.evaluateChangeUseCase = evaluateChangeUseCase - self.liveActivity = liveActivity - self.localNotification = localNotification - self.alarmScheduler = alarmScheduler + self.presenter = presenter self.sessionStore = sessionStore self.sessionRestorer = sessionRestorer - self.isAppActive = isAppActive self.now = now + self.tickInterval = tickInterval } /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 @@ -106,6 +76,11 @@ final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { Task { @MainActor in _ = await self.sync() } } Task { _ = await sync() } + // 시간 경과 만료 감지 — 판정은 Store(=Reconciler), 표출은 여기가 맡는다. + // 이전에는 홈 ViewModel의 배너 타이머가 판정까지 했다. + sessionStore.startTicking(interval: tickInterval) { [presenter] expired in + await presenter.presentLocalExpiry(of: expired) + } } /// 사일런트 푸시(content-available=1) 경로 — 백그라운드 fetch 결과 매핑까지 담당. @@ -123,8 +98,9 @@ final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { func resetForSignOut() async { inFlight?.cancel() inFlight = nil + sessionStore.stopTicking() previousServerInfo = nil - changeBadgeExpiry = nil + presenter.reset() // 세션 기록 자체는 Store가 지운다 — 이전 계정의 세션이 새 계정의 diff·만료 // 판정을 오염시키면 안 된다. await sessionStore.reset() @@ -183,19 +159,18 @@ final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { await sessionRestorer.restartIfNeeded(session: session, now: now()) // 알람 재스케줄은 RefreshAlarmUseCase 안에서 이미 끝났다(반환 = 재스케줄 완료) — // 표출은 그 뒤에만 덧붙으므로 LA·노티 실패가 알람을 막을 구조가 없다. - await propagateChange(previous: previousServerInfo, latest: session.server) + await presenter.propagateChange(previous: previousServerInfo, latest: session.server) return session.server case let .expired(session): // 로컬 sessionEnded 처리. 톰스톤 저장은 Store가 이미 했다. - await presentSessionEnded(previous: session.server, now: now()) - yieldChange(.sessionEnded) + await presenter.presentLocalExpiry(of: session) return info case .ended: // 서버가 세션 종료를 확정 — 표출은 propagateChange의 sessionEnded 분기가 맡는다. if let info { - await propagateChange(previous: previousServerInfo, latest: info) + await presenter.propagateChange(previous: previousServerInfo, latest: info) } return info @@ -204,229 +179,5 @@ final class AlarmSyncService: AlarmChangeEvents, AlarmSyncRequesting { return info } } - - /// 세션이 아는 도보 초로 알람 시각을 계산한다. **도보 초를 읽는 유일한 경로**가 - /// `AlarmSession.fireDate`이므로, 세션이 없을 때만 버퍼 폴백을 쓴다. - private func alarmFireDate(departureTime: Date) -> Date { - AlarmTiming.alarmFireDate( - departureTime: departureTime, - firstWalkSeconds: sessionStore.current?.local.firstWalkSeconds - ) - } - // MARK: - Phase 11·12 변경 표출 (판정 → LA/로컬 노티/인앱 채널) - - /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. - private func propagateChange(previous: AlarmInfo?, latest: AlarmInfo) async { - let now = self.now() - // 첫 수신(이전 값 없음)은 비교 대상이 없다 — 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): - // 못 타게 됨 — LA를 실패 상태(missed)로 전환하고 '막차가 지나갔어요'를 알린다. - await presentMissed(latest: latest, now: now) - yieldChange(verdict) - - case .sessionEnded: - // 운행 종료·경로 소멸 — LA 최종 상태 종료 + 로컬 알람 취소. - // 배너 정리는 changes yield를 받은 홈의 몫. - // 로컬 기록 정리는 Store가 `.ended` outcome에서 이미 했다. - await presentSessionEnded(previous: previous, now: now) - 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 = alarmFireDate(departureTime: departure) - let badgeExpiry = now.addingTimeInterval(Self.changeBadgeDuration) - // 조용한 후속 갱신(unchanged/delayed)이 배지를 10분보다 일찍 지우지 않도록 보존한다. - changeBadgeExpiry = badgeExpiry - - guard alarmTime > now else { - // 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람 - // 시점이 지나 있으므로 조용한 채널로는 늦다: 백그라운드면 즉시 최후통첩. - let ultimatumState = LastTrainActivityState( - departureTime: departure, - alarmTime: alarmTime, - urgency: .imminent, - changeBadgeExpiry: badgeExpiry, - phase: .active - ) - if isAppActive() { - // 포그라운드 — LA alert 소리·로컬 노티 없이 조용한 상태 갱신만(Phase 15 - // 이중 알림 제거). 사용자 주의는 인앱 채널(changes 스트림 → 배너 강조 + - // 토스트)이 단독으로 맡는다 — 일반 advanced·missed 분기와 동일 구조. - await liveActivity.update(state: ultimatumState, alert: nil) - return - } - let alert = ( - title: LastTrainChangeMessages.ultimatumTitle, - body: LastTrainChangeMessages.ultimatumBody(latestDeparture: departure) - ) - if await liveActivity.isAlertReachable { - await liveActivity.update(state: ultimatumState, alert: alert) - } else { - // 도달 불가 폴백(Phase 12→15 확대) — dismissed·activity 없음·LA 비활성 - // 전부 로컬 노티로 갈아탄다. push-to-start 재생성은 하지 않는다(정책 불변). - // 피기백 시점엔 앱이 깨어 있으므로 서버 무관여 로컬 노티로 같은 문구를 보낸다. - await localNotification.post(title: alert.title, body: alert.body) - } - return - } - - let state = LastTrainActivityState( - departureTime: departure, - alarmTime: alarmTime, - urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), - changeBadgeExpiry: badgeExpiry, - phase: .active - ) - if isAppActive() { - // 포그라운드 — LA alert 생략(조용한 업데이트 + 배지). 사용자 주의는 인앱 채널 - // (changes 스트림 → 홈 배너 강조 + 토스트)이 맡는다. 이중 알림 방지. - await liveActivity.update(state: state, alert: nil) - return - } - - // 백그라운드 — 행동 중심 문구를 잠금화면에 싣는다. - let minutesEarlier = max(1, Int((delta / 60).rounded(.up))) - // advanced(by:)의 delta = 이전 출발 − 새 출발. 이전 값이 비어 있으면 새 시각 + delta로 복원. - let previousDeparture = previous?.departureTime ?? departure.addingTimeInterval(delta) - let alert = ( - title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier), - body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure) - ) - if await liveActivity.isAlertReachable { - // 잠금화면 alert + "당겨짐" 배지(만료 now+10분). - await liveActivity.update(state: state, alert: alert) - } else { - // 도달 불가 폴백(Phase 12→15 확대) — LA alert 대신 같은 행동 중심 문구의 - // 로컬 노티. push-to-start 재생성 금지(정책) — 지워진 LA를 되살리지 않는다. - await localNotification.post(title: alert.title, body: alert.body) - } - } - - /// 못 탐(advanced, actionable: false) 표출 — LA를 실패 상태(missed)로 전환한다. - /// 알람은 손대지 않는다: 과거 fireDate 재스케줄은 RefreshAlarmUseCase가 이미 걸렀고, - /// 이미 울렸거나 임박한 알람을 지우는 것은 인지 기회만 줄인다. - 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: alarmFireDate(departureTime: departure), - urgency: .imminent, - changeBadgeExpiry: nil, - phase: .missed - ) - // TODO(#9 임시 — 문구만): 대안 제시 데이터(심야버스·첫차 등) 확보 시 본문에 대안 안내를 싣는다. - let alert = ( - title: LastTrainChangeMessages.missedTitle, - body: LastTrainChangeMessages.missedBody(latestDeparture: departure) - ) - if isAppActive() { - // 포그라운드 — 상태 전환만 조용히. 사용자 주의는 인앱 채널이 맡는다(이중 알림 방지). - await liveActivity.update(state: state, alert: nil) - } else if await liveActivity.isAlertReachable { - await liveActivity.update(state: state, alert: alert) - } else { - // 도달 불가 폴백(Phase 12→15 확대) — push-to-start 재생성 금지, 같은 문구의 - // 로컬 노티로 대신한다. - await localNotification.post(title: alert.title, body: alert.body) - } - } - - /// 운행 종료·경로 소멸(sessionEnded) 표출 — LA를 최종 상태(serviceEnded)로 내리고 - /// 로컬 알람을 취소한다. 서버 측 알람 취소는 부르지 않는다 — 경로 소멸은 서버 재계산 - /// 결과 그 자체라 이미 반영돼 있다. - /// TODO: [미확정] 서버가 종료 후에도 세션을 남겨 두는 스펙으로 확정되면 취소 API 연동을 재검토한다. - private func presentSessionEnded(previous: AlarmInfo?, now: Date) async { - // 더는 울리면 안 되는 것이 정책의 핵심 — 표출(LA 종료)보다 알람 취소를 먼저 한다. - await alarmScheduler.cancelAlarm() - changeBadgeExpiry = nil - - // sessionEnded 응답에는 departureTime이 없다 — 종료 시각 정보용으로 직전 스냅샷 - // (previous = 갱신 전 서버 값)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. - let departure = previous?.departureTime ?? now - // 유저가 이미 LA를 지웠으면 end는 no-op — 종료는 행동을 요구하지 않으므로 - // 로컬 노티 폴백도 없다(배너 정리는 changes 스트림을 받은 홈이 한다). - await liveActivity.end(final: LastTrainActivityState( - departureTime: departure, - alarmTime: alarmFireDate(departureTime: departure), - // 위젯은 serviceEnded phase 키로 그린다 — urgency는 종료 화면에선 의미 없는 방어값. - urgency: .imminent, - changeBadgeExpiry: nil, - phase: .serviceEnded - )) - } - - /// 갱신된 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 = alarmFireDate(departureTime: departure) - if let badgeExpiry = changeBadgeExpiry, badgeExpiry <= now { - changeBadgeExpiry = nil // 만료된 배지 기록은 정리한다. - } - return LastTrainActivityState( - departureTime: departure, - alarmTime: alarmTime, - urgency: LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)), - changeBadgeExpiry: changeBadgeExpiry, - phase: .active - ) - } - - private func yieldChange(_ verdict: AlarmChangeVerdict) { - for continuation in changeSubscribers.values { - continuation.yield(verdict) - } - } - - // 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 4b4adcfd..60e7b8ba 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -40,6 +40,8 @@ final class AppDIContainer { /// 인스턴스를 봐야 한다(actor 격리는 인스턴스 단위). let alarmSessionStore: AlarmSessionStore let alarmSyncService: AlarmSyncService + /// 변경 표출 — 홈의 변경 스트림(`AlarmChangeEvents`) 제공자. + let alarmChangePresenter: AlarmChangePresenter /// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를 /// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다. let alarmSessionLifecycle: AlarmSessionLifecycleService @@ -122,16 +124,22 @@ final class AppDIContainer { ) let localNotificationAdapter = LocalNotificationAdapter() self.localNotificationPort = localNotificationAdapter + // 변경 표출과 트리거 수집을 나눈다 — 셋(세션·표출·트리거)이 서로를 알 필요가 없다. + let presenter = AlarmChangePresenter( + evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), + liveActivity: liveActivityAdapter, + localNotification: localNotificationAdapter, + alarmScheduler: alarmScheduler, + sessionStore: sessionStore + ) + self.alarmChangePresenter = presenter self.alarmSyncService = AlarmSyncService( // 도보 초는 호출자가 세션으로 넘긴다 — refresh 응답에 도보 정보가 없다. refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler ), - evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), - liveActivity: liveActivityAdapter, - localNotification: localNotificationAdapter, - alarmScheduler: alarmScheduler, + presenter: presenter, sessionStore: sessionStore, sessionRestorer: liveActivityAdapter ) @@ -270,7 +278,7 @@ final class AppDIContainer { ), // 세션 스트림은 소유자(Store)가 제공한다 — AlarmSyncService는 사건 채널만. observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSessionStore), - observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmSyncService), + observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmChangePresenter), // 홈 pull-to-refresh(Phase 16) — 4번째 트리거도 같은 동기화 한 곳으로 합류한다. requestAlarmSyncUseCase: DefaultRequestAlarmSyncUseCase(requesting: alarmSyncService), // 재실행 카드 복원(Phase 14) — 기존 미사용 자산(detail 엔드포인트) 재활용. diff --git a/Projects/App/Tests/AlarmSessionStoreTests.swift b/Projects/App/Tests/AlarmSessionStoreTests.swift index 4d4ca788..c3b185cf 100644 --- a/Projects/App/Tests/AlarmSessionStoreTests.swift +++ b/Projects/App/Tests/AlarmSessionStoreTests.swift @@ -13,13 +13,22 @@ private final class InMemoryKeyValueStore: KeyValueStore { func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } } +/// 틱 콜백은 메인 액터 밖에서도 읽히므로 락으로 감싼다. +private final class ValueBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + init(_ value: Value) { self.value = value } + func get() -> Value { lock.lock(); defer { lock.unlock() }; return value } + func set(_ newValue: Value) { lock.lock(); value = newValue; lock.unlock() } +} + @MainActor struct AlarmSessionStoreTests { private let now = Date(timeIntervalSince1970: 1_700_000_000) private let store = InMemoryKeyValueStore() - private func makeSUT() -> AlarmSessionStore { - AlarmSessionStore(store: store) + private func makeSUT(now: @escaping @Sendable () -> Date = { Date() }) -> AlarmSessionStore { + AlarmSessionStore(store: store, now: now) } private func session( @@ -183,6 +192,62 @@ struct AlarmSessionStoreTests { #expect(sut.current == nil) } + // MARK: - 시계 틱 + // + // 홈 ViewModel의 배너 타이머가 하던 일을 Store가 가져왔다 — 화면이 세션을 끝내는 + // 구조를 없애고, 홈이 떠 있지 않아도 만료가 감지되게 한다. + + /// 출발 + 유예가 지나면 틱이 세션을 끝내고 콜백으로 알린다. + @Test + func tick_pastDeparture_expiresAndNotifies() async { + let sut = makeSUT(now: { self.now }) + // 이미 지난 막차 — 다음 틱에서 만료돼야 한다. + await sut.register(session: session(departureOffset: -120)) + let expired = ValueBox(nil) + + sut.startTicking(interval: .milliseconds(10)) { session in + expired.set(session) + } + for _ in 0..<80 where expired.get() == nil { try? await Task.sleep(for: .milliseconds(10)) } + sut.stopTicking() + + #expect(expired.get()?.lifecycle == .ended) + #expect(sut.current?.lifecycle == .ended) + } + + /// 살아 있는 세션은 끝내지 않는다 — 대신 매 틱 재방출해 "출발까지 N분"이 갱신된다. + @Test + func tick_liveSession_doesNotExpire() async { + let sut = makeSUT(now: { self.now }) + await sut.register(session: session(departureOffset: 900)) + let expired = ValueBox(nil) + + sut.startTicking(interval: .milliseconds(10)) { session in + expired.set(session) + } + try? await Task.sleep(for: .milliseconds(120)) + sut.stopTicking() + + #expect(expired.get() == nil) + #expect(sut.current?.lifecycle == .active) + } + + /// 이미 끝난 세션을 반복 통지하면 구독자가 같은 종료를 여러 번 처리한다. + @Test + func tick_endedSession_doesNotNotifyAgain() async { + let sut = makeSUT(now: { self.now }) + await sut.register(session: session(departureOffset: -120, lifecycle: .ended)) + let expired = ValueBox(nil) + + sut.startTicking(interval: .milliseconds(10)) { session in + expired.set(session) + } + try? await Task.sleep(for: .milliseconds(120)) + sut.stopTicking() + + #expect(expired.get() == nil) + } + // MARK: - 구독 (replay-1) // // 세션 그대로를 보는 내부 스트림. 홈이 보는 `AlarmSyncEvents.updates()`는 이 위에 diff --git a/Projects/App/Tests/AlarmSyncServiceTests.swift b/Projects/App/Tests/AlarmSyncServiceTests.swift index f8d89f31..459227b9 100644 --- a/Projects/App/Tests/AlarmSyncServiceTests.swift +++ b/Projects/App/Tests/AlarmSyncServiceTests.swift @@ -144,6 +144,8 @@ private actor RestorerSpy: LastTrainSessionRestoring { @MainActor private struct Harness { let sut: AlarmSyncService + /// 변경 스트림(`AlarmChangeEvents`)은 이제 Presenter가 제공한다. + let presenter: AlarmChangePresenter let refresh: RefreshStub let evaluate: EvaluateStub let activity: ActivitySpy @@ -172,20 +174,26 @@ private func makeHarness( let store = AlarmSessionStore(store: MemoryKeyValueStore(seeded: seeded)) let restorer = RestorerSpy() let active = ValueBox(isAppActive) - let sut = AlarmSyncService( - refreshAlarmUseCase: refresh, + // 표출과 트리거가 분리됐다 — 조립도 둘로 나뉜다. + let presenter = AlarmChangePresenter( evaluateChangeUseCase: evaluate, liveActivity: activity, localNotification: noti, alarmScheduler: scheduler, sessionStore: store, - sessionRestorer: restorer, isAppActive: { active.get() }, now: { fixedNow } ) + let sut = AlarmSyncService( + refreshAlarmUseCase: refresh, + presenter: presenter, + sessionStore: store, + sessionRestorer: restorer, + now: { fixedNow } + ) return Harness( - sut: sut, refresh: refresh, evaluate: evaluate, activity: activity, - noti: noti, scheduler: scheduler, store: store, active: active + sut: sut, presenter: presenter, refresh: refresh, evaluate: evaluate, + activity: activity, noti: noti, scheduler: scheduler, store: store, active: active ) } @@ -355,7 +363,7 @@ struct AlarmSyncServiceTests { local: .empty, syncedAt: fixedNow.addingTimeInterval(-3600) )) - var changeIterator = harness.sut.changes().makeAsyncIterator() + var changeIterator = harness.presenter.changes().makeAsyncIterator() for _ in 0..<20 { await Task.yield() } // 구독 등록 드레인(변경 스트림은 replay 없음) await harness.sut.syncNow() // refresh 큐 비어 있음 → 실패여도 만료는 확정된다. diff --git a/Projects/Domain/Sources/AlarmSessionReconciler.swift b/Projects/Domain/Sources/AlarmSessionReconciler.swift index df3b96f5..54a98c0e 100644 --- a/Projects/Domain/Sources/AlarmSessionReconciler.swift +++ b/Projects/Domain/Sources/AlarmSessionReconciler.swift @@ -53,6 +53,17 @@ public enum AlarmSessionReconciler { } } + /// 시계 틱 전용 진입점 — 서버 왕복 없이 **시간 경과만으로** 세션이 죽었는지 본다. + /// + /// 별도 진입점을 두는 이유: 이 판정이 없으면 앱이 켜져 있는 동안 출발 시각이 + /// 지나도 아무도 알아채지 못한다. 이전에는 홈 ViewModel의 배너 타이머가 이걸 + /// 겸하면서 **화면이 세션을 끝내는** 구조였고, 그게 만료 판정이 여러 곳에 흩어진 + /// 원인 중 하나였다. + public static func tick(current: AlarmSession?, now: Date) -> Outcome { + guard let session = current, !session.isEnded else { return .ignoredStaleEcho } + return expiryOutcome(for: session, now: now) ?? .refreshed(session) + } + // MARK: - private static func reconcile( diff --git a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift index 367c20a7..4871ede7 100644 --- a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift +++ b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift @@ -8,10 +8,16 @@ public struct AlarmSyncUpdate: Sendable, Equatable { /// 서버 확인 시각. 스냅샷 시딩 복원이면 직전 세션의 마지막 확인 시각(스냅샷의 /// syncedAt), 그것도 없으면 nil — nil이면 스탬프를 표시하지 않는다(정직한 기본값). public let checkedAt: Date? + /// 등록 시점 경로의 첫 도보 구간(초). 서버가 주지 않는 값이라 세션이 유일한 + /// 출처다 — 구독자(홈)가 배너 시각을 계산하려면 이 값이 함께 와야 한다. + /// 이전에는 홈이 화면에 떠 있는 경로(`selectedRoute`)에서 추측했고, 재실행 + /// 복원처럼 카드가 없는 상황에서는 버퍼만 적용된 시각을 그렸다. + public let firstWalkSeconds: Int? - public init(info: AlarmInfo, checkedAt: Date?) { + public init(info: AlarmInfo, checkedAt: Date?, firstWalkSeconds: Int? = nil) { self.info = info self.checkedAt = checkedAt + self.firstWalkSeconds = firstWalkSeconds } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index c7f2ec45..80d9bd26 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -109,7 +109,6 @@ final class HomeViewModel { private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase private let now: @Sendable () -> Date - private let bannerTickInterval: Duration private var selectedRoute: LastRoute? /// 칩의 원본 도착지(Phase 18) — 표시는 문자열(State), 재검색은 이 Place가 한다. @@ -123,7 +122,6 @@ final class HomeViewModel { private var alarmTask: Task? private var observeTask: Task? private var changeTask: Task? - private var bannerTask: Task? private var restoreCardTask: Task? private var refreshTask: Task? private var chipTask: Task? @@ -142,8 +140,7 @@ final class HomeViewModel { getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, - now: @escaping @Sendable () -> Date = { Date() }, - bannerTickInterval: Duration = .seconds(60) + now: @escaping @Sendable () -> Date = { Date() } ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.reverseGeocodeUseCase = reverseGeocodeUseCase @@ -156,7 +153,6 @@ final class HomeViewModel { self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase self.now = now - self.bannerTickInterval = bannerTickInterval } deinit { @@ -164,7 +160,6 @@ final class HomeViewModel { alarmTask?.cancel() observeTask?.cancel() changeTask?.cancel() - bannerTask?.cancel() restoreCardTask?.cancel() refreshTask?.cancel() chipTask?.cancel() @@ -249,7 +244,6 @@ final class HomeViewModel { registeredRouteId: registeredRouteId ) state = newState - bannerTask?.cancel() promoteChipDestination(arrival) } @@ -326,7 +320,7 @@ final class HomeViewModel { self.state.isAlarmBusy = false self.refreshAlarmButton() self.refreshFreshness() - self.startBannerTimer( + self.renderBanner( departure: route.departureTime, firstWalkSeconds: route.firstWalkSectionSeconds ) @@ -360,7 +354,6 @@ final class HomeViewModel { do { try await useCase.execute(lastRouteId: routeId) guard !Task.isCancelled, let self else { return } - self.bannerTask?.cancel() self.registeredRouteId = nil self.lastCheckedAt = nil var newState = self.state @@ -414,12 +407,10 @@ final class HomeViewModel { !AlarmTiming.isSessionExpired(departureTime: departure, now: now()) { // 도보 초는 등록 시점 경로에서만 안다 — 같은 경로가 화면에 있으면 그 값, // 재실행 복원(카드 없음)이면 상세 재조회가 끝난 뒤 재시작하며 반영한다. - startBannerTimer( - departure: departure, - firstWalkSeconds: selectedRoute?.id == info.lastRouteId - ? selectedRoute?.firstWalkSectionSeconds - : nil - ) + // 도보 초는 스트림이 싣고 온다 — 세션이 유일한 출처라, 재실행 복원처럼 + // 카드가 없는 상황에서도 정확한 시각을 그린다(이전에는 화면의 경로에서 + // 추측했고 카드가 없으면 버퍼만 적용됐다). + renderBanner(departure: departure, firstWalkSeconds: update.firstWalkSeconds) } restoreRouteCardIfNeeded(info) } @@ -446,10 +437,10 @@ final class HomeViewModel { registeredRouteId: self.registeredRouteId ) self.state = newState - // 배너를 도보 반영 기준으로 재시작한다(등록/refresh/LA와 같은 값 — 이중 시각 금지). + // 배너를 도보 반영 기준으로 다시 그린다(등록/refresh/LA와 같은 값 — 이중 시각 금지). if let departure = info.departureTime, !AlarmTiming.isSessionExpired(departureTime: departure, now: self.now()) { - self.startBannerTimer( + self.renderBanner( departure: departure, firstWalkSeconds: route.firstWalkSectionSeconds ) @@ -482,15 +473,13 @@ final class HomeViewModel { // 이미 못 타는 앞당김 — 카운트다운을 멈추고 배너를 실패 문구로 고정한다. // 알람·LA·서버 정리는 App(AlarmSyncService)·Domain 몫이고, 홈은 표출만 바꾼다. // 긴급 스타일(imminent)은 유지 — 텍스트만 실패 문구로 교체된 같은 배너다. - bannerTask?.cancel() - state.banner = BannerViewData(text: "막차가 지나갔어요", urgency: .imminent) + state.banner = BannerViewData(text: "막차가 지나갔어요", urgency: .imminent) onToast?(.lastTrainMissed) case .sessionEnded: // 운행 종료·경로 소멸 — 알람 세션이 사라졌으므로 배너·버튼·등록 기록을 전부 // 정리한다. 직전 info 이벤트(alarmSynced)가 남긴 죽은 registeredRouteId도 // 여기서 지워진다. LA final state 종료·알람 취소는 App/Domain 경로의 몫. - bannerTask?.cancel() - registeredRouteId = nil + registeredRouteId = nil lastCheckedAt = nil var newState = state newState.banner = nil @@ -499,6 +488,17 @@ final class HomeViewModel { registeredRouteId: nil ) newState.freshnessText = nil + // 출발 시각이 이미 지났으면 "지난 막차"로 전환한다 — 유예 경과로 인한 로컬 + // 만료가 이 경로로 온다(이전에는 홈의 배너 타이머가 직접 전환했다). + // 서버발 운행 종료(출발 전 경로 소멸)는 카드를 그대로 둔다 — 지나간 것이 + // 아니라 사라진 것이라 "지난 막차"는 오정보다. + if let departure = selectedRoute?.departureTime, + AlarmTiming.isSessionExpired(departureTime: departure, now: now()) { + newState.routeCard = newState.routeCard?.asPastTrain(departure: departure) + // 지난 막차에는 등록 버튼을 두지 않는다 — 누르면 tooLate로 실패할 뿐이다. + newState.alarmButton = .hidden + selectedRoute = nil + } state = newState onToast?(.lastTrainServiceEnded) case .delayed, .unchanged: @@ -583,41 +583,15 @@ final class HomeViewModel { ) } - 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, firstWalkSeconds: firstWalkSeconds, now: now - ) else { - // 유예 경과(3단계) — 배너·버튼을 내리고 카드를 "지난 막차"로 전환, - // 틱 종료. 알람·LA·서버 정리는 App(AlarmSyncService)의 wake 판정 몫. - self?.sessionExpired(departure: departure) - return - } - self?.state.banner = banner - // sleep 동안 self를 잡지 않는다 — deinit cancel이 즉시 먹혀야 한다. - guard let interval = self?.bannerTickInterval else { return } - try? await Task.sleep(for: interval) - } - } - } - - /// 유예 경과(클라 자체 만료, Phase 13) — 지나간 막차를 "탈 수 있다"고 보여주는 - /// 서피스를 전부 내린다. 카드가 없으면(재실행 복원 상태) 배너·버튼 정리만 남는다. - private func sessionExpired(departure: Date) { - registeredRouteId = nil - selectedRoute = nil - lastCheckedAt = nil - var newState = state - newState.banner = nil - newState.routeCard = newState.routeCard?.asPastTrain(departure: departure) - newState.alarmButton = .hidden - // "지난 막차" 카드에는 스탬프가 없다 — 세션이 끝난 값의 신선도는 무의미하다. - newState.freshnessText = nil - state = newState + /// 스트림이 세션을 방출할 때마다 배너를 다시 그린다. + /// + /// 이전에는 여기서 타이머를 돌리며 유예 경과를 감지하고 **화면이 세션을 끝냈다** + /// (`sessionExpired`). 이제 틱과 만료 판정은 세션 소유자(Store)가 하고, 홈은 + /// 받은 값을 그리기만 한다 — 만료 시엔 `.sessionEnded` 변경 이벤트가 온다. + private func renderBanner(departure: Date, firstWalkSeconds: Int?) { + state.banner = Self.makeBanner( + departure: departure, firstWalkSeconds: firstWalkSeconds, now: now() + ) } // MARK: - 순수 계산 diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index c3f8533f..17c46c07 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -193,8 +193,7 @@ private func makeSUT( }, recentFetch: @escaping @Sendable () async throws -> [Place] = { [] }, recentSave: @escaping @Sendable (Place) async throws -> Void = { _ in }, - now: @escaping @Sendable () -> Date = { fixedNow }, - bannerTickInterval: Duration = .seconds(60) + now: @escaping @Sendable () -> Date = { fixedNow } ) -> HomeViewModel { HomeViewModel( getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: location), @@ -211,16 +210,15 @@ private func makeSUT( recentSearchesUseCase: StubRecentSearchesUseCase( fetchHandler: recentFetch, saveHandler: recentSave ), - now: now, - bannerTickInterval: bannerTickInterval + now: now ) } /// 스트림 yield용 축약 — 확인 시각이 무관한 기존 시나리오는 checkedAt 없이 흘린다. private nonisolated func syncUpdate( - _ info: AlarmInfo, checkedAt: Date? = nil + _ info: AlarmInfo, checkedAt: Date? = nil, firstWalkSeconds: Int? = nil ) -> AlarmSyncUpdate { - AlarmSyncUpdate(info: info, checkedAt: checkedAt) + AlarmSyncUpdate(info: info, checkedAt: checkedAt, firstWalkSeconds: firstWalkSeconds) } // MARK: - 테스트 @@ -492,47 +490,58 @@ struct HomeViewModelTests { ) == nil) } + /// 유예 경과의 3단계 전이(배너 제거 + "지난 막차" 카드 + 버튼 숨김)는 이제 + /// **세션 종료 이벤트**로 온다 — 이전에는 홈의 배너 타이머가 유예를 스스로 감지해 + /// 세션을 끝냈고, 그게 만료 판정 주체가 여러 곳이던 원인이었다. 판정은 세션 + /// 소유자(Store)가 하고 홈은 통지를 받아 화면만 정리한다. @Test - func bannerTimer_graceElapsed_transitionsToPastTrainState() async { - // 유예 경과 시 틱이 3단계 전이를 수행한다: 배너 제거 + "지난 막차" 카드(비활성 톤) - // + 알람 버튼 숨김 + 틱 종료. - let clock = NowBox(fixedNow) + func sessionEnded_transitionsToPastTrainState() async { let departure = fixedNow.addingTimeInterval(42 * 60) let route = makeRoute(id: "r1", departure: departure) - let sut = makeSUT(now: { clock.get() }, bannerTickInterval: .milliseconds(1)) + let clock = NowBox(fixedNow) + let (changes, changeContinuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmChanges: { changes }, now: { clock.get() }) let recorder = StateRecorder() recorder.attach(to: sut) + sut.viewDidLoad() // 변경 스트림 구독이 여기서 시작된다. sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } + // 유예 경과 후의 종료 — Store의 틱이 감지해 이 이벤트로 알린다. clock.set(departure.addingTimeInterval(60)) + changeContinuation.yield(.sessionEnded) await recorder.waitUntilLast { $0.banner == nil } #expect(sut.state.alarmButton == .hidden) #expect(sut.state.routeCard?.tone == .past) #expect(sut.state.routeCard?.badgeText == "지난 막차") #expect(sut.state.routeCard?.departureTimeText.hasSuffix("출발이었어요") == true) - - // 틱이 종료됐다 — 살아 있다면 1ms 틱이 상태를 계속 다시 쓴다. - let stateCount = recorder.states.count - try? await Task.sleep(for: .milliseconds(30)) - #expect(recorder.states.count == stateCount) } + /// 분 재계산은 **스트림 재방출**로 일어난다 — Store의 틱이 살아 있는 세션을 매 + /// 틱 다시 흘리고, 홈은 그때의 시각으로 배너를 다시 그린다. 홈은 타이머를 갖지 + /// 않으므로, 화면이 떠 있지 않아도 만료가 감지된다. @Test - func bannerTimer_ticksRecomputeMinutes() async { + func syncUpdate_recomputesBannerMinutes() async { let clock = NowBox(fixedNow) - let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) - let sut = makeSUT(now: { clock.get() }, bannerTickInterval: .milliseconds(1)) + let departure = fixedNow.addingTimeInterval(42 * 60) + let route = makeRoute(id: "r1", departure: departure) + let (updates, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { updates }, now: { clock.get() }) let recorder = StateRecorder() recorder.attach(to: sut) - + sut.viewDidLoad() // 세션 스트림 구독이 여기서 시작된다. sut.routeSelected(route, arrival: makeArrival()) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } + // 시간이 흐른 뒤 같은 세션이 재방출되면 남은 분이 줄어든다. clock.set(fixedNow.addingTimeInterval(37 * 60)) + continuation.yield(syncUpdate( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true), + firstWalkSeconds: route.firstWalkSectionSeconds + )) await recorder.waitUntilLast { $0.banner?.text == "출발까지 2분" } #expect(sut.state.banner?.urgency == .imminent) @@ -703,7 +712,7 @@ struct HomeViewModelTests { // 운행 종료·경로 소멸: 배너 제거 + 등록 기록 삭제 + 버튼 리셋 + 원샷 안내. let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) let (stream, continuation) = AsyncStream.makeStream() - let sut = makeSUT(alarmChanges: { stream }, bannerTickInterval: .milliseconds(1)) + let sut = makeSUT(alarmChanges: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() @@ -732,8 +741,7 @@ struct HomeViewModelTests { let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT( alarmChanges: { stream }, - now: { clock.get() }, - bannerTickInterval: .milliseconds(1) + now: { clock.get() } ) let recorder = StateRecorder() recorder.attach(to: sut) @@ -1227,8 +1235,7 @@ struct HomeViewModelTests { let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT( alarmChanges: { stream }, - now: { clock.get() }, - bannerTickInterval: .milliseconds(1) + now: { clock.get() } ) let recorder = StateRecorder() recorder.attach(to: sut) @@ -1242,10 +1249,12 @@ struct HomeViewModelTests { await recorder.waitUntilLast { $0.banner == nil } #expect(sut.state.freshnessText == nil) - // 재등록 후 유예 경과(3단계 전이)도 스탬프를 정리한다. + // 재등록 후 유예 경과도 스탬프를 정리한다. 유예 감지는 이제 홈의 타이머가 + // 아니라 세션 소유자(Store)의 틱이 하고, 홈에는 종료 이벤트로 도착한다. sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil && $0.freshnessText != nil } clock.set(departure.addingTimeInterval(60)) + continuation.yield(.sessionEnded) await recorder.waitUntilLast { $0.banner == nil && $0.freshnessText == nil } #expect(sut.state.routeCard?.tone == .past) }