diff --git a/.github/workflows/tuist-ci.yml b/.github/workflows/tuist-ci.yml index 10c1fc487..6c7111f4e 100644 --- a/.github/workflows/tuist-ci.yml +++ b/.github/workflows/tuist-ci.yml @@ -20,7 +20,7 @@ concurrency: jobs: build-test: - name: Build & Test (UMCApp / Tuist) + name: Build & Test (UMCApp + watchOS / Tuist) runs-on: macos-26 defaults: @@ -120,6 +120,33 @@ jobs: - name: Build & Test (make test) run: make test + # 아래 watchOS 스텝들을 별도 잡으로 빼지 않는 이유(이슈 #1216): + # 시크릿 복원 · mise/Tuist 설치 · SPM 캐시 · tuist generate 를 통째로 한 번 더 + # 돌려야 해서 러너 시간이 두 배가 된다. 같은 잡에 이어 붙이면 생성된 워크스페이스를 + # 그대로 재사용한다. + - name: Check watchOS simulator runtime + run: | + # generic/platform=watchOS Simulator 빌드는 런타임 설치 없이도 통과하는 경우가 + # 많다. 그래서 여기서 실패시키지 않고 경고만 남긴다 — 러너 이미지에서 런타임이 + # 빠졌을 때 뒤 스텝이 깨지면 이 로그가 원인을 바로 가리킨다. + if xcrun simctl list runtimes | grep -qi watchos; then + xcrun simctl list runtimes | grep -i watchos + else + echo "::warning::watchOS simulator runtime not found. Attempting download..." + xcodebuild -downloadPlatform watchOS || \ + echo "::warning::watchOS runtime download failed. Continuing with generic destination." + fi + + - name: Build watchOS app (make build-watch) + run: make build-watch + + # UMCApp 스킴의 test action 에는 UMCAppTests 만 들어 있어서 위 `make test` 로는 + # Core 모듈 테스트가 돌지 않는다. 워치 회귀를 잡으려면 스킴을 명시해야 한다. + # destination 은 Makefile 기본값(iOS Simulator)을 그대로 쓴다 — CoreWatchConnectivity + # 는 iOS/watchOS 멀티플랫폼 타겟이라 iOS 시뮬레이터에서 로직 테스트가 돈다. + - name: Test CoreWatchConnectivity + run: make test SCHEME=CoreWatchConnectivity + notify: name: Notify Discord needs: build-test diff --git a/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift index fe18ad2df..e50bc3406 100644 --- a/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift +++ b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift @@ -58,11 +58,15 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { @ObservationIgnored private let userInfoContinuation: AsyncStream.Continuation - private var session: WCSession { .default } + /// `WCSession` 은 싱글턴이라 직접 만들 수 없다. 상태 전이를 테스트에서 재현하려면 + /// 이 표면만 대역으로 바꿀 수 있어야 해서 ``WatchSessionProviding`` 을 거친다. + @ObservationIgnored + private let session: any WatchSessionProviding // MARK: - Init - public override init() { + public init(session: any WatchSessionProviding = WCSession.default) { + self.session = session let (stream, continuation) = AsyncStream.makeStream() userInfoStream = stream userInfoContinuation = continuation @@ -73,9 +77,9 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { /// WCSession 을 활성화한다. 앱 시작 시 한 번 호출한다. public func activate() { - guard WCSession.isSupported() else { return } - session.delegate = self - session.activate() + guard session.isSupported else { return } + session.attach(delegate: self) + session.startActivation() } /// 최신 스냅샷을 요청한다. @@ -125,7 +129,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { try requireActivated() do { let payload = try WatchEnvelope.encode(WatchMessage.sessionState(state)) - try session.updateApplicationContext(payload) + try session.apply(applicationContext: payload) } catch let error as WatchConnectivityError { throw error } catch { @@ -144,7 +148,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { throw WatchConnectivityError.unsupportedChannel(message) } try requireActivated() - session.transferUserInfo(try WatchEnvelope.encode(message)) + session.transfer(userInfo: try WatchEnvelope.encode(message)) } /// 아직 전송되지 않은 큐 항목. @@ -153,7 +157,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { /// 추적하지 못해, SwiftUI 가 바인딩해도 한 번 그린 뒤 영원히 갱신되지 않는다. 호출 시점의 /// 스냅샷이므로 화면 캡션은 ``purgeExpiredQueue(now:)`` 의 반환값이나 타이머로 갱신한다. public var pendingMessages: [WatchMessage] { - session.outstandingUserInfoTransfers.compactMap { + session.outstandingTransfers.compactMap { try? WatchEnvelope.decode(WatchMessage.self, from: $0.userInfo) } } @@ -166,7 +170,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { @discardableResult public func purgeExpiredQueue(now: Date = Date()) -> [WatchAttendanceRequest] { var purged: [WatchAttendanceRequest] = [] - for transfer in session.outstandingUserInfoTransfers { + for transfer in session.outstandingTransfers { guard let message = try? WatchEnvelope.decode( WatchMessage.self, from: transfer.userInfo @@ -199,10 +203,10 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { // MARK: - Private private func requireActivated() throws { - guard WCSession.isSupported() else { + guard session.isSupported else { throw WatchConnectivityError.notSupported } - guard session.activationState == .activated else { + guard session.isActivated else { throw WatchConnectivityError.sessionNotActivated } } @@ -217,63 +221,65 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { let payload = try WatchEnvelope.encode(message) return try await withCheckedThrowingContinuation { continuation in - session.sendMessage(payload) { raw in + session.send(payload) { raw in do { let reply = try WatchEnvelope.decode(WatchReply.self, from: raw) continuation.resume(returning: reply) } catch { continuation.resume(throwing: error) } - } errorHandler: { error in + } onError: { error in continuation.resume(throwing: WatchConnectivityError.from(error)) } } } - // MARK: - WCSessionDelegate + // MARK: - Internal - public nonisolated func session( - _ session: WCSession, - activationDidCompleteWith activationState: WCSessionActivationState, - error: Error? - ) { - // WCSession 은 Sendable 이 아니다. hop 하기 전에 값만 읽어 둔다. - let activated = activationState == .activated - let reachable = session.isReachable - // `receivedApplicationContext` 는 활성화가 끝난 뒤에야 채워진다. 활성화는 비동기라 - // `activate()` 직후에 읽으면 빈 딕셔너리를 받아 시딩이 조용히 무산된다. - let context: [String: Any] = activated ? session.receivedApplicationContext : [:] - let seeded = try? WatchEnvelope.decode(WatchMessage.self, from: context) - Task { @MainActor in - self.isActivated = activated - self.isReachable = reachable - // 델리게이트 콜백이 이미 더 최신 컨텍스트를 넣었다면 덮어쓰지 않는다. - if case .sessionState(let state)? = seeded, self.receivedState == nil { - self.receivedState = state - } + /// 활성화 콜백이 실어 온 값을 상태에 반영한다. + /// + /// 델리게이트 콜백에서 갈라낸 이유는 `WCSession` 인스턴스를 테스트에서 만들 수 없기 + /// 때문이다. 콜백은 값만 뽑아 이 함수로 hop 하고, 테스트는 값을 직접 넣는다. + func applyActivation(_ activated: Bool, reachable: Bool, seeded: WatchMessage?) { + isActivated = activated + isReachable = activated && reachable + // 델리게이트 콜백이 이미 더 최신 컨텍스트를 넣었다면 덮어쓰지 않는다. + if case .sessionState(let state)? = seeded, receivedState == nil { + receivedState = state } } - public nonisolated func sessionReachabilityDidChange(_ session: WCSession) { - let reachable = session.isReachable - Task { @MainActor in - self.isReachable = reachable + /// 활성화 전 도달성은 의미가 없다 — 「도달 가능」인데 전송이 `.sessionNotActivated` + /// 로 실패하는 상태를 만들지 않는다. + func applyReachability(_ reachable: Bool) { + isReachable = isActivated && reachable + } + + func applyReceivedContext(_ message: WatchMessage?) { + guard case .sessionState(let state)? = message else { return } + receivedState = state + } + + nonisolated func ingest(userInfo: [String: Any]) { + guard let message = try? WatchEnvelope.decode(WatchMessage.self, from: userInfo) else { + return } + // hop 하지 않는다 — continuation 은 Sendable 이고 yield 순서를 그대로 보존한다. + userInfoContinuation.yield(message) } /// `replyHandler` 는 송신자의 타임아웃(7012) 안에 **정확히 한 번** 호출돼야 한다. /// early return 과 `Task` 가 상호 배타적인 형태라 「한 번만 호출」 장치가 따로 필요 없다. - public nonisolated func session( - _ session: WCSession, - didReceiveMessage message: [String: Any], - replyHandler: @escaping ([String: Any]) -> Void + nonisolated func handle( + request: [String: Any], + reply replyHandler: @escaping ([String: Any]) -> Void ) { nonisolated(unsafe) let reply = replyHandler // 실패는 hop 없이 즉시 응답한다. let decoded: WatchMessage do { - decoded = try WatchEnvelope.decode(WatchMessage.self, from: message) + decoded = try WatchEnvelope.decode(WatchMessage.self, from: request) } catch WatchConnectivityError.unsupportedSchemaVersion(let version) { // 손상이 아니라 상대가 더 새로운 스키마를 쓴다는 신호다. 「손상」으로 뭉개면 // 상대는 업데이트가 필요하다는 사실을 알 수 없다. @@ -299,18 +305,50 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { } } + // MARK: - WCSessionDelegate + + public nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + // WCSession 은 Sendable 이 아니다. hop 하기 전에 값만 읽어 둔다. + // `.activated` 라도 에러가 함께 오면 전송은 전부 실패한다 — 에러를 무시하면 + // 화면은 「연결됨」인데 모든 요청이 조용히 죽는다. + let activated = activationState == .activated && error == nil + // `receivedApplicationContext` 는 활성화가 끝난 뒤에야 채워진다. 활성화는 비동기라 + // `activate()` 직후에 읽으면 빈 딕셔너리를 받아 시딩이 조용히 무산된다. + let context: [String: Any] = activated ? session.receivedApplicationContext : [:] + let seeded = try? WatchEnvelope.decode(WatchMessage.self, from: context) + let reachable = session.isReachable + Task { @MainActor in + self.applyActivation(activated, reachable: reachable, seeded: seeded) + } + } + + public nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.applyReachability(reachable) + } + } + + public nonisolated func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + handle(request: message, reply: replyHandler) + } + public nonisolated func session( _ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any] ) { - guard - let message = try? WatchEnvelope.decode( - WatchMessage.self, from: applicationContext - ), - case .sessionState(let state) = message - else { return } + // `[String: Any]` 는 Sendable 이 아니다. hop 전에 봉투를 벗겨 값만 넘긴다. + let message = try? WatchEnvelope.decode(WatchMessage.self, from: applicationContext) Task { @MainActor in - self.receivedState = state + self.applyReceivedContext(message) } } @@ -318,11 +356,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { _ session: WCSession, didReceiveUserInfo userInfo: [String: Any] ) { - guard let message = try? WatchEnvelope.decode(WatchMessage.self, from: userInfo) else { - return - } - // hop 하지 않는다 — continuation 은 Sendable 이고 yield 순서를 그대로 보존한다. - userInfoContinuation.yield(message) + ingest(userInfo: userInfo) } #if os(iOS) @@ -330,8 +364,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { /// 남겨 두면 화면은 「연결됨」인데 전송만 조용히 실패한다. public nonisolated func sessionDidBecomeInactive(_ session: WCSession) { Task { @MainActor in - self.isActivated = false - self.isReachable = false + self.applyActivation(false, reachable: false, seeded: nil) } } diff --git a/UMCApp/Core/WatchConnectivity/Sources/WatchSessionProviding.swift b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionProviding.swift new file mode 100644 index 000000000..ba8c8943c --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionProviding.swift @@ -0,0 +1,108 @@ +// +// WatchSessionProviding.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import WatchConnectivity + +// MARK: - WatchUserInfoTransfer + +/// 큐에 남아 있는 `transferUserInfo` 항목. +/// +/// `WCSessionUserInfoTransfer` 도 직접 만들 수 없어서, 큐 만료 취소를 검증하려면 이 표면까지 +/// 대역으로 바꿀 수 있어야 한다. +public protocol WatchUserInfoTransfer: AnyObject { + var userInfo: [String: Any] { get } + func cancel() +} + +extension WCSessionUserInfoTransfer: WatchUserInfoTransfer {} + +// MARK: - WatchSessionProviding + +/// ``WatchSessionCoordinator`` 가 실제로 쓰는 `WCSession` 표면만 추린 seam. +/// +/// `WCSession` 은 싱글턴(`WCSession.default`)이고 직접 생성할 수 없다. 이 프로토콜이 없으면 +/// 활성화 결과·도달성 변화·전송 실패·큐 만료 같은 상태 전이를 테스트에서 결정적으로 재현할 +/// 방법이 없다 — 페어링된 워치가 없는 CI 에서는 `isReachable` 이 항상 `false` 다. +/// +/// 메서드 이름을 `WCSession` 원본과 다르게 둔 이유: 같은 시그니처로 선언하면 아래 준수 +/// 익스텐션의 구현이 자기 자신을 호출한다(무한 재귀). 이름을 분리해 전달만 하면 그 위험이 없다. +public protocol WatchSessionProviding: AnyObject { + + /// 현재 기기가 WatchConnectivity 를 지원하는지 여부 (iPad 등은 false). + var isSupported: Bool { get } + + /// 활성화가 끝났는지 여부. 전송 API 는 전부 활성화 이후에만 유효하다. + var isActivated: Bool { get } + + /// 상대 기기가 즉시 메시지를 받을 수 있는 상태인지 여부. + var isReachable: Bool { get } + + /// 상대가 마지막으로 퍼블리시해 둔 컨텍스트. 콜드런치 시딩에 쓴다. + var receivedContext: [String: Any] { get } + + /// 아직 전송되지 않은 `transferUserInfo` 큐 항목. + var outstandingTransfers: [any WatchUserInfoTransfer] { get } + + /// 세션 델리게이트를 연결한다. ``startActivation()`` 전에 호출해야 한다. + func attach(delegate: any WCSessionDelegate) + + /// 세션 활성화를 시작한다. 완료는 델리게이트 콜백으로 통지된다. + func startActivation() + + /// 메시지를 즉시 전송한다. 성공 시 `onReply`, 실패 시 `onError` 가 호출된다. + func send( + _ message: [String: Any], + onReply: @escaping ([String: Any]) -> Void, + onError: @escaping (any Error) -> Void + ) + + /// 애플리케이션 컨텍스트를 갱신한다 (덮어쓰기). + func apply(applicationContext: [String: Any]) throws + + /// FIFO 큐에 넣는다. 앱이 종료돼도 시스템이 전송을 이어간다. + func transfer(userInfo: [String: Any]) +} + +// MARK: - WCSession Conformance + +extension WCSession: WatchSessionProviding { + + public var isSupported: Bool { WCSession.isSupported() } + + public var isActivated: Bool { activationState == .activated } + + public var receivedContext: [String: Any] { receivedApplicationContext } + + public var outstandingTransfers: [any WatchUserInfoTransfer] { + outstandingUserInfoTransfers + } + + public func attach(delegate: any WCSessionDelegate) { + self.delegate = delegate + } + + public func startActivation() { + activate() + } + + public func send( + _ message: [String: Any], + onReply: @escaping ([String: Any]) -> Void, + onError: @escaping (any Error) -> Void + ) { + sendMessage(message, replyHandler: onReply, errorHandler: onError) + } + + public func apply(applicationContext: [String: Any]) throws { + try updateApplicationContext(applicationContext) + } + + public func transfer(userInfo: [String: Any]) { + transferUserInfo(userInfo) + } +} diff --git a/UMCApp/Core/WatchConnectivity/Tests/FakeWatchSession.swift b/UMCApp/Core/WatchConnectivity/Tests/FakeWatchSession.swift new file mode 100644 index 000000000..578514e8a --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/FakeWatchSession.swift @@ -0,0 +1,107 @@ +// +// FakeWatchSession.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import WatchConnectivity +@testable import CoreWatchConnectivity + +// MARK: - FakeUserInfoTransfer + +/// `WCSessionUserInfoTransfer` 대역. 취소 여부만 관측한다. +final class FakeUserInfoTransfer: WatchUserInfoTransfer { + + let userInfo: [String: Any] + private(set) var isCancelled = false + + init(userInfo: [String: Any]) { + self.userInfo = userInfo + } + + func cancel() { + isCancelled = true + } +} + +// MARK: - FakeWatchSession + +/// `WCSession` 대역. +/// +/// 실제 `WCSession` 은 싱글턴이라 활성화 결과·도달성을 테스트에서 바꿀 수 없고, 페어링된 +/// 워치가 없는 CI 에서는 `isReachable` 이 항상 `false` 다. 그 환경 의존을 걷어내 상태 전이와 +/// 전송 실패 경로를 결정적으로 검증하기 위한 대역이다. +final class FakeWatchSession: WatchSessionProviding { + + // MARK: - Property + + var isSupported: Bool = true + var isActivated: Bool = true + var isReachable: Bool = true + var receivedContext: [String: Any] = [:] + + /// ``send(_:onReply:onError:)`` 가 돌려줄 결과. 기본값은 빈 응답 성공. + var sendOutcome: Result<[String: Any], any Error> = .success([:]) + + /// ``apply(applicationContext:)`` 가 던질 에러. `nil` 이면 성공. + var applyContextError: (any Error)? + + private(set) weak var attachedDelegate: (any WCSessionDelegate)? + private(set) var activationCallCount = 0 + private(set) var sentMessages: [[String: Any]] = [] + private(set) var appliedContexts: [[String: Any]] = [] + private(set) var transfers: [FakeUserInfoTransfer] = [] + + var outstandingTransfers: [any WatchUserInfoTransfer] { transfers } + + // MARK: - Function + + /// 큐에 미리 항목을 심는다. `enqueue` 를 거치지 않는 손상 페이로드도 넣을 수 있어야 한다. + func seedTransfer(_ userInfo: [String: Any]) { + transfers.append(FakeUserInfoTransfer(userInfo: userInfo)) + } + + // MARK: - WatchSessionProviding + + func attach(delegate: any WCSessionDelegate) { + attachedDelegate = delegate + } + + func startActivation() { + activationCallCount += 1 + } + + func send( + _ message: [String: Any], + onReply: @escaping ([String: Any]) -> Void, + onError: @escaping (any Error) -> Void + ) { + sentMessages.append(message) + switch sendOutcome { + case .success(let reply): + onReply(reply) + case .failure(let error): + onError(error) + } + } + + func apply(applicationContext: [String: Any]) throws { + if let applyContextError { + throw applyContextError + } + appliedContexts.append(applicationContext) + } + + func transfer(userInfo: [String: Any]) { + seedTransfer(userInfo) + } +} + +// MARK: - TransportFailure + +/// 전송 실패 경로에서 원본 에러가 그대로 올라오는지 확인하기 위한 표식 에러. +struct TransportFailure: Error, Equatable { + let reason: String +} diff --git a/UMCApp/Core/WatchConnectivity/Tests/WatchSessionCoordinatorTests.swift b/UMCApp/Core/WatchConnectivity/Tests/WatchSessionCoordinatorTests.swift new file mode 100644 index 000000000..1f76c4ad2 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/WatchSessionCoordinatorTests.swift @@ -0,0 +1,429 @@ +// +// WatchSessionCoordinatorTests.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import Testing +import WatchConnectivity +@testable import CoreWatchConnectivity + +/// 세션 어댑터의 상태 전이와 채널 계약. +/// +/// 여기서 검증하는 건 `WCSession` 자체가 아니라 **그 위에 얹은 규칙**이다 — 활성화 실패를 +/// 「연결됨」으로 오인하지 않는지, 왕복이 필요한 종류가 큐로 새지 않는지, 만료된 출석 요청이 +/// 큐에서 빠지는지. 이 규칙이 무너지면 화면은 정상인데 전송만 조용히 죽는다. +@MainActor +@Suite("WatchSessionCoordinator — 상태 전이 · 채널 계약") +struct WatchSessionCoordinatorTests { + + // MARK: - Fixture + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func makeState(isSignedIn: Bool = true) -> WatchSessionState { + WatchSessionState( + isSignedIn: isSignedIn, + schedules: [], + notices: [], + generatedAt: now + ) + } + + private func makeRequest(measuredAt: Date) -> WatchAttendanceRequest { + WatchAttendanceRequest( + scheduleId: "42", + latitude: 37.5, + longitude: 127.0, + locationVerified: true, + measuredAt: measuredAt + ) + } + + private func makeResult() -> WatchAttendanceResult { + WatchAttendanceResult( + scheduleId: "42", + status: "ATTENDED", + decidedAt: now, + reason: nil + ) + } + + private func makeCoordinator( + _ configure: (FakeWatchSession) -> Void = { _ in } + ) -> (WatchSessionCoordinator, FakeWatchSession) { + let session = FakeWatchSession() + configure(session) + return (WatchSessionCoordinator(session: session), session) + } + + // MARK: - Activation + + @Test("activate() 는 델리게이트를 붙인 뒤 활성화를 시작한다") + func activateAttachesDelegate() { + let (coordinator, session) = makeCoordinator() + + coordinator.activate() + + #expect(session.attachedDelegate === coordinator) + #expect(session.activationCallCount == 1) + } + + @Test("미지원 기기에서는 활성화를 시도하지 않는다") + func activateSkipsWhenUnsupported() { + let (coordinator, session) = makeCoordinator { $0.isSupported = false } + + coordinator.activate() + + #expect(session.attachedDelegate == nil) + #expect(session.activationCallCount == 0) + } + + @Test("활성화 실패는 도달성까지 함께 내린다") + func failedActivationClearsReachability() { + let (coordinator, _) = makeCoordinator() + + coordinator.applyActivation(false, reachable: true, seeded: nil) + + #expect(coordinator.isActivated == false) + // 「도달 가능」인데 전송은 `.sessionNotActivated` 로 죽는 상태를 만들지 않는다. + #expect(coordinator.isReachable == false) + } + + @Test("콜드런치 컨텍스트가 첫 스냅샷을 시딩한다") + func activationSeedsReceivedState() { + let (coordinator, _) = makeCoordinator() + let state = makeState() + + coordinator.applyActivation(true, reachable: true, seeded: .sessionState(state)) + + #expect(coordinator.isActivated) + #expect(coordinator.isReachable) + #expect(coordinator.receivedState == state) + } + + @Test("이미 최신 스냅샷이 있으면 시딩이 덮어쓰지 않는다") + func seedingDoesNotOverwriteFresherState() { + let (coordinator, _) = makeCoordinator() + let fresh = makeState(isSignedIn: true) + let stale = makeState(isSignedIn: false) + + coordinator.applyReceivedContext(.sessionState(fresh)) + coordinator.applyActivation(true, reachable: true, seeded: .sessionState(stale)) + + #expect(coordinator.receivedState == fresh) + } + + @Test("활성화 전 도달성 변화는 무시된다") + func reachabilityRequiresActivation() { + let (coordinator, _) = makeCoordinator() + + coordinator.applyReachability(true) + #expect(coordinator.isReachable == false) + + coordinator.applyActivation(true, reachable: false, seeded: nil) + coordinator.applyReachability(true) + #expect(coordinator.isReachable) + } + + // MARK: - Request + + @Test("requestSync 는 상태 응답을 그대로 돌려준다") + func requestSyncReturnsState() async throws { + let state = makeState() + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .success(try WatchEnvelope.encode(WatchReply.state(state))) + + #expect(try await coordinator.requestSync() == state) + #expect(session.sentMessages.count == 1) + } + + @Test("상대가 실패를 응답하면 원인을 그대로 올린다") + func requestSyncSurfacesRemoteFailure() async throws { + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .success( + try WatchEnvelope.encode(WatchReply.failure(.init(reason: .notSignedIn))) + ) + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard case WatchConnectivityError.remote(let failure) = error else { return false } + return failure.reason == .notSignedIn + } + } + + @Test("요청과 어긋난 응답은 unexpectedReply 다") + func requestSyncRejectsMismatchedReply() async throws { + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .success(try WatchEnvelope.encode(WatchReply.ack)) + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard case WatchConnectivityError.unexpectedReply(.ack) = error else { return false } + return true + } + } + + @Test("활성화 전 전송은 sessionNotActivated 로 막힌다") + func sendRequiresActivation() async { + let (coordinator, session) = makeCoordinator { $0.isActivated = false } + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard case WatchConnectivityError.sessionNotActivated = error else { return false } + return true + } + #expect(session.sentMessages.isEmpty) + } + + @Test("도달 불가면 전송을 시도하지 않는다 — 호출자가 큐로 넘길 신호다") + func sendRequiresReachability() async { + let (coordinator, session) = makeCoordinator { $0.isReachable = false } + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard case WatchConnectivityError.notReachable = error else { return false } + return true + } + #expect(session.sentMessages.isEmpty) + } + + @Test("WCError 가 아닌 전송 실패는 원본을 감싸 올린다") + func transportFailureKeepsUnderlyingError() async { + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .failure(TransportFailure(reason: "socket")) + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard + case WatchConnectivityError.transportFailure(let underlying) = error, + let failure = underlying as? TransportFailure + else { return false } + return failure.reason == "socket" + } + } + + @Test("WCError 코드는 도메인 에러로 분류된다") + func wcErrorMapsToDomainError() async { + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .failure( + NSError( + domain: WCError.errorDomain, + code: WCError.Code.payloadTooLarge.rawValue + ) + ) + + await #expect { + _ = try await coordinator.requestSync() + } throws: { error in + guard case WatchConnectivityError.payloadTooLarge = error else { return false } + return true + } + } + + @Test("requestAttendance 는 판정 결과를 돌려준다") + func requestAttendanceReturnsResult() async throws { + let result = makeResult() + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .success( + try WatchEnvelope.encode(WatchReply.attendance(result)) + ) + + let received = try await coordinator.requestAttendance(makeRequest(measuredAt: now)) + #expect(received == result) + } + + @Test("notifyAttendanceChanged 는 ack 만 정상으로 본다") + func notifyAttendanceRequiresAck() async throws { + let (coordinator, session) = makeCoordinator() + session.sendOutcome = .success(try WatchEnvelope.encode(WatchReply.ack)) + try await coordinator.notifyAttendanceChanged(makeResult()) + + session.sendOutcome = .success( + try WatchEnvelope.encode(WatchReply.state(makeState())) + ) + await #expect { + try await coordinator.notifyAttendanceChanged(self.makeResult()) + } throws: { error in + guard case WatchConnectivityError.unexpectedReply = error else { return false } + return true + } + } + + // MARK: - Context + + @Test("publishSessionState 는 봉투를 컨텍스트로 올린다") + func publishSessionStateAppliesContext() throws { + let (coordinator, session) = makeCoordinator() + let state = makeState() + + try coordinator.publishSessionState(state) + + let applied = try #require(session.appliedContexts.first) + let message = try WatchEnvelope.decode(WatchMessage.self, from: applied) + #expect(message == .sessionState(state)) + } + + @Test("활성화 전에는 컨텍스트를 올리지 않는다") + func publishSessionStateRequiresActivation() { + let (coordinator, session) = makeCoordinator { $0.isActivated = false } + + #expect(throws: WatchConnectivityError.self) { + try coordinator.publishSessionState(self.makeState()) + } + #expect(session.appliedContexts.isEmpty) + } + + // MARK: - Queue + + @Test("읽음 확인은 큐로 보낸다") + func enqueueAcceptsNoticeRead() throws { + let (coordinator, session) = makeCoordinator() + let read = WatchNoticeRead(noticeId: "7", readAt: now) + + try coordinator.enqueue(.noticeRead(read)) + + #expect(session.transfers.count == 1) + #expect(coordinator.pendingMessages == [.noticeRead(read)]) + } + + @Test( + "왕복이 필요하거나 최신 1건만 의미 있는 종류는 큐를 거부한다", + arguments: [ + WatchMessage.syncRequest, + WatchMessage.sessionState( + WatchSessionState( + isSignedIn: true, + schedules: [], + notices: [], + generatedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + ), + WatchMessage.attendanceChanged( + WatchAttendanceResult( + scheduleId: "42", + status: "ATTENDED", + decidedAt: nil, + reason: nil + ) + ), + ] + ) + func enqueueRejectsRoundTripChannels(message: WatchMessage) { + let (coordinator, session) = makeCoordinator() + + #expect(throws: WatchConnectivityError.self) { + try coordinator.enqueue(message) + } + #expect(session.transfers.isEmpty) + } + + @Test("디코딩되지 않는 큐 항목은 조용히 건너뛴다") + func pendingMessagesSkipsUndecodable() throws { + let (coordinator, session) = makeCoordinator() + session.seedTransfer(["p": Data("nope".utf8)]) + try coordinator.enqueue(.attendanceRequest(makeRequest(measuredAt: now))) + + #expect(coordinator.pendingMessages.count == 1) + } + + @Test("180분을 넘긴 출석 요청만 큐에서 취소한다 — 경계값은 유효") + func purgeExpiredQueueRespectsBoundary() throws { + let (coordinator, session) = makeCoordinator() + let expired = makeRequest(measuredAt: now - WatchAttendanceRequest.maxQueueAge - 1) + let boundary = makeRequest(measuredAt: now - WatchAttendanceRequest.maxQueueAge) + try coordinator.enqueue(.attendanceRequest(expired)) + try coordinator.enqueue(.attendanceRequest(boundary)) + try coordinator.enqueue(.noticeRead(.init(noticeId: "7", readAt: now))) + + let purged = coordinator.purgeExpiredQueue(now: now) + + #expect(purged == [expired]) + #expect(session.transfers.map(\.isCancelled) == [true, false, false]) + } + + // MARK: - Receive + + @Test("transferUserInfo 수신은 도착 순서대로 스트림에 흐른다") + func receivedUserInfoPreservesOrder() async throws { + let (coordinator, _) = makeCoordinator() + let first = WatchNoticeRead(noticeId: "1", readAt: now) + let second = WatchNoticeRead(noticeId: "2", readAt: now) + + coordinator.ingest(userInfo: try WatchEnvelope.encode(WatchMessage.noticeRead(first))) + coordinator.ingest(userInfo: try WatchEnvelope.encode(WatchMessage.noticeRead(second))) + coordinator.ingest(userInfo: ["p": Data("nope".utf8)]) + + var received: [WatchMessage] = [] + for await message in coordinator.receivedUserInfo() { + received.append(message) + if received.count == 2 { break } + } + + #expect(received == [.noticeRead(first), .noticeRead(second)]) + } + + @Test("핸들러 미등록 요청에도 응답은 정확히 한 번 돌아간다") + func unhandledRequestStillReplies() async throws { + let (coordinator, _) = makeCoordinator() + let payload = try WatchEnvelope.encode(WatchMessage.syncRequest) + + #expect(await reply(to: payload, on: coordinator) == .failure(.init(reason: .unsupportedRequest))) + } + + @Test("손상된 봉투는 hop 없이 malformedPayload 로 응답한다") + func malformedRequestRepliesImmediately() async { + let (coordinator, _) = makeCoordinator() + + #expect( + await reply(to: ["p": Data("nope".utf8)], on: coordinator) + == .failure(.init(reason: .malformedPayload)) + ) + } + + @Test("더 새로운 스키마는 손상과 구분해 응답한다 — 업데이트 안내의 근거다") + func futureSchemaRepliesWithVersion() async { + let (coordinator, _) = makeCoordinator() + let future = WatchSchema.currentVersion + 1 + let json = Data(#"{"kind":"syncRequest","version":\#(future)}"#.utf8) + + #expect( + await reply(to: ["p": json], on: coordinator) + == .failure(.init(reason: .unsupportedSchemaVersion, message: "v\(future)")) + ) + } + + @Test("등록된 핸들러의 응답이 그대로 회신된다") + func registeredHandlerReplyIsForwarded() async throws { + let (coordinator, _) = makeCoordinator() + let state = makeState() + coordinator.setRequestHandler { _ in .state(state) } + + let payload = try WatchEnvelope.encode(WatchMessage.syncRequest) + #expect(await reply(to: payload, on: coordinator) == .state(state)) + } + + // MARK: - Function + + /// `[String: Any]` 는 Sendable 이 아니라 continuation 밖으로 못 내보낸다. 클로저 안에서 + /// 봉투를 벗겨 값 타입만 꺼낸다. + private func reply( + to request: [String: Any], + on coordinator: WatchSessionCoordinator + ) async -> WatchReply? { + await withCheckedContinuation { continuation in + coordinator.handle(request: request) { raw in + continuation.resume( + returning: try? WatchEnvelope.decode(WatchReply.self, from: raw) + ) + } + } + } +} diff --git a/UMCApp/MAKEFILE_GUIDE.md b/UMCApp/MAKEFILE_GUIDE.md index accc9807b..a0657e41f 100644 --- a/UMCApp/MAKEFILE_GUIDE.md +++ b/UMCApp/MAKEFILE_GUIDE.md @@ -58,10 +58,34 @@ make open # Xcode 실행 | `make test-network` | CoreNetwork 단위+통합 테스트 (`TEST_SERVER_URL` 자동 전달) | 네트워크 레이어 변경 후 | | `make build` | Debug 빌드 | 빌드 가능 여부만 확인할 때 | | `make build SCHEME=…` | 특정 스킴(모듈)만 빌드 | 한 모듈만 빠르게 검증할 때 | +| `make build-watch` | watchOS 앱(`UMCWatchApp`) 빌드 | 워치 타겟·`CoreWatchConnectivity` 변경 후 | | `make pick` | 스킴 목록에서 골라 빌드 (대화형) | 스킴 이름이 안 떠오를 때 | | `make doctor` | 환경 진단 (mise/tuist/xcode 버전) | 다른 팀원과 증상이 다를 때 | | `make help` | 전체 타겟 목록 | 까먹었을 때 | +### watchOS 타겟 빌드·테스트 + +`make build` / `make test` 의 기본 목적지는 iOS 시뮬레이터라 **워치 타겟은 하나도 건드리지 +않습니다.** 워치 코드를 바꿨다면 아래 두 개를 따로 돌려야 합니다 (CI 도 같은 명령을 씁니다). + +```bash +# watchOS 앱 컴파일 검증 +make build-watch + +# WatchConnectivity 계약·상태 전이 테스트 (iOS 시뮬레이터에서 로직 테스트로 실행) +make test SCHEME=CoreWatchConnectivity +``` + +> **왜 목적지에 워치 기종을 안 박나**: 설치된 워치 시뮬레이터 기종은 머신마다 다릅니다 +> (로컬에 `Apple Watch Series 11` 만 있는데 러너엔 다른 세대가 깔려 있는 식). 기종을 고정하면 +> 한쪽이 반드시 깨지므로 `WATCH_DESTINATION` 기본값을 `generic/platform=watchOS Simulator` +> 로 두고 **컴파일만** 검증합니다. 특정 기종에서 실행까지 보려면 아래처럼 덮어쓰세요. +> +> ```bash +> xcrun simctl list devices watchOS # 설치된 기종 확인 +> make build-watch WATCH_DESTINATION='platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)' +> ``` + --- ## 3. 정리 / 초기화 @@ -95,6 +119,8 @@ make open | `CONFIGURATION` | `Debug` | `make build CONFIGURATION=Release` | | `DESTINATION` | `platform=iOS Simulator,name=iPhone 17 Pro` | `make test DESTINATION='platform=iOS Simulator,name=iPhone 17'` | | `TEST_SERVER_URL` | `http://127.0.0.1:8080` | `make test-network TEST_SERVER_URL=http://127.0.0.1:9090` | +| `WATCH_SCHEME` | `UMCWatchApp` | `make build-watch WATCH_SCHEME=CoreWatchConnectivity` | +| `WATCH_DESTINATION` | `generic/platform=watchOS Simulator` | `make build-watch WATCH_DESTINATION='platform=watchOS Simulator,name=Apple Watch Ultra 3 (49mm)'` | 예시: diff --git a/UMCApp/Makefile b/UMCApp/Makefile index 7a35e7bde..246d4aea8 100644 --- a/UMCApp/Makefile +++ b/UMCApp/Makefile @@ -18,6 +18,11 @@ ROOT := .. DESTINATION ?= platform=iOS Simulator,name=iPhone 17 Pro CONFIGURATION ?= Debug +WATCH_SCHEME ?= UMCWatchApp +# 워치 시뮬레이터 디바이스 목록은 머신마다 다르므로(로컬 Series 11 / CI 러너는 다른 세대) +# 디바이스명을 박으면 한쪽이 반드시 깨진다 → generic 목적지로 컴파일만 검증한다. +WATCH_DESTINATION ?= generic/platform=watchOS Simulator + MISE ?= mise TUIST := $(MISE) exec -- tuist @@ -38,6 +43,7 @@ help: ## 이 도움말 출력 @echo " make test SCHEME=AuthDomain # 모듈 단위 테스트" @echo " make test DESTINATION='platform=iOS Simulator,name=iPhone 17'" @echo " make build CONFIGURATION=Release" + @echo " make build-watch # watchOS 앱 빌드 (generic 목적지)" @echo "" @echo "사용 가능한 스킴 목록:" @echo " xcodebuild -workspace $(WORKSPACE) -list" @@ -123,6 +129,15 @@ build: ## 빌드 ($(CONFIGURATION)) -destination "$(DESTINATION)" \ build +.PHONY: build-watch +build-watch: ## watchOS 앱 빌드 ($(WATCH_SCHEME)) + @xcodebuild \ + -workspace $(WORKSPACE) \ + -scheme $(WATCH_SCHEME) \ + -configuration $(CONFIGURATION) \ + -destination "$(WATCH_DESTINATION)" \ + build + .PHONY: pick pick: ## 스킴을 골라 빌드 (fzf 있으면 fuzzy, 없으면 번호 선택) @if [ ! -d "$(WORKSPACE)" ]; then \