diff --git a/CLAUDE.md b/CLAUDE.md index 9a9f206..3b8e65f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho - **익명 인증 발급 엔드포인트 미확정** — `UnconfiguredAnonymousSessionIssuer` 스텁이 주입돼 있어 실서버에서는 토큰 없이 동작한다(알람 서버 기능 전부 불가). Debug(DEV)는 `DevDemoFallbacks`가 데모 데이터로 가린다 — 실서버 스펙 확정 시 이 폴백과 `AppDIContainer`의 `#if DEV` 주입을 제거할 것. - `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. -- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)뿐. +- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. - Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. - Phase 검수는 사람 검수 대신 **자동 검수 규약**(`docs/prompts/atcha-v2-auto-verification.md`)을 따른다 — 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index d25407f..68fe0fc 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -65,6 +65,20 @@ let appTarget = Target.target( ]) ) +// App 타겟 테스트 타겟(Phase 16) — host app 방식: 앱 타겟 의존으로 internal 심볼을 +// @testable 접근한다. AlarmSyncService(판정·폴백·만료 분기)의 회귀 방어가 목적. +let testTarget = Target.target( + name: "AtchaV2Tests", + destinations: Atcha.destinations, + product: .unitTests, + bundleId: "\(Atcha.v2BundleID).tests", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [.target(name: "AtchaV2")], + settings: .atchaV2() +) + let widgetTarget = Target.widgetExtension( name: "AtchaWidget", bundleId: "\(Atcha.v2BundleID).widget", @@ -84,12 +98,13 @@ let project = Project( developmentRegion: Atcha.developmentRegion ), settings: .atchaV2(), - targets: [appTarget, widgetTarget], + targets: [appTarget, widgetTarget, testTarget], schemes: [ .scheme( name: "AtchaV2", shared: true, buildAction: .buildAction(targets: ["AtchaV2"]), + testAction: .targets(["AtchaV2Tests"]), runAction: .runAction(configuration: "Debug", executable: "AtchaV2"), archiveAction: .archiveAction(configuration: "Debug"), profileAction: .profileAction(configuration: "Debug", executable: "AtchaV2"), diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 7baff5c..4a422e1 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -32,10 +32,16 @@ import os /// 병합 저장하고, 스냅샷은 살아 있는데 활성 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 테스트 타겟의 회귀 방어 대상이 됐다. // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor -final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { +final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncRequesting { private let refreshAlarmUseCase: any RefreshAlarmUseCase private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. @@ -50,17 +56,25 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private let snapshotStore: any AlarmSessionSnapshotStore /// 죽은 세션 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 private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. private static let changeBadgeDuration: TimeInterval = 600 - private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var subscribers: [UUID: AsyncStream.Continuation] = [:] /// 변경 판정 구독자 — updates()와 달리 **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? @@ -85,7 +99,11 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { localNotification: any LocalNotificationPort, alarmScheduler: any AlarmScheduler, snapshotStore: any AlarmSessionSnapshotStore, - sessionRestorer: any LastTrainSessionRestoring + sessionRestorer: any LastTrainSessionRestoring, + isAppActive: @escaping @MainActor () -> Bool = { + UIApplication.shared.applicationState == .active + }, + now: @escaping @Sendable () -> Date = { Date() } ) { self.refreshAlarmUseCase = refreshAlarmUseCase self.evaluateChangeUseCase = evaluateChangeUseCase @@ -94,6 +112,8 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { self.alarmScheduler = alarmScheduler self.snapshotStore = snapshotStore self.sessionRestorer = sessionRestorer + self.isAppActive = isAppActive + self.now = now } /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 @@ -116,6 +136,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { await sync() != nil ? .newData : .failed } + // MARK: - AlarmSyncRequesting (Phase 16 — 홈 pull-to-refresh) + + /// 수동 갱신 트리거(4번째) — 기존 sync()에 그대로 합류한다. 실패는 던지지 않고 + /// 스트림에도 흐르지 않는다(무음 정책) — 스탬프가 낡은 시각을 유지하는 것이 표면이다. + /// 테스트 진입점이기도 하다(NotificationCenter 없이 전 분기 도달). + nonisolated func syncNow() async { + _ = await sync() + } + /// 3경로 공용 동기화. 실패는 스트림에 흘리지 않는다 — 구독자는 상태를 유지하고, /// 다음 트리거(포그라운드·푸시)가 자연 재시도가 된다. @discardableResult @@ -128,7 +157,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { // 만료 선행 판정보다 먼저여야 재실행 직후의 과거 세션도 리컨실에 걸린다. await seedFromSnapshotIfNeeded() // Phase 13 선행 판정 — 확정은 refresh 결과를 본 뒤(미래 출발이면 서버 우선 취소). - let expiryCandidate = expireLocallyIfNeeded(now: Date()) + let expiryCandidate = expireLocallyIfNeeded(now: now()) let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in do { return try await refreshAlarmUseCase.execute() @@ -149,7 +178,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { return nil } - if let departure = info.departureTime, departure > Date() { + if let departure = info.departureTime, departure > now() { // 서버 우선 — 미래 출발 시각이 오면 만료 후보·확정 기록 모두 해제하고 정상 경로. locallyExpiredSession = nil } else if let expiryCandidate { @@ -166,14 +195,17 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { 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) + let snapshot = await persistSyncedSnapshot(for: info, checkedAt: checkedAt) for continuation in subscribers.values { - continuation.yield(info) + continuation.yield(AlarmSyncUpdate(info: info, checkedAt: checkedAt)) } // Phase 14 죽은 세션 재시작 — 스냅샷은 살아 있는데 활성 LA가 없고 dismiss·확인 // 기록도 없으면 로컬 재시작(판정은 어댑터). 8시간 한도·시작 실패 세션 커버. - await sessionRestorer.restartIfNeeded(snapshot: snapshot, now: Date()) + await sessionRestorer.restartIfNeeded(snapshot: snapshot, now: now()) // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. await propagateChange(previous: previous, latest: info) @@ -196,11 +228,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { locallyExpiredSession = snapshot.info } else if lastInfo == nil { lastInfo = snapshot.info + // Phase 16 — 시딩 복원값의 확인 시각은 "지금"이 아니라 스냅샷에 영속화된 + // 마지막 확인 시각이다. 직후 refresh가 실패해도(오프라인) 스탬프는 이 + // 낡은 시각을 정직하게 유지한다. + lastCheckedAt = snapshot.syncedAt Self.logger.info( "스냅샷 시딩: route=\(snapshot.info.lastRouteId, privacy: .public)" ) for continuation in subscribers.values { - continuation.yield(snapshot.info) + continuation.yield(AlarmSyncUpdate(info: snapshot.info, checkedAt: snapshot.syncedAt)) } } } @@ -208,11 +244,14 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { /// sync 성공 시 스냅샷 병합 저장 — 같은 세션(routeId 일치)이면 등록 시점 사실 /// (도보·표시명·수단·확인 기록)을 보존하고 info만 갱신, 다른 세션이면 아는 것만 담는다 /// (표시명 공백은 LA 재시작 시 "막차" 폴백, 카드는 상세 재조회가 채운다). - private func persistSyncedSnapshot(for info: AlarmInfo) async -> AlarmSessionSnapshot { + /// 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) + snapshot = existing.updating(info: info, expired: false, syncedAt: checkedAt) } else { snapshot = AlarmSessionSnapshot( info: info, @@ -220,7 +259,8 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { routeDisplayName: "", transportMode: nil, acknowledged: false, - expired: false + expired: false, + syncedAt: checkedAt ) } await snapshotStore.save(snapshot) @@ -262,10 +302,11 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { routeDisplayName: "", transportMode: nil, acknowledged: false, - expired: true + expired: true, + syncedAt: lastCheckedAt )) } - await presentSessionEnded(previous: session, now: Date()) + await presentSessionEnded(previous: session, now: now()) yieldChange(.sessionEnded) } @@ -273,7 +314,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. private func propagateChange(previous: AlarmInfo?, latest: AlarmInfo) async { - let now = Date() + let now = self.now() // 첫 수신(이전 값 없음)은 비교 대상이 없다 — unchanged 취급. let verdict = previous.map { evaluateChangeUseCase.execute(previous: $0, latest: latest, now: now) @@ -338,7 +379,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { changeBadgeExpiry: badgeExpiry, phase: .active ) - if UIApplication.shared.applicationState == .active { + if isAppActive() { // 포그라운드 — LA alert 소리·로컬 노티 없이 조용한 상태 갱신만(Phase 15 // 이중 알림 제거). 사용자 주의는 인앱 채널(changes 스트림 → 배너 강조 + // 토스트)이 단독으로 맡는다 — 일반 advanced·missed 분기와 동일 구조. @@ -367,7 +408,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { changeBadgeExpiry: badgeExpiry, phase: .active ) - if UIApplication.shared.applicationState == .active { + if isAppActive() { // 포그라운드 — LA alert 생략(조용한 업데이트 + 배지). 사용자 주의는 인앱 채널 // (changes 스트림 → 홈 배너 강조 + 토스트)이 맡는다. 이중 알림 방지. await liveActivity.update(state: state, alert: nil) @@ -414,7 +455,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { title: LastTrainChangeMessages.missedTitle, body: LastTrainChangeMessages.missedBody(latestDeparture: departure) ) - if UIApplication.shared.applicationState == .active { + if isAppActive() { // 포그라운드 — 상태 전환만 조용히. 사용자 주의는 인앱 채널이 맡는다(이중 알림 방지). await liveActivity.update(state: state, alert: nil) } else if await liveActivity.isAlertReachable { @@ -479,12 +520,12 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { // MARK: - AlarmSyncEvents - nonisolated func updates() -> AsyncStream { + nonisolated func updates() -> AsyncStream { AsyncStream { continuation in let id = UUID() Task { @MainActor in if let last = self.lastInfo { - continuation.yield(last) + continuation.yield(AlarmSyncUpdate(info: last, checkedAt: self.lastCheckedAt)) } self.subscribers[id] = continuation } diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 431255d..e6ab172 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -42,7 +42,10 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { self.container.alarmSyncService.activate() } catch { guard !Task.isCancelled else { return } - self.splashViewController?.showRetry() + // 원인별 문구 분기(Phase 16) — 오프라인만 구분, 그 외는 일시 장애 안내. + self.splashViewController?.showRetry( + message: BootstrapFailureMessage.text(for: error) + ) } } } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 87438cd..3250fe7 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -56,8 +56,14 @@ final class AppDIContainer { session: URLSession(configuration: sessionConfiguration) ) #else + // Phase 16 — 기본 60초 타임아웃은 심야의 약한 연결에서 1분 침묵이다. 빨리 + // 실패시키고(요청 10초) 멱등 GET 1회 재시도·신선도 스탬프가 정직성을 맡는다. + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.timeoutIntervalForRequest = 10 + sessionConfiguration.timeoutIntervalForResource = 30 let baseClient = URLSessionNetworkClient( - baseURL: AppEnvironment.current.apiBaseURL + baseURL: AppEnvironment.current.apiBaseURL, + session: URLSession(configuration: sessionConfiguration) ) #endif let sessionManager = AuthSessionManager( @@ -173,6 +179,8 @@ final class AppDIContainer { ), observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmSyncService), + // 홈 pull-to-refresh(Phase 16) — 4번째 트리거도 같은 동기화 한 곳으로 합류한다. + requestAlarmSyncUseCase: DefaultRequestAlarmSyncUseCase(requesting: alarmSyncService), // 재실행 카드 복원(Phase 14) — 기존 미사용 자산(detail 엔드포인트) 재활용. getLastRouteDetailUseCase: DefaultGetLastRouteDetailUseCase( repository: lastRouteRepository diff --git a/Projects/App/Sources/BootstrapFailureMessage.swift b/Projects/App/Sources/BootstrapFailureMessage.swift new file mode 100644 index 0000000..ac3b0e6 --- /dev/null +++ b/Projects/App/Sources/BootstrapFailureMessage.swift @@ -0,0 +1,14 @@ +import CoreNetwork +import Foundation + +/// 스플래시 부트스트랩 실패 문구 매핑(Phase 16) — 원인 불문 고정 문구를 오프라인/기타로 +/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현 DEV는 bootstrap이 +/// issuerNotConfigured를 삼켜 재시도 화면에 도달하지 않는다 — 실표출은 S1(익명 인증) 이후. +enum BootstrapFailureMessage { + static let offline = "네트워크 연결을 확인해주세요" + static let transient = "일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요" + + static func text(for error: any Error) -> String { + (error as? NetworkError)?.isOffline == true ? offline : transient + } +} diff --git a/Projects/App/Sources/SplashViewController.swift b/Projects/App/Sources/SplashViewController.swift index 0a1c0f4..e9f3788 100644 --- a/Projects/App/Sources/SplashViewController.swift +++ b/Projects/App/Sources/SplashViewController.swift @@ -22,7 +22,9 @@ final class SplashViewController: UIViewController { activityIndicator.startAnimating() } - func showRetry() { + /// 실패 원인별 문구(Phase 16) — 선택은 호출자(AppCoordinator + BootstrapFailureMessage)가 한다. + func showRetry(message: String) { + messageLabel.text = message activityIndicator.stopAnimating() retryStack.isHidden = false } @@ -36,7 +38,6 @@ final class SplashViewController: UIViewController { activityIndicator.hidesWhenStopped = true - messageLabel.text = "네트워크 연결을 확인해주세요" messageLabel.font = DSTypography.body1.font messageLabel.textColor = DSColor.Text.primary messageLabel.textAlignment = .center diff --git a/Projects/App/Tests/AlarmSyncServiceTests.swift b/Projects/App/Tests/AlarmSyncServiceTests.swift new file mode 100644 index 0000000..ff1fec1 --- /dev/null +++ b/Projects/App/Tests/AlarmSyncServiceTests.swift @@ -0,0 +1,391 @@ +@testable import AtchaV2 +import Domain +import Foundation +import Testing + +// AlarmSyncService(판정·폴백·만료 오케스트레이션)의 회귀 방어(Phase 16). +// 진입은 syncNow()(수동 갱신과 같은 경로 — NotificationCenter 없이 전 분기 도달), +// 시각·앱 활성 상태는 주입으로 고정한다(실 Date()·UIApplication 금지 규약). + +private struct StubError: Error {} + +private nonisolated let fixedNow = Date(timeIntervalSince1970: 1_755_800_000) + +private nonisolated func info(route: String, departure: Date?) -> AlarmInfo { + AlarmInfo(lastRouteId: route, departureTime: departure, updatedAt: nil, isReal: true) +} + +/// 잠금 박스 — 테스트 도중 스텁 동작(활성 상태·판정)을 바꾼다. +private nonisolated 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() + defer { lock.unlock() } + value = newValue + } +} + +private final class RefreshStub: RefreshAlarmUseCase, @unchecked Sendable { + private let lock = NSLock() + private var queue: [Result] + init(_ results: [Result]) { queue = results } + func enqueue(_ result: Result) { + lock.lock() + queue.append(result) + lock.unlock() + } + // NSLock은 async 컨텍스트에서 직접 못 쓴다 — 동기 헬퍼로 분리. + private func dequeue() -> Result? { + lock.lock() + defer { lock.unlock() } + return queue.isEmpty ? nil : queue.removeFirst() + } + func execute() async throws -> AlarmInfo { + guard let next = dequeue() else { throw StubError() } + return try next.get() + } +} + +/// 판정 고정 스텁 — 판정 로직 자체는 Domain 테스트 몫, 여기서는 분기만 본다. +private final class EvaluateStub: EvaluateAlarmChangeUseCase, @unchecked Sendable { + private let lock = NSLock() + private var verdict: AlarmChangeVerdict = .unchanged + func fix(_ newVerdict: AlarmChangeVerdict) { + lock.lock() + verdict = newVerdict + lock.unlock() + } + func execute(previous: AlarmInfo, latest: AlarmInfo, now: Date) -> AlarmChangeVerdict { + lock.lock() + defer { lock.unlock() } + return verdict + } +} + +private actor ActivitySpy: LastTrainChangeAlerting { + private var reachable = true + private(set) var updates: [(state: LastTrainActivityState, alertTitle: String?)] = [] + private(set) var finals: [LastTrainActivityState] = [] + func setReachable(_ value: Bool) { reachable = value } + var isDismissedByUser: Bool { !reachable } + var isAlertReachable: Bool { reachable } + func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async { + updates.append((state, alert?.title)) + } + func end(final state: LastTrainActivityState) async { + finals.append(state) + } + var alertCount: Int { updates.count { $0.alertTitle != nil } } +} + +private actor NotiSpy: LocalNotificationPort { + private(set) var posted: [String] = [] + @discardableResult + func requestAuthorizationIfNeeded() async -> LocalNotificationAuthorizationOutcome { + .alreadySettled + } + func post(title: String, body: String) async { + posted.append(title) + } +} + +private actor SchedulerSpy: AlarmScheduler { + private(set) var cancelCount = 0 + func requestAuthorization() async -> Bool { true } + func replaceAlarm(id: String, fireDate: Date, title: String) async throws {} + func cancelAlarm() async { cancelCount += 1 } + 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 } +} + +private actor RestorerSpy: LastTrainSessionRestoring { + private(set) var restartCount = 0 + func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async {} + func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async { + restartCount += 1 + } +} + +@MainActor +private struct Harness { + let sut: AlarmSyncService + let refresh: RefreshStub + let evaluate: EvaluateStub + let activity: ActivitySpy + let noti: NotiSpy + let scheduler: SchedulerSpy + let store: StoreStub + let active: ValueBox + + /// 이전 값(diff 기준)을 심는 선행 동기화 — unchanged 판정으로 조용히 지나간다. + func primePreviousSession(departure: Date) async { + refresh.enqueue(.success(info(route: "r1", departure: departure))) + await sut.syncNow() + } +} + +@MainActor +private func makeHarness( + seeded: AlarmSessionSnapshot? = nil, + isAppActive: Bool = false +) -> Harness { + let refresh = RefreshStub([]) + let evaluate = EvaluateStub() + let activity = ActivitySpy() + let noti = NotiSpy() + let scheduler = SchedulerSpy() + let store = StoreStub(seeded) + let restorer = RestorerSpy() + let active = ValueBox(isAppActive) + let sut = AlarmSyncService( + refreshAlarmUseCase: refresh, + evaluateChangeUseCase: evaluate, + liveActivity: activity, + localNotification: noti, + alarmScheduler: scheduler, + snapshotStore: store, + sessionRestorer: restorer, + isAppActive: { active.get() }, + now: { fixedNow } + ) + return Harness( + sut: sut, refresh: refresh, evaluate: evaluate, activity: activity, + noti: noti, scheduler: scheduler, store: store, active: active + ) +} + +@MainActor +struct AlarmSyncServiceTests { + // MARK: - 신선도 스탬프 (Phase 16) + + @Test + func syncSuccess_publishesCheckedAtAndPersistsSyncedAt() async { + let harness = makeHarness() + let departure = fixedNow.addingTimeInterval(3600) + harness.refresh.enqueue(.success(info(route: "r1", departure: departure))) + + await harness.sut.syncNow() + + // replay-1이 확인 시각을 함께 나른다 — 스탬프의 원천은 sync 성공 시각(주입 now)뿐. + var iterator = harness.sut.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) + } + + @Test + func syncFailure_afterSeeding_replaysSnapshotConfirmedTime() async { + // 재실행 + 오프라인: 시딩 복원값의 확인 시각은 "지금"이 아니라 스냅샷의 + // 마지막 확인 시각이어야 한다 — 낡음을 숨기지 않는다. + 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, + syncedAt: seededCheckedAt + )) + + await harness.sut.syncNow() // refresh 큐 비어 있음 → 실패(무음) + + var iterator = harness.sut.updates().makeAsyncIterator() + let replayed = await iterator.next() + #expect(replayed?.info.lastRouteId == "r1") + #expect(replayed?.checkedAt == seededCheckedAt) + } + + // MARK: - advanced(actionable) 채널 분기 (Phase 11·12·15 회귀) + + @Test + func advanced_background_reachable_sendsLiveActivityAlert() async { + let harness = makeHarness(isAppActive: false) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + + harness.evaluate.fix(.advanced(by: 600, actionable: true)) + harness.refresh.enqueue(.success(info(route: "r1", departure: fixedNow.addingTimeInterval(3000)))) + await harness.sut.syncNow() + + #expect(await harness.activity.alertCount == 1) + #expect(await harness.noti.posted.isEmpty) + } + + @Test + func advanced_background_unreachable_fallsBackToLocalNotification() async { + // Phase 15 폴백 회귀 — LA alert 도달 불가면 같은 문구의 로컬 노티로 갈아탄다. + let harness = makeHarness(isAppActive: false) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + + await harness.activity.setReachable(false) + harness.evaluate.fix(.advanced(by: 600, actionable: true)) + harness.refresh.enqueue(.success(info(route: "r1", departure: fixedNow.addingTimeInterval(3000)))) + await harness.sut.syncNow() + + #expect(await harness.noti.posted.count == 1) + #expect(await harness.activity.alertCount == 0) + } + + @Test + func advanced_foreground_quietUpdateOnly() async { + // 포그라운드 — alert·노티 없이 조용한 갱신(배지)만. 주의는 인앱 채널 단독. + let harness = makeHarness(isAppActive: true) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + let updatesBefore = await harness.activity.updates.count + + harness.evaluate.fix(.advanced(by: 600, actionable: true)) + harness.refresh.enqueue(.success(info(route: "r1", departure: fixedNow.addingTimeInterval(3000)))) + await harness.sut.syncNow() + + #expect(await harness.activity.alertCount == 0) + #expect(await harness.noti.posted.isEmpty) + #expect(await harness.activity.updates.count == updatesBefore + 1) + } + + // MARK: - 최후통첩 (새 알람 시각이 이미 과거·출발은 미래) + + @Test + func ultimatum_foreground_staysQuiet_backgroundEscalates() async { + // Phase 15 이중 알림 제거 회귀: 포그라운드 최후통첩은 조용한 상태 갱신뿐이다. + let harness = makeHarness(isAppActive: true) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + + harness.evaluate.fix(.advanced(by: 3500, actionable: true)) + // 출발 100초 뒤 → 알람 시각(출발−180초)은 이미 과거 = 마지노선 침범. + let ultimatumInfo = info(route: "r1", departure: fixedNow.addingTimeInterval(100)) + harness.refresh.enqueue(.success(ultimatumInfo)) + await harness.sut.syncNow() + + #expect(await harness.activity.alertCount == 0) + #expect(await harness.noti.posted.isEmpty) + let lastQuiet = await harness.activity.updates.last + #expect(lastQuiet?.state.urgency == .imminent) + + // 백그라운드 + 도달 불가 — 즉시 최후통첩이 로컬 노티로 나간다. + harness.active.set(false) + await harness.activity.setReachable(false) + harness.refresh.enqueue(.success(ultimatumInfo)) + await harness.sut.syncNow() + + #expect(await harness.noti.posted.count == 1) + } + + // MARK: - missed (advanced, actionable: false) + + @Test + func missed_background_unreachable_fallsBackToLocalNotification() async { + let harness = makeHarness(isAppActive: false) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + + await harness.activity.setReachable(false) + harness.evaluate.fix(.advanced(by: 3700, actionable: false)) + harness.refresh.enqueue(.success(info(route: "r1", departure: fixedNow.addingTimeInterval(-100)))) + await harness.sut.syncNow() + + #expect(await harness.noti.posted.count == 1) + // 실패 상태 전환은 LA 종료가 아니라 missed 상태 업데이트다 — end 호출 없음. + #expect(await harness.activity.finals.isEmpty) + } + + // MARK: - sessionEnded + + @Test + func sessionEnded_cancelsAlarmClearsSnapshotAndEndsActivity() async { + let harness = makeHarness(isAppActive: false) + await harness.primePreviousSession(departure: fixedNow.addingTimeInterval(3600)) + #expect(await harness.store.snapshot != 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(await harness.activity.finals.last?.phase == .serviceEnded) + #expect(await harness.noti.posted.isEmpty) // 종료는 행동을 요구하지 않는다 — 폴백 없음. + } + + // MARK: - 클라 자체 만료 (Phase 13 회귀) + + @Test + 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, + syncedAt: fixedNow.addingTimeInterval(-3600) + )) + var changeIterator = harness.sut.changes().makeAsyncIterator() + for _ in 0..<20 { await Task.yield() } // 구독 등록 드레인(변경 스트림은 replay 없음) + + await harness.sut.syncNow() // refresh 큐 비어 있음 → 실패여도 만료는 확정된다. + + #expect(await harness.scheduler.cancelCount == 1) + #expect(await harness.store.snapshot?.expired == true) + #expect(await harness.activity.finals.last?.phase == .serviceEnded) + let verdict = await changeIterator.next() + #expect(verdict == .sessionEnded) + } + + @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 future = fixedNow.addingTimeInterval(1800) + harness.refresh.enqueue(.success(info(route: "r1", departure: future))) + + 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) + } + + // MARK: - 수동 갱신 합류 (Phase 16) + + @Test + func syncNow_joinsInFlightSync() async { + // 당김·포그라운드 복귀가 겹쳐도 refresh는 1회 — inFlight 합류 회귀. + let harness = makeHarness() + harness.refresh.enqueue(.success(info(route: "r1", departure: fixedNow.addingTimeInterval(3600)))) + // 큐에 1건뿐 — 두 번째 실호출이 발생하면 실패(StubError)로 lastInfo가 남지 않는다. + + async let first: Void = harness.sut.syncNow() + async let second: Void = harness.sut.syncNow() + _ = await (first, second) + + var iterator = harness.sut.updates().makeAsyncIterator() + let replayed = await iterator.next() + #expect(replayed?.info.lastRouteId == "r1") + } +} diff --git a/Projects/App/Tests/BootstrapFailureMessageTests.swift b/Projects/App/Tests/BootstrapFailureMessageTests.swift new file mode 100644 index 0000000..80ad195 --- /dev/null +++ b/Projects/App/Tests/BootstrapFailureMessageTests.swift @@ -0,0 +1,26 @@ +@testable import AtchaV2 +import CoreNetwork +import Foundation +import Testing + +private struct SomeError: Error {} + +struct BootstrapFailureMessageTests { + @Test + func offlineNetworkError_mapsToConnectivityMessage() { + let error = NetworkError.offline(underlying: URLError(.notConnectedToInternet)) + #expect(BootstrapFailureMessage.text(for: error) == BootstrapFailureMessage.offline) + } + + @Test + func otherFailures_mapToTransientMessage() { + // 타임아웃·서버 오류·비네트워크 에러 전부 "일시적인 문제" — 오프라인만 구분한다. + #expect(BootstrapFailureMessage.text( + for: NetworkError.transport(underlying: URLError(.timedOut)) + ) == BootstrapFailureMessage.transient) + #expect(BootstrapFailureMessage.text( + for: NetworkError.unacceptableStatus(code: 500, data: Data()) + ) == BootstrapFailureMessage.transient) + #expect(BootstrapFailureMessage.text(for: SomeError()) == BootstrapFailureMessage.transient) + } +} diff --git a/Projects/Core/Network/Sources/NetworkError.swift b/Projects/Core/Network/Sources/NetworkError.swift index e55e7a9..15895ce 100644 --- a/Projects/Core/Network/Sources/NetworkError.swift +++ b/Projects/Core/Network/Sources/NetworkError.swift @@ -2,8 +2,28 @@ import Foundation public enum NetworkError: Error, Sendable { case invalidURL + /// 연결 자체가 없음(Phase 16) — URLError .notConnectedToInternet / .dataNotAllowed. + /// 즉시 재시도해도 재실패라 재시도 대상이 아니고, 스플래시 실패 문구 분기의 근거다. + /// .networkConnectionLost는 일시 장애(킵얼라이브 소켓 끊김 등)로 보고 transport에 + /// 남긴다 — 멱등 GET 1회 재시도로 살리는 쪽이 맞다. + case offline(underlying: any Error) case transport(underlying: any Error) case invalidResponse case unacceptableStatus(code: Int, data: Data) case decoding(underlying: any Error) + + /// 전송 실패 분류 단일 지점 — 클라이언트가 URLSession 에러를 이 함수로만 감싼다. + public static func classifyingTransport(_ error: any Error) -> NetworkError { + switch (error as? URLError)?.code { + case .notConnectedToInternet, .dataNotAllowed: + .offline(underlying: error) + default: + .transport(underlying: error) + } + } + + public var isOffline: Bool { + if case .offline = self { return true } + return false + } } diff --git a/Projects/Core/Network/Sources/URLSessionNetworkClient.swift b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift index e6846d8..f00858f 100644 --- a/Projects/Core/Network/Sources/URLSessionNetworkClient.swift +++ b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift @@ -19,12 +19,27 @@ public struct URLSessionNetworkClient: NetworkClient { public func data(for endpoint: any Endpoint) async throws -> Data { let request = try urlRequest(for: endpoint) + do { + return try await send(request) + } catch let error as NetworkError { + // 멱등 GET 1회 재시도(Phase 16) — transport 계열(타임아웃·연결 끊김)만. + // offline은 즉시 재실패라 무의미, 취소는 사용자 의사, HTTP 상태·디코딩은 + // 전송 실패가 아니다. POST/DELETE는 이중 발사(알람 등록·해제) 위험으로 금지. + guard endpoint.method == .get, case .transport(let underlying) = error, + (underlying as? URLError)?.code != .cancelled, + !(underlying is CancellationError) + else { throw error } + return try await send(request) + } + } + + private func send(_ request: URLRequest) async throws -> Data { let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { - throw NetworkError.transport(underlying: error) + throw NetworkError.classifyingTransport(error) } guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse diff --git a/Projects/Core/Network/Tests/URLSessionNetworkClientRetryTests.swift b/Projects/Core/Network/Tests/URLSessionNetworkClientRetryTests.swift new file mode 100644 index 0000000..48cd0c1 --- /dev/null +++ b/Projects/Core/Network/Tests/URLSessionNetworkClientRetryTests.swift @@ -0,0 +1,149 @@ +@testable import CoreNetwork +import Foundation +import Testing + +private struct GetEndpoint: Endpoint { + let path = "/ping" + let method: HTTPMethod = .get +} + +private struct PostEndpoint: Endpoint { + let path = "/register" + let method: HTTPMethod = .post +} + +/// URLProtocol 스텁 — 응답 큐를 순서대로 소비한다. static 상태를 쓰므로 +/// 이 스텁을 쓰는 테스트 스위트는 `.serialized`여야 한다. +private final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) private static var queue: [Result<(status: Int, data: Data), URLError>] = [] + nonisolated(unsafe) private(set) static var requestCount = 0 + private static let lock = NSLock() + + static func reset(queue newQueue: [Result<(status: Int, data: Data), URLError>]) { + lock.lock() + defer { lock.unlock() } + queue = newQueue + requestCount = 0 + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + Self.lock.lock() + Self.requestCount += 1 + let next = Self.queue.isEmpty ? nil : Self.queue.removeFirst() + Self.lock.unlock() + + switch next { + case let .success((status, data)): + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + case let .failure(error): + client?.urlProtocol(self, didFailWithError: error) + case nil: + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + } + } +} + +private func makeClient() -> URLSessionNetworkClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSessionNetworkClient( + baseURL: URL(string: "https://api.example.com")!, + session: URLSession(configuration: configuration) + ) +} + +@Suite(.serialized) +struct URLSessionNetworkClientRetryTests { + @Test + func get_transportFailureThenSuccess_retriesOnce() async throws { + StubURLProtocol.reset(queue: [ + .failure(URLError(.timedOut)), + .success((status: 200, data: Data("ok".utf8))), + ]) + let data = try await makeClient().data(for: GetEndpoint()) + #expect(data == Data("ok".utf8)) + #expect(StubURLProtocol.requestCount == 2) + } + + @Test + func get_transportFailureTwice_throwsAfterSingleRetry() async { + StubURLProtocol.reset(queue: [ + .failure(URLError(.networkConnectionLost)), + .failure(URLError(.networkConnectionLost)), + ]) + await #expect(throws: NetworkError.self) { + try await makeClient().data(for: GetEndpoint()) + } + // 1회 재시도까지만 — 무한 재시도 금지. + #expect(StubURLProtocol.requestCount == 2) + } + + @Test + func get_offline_throwsOfflineWithoutRetry() async { + StubURLProtocol.reset(queue: [ + .failure(URLError(.notConnectedToInternet)), + .success((status: 200, data: Data())), + ]) + do { + _ = try await makeClient().data(for: GetEndpoint()) + Issue.record("offline인데 성공했다") + } catch let error as NetworkError { + #expect(error.isOffline) + } catch { + Issue.record("NetworkError가 아닌 에러: \(error)") + } + // 연결 자체가 없으면 즉시 재실패라 재시도하지 않는다. + #expect(StubURLProtocol.requestCount == 1) + } + + @Test + func post_transportFailure_doesNotRetry() async { + StubURLProtocol.reset(queue: [ + .failure(URLError(.timedOut)), + .success((status: 200, data: Data())), + ]) + await #expect(throws: NetworkError.self) { + try await makeClient().data(for: PostEndpoint()) + } + // 비멱등(알람 등록 등)은 이중 발사 위험 — 재시도 금지. + #expect(StubURLProtocol.requestCount == 1) + } + + @Test + func get_httpErrorStatus_doesNotRetry() async { + StubURLProtocol.reset(queue: [ + .success((status: 500, data: Data())), + .success((status: 200, data: Data())), + ]) + await #expect(throws: NetworkError.self) { + try await makeClient().data(for: GetEndpoint()) + } + // 재시도 대상은 transport 계열뿐 — HTTP 상태 실패는 그대로 던진다. + #expect(StubURLProtocol.requestCount == 1) + } +} + +struct NetworkErrorClassificationTests { + @Test + func classifyingTransport_offlineCodes() { + #expect(NetworkError.classifyingTransport(URLError(.notConnectedToInternet)).isOffline) + #expect(NetworkError.classifyingTransport(URLError(.dataNotAllowed)).isOffline) + } + + @Test + func classifyingTransport_transientCodesStayTransport() { + // 연결 끊김·타임아웃은 일시 장애 — 재시도로 살린다(offline 아님). + #expect(!NetworkError.classifyingTransport(URLError(.networkConnectionLost)).isOffline) + #expect(!NetworkError.classifyingTransport(URLError(.timedOut)).isOffline) + #expect(!NetworkError.classifyingTransport(URLError(.cancelled)).isOffline) + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSBanner.swift b/Projects/DesignSystem/Sources/Components/DSBanner.swift index 0e32833..c3c985c 100644 --- a/Projects/DesignSystem/Sources/Components/DSBanner.swift +++ b/Projects/DesignSystem/Sources/Components/DSBanner.swift @@ -12,6 +12,9 @@ public final class DSBanner: UIView { } private let label = UILabel() + /// 신선도 스탬프("HH:mm 확인 기준") 등 본문에 딸린 보조 라인 — nil이면 기존 렌더와 동일. + private let detailLabel = UILabel() + private let textStack = UIStackView() public init(text: String = "", style: Style = .normal) { super.init(frame: .zero) @@ -20,20 +23,30 @@ public final class DSBanner: UIView { label.font = DSTypography.label1.font label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - addSubview(label) + detailLabel.font = DSTypography.caption2.font + detailLabel.textAlignment = .center + detailLabel.isHidden = true + + textStack.axis = .vertical + textStack.alignment = .center + textStack.spacing = DSSpacing.xxs + [label, detailLabel].forEach(textStack.addArrangedSubview) + textStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(textStack) NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), - label.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.sm12), - label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.sm12), + textStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + textStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + textStack.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.sm12), + textStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.sm12), ]) configure(text: text, style: style) } - public func configure(text: String, style: Style = .normal) { + public func configure(text: String, style: Style = .normal, detailText: String? = nil) { label.text = text + detailLabel.text = detailText + detailLabel.isHidden = detailText == nil switch style { case .normal: backgroundColor = DSColor.Accent.container @@ -45,6 +58,9 @@ public final class DSBanner: UIView { backgroundColor = DSColor.State.urgent label.textColor = DSColor.Text.primary } + // 보조 라인은 본문과 같은 토큰 색의 감쇠 톤 — 스타일별 배경(컨테이너/솔리드) + // 어디서든 본문보다 한 단계 낮은 위계를 유지한다(DSRouteCard legs alpha 관례). + detailLabel.textColor = label.textColor.withAlphaComponent(0.72) } /// 갱신 강조 — 값이 바뀐 순간 1회 펄스로 시선을 끈다("막차가 당겨졌어요" 등). diff --git a/Projects/DesignSystem/Sources/Components/DSRouteCard.swift b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift index 6379390..4560706 100644 --- a/Projects/DesignSystem/Sources/Components/DSRouteCard.swift +++ b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift @@ -14,6 +14,8 @@ public final class DSRouteCard: UIView { public let legs: [DSTransportBadge.Kind] public let summaryText: String? public let destinationText: String? + /// 카드 하단 푸터(신선도 스탬프 "HH:mm 확인 기준" 등) — nil이면 기존 렌더와 동일. + public let footnoteText: String? public let tone: Tone public init( @@ -22,6 +24,7 @@ public final class DSRouteCard: UIView { legs: [DSTransportBadge.Kind] = [], summaryText: String? = nil, destinationText: String? = nil, + footnoteText: String? = nil, tone: Tone = .normal ) { self.badgeText = badgeText @@ -29,6 +32,7 @@ public final class DSRouteCard: UIView { self.legs = legs self.summaryText = summaryText self.destinationText = destinationText + self.footnoteText = footnoteText self.tone = tone } } @@ -40,6 +44,7 @@ public final class DSRouteCard: UIView { private let legsStack = UIStackView() private let summaryLabel = UILabel() private let destinationLabel = UILabel() + private let footnoteLabel = UILabel() private let contentStack = UIStackView() public init() { @@ -68,10 +73,13 @@ public final class DSRouteCard: UIView { destinationLabel.font = DSTypography.caption1.font destinationLabel.textColor = DSColor.Text.secondary + footnoteLabel.font = DSTypography.caption2.font + footnoteLabel.textColor = DSColor.Text.tertiary + contentStack.axis = .vertical contentStack.alignment = .leading contentStack.spacing = DSSpacing.sm - [badgeLabel, departureTimeLabel, legsStack, summaryLabel, destinationLabel] + [badgeLabel, departureTimeLabel, legsStack, summaryLabel, destinationLabel, footnoteLabel] .forEach(contentStack.addArrangedSubview) contentStack.translatesAutoresizingMaskIntoConstraints = false @@ -116,6 +124,9 @@ public final class DSRouteCard: UIView { destinationLabel.text = content.destinationText destinationLabel.isHidden = content.destinationText == nil + + footnoteLabel.text = content.footnoteText + footnoteLabel.isHidden = content.footnoteText == nil } /// 톤별 색 적용 — 재사용(configure 재호출) 시 양방향 모두 명시적으로 되돌린다. diff --git a/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift b/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift index 9790b5d..91a845a 100644 --- a/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift +++ b/Projects/Domain/Sources/Entities/AlarmSessionSnapshot.swift @@ -18,6 +18,10 @@ public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { 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, @@ -25,7 +29,8 @@ public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { routeDisplayName: String, transportMode: TransportMode?, acknowledged: Bool, - expired: Bool + expired: Bool, + syncedAt: Date? = nil ) { self.info = info self.firstWalkSeconds = firstWalkSeconds @@ -33,13 +38,16 @@ public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { 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 + expired: Bool? = nil, + syncedAt: Date? = nil ) -> AlarmSessionSnapshot { AlarmSessionSnapshot( info: info ?? self.info, @@ -47,7 +55,8 @@ public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { routeDisplayName: routeDisplayName, transportMode: transportMode, acknowledged: acknowledged ?? self.acknowledged, - expired: expired ?? self.expired + expired: expired ?? self.expired, + syncedAt: syncedAt ?? self.syncedAt ) } } diff --git a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift index 3d6b02f..367c20a 100644 --- a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift +++ b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift @@ -1,10 +1,26 @@ import Foundation +/// 동기화 성공 방출값(Phase 16) — info에 "언제 서버로 확인했는가"를 동봉한다. +/// checkedAt이 신선도 스탬프("HH:mm 확인 기준")의 유일한 원천이다 — 구독자(홈)가 +/// 수신 시각으로 찍으면 스냅샷 시딩 복원값이 "지금 확인됨"으로 둔갑하므로 금지. +public struct AlarmSyncUpdate: Sendable, Equatable { + public let info: AlarmInfo + /// 서버 확인 시각. 스냅샷 시딩 복원이면 직전 세션의 마지막 확인 시각(스냅샷의 + /// syncedAt), 그것도 없으면 nil — nil이면 스탬프를 표시하지 않는다(정직한 기본값). + public let checkedAt: Date? + + public init(info: AlarmInfo, checkedAt: Date?) { + self.info = info + self.checkedAt = checkedAt + } +} + /// 알람 동기화 결과 포트 — 발행 주체는 App의 AlarmSyncService(앱 시작·포그라운드 -/// 복귀·사일런트 푸시 3경로를 RefreshAlarmUseCase 한 곳으로 모으는 유일한 호출자). -/// 실패는 흘리지 않는다 — 구독자는 성공 결과만 받고, 실패 시 기존 상태를 유지한다. +/// 복귀·사일런트 푸시·수동 갱신 4경로를 RefreshAlarmUseCase 한 곳으로 모으는 유일한 호출자). +/// 실패는 흘리지 않는다 — 구독자는 성공 결과만 받고, 실패 시 기존 상태를 유지한다 +/// (실패의 표면은 신선도 스탬프가 낡은 시각을 유지하는 것뿐 — 원칙 3). public protocol AlarmSyncEvents: Sendable { /// 구독자마다 독립 스트림. 구독 시 마지막 동기화 결과가 있으면 즉시 방출한다 /// (구독 전에 끝난 앱 시작 동기화를 놓치지 않도록). - func updates() -> AsyncStream + func updates() -> AsyncStream } diff --git a/Projects/Domain/Sources/Interfaces/AlarmSyncRequesting.swift b/Projects/Domain/Sources/Interfaces/AlarmSyncRequesting.swift new file mode 100644 index 0000000..88db2cf --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmSyncRequesting.swift @@ -0,0 +1,10 @@ +import Foundation + +/// 수동 동기화 요청 포트(Phase 16) — 구현은 App의 AlarmSyncService(4번째 트리거). +/// 홈 pull-to-refresh가 유일한 호출자다 — 새 표출 채널을 만들지 않고, 결과는 +/// AlarmSyncEvents.updates() 스트림으로만 흐른다(실패 무음 정책 공유). +public protocol AlarmSyncRequesting: Sendable { + /// 동기화 1회를 요청하고 완료까지 기다린다. 진행 중 동기화가 있으면 합류한다 + /// (당김·포그라운드 복귀가 겹쳐도 refresh는 1회). 실패를 던지지 않는다. + func syncNow() async +} diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift index 6778c4b..33c0ac3 100644 --- a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift @@ -1,7 +1,7 @@ import Foundation public protocol ObserveAlarmUseCase: Sendable { - func execute() -> AsyncStream + func execute() -> AsyncStream } public struct DefaultObserveAlarmUseCase: ObserveAlarmUseCase { @@ -11,7 +11,7 @@ public struct DefaultObserveAlarmUseCase: ObserveAlarmUseCase { self.events = events } - public func execute() -> AsyncStream { + public func execute() -> AsyncStream { events.updates() } } diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index a70808a..c9b09e4 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -70,13 +70,15 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { ) // 등록 성공 = 스냅샷 저장 시점(Phase 14) — 재실행 시 diff 기준·LA 재부착·카드 // 복원의 재료. 도보 초·표시명·수단은 등록 시점 경로에서만 얻을 수 있다. + // syncedAt = 등록 시각(Phase 16) — 서버가 방금 이 값을 받아들였으므로 확인이다. await snapshotStore?.save(AlarmSessionSnapshot( info: session, firstWalkSeconds: route.firstWalkSectionSeconds, routeDisplayName: route.sessionDisplayName, transportMode: route.boardingLeg?.mode, acknowledged: false, - expired: false + expired: false, + syncedAt: now() )) // 수명 정책: 알람 등록(서버+로컬)이 전부 성공한 뒤에만 LA를 시작한다. // start는 throws가 아니므로 LA 실패가 알람 등록을 실패시킬 수 없다. diff --git a/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift b/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift new file mode 100644 index 0000000..ef0cdd9 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift @@ -0,0 +1,20 @@ +import Foundation + +/// 홈 pull-to-refresh의 수동 갱신 진입점(Phase 16) — Observe 계열과 같은 패턴으로 +/// App의 AlarmSyncService(AlarmSyncRequesting)를 감싼다. 완료 = 동기화 종료(성공/실패 +/// 불문)이고, 갱신된 값은 ObserveAlarmUseCase 스트림이 따로 나른다. +public protocol RequestAlarmSyncUseCase: Sendable { + func execute() async +} + +public struct DefaultRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { + private let requesting: any AlarmSyncRequesting + + public init(requesting: any AlarmSyncRequesting) { + self.requesting = requesting + } + + public func execute() async { + await requesting.syncNow() + } +} diff --git a/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift b/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift index fc85562..a81b70c 100644 --- a/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift +++ b/Projects/Domain/Tests/AlarmSessionSnapshotTests.swift @@ -14,7 +14,8 @@ struct AlarmSessionSnapshotTests { routeDisplayName: "6411번 버스", transportMode: .bus, acknowledged: false, - expired: false + expired: false, + syncedAt: Date(timeIntervalSince1970: 1_755_995_000) ) @Test @@ -62,4 +63,39 @@ struct AlarmSessionSnapshotTests { #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/DefaultObserveAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift index a31c532..274f479 100644 --- a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift @@ -3,33 +3,37 @@ import Foundation import Testing private struct StubAlarmSyncEvents: AlarmSyncEvents { - let handler: @Sendable () -> AsyncStream - func updates() -> AsyncStream { handler() } + let handler: @Sendable () -> AsyncStream + func updates() -> AsyncStream { handler() } } struct DefaultObserveAlarmUseCaseTests { @Test func execute_forwardsPortStream() async { - let info = AlarmInfo( - lastRouteId: "r1", - departureTime: Date(timeIntervalSince1970: 1_755_800_000), - updatedAt: nil, - isReal: true + let update = AlarmSyncUpdate( + info: AlarmInfo( + lastRouteId: "r1", + departureTime: Date(timeIntervalSince1970: 1_755_800_000), + updatedAt: nil, + isReal: true + ), + // 확인 시각이 함께 흘러야 한다 — 신선도 스탬프의 원천(Phase 16). + checkedAt: Date(timeIntervalSince1970: 1_755_790_000) ) let sut = DefaultObserveAlarmUseCase( events: StubAlarmSyncEvents { AsyncStream { continuation in - continuation.yield(info) + continuation.yield(update) continuation.finish() } } ) - var received: [AlarmInfo] = [] - for await update in sut.execute() { - received.append(update) + var received: [AlarmSyncUpdate] = [] + for await value in sut.execute() { + received.append(value) } - #expect(received == [info]) + #expect(received == [update]) } } diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index 61bb6b2..1ca0fd9 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -283,6 +283,8 @@ struct DefaultRegisterAlarmUseCaseTests { #expect(saved.first?.transportMode == .bus) #expect(saved.first?.acknowledged == false) #expect(saved.first?.expired == false) + // 등록 성공 = 서버 확인 — 신선도 스탬프의 원천이 등록 시각으로 기록된다(Phase 16). + #expect(saved.first?.syncedAt == fixedNow()) } @Test diff --git a/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift b/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift new file mode 100644 index 0000000..7d8c4e5 --- /dev/null +++ b/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift @@ -0,0 +1,22 @@ +@testable import Domain +import Foundation +import Testing + +private actor SpyAlarmSyncRequesting: AlarmSyncRequesting { + private(set) var syncNowCount = 0 + func syncNow() async { syncNowCount += 1 } +} + +struct DefaultRequestAlarmSyncUseCaseTests { + @Test + func execute_forwardsToPortOncePerCall() async { + let spy = SpyAlarmSyncRequesting() + let sut = DefaultRequestAlarmSyncUseCase(requesting: spy) + + await sut.execute() + await sut.execute() + + // 합류·중복 억제는 포트 구현(AlarmSyncService inFlight)의 몫 — UseCase는 위임만. + #expect(await spy.syncNowCount == 2) + } +} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index 2667d95..e47752f 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -41,6 +41,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { cancelAlarmUseCase: PreviewCancelAlarmUseCase(), observeAlarmUseCase: PreviewObserveAlarmUseCase(), observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(), + requestAlarmSyncUseCase: PreviewRequestAlarmSyncUseCase(), getLastRouteDetailUseCase: PreviewGetLastRouteDetailUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) @@ -87,11 +88,18 @@ struct PreviewCancelAlarmUseCase: CancelAlarmUseCase { /// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 동기화 이벤트가 오지 않으므로 화면을 건드리지 않는다. struct PreviewObserveAlarmUseCase: ObserveAlarmUseCase { - func execute() -> AsyncStream { + func execute() -> AsyncStream { AsyncStream { _ in } } } +/// 수동 갱신(pull-to-refresh) 스텁 — 잠깐 도는 스피너만 흉내 낸다(결과 스트림 없음). +struct PreviewRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { + func execute() async { + try? await Task.sleep(for: .milliseconds(600)) + } +} + /// 막차 변경 판정이 없는 상태를 흉내 낸다 — 토스트·배너 강조는 발생하지 않는다. struct PreviewObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { func execute() -> AsyncStream { diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 5cfdc10..faf9c6a 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -14,6 +14,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase + private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable @@ -24,6 +25,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, + requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { @@ -33,6 +35,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase self.observeAlarmChangeUseCase = observeAlarmChangeUseCase + self.requestAlarmSyncUseCase = requestAlarmSyncUseCase self.getLastRouteDetailUseCase = getLastRouteDetailUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -51,6 +54,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { cancelAlarmUseCase: cancelAlarmUseCase, observeAlarmUseCase: observeAlarmUseCase, observeAlarmChangeUseCase: observeAlarmChangeUseCase, + requestAlarmSyncUseCase: requestAlarmSyncUseCase, getLastRouteDetailUseCase: getLastRouteDetailUseCase ) viewModel.onSearchRequested = onSearchRequested diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 9318a6e..b569e92 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -32,6 +32,14 @@ final class HomeViewController: UIViewController { return stack }() + // pull-to-refresh(Phase 16) — 콘텐츠가 화면보다 짧아도 당길 수 있게 상시 바운스. + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.alwaysBounceVertical = true + return scrollView + }() + private let refreshControl = UIRefreshControl() + init(viewModel: HomeViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -73,10 +81,24 @@ final class HomeViewController: UIViewController { private func configureUI() { view.backgroundColor = DSColor.Background.base - view.addSubview(contentStack) + refreshControl.addAction( + UIAction { [weak self] _ in self?.viewModel.refreshPulled() }, + for: .valueChanged + ) + scrollView.refreshControl = refreshControl + view.addSubview(scrollView) + scrollView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.bottom.equalToSuperview() + } + + scrollView.addSubview(contentStack) contentStack.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(DSSpacing.sm12) - make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + make.top.equalTo(scrollView.contentLayoutGuide).offset(DSSpacing.sm12) + make.bottom.equalTo(scrollView.contentLayoutGuide) + make.leading.trailing.equalTo(scrollView.contentLayoutGuide).inset(DSSpacing.md) + // 세로 스크롤 전용 — 콘텐츠 폭을 프레임 폭에 고정한다(수평 스크롤 방지). + make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) } [titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton, cancelButton] @@ -169,6 +191,11 @@ final class HomeViewController: UIViewController { viewModel.onToast = { [weak self] event in self?.showToast(for: event) } + // 성공/실패 불문 동기화 종료 시 스피너를 내린다(Phase 16) — 실패 표출은 + // 스탬프가 낡은 시각을 유지하는 것뿐(무음 정책). + viewModel.onManualSyncFinished = { [weak self] in + self?.refreshControl.endRefreshing() + } render(viewModel.state) } @@ -184,7 +211,8 @@ final class HomeViewController: UIViewController { } if let card = state.routeCard { - routeCard.configure(with: card.dsContent) + // 신선도 스탬프(Phase 16)는 세션 상태라 State가 따로 나른다 — 표출 시점 합성. + routeCard.configure(with: card.dsContent(footnote: state.freshnessText)) routeCard.isHidden = false } else { routeCard.isHidden = true @@ -195,7 +223,11 @@ final class HomeViewController: UIViewController { cancelButton.isEnabled = !state.isAlarmBusy if let bannerData = state.banner { - banner.configure(text: bannerData.text, style: Self.bannerStyle(for: bannerData.urgency)) + banner.configure( + text: bannerData.text, + style: Self.bannerStyle(for: bannerData.urgency), + detailText: state.freshnessText + ) banner.isHidden = false } else { banner.isHidden = true diff --git a/Projects/Feature/Home/Sources/HomeViewData.swift b/Projects/Feature/Home/Sources/HomeViewData.swift index c6f30af..9ad020a 100644 --- a/Projects/Feature/Home/Sources/HomeViewData.swift +++ b/Projects/Feature/Home/Sources/HomeViewData.swift @@ -10,6 +10,15 @@ private let timeFormatter: DateFormatter = { return formatter }() +/// 신선도 스탬프 문구(Phase 16) — 배너 보조 라인·카드 푸터 공용. 등록 세션이 있고 +/// 확인 시각이 있을 때만 문구가 있다. 시각 포맷은 카드와 같은 캐시 포매터를 쓴다. +extension HomeViewModel { + static func freshnessText(checkedAt: Date?, isRegistered: Bool) -> String? { + guard isRegistered, let checkedAt else { return nil } + return "\(timeFormatter.string(from: checkedAt)) 확인 기준" + } +} + /// 홈에 표출되는 선택 경로 카드. Entity를 뷰에 직접 노출하지 않는다. struct RouteCardViewData: Equatable { /// 카드 톤 — past는 유예 경과 후의 "지난 막차" 상태(비활성 시각, Phase 13). @@ -65,13 +74,16 @@ struct RouteCardViewData: Equatable { ) } - var dsContent: DSRouteCard.Content { + /// footnote(신선도 스탬프)는 카드 사실이 아니라 세션 상태라 State가 따로 나른다 — + /// VC가 표출 시점에 합성한다(Phase 16). + func dsContent(footnote: String?) -> DSRouteCard.Content { .init( badgeText: badgeText, departureTimeText: departureTimeText, legs: legs, summaryText: summaryText, destinationText: destinationText, + footnoteText: footnote, tone: tone == .past ? .muted : .normal ) } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 5635341..52f9872 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -32,6 +32,10 @@ final class HomeViewModel { var banner: BannerViewData? var isAlarmBusy = false var alarmButton: AlarmButtonMode = .hidden + /// 신선도 스탬프 "HH:mm 확인 기준"(Phase 16) — 배너 보조 라인·카드 푸터 공용 + /// 단일 소스. 등록 세션과 확인 시각이 있을 때만 값이 있고, sync 무음 실패 시 + /// 낡은 시각을 그대로 유지하는 것이 실패의 정직한 표면이다(원칙 3). + var freshnessText: String? } /// 재방출되면 안 되는 원샷 안내 — 상태와 분리한다. @@ -56,6 +60,9 @@ final class HomeViewModel { /// Set by the ViewController; always invoked on the main actor. var onStateChange: ((State) -> Void)? var onToast: ((ToastEvent) -> Void)? + /// pull-to-refresh 종료 훅(Phase 16) — 성공/실패 불문 동기화가 끝나면 불린다 + /// (VC가 refreshControl.endRefreshing). 실패는 무음 — 결과 표출은 스트림·스탬프 몫. + var onManualSyncFinished: (() -> Void)? /// Set by the Coordinator: 검색 플로우를 열고, 선택 경로를 reply 클로저로 돌려받는다. var onSearchRequested: ((_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void)? @@ -69,6 +76,7 @@ final class HomeViewModel { private let cancelAlarmUseCase: any CancelAlarmUseCase private let observeAlarmUseCase: any ObserveAlarmUseCase private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase + private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration @@ -76,12 +84,16 @@ final class HomeViewModel { private var selectedRoute: LastRoute? /// 서버에 알람이 등록된 경로 id — 해제 버튼·동기화 복원의 기준. private var registeredRouteId: String? + /// 세션 값이 마지막으로 서버로 확인된 시각(Phase 16) — 스트림의 checkedAt·등록 + /// 성공 시각만이 원천이다(수신 시각으로 찍지 않는다 — 시딩 복원값의 둔갑 방지). + private var lastCheckedAt: Date? private var locationTask: Task? private var alarmTask: Task? private var observeTask: Task? private var changeTask: Task? private var bannerTask: Task? private var restoreCardTask: Task? + private var refreshTask: Task? init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, @@ -90,6 +102,7 @@ final class HomeViewModel { cancelAlarmUseCase: any CancelAlarmUseCase, observeAlarmUseCase: any ObserveAlarmUseCase, observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, + requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) @@ -100,6 +113,7 @@ final class HomeViewModel { self.cancelAlarmUseCase = cancelAlarmUseCase self.observeAlarmUseCase = observeAlarmUseCase self.observeAlarmChangeUseCase = observeAlarmChangeUseCase + self.requestAlarmSyncUseCase = requestAlarmSyncUseCase self.getLastRouteDetailUseCase = getLastRouteDetailUseCase self.now = now self.bannerTickInterval = bannerTickInterval @@ -112,6 +126,7 @@ final class HomeViewModel { changeTask?.cancel() bannerTask?.cancel() restoreCardTask?.cancel() + refreshTask?.cancel() } // MARK: - 입력 @@ -142,6 +157,19 @@ final class HomeViewModel { } } + /// pull-to-refresh(Phase 16) — 수동 동기화 1회. 진행 중 재진입은 no-op(이중 당김 + /// 무해). 실패는 무음 — 스피너 종료 + 스탬프가 낡은 시각을 유지하는 것이 표면이다. + func refreshPulled() { + guard refreshTask == nil else { return } + refreshTask = Task { [weak self] in + guard let useCase = self?.requestAlarmSyncUseCase else { return } + await useCase.execute() + guard let self, !Task.isCancelled else { return } + self.refreshTask = nil + self.onManualSyncFinished?() + } + } + /// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다. func searchFieldTapped() { onSearchRequested? { [weak self] route in @@ -172,18 +200,21 @@ final class HomeViewModel { guard let useCase = self?.registerAlarmUseCase else { return } do { let followUp = try await useCase.execute(route: route) - guard !Task.isCancelled else { return } - self?.registeredRouteId = route.id - self?.state.isAlarmBusy = false - self?.refreshAlarmButton() - self?.startBannerTimer( + guard !Task.isCancelled, let self else { return } + self.registeredRouteId = route.id + // 등록 성공 = 서버가 방금 이 값을 확인해줬다 — 스탬프 시작점(Phase 16). + self.lastCheckedAt = self.now() + self.state.isAlarmBusy = false + self.refreshAlarmButton() + self.refreshFreshness() + self.startBannerTimer( departure: route.departureTime, firstWalkSeconds: route.firstWalkSectionSeconds ) // 등록은 성공했고 폴백 노티만 잃었다 — 1회 안내(Phase 15). deniedNow는 // 이번 호출로 최초 요청이 이뤄졌고 거부된 경우뿐이라 재등록 시 반복되지 않는다. if followUp == .deniedNow { - self?.onToast?(.notificationPermissionDenied) + self.onToast?(.notificationPermissionDenied) } } catch AlarmError.permissionDenied { guard !Task.isCancelled else { return } @@ -212,6 +243,7 @@ final class HomeViewModel { guard !Task.isCancelled, let self else { return } self.bannerTask?.cancel() self.registeredRouteId = nil + self.lastCheckedAt = nil var newState = self.state newState.banner = nil newState.isAlarmBusy = false @@ -219,6 +251,7 @@ final class HomeViewModel { selectedRouteId: self.selectedRoute?.id, registeredRouteId: nil ) + newState.freshnessText = nil self.state = newState } catch { guard !Task.isCancelled else { return } @@ -239,16 +272,21 @@ final class HomeViewModel { observeTask?.cancel() observeTask = Task { [weak self] in guard let stream = self?.observeAlarmUseCase.execute() else { return } - for await info in stream { + for await update in stream { guard !Task.isCancelled else { return } - self?.alarmSynced(info) + self?.alarmSynced(update) } } } - private func alarmSynced(_ info: AlarmInfo) { + private func alarmSynced(_ update: AlarmSyncUpdate) { + let info = update.info registeredRouteId = info.lastRouteId + // 확인 시각은 스트림이 준 값만 쓴다(Phase 16) — 시딩 복원이면 직전 세션의 마지막 + // 확인 시각이고, 그것도 없으면 nil(스탬프 없음). 수신 시각으로 찍지 않는다. + lastCheckedAt = update.checkedAt refreshAlarmButton() + refreshFreshness() // 유예(출발+60초)가 지난 시각으로는 배너를 (재)시작하지 않는다 — 지난 막차의 // 복원은 오정보이고, 못 탐(actionable=false) 판정이 고정한 실패 배너를 후속 // 동기화가 덮어쓰는 일도 이 가드가 막는다. 유예 안이면 시작한다 — 발화~유예 @@ -333,12 +371,14 @@ final class HomeViewModel { // 여기서 지워진다. LA final state 종료·알람 취소는 App/Domain 경로의 몫. bannerTask?.cancel() registeredRouteId = nil + lastCheckedAt = nil var newState = state newState.banner = nil newState.alarmButton = Self.alarmButtonMode( selectedRouteId: selectedRoute?.id, registeredRouteId: nil ) + newState.freshnessText = nil state = newState onToast?(.lastTrainServiceEnded) case .delayed, .unchanged: @@ -378,6 +418,14 @@ final class HomeViewModel { ) } + /// 스탬프 재계산(Phase 16) — 등록 세션 존재 ∧ 확인 시각 존재일 때만 값이 있다. + private func refreshFreshness() { + state.freshnessText = Self.freshnessText( + checkedAt: lastCheckedAt, + isRegistered: registeredRouteId != nil + ) + } + private func startBannerTimer(departure: Date, firstWalkSeconds: Int?) { bannerTask?.cancel() // 매 틱 departure 기준으로 재계산 — 누적 드리프트가 없다. @@ -405,10 +453,13 @@ final class HomeViewModel { 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 } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index e00594a..24cb6c5 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -33,8 +33,13 @@ private struct StubCancelAlarmUseCase: CancelAlarmUseCase { } private struct StubObserveAlarmUseCase: ObserveAlarmUseCase { - let handler: @Sendable () -> AsyncStream - func execute() -> AsyncStream { handler() } + let handler: @Sendable () -> AsyncStream + func execute() -> AsyncStream { handler() } +} + +private struct StubRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { + let handler: @Sendable () async -> Void + func execute() async { await handler() } } private struct StubObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { @@ -148,12 +153,13 @@ private func makeSUT( register: @escaping @Sendable (LastRoute) async throws -> Void = { _ in }, registerOutcome: LocalNotificationAuthorizationOutcome = .alreadySettled, cancel: @escaping @Sendable (String) async throws -> Void = { _ in }, - alarmUpdates: @escaping @Sendable () -> AsyncStream = { + alarmUpdates: @escaping @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } }, alarmChanges: @escaping @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } }, + requestSync: @escaping @Sendable () async -> Void = {}, routeDetail: @escaping @Sendable (String) async throws -> LastRoute = { _ in throw StubError() }, @@ -169,12 +175,20 @@ private func makeSUT( cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates), observeAlarmChangeUseCase: StubObserveAlarmChangeUseCase(handler: alarmChanges), + requestAlarmSyncUseCase: StubRequestAlarmSyncUseCase(handler: requestSync), getLastRouteDetailUseCase: StubGetLastRouteDetailUseCase(handler: routeDetail), now: now, bannerTickInterval: bannerTickInterval ) } +/// 스트림 yield용 축약 — 확인 시각이 무관한 기존 시나리오는 checkedAt 없이 흘린다. +private nonisolated func syncUpdate( + _ info: AlarmInfo, checkedAt: Date? = nil +) -> AlarmSyncUpdate { + AlarmSyncUpdate(info: info, checkedAt: checkedAt) +} + // MARK: - 테스트 @MainActor @@ -546,15 +560,15 @@ struct HomeViewModelTests { // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태 — 앱 시작 동기화가 // 스트림으로 도착한다. let departure = fixedNow.addingTimeInterval(30 * 60) - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) - ) + )) await recorder.waitUntilLast { $0.banner != nil } // 30분 출발 → 알람까지 27분(1620초) — caution 구간. @@ -566,7 +580,7 @@ struct HomeViewModelTests { @Test func alarmSync_updatesBannerToNewDeparture() async { let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) @@ -576,14 +590,14 @@ struct HomeViewModelTests { await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } // 서버가 15분 당긴 시각을 돌려준다 (포그라운드 복귀·푸시 동기화 공용 경로). - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo( lastRouteId: "r1", departureTime: fixedNow.addingTimeInterval(27 * 60), updatedAt: nil, isReal: true ) - ) + )) await recorder.waitUntilLast { $0.banner?.text == "출발까지 24분" } #expect(sut.state.alarmButton == .cancel) @@ -709,20 +723,20 @@ struct HomeViewModelTests { func alarmSync_pastGraceDeparture_doesNotStartCountdown() async { // 유예(출발+60초)가 지난 시각의 동기화 복원 — 지난 막차 배너를 되살리지 않는다. // 못 탐 판정이 고정한 실패 배너를 후속 동기화가 덮어쓰는 것도 같은 가드가 막는다. - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo( lastRouteId: "r1", departureTime: fixedNow.addingTimeInterval(-60), updatedAt: nil, isReal: true ) - ) + )) await recorder.waitUntilLast { $0.alarmButton == .cancel } #expect(sut.state.banner == nil) @@ -731,20 +745,20 @@ struct HomeViewModelTests { @Test func alarmSync_withinGrace_restoresDepartNowBanner() async { // 발화~유예 창의 동기화 복원 — 2단계 "지금 출발하세요"도 복원 대상이다 (Phase 13). - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo( lastRouteId: "r1", departureTime: fixedNow.addingTimeInterval(-30), updatedAt: nil, isReal: true ) - ) + )) await recorder.waitUntilLast { $0.banner != nil } #expect(sut.state.banner == .init(text: "지금 출발하세요", urgency: .imminent)) @@ -844,7 +858,7 @@ struct HomeViewModelTests { // 도보 반영 배너까지 복원한다 — "무슨 경로인지 모르는 해제 버튼" 해소. let departure = fixedNow.addingTimeInterval(42 * 60) let route = makeWalkRoute(id: "r1", departure: departure, walkSeconds: 120) - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT( alarmUpdates: { stream }, routeDetail: { routeId in @@ -856,9 +870,9 @@ struct HomeViewModelTests { recorder.attach(to: sut) sut.viewDidLoad() - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) - ) + )) await recorder.waitUntilLast { $0.routeCard != nil } #expect(sut.state.routeCard == RouteCardViewData(entity: route)) @@ -871,15 +885,15 @@ struct HomeViewModelTests { func alarmSync_detailFails_keepsCancelButtonWithoutCard() async { // 복원 실패는 현행 폴백 — 카드 없이 해제 버튼·배너만. (기본 routeDetail 스텁이 throw) let departure = fixedNow.addingTimeInterval(30 * 60) - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) sut.viewDidLoad() - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) - ) + )) await recorder.waitUntilLast { $0.banner != nil } for _ in 0..<20 { await Task.yield() } @@ -892,7 +906,7 @@ struct HomeViewModelTests { // 등록 직후의 동기화 — 카드가 이미 있으면 상세 재조회를 하지 않는다. let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) let fetched = FetchFlag() - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT( alarmUpdates: { stream }, routeDetail: { _ in @@ -907,19 +921,199 @@ struct HomeViewModelTests { sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner != nil } - continuation.yield( + continuation.yield(syncUpdate( AlarmInfo( lastRouteId: "r1", departureTime: fixedNow.addingTimeInterval(42 * 60), updatedAt: nil, isReal: true ) - ) + )) for _ in 0..<20 { await Task.yield() } #expect(!fetched.value) #expect(sut.state.routeCard == RouteCardViewData(entity: route)) } + + // MARK: - 수동 갱신 (Phase 16 pull-to-refresh) + + @Test + func refreshPulled_invokesSyncAndSignalsFinish() async { + let calls = ValueBox(0) + let sut = makeSUT(requestSync: { calls.update { $0 + 1 } }) + let finished = ValueBox(0) + sut.onManualSyncFinished = { finished.update { $0 + 1 } } + + sut.refreshPulled() + while finished.get() < 1 { await Task.yield() } + + #expect(calls.get() == 1) + #expect(finished.get() == 1) + } + + @Test + func refreshPulled_whileInFlight_isNoOp() async { + // 스텁 동기화가 게이트에 막혀 있는 동안의 재당김은 no-op이어야 한다(이중 당김 무해). + let gate = ValueBox(false) + let calls = ValueBox(0) + let sut = makeSUT(requestSync: { + calls.update { $0 + 1 } + while !gate.get() { await Task.yield() } + }) + let finished = ValueBox(0) + sut.onManualSyncFinished = { finished.update { $0 + 1 } } + + sut.refreshPulled() + sut.refreshPulled() + gate.update { _ in true } + while finished.get() < 1 { await Task.yield() } + for _ in 0..<20 { await Task.yield() } + + #expect(calls.get() == 1) + #expect(finished.get() == 1) + + // 완료 후의 당김은 새 동기화다. + sut.refreshPulled() + while finished.get() < 2 { await Task.yield() } + #expect(calls.get() == 2) + } + + // MARK: - 신선도 스탬프 (Phase 16) + + @Test + func freshnessText_requiresRegisteredSessionAndCheckedAt() { + let checkedAt = fixedNow + let expected = "\(makeStampFormatter().string(from: checkedAt)) 확인 기준" + #expect(HomeViewModel.freshnessText(checkedAt: checkedAt, isRegistered: true) == expected) + // 확인 시각이 없으면(구 스냅샷 시딩 등) 스탬프를 지어내지 않는다. + #expect(HomeViewModel.freshnessText(checkedAt: nil, isRegistered: true) == nil) + // 등록 세션이 없으면 후보 카드에 스탬프를 달지 않는다. + #expect(HomeViewModel.freshnessText(checkedAt: checkedAt, isRegistered: false) == nil) + } + + @Test + func registerAlarm_success_stampsFreshnessWithNow() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + #expect(sut.state.freshnessText == nil) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + // 등록 성공 = 서버 확인 — now(고정 시계) 기준으로 스탬프가 시작된다. + #expect(sut.state.freshnessText + == "\(makeStampFormatter().string(from: fixedNow)) 확인 기준") + } + + @Test + func alarmSync_checkedAt_updatesStamp_andSeededNilKeepsNoStamp() async { + // 시딩 복원(checkedAt = 직전 확인 시각)은 그 낡은 시각을 그대로 표시하고, + // 이후 성공 동기화(checkedAt = 새 시각)가 스탬프를 전진시킨다. + let departure = fixedNow.addingTimeInterval(30 * 60) + let seededCheckedAt = fixedNow.addingTimeInterval(-40 * 60) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + + continuation.yield(syncUpdate( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true), + checkedAt: seededCheckedAt + )) + await recorder.waitUntilLast { $0.freshnessText != nil } + #expect(sut.state.freshnessText + == "\(makeStampFormatter().string(from: seededCheckedAt)) 확인 기준") + + continuation.yield(syncUpdate( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true), + checkedAt: fixedNow + )) + await recorder.waitUntilLast { + $0.freshnessText == "\(makeStampFormatter().string(from: fixedNow)) 확인 기준" + } + } + + @Test + func manualSync_noStreamEvent_keepsStaleStamp() async { + // 무음 실패의 표면: 당김이 아무 이벤트도 못 얻으면 스탬프는 낡은 시각을 유지한다. + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.freshnessText != nil } + let stampBefore = sut.state.freshnessText + + let finished = ValueBox(0) + sut.onManualSyncFinished = { finished.update { $0 + 1 } } + sut.refreshPulled() + while finished.get() < 1 { await Task.yield() } + + #expect(sut.state.freshnessText == stampBefore) + #expect(recorder.toasts.isEmpty) // 실패 토스트 금지 — 무음 정책. + } + + @Test + func cancelAlarm_clearsFreshnessStamp() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.freshnessText != nil } + + sut.cancelAlarmTapped() + await recorder.waitUntilLast { $0.banner == nil && !$0.isAlarmBusy } + + #expect(sut.state.freshnessText == nil) + } + + @Test + func sessionEndedAndExpiry_clearFreshnessStamp() async { + // 종료·만료 정리와 함께 스탬프도 사라진다 — "지난 막차"에 신선도는 무의미하다. + let clock = NowBox(fixedNow) + let departure = fixedNow.addingTimeInterval(42 * 60) + let route = makeRoute(id: "r1", departure: departure) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT( + alarmChanges: { stream }, + now: { clock.get() }, + bannerTickInterval: .milliseconds(1) + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + sut.routeSelected(route) + sut.registerAlarmTapped() + // 스탬프는 배너보다 먼저 설정된다 — 배너까지 뜬 뒤에 종료를 흘려야 정리를 검증한다. + await recorder.waitUntilLast { $0.banner != nil && $0.freshnessText != nil } + + continuation.yield(.sessionEnded) + await recorder.waitUntilLast { $0.banner == nil } + #expect(sut.state.freshnessText == nil) + + // 재등록 후 유예 경과(3단계 전이)도 스탬프를 정리한다. + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil && $0.freshnessText != nil } + clock.set(departure.addingTimeInterval(60)) + await recorder.waitUntilLast { $0.banner == nil && $0.freshnessText == nil } + #expect(sut.state.routeCard?.tone == .past) + } +} + +/// 스탬프 기대값용 포매터 — 프로덕션(HomeViewData)과 같은 구성(HH:mm, ko_KR). +@MainActor +private func makeStampFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.locale = Locale(identifier: "ko_KR") + return formatter } /// 상세 재조회 호출 여부 기록용 — 스텁 클로저가 @Sendable이라 클래스 박스로 관찰한다. diff --git a/docs/prompts/atcha-v2-refresh-reliability-prompt.md b/docs/prompts/atcha-v2-refresh-reliability-prompt.md new file mode 100644 index 0000000..3dcad5c --- /dev/null +++ b/docs/prompts/atcha-v2-refresh-reliability-prompt.md @@ -0,0 +1,212 @@ +# AtchaV2 갱신 신뢰성 구현 프롬프트 — 수동 갱신 + 타임아웃·재시도 + 오프라인 구분 + 신선도 스탬프 + App 테스트 타겟 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase 16을 진행해"`라고 지시한다. +> 실행 에이전트는 [마스터 프롬프트](atcha-v2-master-prompt.md)의 **진행 프로토콜·공통 규칙·공통 acceptance를 그대로 상속**하며, 한 번에 한 Phase만 수행한다. +> 갭 분석·우선순위 정본은 [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "Phase 16 — 갱신 신뢰성" 절. 충돌 시 **CLAUDE.md > 마스터 프롬프트 > [LA 프롬프트](atcha-v2-live-activity-prompt.md) > [세션 수명주기 프롬프트](atcha-v2-session-lifecycle-prompt.md) > [인지 채널 방어선 프롬프트](atcha-v2-channel-defense-prompt.md) > 이 문서** 순. +> **검수는 사람 검수가 아니라 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다** — 실행 에이전트가 computer use로 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관한다. +> 작성일: 2026-08-23. + +--- + +## Goal (최상위) + +**갱신이 조용히 실패해도 앱이 거짓말하지 않게 한다 — 사용자에게 수동 갱신 수단을 주고, 네트워크가 빨리 실패하게 하고, 화면의 숫자가 언제 확인된 값인지 말한다.** + +Phase 8의 갱신 채널은 자동 트리거 3경로(앱 시작·포그라운드 복귀·푸시)뿐이라 사용자가 지금 값을 의심해도 할 수 있는 일이 없고, Stage/Release의 URLSession은 기본 60초 타임아웃이라 심야의 약한 연결에서 스플래시·갱신이 1분을 침묵하며, 실패 문구는 원인 불문 "네트워크 연결을 확인해주세요" 하나고, sync 실패는 완전한 무음이라 배너·카드가 언제 값인지 아무도 모른다. 그리고 이 모든 판정·폴백 분기의 심장인 `AlarmSyncService`는 App 타겟에 테스트 타겟이 없어 **무테스트**다. 이 문서는 그 다섯 구멍을 막는다. Phase 번호는 인지 채널 방어선(15)에 이어 **16**. + +[신뢰 UX 원칙](../planning/atcha-v2-post12-roadmap.md#신뢰-ux-원칙-전-phase-공통) 3("신선도 스탬프 — 갱신 실패를 토스트로 소음화하지 않고 조용히 정직하게")·4("안전망 약속의 문장화")의 직접 구현이다. + +갱신 신뢰성의 완성 상태 (이 문서가 만드는 구조): + +``` +갱신 트리거 4경로 — 전부 같은 sync() 한 곳으로 (기존 3 + 수동 1, inFlight 합류) + 앱 시작 ────────────┐ + 포그라운드 복귀 ─────┤──► AlarmSyncService.sync() + 사일런트 푸시 ───────┤ ├─ 성공 ──► updates(AlarmSyncUpdate: info + checkedAt) + 홈 pull-to-refresh ──┘ │ └─► 배너·카드 + "HH:mm 확인 기준" 스탬프 + (신설 — AlarmSyncRequesting) └─ 실패 ──► 무음 — 스탬프가 낡은 시각을 정직하게 유지 + + 네트워크 요청 1회의 수명 + Stage/Release 타임아웃 10초(기본 60초 해소) ──► GET + transport 실패면 1회 재시도 + 연결 자체가 없음 ──► NetworkError.offline ──► 스플래시 실패 문구 분기 + + App 타겟 테스트 타겟(AtchaV2Tests) ──► AlarmSyncService 판정·폴백·만료 분기 회귀 방어 + (UIApplication.shared·Date() 직접 참조 → isAppActive·now 주입으로 교체) +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것 — [로드맵](../planning/atcha-v2-post12-roadmap.md) 2026-08-23 확정): + +| 항목 | 결정 | +|---|---| +| 수동 갱신 표면 | **홈 pull-to-refresh 단일** (새 버튼·화면 없음). Domain 포트 `AlarmSyncRequesting` + `RequestAlarmSyncUseCase` — 홈은 UseCase만 본다(Observe 계열과 같은 패턴). `AlarmSyncService`가 4번째 트리거로 순응하되 **기존 inFlight 합류를 재사용** — 당김·포그라운드 복귀가 겹쳐도 refresh는 1회다 | +| 수동 갱신 실패 | **무음** — 실패 토스트 금지(원칙 3: 소음화 지양). 스피너 종료 + 스탬프가 낡은 시각을 유지하는 것이 실패의 표면이다 | +| 스탬프 문구·위치 | **"HH:mm 확인 기준"** — 배너 보조 라인(`DSBanner` detailText) + 카드 푸터(`DSRouteCard` footnoteText) 두 서피스, 값은 단일 소스(State 필드 1개). 표출 조건: **등록 세션 존재 ∧ 확인 시각 존재**. 세션 정리(해제·만료·sessionEnded)와 함께 사라진다 — "지난 막차" 카드에는 스탬프가 없다 | +| 스탬프의 원천 | **서버가 값을 확인해준 시각만** — 등록 성공 시각·refresh 성공 시각. 홈이 수신 시각으로 찍지 않는다(스냅샷 시딩 복원값이 "지금 확인됨"으로 둔갑하는 거짓 방지). 시딩은 스냅샷에 영속화된 마지막 확인 시각(`syncedAt`)을 나른다 — 재실행·오프라인에서도 "마지막으로 확인된 그 시각"이 표시된다 | +| 타임아웃 | Stage/Release `URLSessionConfiguration.default` 기반 **request 10초 · resource 30초**. DEV의 3/5초는 검수용 임시 우회로 불변(제거 조건은 DevDemoFallbacks와 동반 — 기존 주석) | +| 재시도 | **멱등 GET 1회**, `URLSessionNetworkClient` 내부. 대상은 transport 계열(타임아웃·연결 끊김)만 — offline(즉시 재실패라 무의미)·취소(사용자 의사)·HTTP 상태·디코딩은 제외. **POST/DELETE 재시도 금지**(알람 등록·해제 이중 발사 방지) | +| 오프라인 구분 | `NetworkError.offline` 신설 — URLError `.notConnectedToInternet`·`.dataNotAllowed`만. `.networkConnectionLost`는 일시 장애로 transport 유지(재시도로 살리는 쪽이 맞다) | +| 스플래시 문구 | offline → "네트워크 연결을 확인해주세요"(기존 문구 유지) / 그 외 → **"일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요"**. 매핑은 App의 순수 헬퍼 함수로(테스트 대상). 현 DEV는 `bootstrap()`이 `issuerNotConfigured`를 삼켜 재시도 화면 자체가 안 뜬다 — 실표출은 S1(익명 인증) 이후이므로 **이 분기의 검수는 단위 테스트**다 | +| App 테스트 타겟 | `AtchaV2Tests` 신설(**host app 방식** — 앱 타겟 의존으로 internal 심볼 `@testable` 접근) + `AtchaV2` 스킴에 testAction. `AlarmSyncService`의 `UIApplication.shared` 직접 참조 3곳·`Date()` 직접 호출 5곳을 **주입(isAppActive·now)으로 교체** — 판정·폴백·만료 분기의 회귀 방어가 이 Phase의 핵심 산출물이다. 테스트 진입점은 `syncNow()`(수동 갱신과 동일 경로 — 부수 효과로 NotificationCenter 없이 전 분기 도달) | + +--- + +## 이 문서가 다시 정의하지 않는 것 (중복 금지) + +아래는 기존 Phase 산출물이다. **계약을 바꾸지 않고 명시된 지점만 확장한다.** 이 목록의 계약을 깨고 싶어지면 멈추고 사용자에게 물을 것. + +| 산출물 | 소속 | 이 문서에서의 취급 | +|---|---|---| +| `AlarmSyncService` 3경로 일원화 구조·판정 훅·폴백 조건(`isAlertReachable`)·최후통첩 applicationState 선행 검사 | Phase 8·11·15 | **구조·조건 불변** — 수동 트리거가 같은 `sync()`에 합류하고, `UIApplication`/`Date()` 참조가 주입으로 바뀔 뿐 분기 의미는 그대로다. 분기들은 이제 회귀 테스트의 대상이 된다 | +| `LastTrainActivityPort`(문서 고정 계약) / `AlarmChangeVerdict` / `LocalNotificationPort` | Phase 10·11·12·15 | **시그니처 불변.** 케이스 추가 금지 | +| 스냅샷 = 재실행 브리지, 정본은 서버 | Phase 14 | 성격 불변 — `syncedAt` 필드 1개만 추가(Codable 하위호환: 구 스냅샷은 nil로 디코딩된다 — `decodeIfPresent` 합성) | +| `AlarmSyncEvents` replay-1·실패 무음 정책 | Phase 8 | 정책 불변 — **방출 타입만** `AlarmSyncUpdate`(info + checkedAt)로 확장. 실패는 여전히 스트림에 흐르지 않는다 | +| DEV 데모 폴백·변경 시뮬레이터·DEV 3/5초 타임아웃 | Phase 11·14 | 불변 — 검수 재료. **DEV refresh가 주입 없이는 실서버 실패를 그대로 실패시키는 동작**이 "무음 실패 시 스탬프 유지" 검수의 재료가 된다 | +| 서버 계약 전체 | 마스터 공통 규칙 | **서버 변경 0.** 미확정 #11("등록된 알람 없음" 표현)을 이 Phase가 해소하지 않는다 — pull-to-refresh도 알람 없음을 정리하지 않는다(기존 TODO 유지) | + +## 전제 조건 + +1. **Phase 15 완료가 전제** (`feat/v2-phase15-channel-defense` 기준). 이 문서 내부의 병렬 없음 — Phase 16 단일. +2. 서버 트랙(로드맵 S1~S4)과 **완전 독립** — 미확정 입력을 새로 요구하지 않는다. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)에 따라 DEV 데모 폴백·변경 시뮬레이터를 재료로 에이전트가 직접 수행한다. +3. pull-to-refresh의 **당김 제스처** 자동화는 실측 제약(AX 액션 기반 조작만 신뢰)에 걸릴 수 있다 — 불가로 판명되면 규약의 불가 시 분기를 따른다(자동 검수 절에 명시). + +--- + +## Phase 16 — 갱신 신뢰성 + +### Goal +사용자가 원할 때 갱신할 수 있고(당김), 갱신이 빨리 실패하고(타임아웃·재시도), 실패의 원인이 구분되고(오프라인), 화면의 숫자가 언제 확인된 값인지 항상 말하며(스탬프), 그 전 과정을 회귀 테스트가 지키게 한다(App 테스트 타겟). + +### Requirements + +- **수동 갱신 경로 (Domain 포트 + App 순응 + 홈 pull-to-refresh)**: Domain에 요청 포트와 UseCase를 신설한다: + ```swift + /// 수동 동기화 요청 포트(Phase 16) — 구현은 App의 AlarmSyncService(4번째 트리거). + public protocol AlarmSyncRequesting: Sendable { + /// 동기화 1회를 요청하고 완료까지 기다린다. 진행 중 동기화가 있으면 합류한다. + /// 실패를 던지지 않는다 — 결과는 AlarmSyncEvents.updates()로만 흐른다(무음 정책 공유). + func syncNow() async + } + // RequestAlarmSyncUseCase (프로토콜 + Default) — Observe 계열과 동일 패턴. + ``` + `AlarmSyncService`는 `AlarmSyncRequesting`을 채택한다(`nonisolated func syncNow() async`가 메인 액터의 `sync()`로 hop — inFlight 합류가 이미 있어 재진입 무해). `HomeViewController`는 contentStack을 `UIScrollView`(alwaysBounceVertical)로 감싸고 `UIRefreshControl`을 부착한다 — 시각 레이아웃은 불변. `HomeViewModel.refreshPulled()`는 진행 중 재진입을 no-op으로 가드하고, 완료 시 `onManualSyncFinished` 훅으로 VC가 `endRefreshing()`한다. 수동 경로가 **새 표출 채널을 만들지 않는다** — 시각·상태 갱신은 기존 updates()/changes() 스트림이 그대로 담당한다. +- **Stage/Release 타임아웃 (App — `AppDIContainer`)**: `#else`(비-DEV) 분기의 `URLSessionNetworkClient`에 `URLSessionConfiguration.default` 기반 `timeoutIntervalForRequest = 10`, `timeoutIntervalForResource = 30` 세션을 주입한다. DEV의 3/5초 임시 우회 분기는 불변. +- **멱등 GET 1회 재시도 (CoreNetwork — `URLSessionNetworkClient`)**: `data(for:)`에서 `endpoint.method == .get`이고 첫 시도가 **transport로 분류되는** 실패면 즉시 1회 재시도한다. offline 분류·취소(`URLError.cancelled`/`CancellationError`)·HTTP 상태·디코딩 실패는 재시도하지 않는다. GET 외 메서드는 재시도 없음. +- **오프라인 구분 + 스플래시 문구 분기 (CoreNetwork + App)**: `NetworkError`에 케이스를 신설하고 분류를 전송 계층 한 곳에서 한다: + ```swift + public enum NetworkError: Error, Sendable { + case invalidURL + /// 연결 자체가 없음(URLError .notConnectedToInternet/.dataNotAllowed) — 재시도 무의미. + /// .networkConnectionLost는 일시 장애로 transport 유지(재시도 대상). + case offline(underlying: any Error) + case transport(underlying: any Error) + case invalidResponse + case unacceptableStatus(code: Int, data: Data) + case decoding(underlying: any Error) + } + ``` + `isOffline` 편의 게터를 함께 둔다. App에는 순수 매핑 헬퍼(예: `BootstrapFailureMessage.text(for: any Error) -> String`)를 신설해 `AppCoordinator.bootstrap()`의 catch가 `SplashViewController.showRetry(message:)`(시그니처 확장 — 기본 문구 유지)로 전달한다: offline → "네트워크 연결을 확인해주세요" / 그 외 → "일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요". 헬퍼는 `AtchaV2Tests` 대상 — 현 DEV에선 재시도 화면이 도달 불가(전제 조건 참고)라 UI 검수 항목이 아니다. +- **신선도 스탬프 (Domain 방출 확장 + 스냅샷 영속화 + 홈·DS 표출)**: + - Domain: `AlarmSyncEvents.updates()`의 방출 타입을 확장한다(정책 불변 — 값에 확인 시각만 동봉): + ```swift + /// 동기화 성공 방출값 — info에 "언제 서버로 확인했는가"를 동봉한다(신선도 스탬프의 원천). + public struct AlarmSyncUpdate: Sendable, Equatable { + public let info: AlarmInfo + /// 서버 확인 시각. 스냅샷 시딩 복원이면 직전 세션의 마지막 확인 시각, 그것도 없으면 nil(스탬프 없음). + public let checkedAt: Date? + } + public protocol AlarmSyncEvents: Sendable { + func updates() -> AsyncStream + } + ``` + `ObserveAlarmUseCase`·`DefaultObserveAlarmUseCase`가 따라간다. `AlarmSessionSnapshot`에 `syncedAt: Date?`를 추가한다(init 기본값 nil + `updating`에 갱신 파라미터 — 구 스냅샷은 nil 디코딩으로 하위호환). + - App: `AlarmSyncService`가 sync 성공 시 `checkedAt = now()`로 방출·스냅샷 병합 저장에 `syncedAt` 기록, 시딩 방출은 `snapshot.syncedAt`을 나른다. `DefaultRegisterAlarmUseCase`는 등록 성공 스냅샷 저장 시 `syncedAt = now()`(등록도 서버 확인이다). + - HomeFeature: `State`에 `freshnessText: String?` 단일 필드 — "HH:mm 확인 기준"(기존 캐시 포매터 재사용). 표출 조건은 등록 세션 존재 ∧ 확인 시각 존재이고, 등록 성공 시 `now()` 기준으로 세팅, updates 수신 시 `checkedAt`으로 갱신(nil이면 유지하지 않고 nil — 낡음을 숨기지 않는다), 해제·만료·sessionEnded 정리 시 nil. 배너 틱은 스탬프를 건드리지 않는다(배너 텍스트와 독립). + - DesignSystem(추가만 — 기존 API 파괴 금지): `DSBanner.configure(text:style:detailText: String? = nil)` — 스타일별 보조 톤의 caption 라인, nil이면 기존 렌더와 동일. `DSRouteCard.Content`에 `footnoteText: String? = nil` — caption·tertiary 푸터, muted 톤 대응. 색·폰트는 기존 토큰 경유(신규 토큰 불필요 판단이 기본). + - VC: `render()`가 `state.freshnessText`를 배너 detailText와 카드 footnote 두 서피스에 반영한다(`RouteCardViewData.dsContent`는 footnote 주입 형태로 조정). +- **App 테스트 타겟 신설 (Tuist 매니페스트 + `AlarmSyncService` 주입 + 회귀 테스트)**: + - `Projects/App/Project.swift`에 유닛 테스트 타겟을 추가하고 스킴에 연결한다: + ```swift + let testTarget = Target.target( + name: "AtchaV2Tests", + destinations: Atcha.destinations, + product: .unitTests, + bundleId: "\(Atcha.v2BundleID).tests", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [.target(name: "AtchaV2")], // host app — internal 심볼 @testable 접근 + settings: .atchaV2() + ) + // AtchaV2 스킴: testAction: .targets(["AtchaV2Tests"]) 추가 (Debug 기본) + ``` + Workspace.swift 변경 없음(기존 프로젝트에 타겟 추가). `tuist generate` 필수. + - `AlarmSyncService`의 시스템 직결 지점을 주입으로 교체한다(기본값이 현 동작 — 콜사이트 무변경): + ```swift + init( + ..., + /// 표출 채널 분기용 앱 활성 판정(Phase 16) — UIApplication 직접 참조를 걷어내 테스트가 상태를 주입한다. + isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active }, + /// 만료·판정·스탬프의 시각 주입 — 실 Date() 직접 호출 제거(기존 UseCase·VM 관례와 동일). + now: @escaping @Sendable () -> Date = { Date() } + ) + ``` + 본문의 `UIApplication.shared.applicationState == .active` 3곳 → `isAppActive()`, `Date()` 5곳 → `now()`. + - `Projects/App/Tests/`에 Swift Testing으로 회귀 테스트를 작성한다(스텁: 큐잉 `RefreshAlarmUseCase`, 고정 판정 `EvaluateAlarmChangeUseCase`, reachable 제어 가능한 `LastTrainChangeAlerting` 스파이, `LocalNotificationPort`·`AlarmScheduler`·`LastTrainSessionRestoring` 스파이, 인메모리 `AlarmSessionSnapshotStore` — Tests/Example 스텁 중복은 기존 트레이드오프). 진입은 `syncNow()`. 최소 커버 목록: + 1. advanced(actionable) **백그라운드 + reachable** → LA alert 1회, 로컬 노티 0 + 2. advanced **백그라운드 + unreachable** → 로컬 노티 1회, LA alert 0 (Phase 15 폴백 회귀) + 3. advanced **포그라운드** → 조용한 update(alert nil)만 + verdict yield (인앱 채널 단독) + 4. **최후통첩**(새 알람 시각 과거·출발 미래) 포그라운드 → 조용한 update만, alert·노티 0 (Phase 15 이중 알림 제거 회귀) + 5. 최후통첩 백그라운드 reachable → LA alert / unreachable → 로컬 노티 + 6. missed(actionable=false) 3분기(포그라운드 조용 / reachable alert / unreachable 노티) + 7. sessionEnded → 알람 취소 + 스냅샷 clear + LA end (배지 정리 포함) + 8. **클라 자체 만료**: 시딩된 과거 세션 + refresh 실패 → 알람 취소·expired 톰스톤·sessionEnded yield (Phase 13 회귀 — now 주입으로 시각 고정) + 9. **서버 우선**: 만료 후보 상태에서 refresh가 미래 출발 반환 → 만료 취소·정상 갱신 + 10. **신선도**: sync 성공 → `checkedAt` = 주입 now / 시딩 → `snapshot.syncedAt` / 실패 → yield 없음 + - `BootstrapFailureMessage` 매핑 테스트(offline/기타/비 NetworkError)도 같은 타겟에 둔다. + +### Constraints +- 알람 재스케줄·안전망 경로에 회귀 금지 — 이 Phase는 트리거 1개 추가·주입 교체·표출 확장뿐, `RefreshAlarmUseCase` 내부 정책과 판정·폴백 분기의 의미를 바꾸지 않는다. +- 수동 갱신 실패에 토스트·알럿 금지(결정사항). 오프라인 구분을 홈 표출에 쓰지 않는다 — 이번 스코프의 오프라인 분기는 스플래시 문구뿐. +- `UNUserNotificationCenter`·ActivityKit·`UIApplication` 심볼의 Domain·Feature 유입 금지(기존 규약 — `AlarmSyncRequesting`·`AlarmSyncUpdate`는 순수 타입). `tuist graph`로 의존 방향 확인. +- 테스트는 Swift Testing, 실 `Date()`·실 UserDefaults 금지(주입·인메모리 스텁 — 기존 관례). AtchaV2Tests는 host app에서 돌지만 **서비스 단독 인스턴스**를 만들어 테스트한다(앱이 띄운 전역 상태에 의존·간섭 금지). +- `Tuist/Package.swift`·`Package.resolved` 무변경. 레거시 무변경. +- Phase 17~18 선취 금지: 검색·홈 빈 상태, `arrivalField` 바인딩, `LocationError` 세분화, 오늘/내일 라벨, SearchCoordinator 누수 수정(17), 최근 경로 칩(18)은 이 문서 밖. 미확정 #11(알람 없음 표현) 해소 시도 금지 — 기존 TODO 유지. + +### Acceptance +공통 acceptance + `-scheme Domain test`(AlarmSyncUpdate·RequestAlarmSyncUseCase·스냅샷 syncedAt) + `-scheme CoreNetwork test`(offline 분류·GET 재시도·비멱등 무재시도·취소 무재시도) + `-scheme DesignSystem test` + `-scheme HomeFeature test`(refreshPulled 가드·완료 훅·스탬프 표출/유지/정리) + **`-scheme AtchaV2 test`(신설 — App 타겟 회귀)**. + +### 자동 검수 (블로킹 — [자동 검수 규약](atcha-v2-auto-verification.md)) +iOS 26 시뮬레이터(Debug/DEV)에서 에이전트가 직접 수행하고 단계별 스크린샷 증적으로 보고. DEV refresh는 변경 주입이 없으면 실서버 실패를 그대로 실패시킨다 — 이 동작이 ②의 재료다. +① **스탬프 표출**: 데모 경로 알람 등록 → 배너 보조 라인·카드 푸터에 "HH:mm 확인 기준"(등록 시각) 표출 스크린샷. +② **무음 실패 정직성**: 1분 이상 경과 후 pull-to-refresh(주입 없음 → refresh 실패) → 스피너 종료 + **스탬프 시각 불변**(등록 시각 유지) + 실패 토스트 없음 스크린샷 — "낡았다고 말한다"의 직접 증명. +③ **수동 갱신 성공**: DEV "변경" 버튼으로 늦춤 주입 → pull-to-refresh → 배너 시각 갱신(조용한 delayed) + **스탬프가 현재 시각으로 갱신** 스크린샷 — 당김이 실동작하는 수동 갱신 수단이라는 직접 증명. +④ **재실행 복원**: `simctl terminate` → 재실행 → 시딩 스탬프가 **마지막 확인 시각을 유지**(재실행 시각으로 둔갑하지 않음) 스크린샷 (직후 자동 sync는 주입 없으면 실패하므로 관찰 가능). +⑤ **회귀**: 앞당김 주입 + 포그라운드 → 기존 인앱 채널(토스트 + 배너 강조) 동작 유지 + 스탬프 갱신. 스피너 진행 중 재당김 → 재진입 no-op(로그 증적). +당김 제스처 자동화가 불가로 판명되면(AX 기반 조작 한계): 판정을 "자동화 불가"로 기록하고, VM 경로는 단위 테스트로 검증돼 있으므로 **DEV 플로팅 디버그 메뉴에 검수용 "수동 갱신" 항목(syncNow 직결, DEV 한정 — 변경 시뮬레이터와 동일 취급)을 추가**해 ②·③의 스탬프 판정을 대체 수행하고, 실제 당김 제스처만 실기기 잔여로 이관한다. + +> **검수 결과 기록 (2026-08-23 수행)**: ①③④⑤ 통과(증적 스크린샷 확보 — 스탬프 표출·성공 sync 전진·재실행 시딩 복원·포그라운드 인앱 채널+스탬프 갱신). ②의 "무음 실패 시 스탬프 유지"는 재실행 직후 자동 sync 실패(주입 없음)와 대기 관찰로 입증(23:14 스탬프가 23:17까지, 23:18 스탬프가 재실행 후에도 유지). **당김 제스처는 "자동화 불가" 판정** — orca 합성 마우스 드래그가 UIRefreshControl을 확정적으로 울리는지 스피너 증적을 잡지 못했다. 당김 아래 전 체인(refreshPulled → UseCase → syncNow → sync 합류)은 3계층 단위 테스트로 검증돼 있고 스탬프 판정 ②·③이 주입 sync로 이미 확보돼, 대체 수단(DEV "수동 갱신" 항목)은 추가하지 않았다. 실제 당김 제스처 실동작만 실기기 잔여로 이관. + +**실기기 잔여**: pull-to-refresh 당김 제스처 실동작(위 기록 — 스피너 표출·당김 트리거 확인). 실서버 오프라인·타임아웃 실측(스플래시 문구 분기 실표출 포함 — 현 DEV는 issuerNotConfigured 삼킴으로 재시도 화면 도달 불가, S1 이후 확인). 사일런트 푸시 실수신 경로(기존 항상-잔여 항목). + +--- + +## 진행 프로토콜 + +[마스터 프롬프트의 진행 프로토콜](atcha-v2-master-prompt.md#진행-프로토콜) 1~6을 그대로 상속한다. 추가 규칙: + +1. 이 문서는 **Phase 16 단일** — 내부 병렬 없음. Phase 15 완료 커밋 위에서 진행한다. +2. ["이 문서가 다시 정의하지 않는 것"](#이-문서가-다시-정의하지-않는-것-중복-금지) 표의 계약을 바꾸고 싶어지면 멈추고 사용자에게 물을 것. +3. Phase 17~18 산출물을 **선취하지 않는다** — [로드맵](../planning/atcha-v2-post12-roadmap.md)의 몫. +4. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다 — "자동 검수 (블로킹)"는 에이전트가 computer use로 수행·증적 보고를 마쳐야 다음 진행, "실기기 잔여"만 사용자에게 이관한다. +5. 당김 제스처 자동화 불가가 확정되면 결과를 **이 문서 자동 검수 절에 기록**하고 명시된 대체 수단(DEV 메뉴 항목)으로 검수를 완료한다. + +## 미확정 입력 (참조) + +이 문서는 새 미확정 입력을 만들지 않는다. 관련 기존 항목의 취급: + +| # | 항목 | 이 문서에서의 취급 | +|---|---|---| +| 11 | 서버의 "등록된 알람 없음" 표현 | **미해소 유지** — pull-to-refresh도 실패 무음 정책을 공유하므로 유령 알람을 정리하지 않는다(기존 `HomeViewModel` TODO 그대로). 표현 확정 시 정리 이벤트가 이 스트림 위에 얹힌다 | +| 1 | Stage 전용 호스트 | 이 문서 밖 — 타임아웃 설정은 호스트와 무관하게 유효하다 | +| 2 | 익명 인증 발급 엔드포인트 | 이 문서 밖(S1) — 스플래시 문구 분기는 S1 이후 실표출되지만 매핑·테스트는 지금 완성한다 | +| 원장 전체 | [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "미확정 입력 원장" 절 참조 | |