From 6e1f35d7a2d1f2c8c6683290ebf525af7fe48c25 Mon Sep 17 00:00:00 2001 From: MinHeokChoi Date: Sat, 12 Sep 2026 20:08:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Refactor]=20RoutineTTSWarmupCoordinator?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B0=92=20=ED=83=80=EC=9E=85=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=94=EC=9D=B8=EB=94=A9=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EB=96=BC=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1,975줄 한 파일이었다. 동작은 그대로 두고 이음매 두 곳만 갈랐다. 1. 맨 위 175줄은 코디네이터가 아니라 어휘였다. 폴링 정책 둘과 대기 게이트, 진단 이벤트, 프로토콜 둘. 셋 중 셋(PrefetchPollingPolicy, VoiceSelectionVersionStoring, Diagnostics)은 ServerVoiceCommonAudioProvider와 RemoteFirstRoutineGuidancePlayer도 쓰는 공용 어휘라 코디네이터 파일에 있을 이유가 없었다. 세 파일로 나눴다. RoutineTTSForegroundWaitGate는 private이라 파일을 나누면 안 보인다. 모듈 내부로 올렸다 — 쓰는 쪽은 여전히 코디네이터 하나뿐이다. 2. 바인딩 검증 8개 함수는 코디네이터의 가변 상태를 하나도 건드리지 않고 동기화 저장소만 읽는다. RoutineTTSBindingValidator로 옮겼다. validateCurrentBinding이 중첩 private 타입(PreparedPlan/LocalPlanKey)을 받던 것은 remote ID 두 개로 풀어서 넘긴다. 값어치는 줄 수가 아니라 시험 가능성이다. 이 규칙을 확인하려면 전에는 원격 서비스·오디오 캐시·다운로더·음성 선택 저장소까지 세워 코디네이터를 만들어야 했다. 지금은 동기화 저장소 하나면 된다. 그래서 "남의 루틴 음성이 재생되면 안 된다"를 여섯 가지 상황으로 직접 고정할 수 있었다 — remote ID가 바뀐 경우, 계정이 바뀐 경우, 바인딩이 없는 경우까지. 코디네이터 1,975 → 1,648줄. 스모크 1,055개 통과. Co-Authored-By: Claude --- .../Voice/RoutineTTSBindingValidator.swift | 202 ++++++++++ .../Voice/RoutineTTSDiagnostics.swift | 65 ++++ .../Services/Voice/RoutineTTSWarming.swift | 60 +++ .../Voice/RoutineTTSWarmupCoordinator.swift | 363 +----------------- .../Voice/RoutineTTSWarmupPolicies.swift | 73 ++++ .../RoutineTTSBindingValidatorTests.swift | 171 +++++++++ 6 files changed, 589 insertions(+), 345 deletions(-) create mode 100644 Moru/Moru/Domain/Services/Voice/RoutineTTSBindingValidator.swift create mode 100644 Moru/Moru/Domain/Services/Voice/RoutineTTSDiagnostics.swift create mode 100644 Moru/Moru/Domain/Services/Voice/RoutineTTSWarming.swift create mode 100644 Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupPolicies.swift create mode 100644 Moru/MoruTests/RoutineTTSBindingValidatorTests.swift diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSBindingValidator.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSBindingValidator.swift new file mode 100644 index 0000000..2e22908 --- /dev/null +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSBindingValidator.swift @@ -0,0 +1,202 @@ +// +// RoutineTTSBindingValidator.swift +// Moru +// + +import Foundation + +/// 준비해 둔 오디오가 **지금도 그 루틴의 것인지** 판단한다. +/// +/// 로컬 루틴과 서버 루틴은 `RoutineServerBinding`으로 이어진다. 이 연결이 +/// 끊기거나 다른 계정/네임스페이스의 것으로 바뀌면, 캐시해 둔 오디오는 남의 +/// 루틴 음성이 된다. 그래서 재생 직전에 매번 다시 확인한다. +/// +/// `RoutineTTSWarmupCoordinator`에서 떼어냈다. 이 여덟 개 함수는 코디네이터의 +/// 가변 상태를 하나도 건드리지 않고 동기화 저장소만 읽는다. 붙어 있을 이유가 +/// 없었고, 떨어져 나오니 코디네이터를 띄우지 않고도 규칙을 시험할 수 있다. +@MainActor +struct RoutineTTSBindingValidator { + /// 준비된 오디오의 바인딩 검증 결과. + enum Validation { + case valid + case invalid + /// 저장소 읽기가 실패했다. 바인딩이 틀렸다는 뜻이 아니므로 버리지 않는다. + case unavailable + } + + private let bindingRepository: any RoutineSyncRepository + private let serverNamespace: RoutineSyncServerNamespace + + init( + bindingRepository: any RoutineSyncRepository, + serverNamespace: RoutineSyncServerNamespace + ) { + self.bindingRepository = bindingRepository + self.serverNamespace = serverNamespace + } + + // MARK: - 서버 생성 의도 + + func hasPendingGroupBinding( + memberID: Int64, + routineGroupLocalID: UUID + ) throws -> Bool { + guard let mutation = try bindingRepository.mutation( + memberID: memberID, + operation: .createRoutineGroup, + entityKind: .routineGroup, + localEntityID: routineGroupLocalID + ) else { + return false + } + + return Self.isDeliveryPending(mutation.state) + } + + func hasServerSyncIntent( + memberID: Int64, + routineGroupLocalID: UUID, + routineLocalID: UUID + ) throws -> Bool { + if try bindingRepository.mutation( + memberID: memberID, + operation: .createRoutineGroup, + entityKind: .routineGroup, + localEntityID: routineGroupLocalID + ) != nil { + return true + } + + if try bindingRepository.mutation( + memberID: memberID, + operation: .addRoutine, + entityKind: .routine, + localEntityID: routineLocalID + ) != nil { + return true + } + + return try bindingRepository.binding( + memberID: memberID, + entityKind: .routine, + localEntityID: routineLocalID + ) != nil + } + + func hasPendingRoutineBinding( + memberID: Int64, + routineGroupLocalID: UUID, + routineLocalID: UUID + ) throws -> Bool { + if try hasPendingGroupBinding( + memberID: memberID, + routineGroupLocalID: routineGroupLocalID + ) { + return true + } + + guard let mutation = try bindingRepository.mutation( + memberID: memberID, + operation: .addRoutine, + entityKind: .routine, + localEntityID: routineLocalID + ) else { + return false + } + + return Self.isDeliveryPending(mutation.state) + } + + /// 아직 서버 응답을 기다릴 만한 상태인가. + static func isDeliveryPending(_ state: RoutineSyncMutationState) -> Bool { + switch state { + // Newly saved groups start waiting for runtime contract admission, then + // become queued. All three states can still gain a server binding during + // the same bounded first-cue window, so none should fall through + // silently. `needsReconciliation` means the request may already have + // reached the server; `RoutineSyncSender` retries it automatically + // (see its `pendingReplay` branch), so it is not a dead end either. + case .waitingForServerContract, .queued, .attempting, .needsReconciliation: + true + // `blocked` is the only state that requires explicit intervention and + // will never resolve on its own. + case .blocked: + false + } + } + + // MARK: - 준비된 오디오의 바인딩 재확인 + + func validateCurrentBinding( + routineGroupLocalID: UUID, + routineLocalID: UUID, + expectedGroupRemoteID: Int64, + expectedRoutineRemoteID: Int64, + identity: AccountSessionIdentity + ) -> Validation { + do { + guard let groupBinding = try bindingRepository.binding( + memberID: identity.memberID, + entityKind: .routineGroup, + localEntityID: routineGroupLocalID + ), let routineBinding = try bindingRepository.binding( + memberID: identity.memberID, + entityKind: .routine, + localEntityID: routineLocalID + ) else { + return .invalid + } + + guard isValidGroupBinding( + groupBinding, + routineGroupLocalID: routineGroupLocalID, + identity: identity + ), groupBinding.remoteID == expectedGroupRemoteID, + isValidRoutineBinding( + routineBinding, + routineGroupLocalID: routineGroupLocalID, + routineLocalID: routineLocalID, + groupBinding: groupBinding, + identity: identity + ), routineBinding.remoteID == expectedRoutineRemoteID else { + return .invalid + } + + return .valid + } catch { + return .unavailable + } + } + + /// 그룹 바인딩 한 건이 이 계정·네임스페이스의 것인지. + func isValidGroupBinding( + _ binding: RoutineServerBinding, + routineGroupLocalID: UUID, + identity: AccountSessionIdentity + ) -> Bool { + binding.entityKind == .routineGroup + && binding.localEntityID == routineGroupLocalID + && binding.remoteID > 0 + && binding.memberID == identity.memberID + && binding.serverNamespace == serverNamespace + } + + /// 루틴 바인딩 한 건이 위 그룹 바인딩과 짝이 맞는지. + func isValidRoutineBinding( + _ binding: RoutineServerBinding, + routineGroupLocalID: UUID, + routineLocalID: UUID, + groupBinding: RoutineServerBinding, + identity: AccountSessionIdentity + ) -> Bool { + binding.entityKind == .routine + && binding.localEntityID == routineLocalID + && binding.remoteID > 0 + && binding.memberID == identity.memberID + && binding.serverNamespace == serverNamespace + && binding.parentEntityKind == .routineGroup + && binding.parentLocalEntityID == routineGroupLocalID + && binding.memberID == groupBinding.memberID + && binding.serverNamespace == groupBinding.serverNamespace + } +} diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSDiagnostics.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSDiagnostics.swift new file mode 100644 index 0000000..a9531d5 --- /dev/null +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSDiagnostics.swift @@ -0,0 +1,65 @@ +// +// RoutineTTSDiagnostics.swift +// Moru +// + +import Foundation +import OSLog + +/// The result of the bounded foreground wait that precedes a server-only +/// routine cue. Keeping the result explicit prevents callers from treating a +/// missing or still-generating cue as a successfully completed silent cue. +nonisolated enum RoutineTTSForegroundPreparationStatus: Equatable, Sendable { + case prepared + case retryablePending + case unavailable + case cancelled +} + +/// Events are intentionally identifier- and URL-free so they can be read from +/// a TestFlight device console without disclosing account or routine content. +nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable { + case cachePlanMissing + case missingGroupBinding + /// No binding AND no createRoutineGroup mutation record exists at all for + /// this local group. Distinguishes "sync never even recorded intent" from + /// the other missingGroupBinding causes below. + case missingGroupBindingNoMutationRecord + /// A createRoutineGroup mutation exists but is in the terminal `.blocked` + /// state, which never resolves automatically. + case missingGroupBindingMutationBlocked + /// A binding record exists but failed identity/shape validation + /// (wrong member, namespace, or remoteID) rather than being absent. + case missingGroupBindingInvalidExistingBinding + case missingRoutineBinding + case remoteFetchFailed + case responseUnavailable + case waitingForBinding + case waitingForGeneration + case foregroundPrepared + case foregroundRetryExhausted + case audioDownloadFailed + case voiceCacheInvalidated + case cachePurgeFailed + case customCueUnavailable + case serverCueUnavailable + /// The done/remind server-voice common cue's plan was not yet prepared + /// when playback needed it. Fails open (silently completes) by design. + case commonCueUnavailableForServerVoice + /// A common cue's local file was already cache-validated but failed to + /// start playback at cue time. Also fails open by design. + case commonCueLateFailure +} + +nonisolated struct RoutineTTSDiagnostics: Sendable { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru", + category: "RoutineTTS" + ) + + func record(_ event: RoutineTTSDiagnosticEvent) { + logger.notice( + "Routine TTS: \(event.rawValue, privacy: .public)" + ) + } +} diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarming.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarming.swift new file mode 100644 index 0000000..c4ad18a --- /dev/null +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarming.swift @@ -0,0 +1,60 @@ +// +// RoutineTTSWarming.swift +// Moru +// + +import Foundation + +@MainActor +protocol RoutineTTSWarming: AnyObject { + func prepare(routineGroupLocalID: UUID, routineLocalIDs: [UUID]) + /// Indicates whether this step is synced already or has a pending synced + /// creation on the current account. A step without that intent remains a + /// local cue instead of waiting for server audio. + func expectsServerGeneratedIntro( + routineGroupLocalID: UUID, + routineLocalID: UUID + ) -> Bool + func prepareAndWait( + routineGroupLocalID: UUID, + routineLocalIDs: [UUID] + ) async -> RoutineTTSForegroundPreparationStatus +} + +@MainActor +protocol RoutineTTSVoiceSelectionVersionStoring: AnyObject { + func selectionVersion(forMemberID memberID: Int64) -> Int64? + func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) + func removeSelectionVersion(forMemberID memberID: Int64) + func selectedTTSID(forMemberID memberID: Int64) -> Int64? + func setSelectedTTSID(_ ttsID: Int64, forMemberID memberID: Int64) + func removeSelectedTTSID(forMemberID memberID: Int64) +} + +extension RoutineTTSVoiceSelectionVersionStoring { + func selectedTTSID(forMemberID memberID: Int64) -> Int64? { nil } + func setSelectedTTSID(_ ttsID: Int64, forMemberID memberID: Int64) {} + func removeSelectedTTSID(forMemberID memberID: Int64) {} +} + +extension RoutineTTSWarming { + func expectsServerGeneratedIntro( + routineGroupLocalID: UUID, + routineLocalID: UUID + ) -> Bool { + false + } + + /// Keeps lightweight test and preview doubles source-compatible while the + /// production coordinator supplies the foreground readiness wait. + func prepareAndWait( + routineGroupLocalID: UUID, + routineLocalIDs: [UUID] + ) async -> RoutineTTSForegroundPreparationStatus { + prepare( + routineGroupLocalID: routineGroupLocalID, + routineLocalIDs: routineLocalIDs + ) + return .prepared + } +} diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift index 9b10039..da680af 100644 --- a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift @@ -6,184 +6,6 @@ import Foundation import OSLog -/// A bounded retry window used only when a user is about to hear a -/// server-generated routine. Background warming intentionally remains a -/// single request so foregrounding many routines cannot amplify traffic. -nonisolated struct RoutineTTSForegroundPollingPolicy: Equatable, Sendable { - let maximumAttempts: Int - let retryDelay: Duration - let maximumWait: Duration - - init( - maximumAttempts: Int = 31, - retryDelay: Duration = .seconds(1), - maximumWait: Duration = .seconds(30) - ) { - precondition(maximumAttempts > 0) - precondition(maximumWait > .zero) - self.maximumAttempts = maximumAttempts - self.retryDelay = retryDelay - self.maximumWait = maximumWait - } -} - -private actor RoutineTTSForegroundWaitGate { - private var result: RoutineTTSForegroundPreparationStatus? - private var continuation: - CheckedContinuation? - - func value() async -> RoutineTTSForegroundPreparationStatus { - if let result { - return result - } - return await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - func resolve(_ result: RoutineTTSForegroundPreparationStatus) { - guard self.result == nil else { return } - self.result = result - continuation?.resume(returning: result) - continuation = nil - } -} - -nonisolated struct RoutineTTSPrefetchPollingPolicy: Sendable { - let maximumAttempts: Int - let retryDelays: [Duration] - let now: @Sendable () -> Date - let sleep: @Sendable (Duration) async throws -> Void - - init( - maximumAttempts: Int = 4, - retryDelays: [Duration] = [.seconds(1), .seconds(2), .seconds(4)], - now: @escaping @Sendable () -> Date = Date.init, - sleep: @escaping @Sendable (Duration) async throws -> Void = { - try await Task.sleep(for: $0) - } - ) { - precondition(maximumAttempts > 0) - precondition(retryDelays.count >= max(0, maximumAttempts - 1)) - self.maximumAttempts = maximumAttempts - self.retryDelays = retryDelays - self.now = now - self.sleep = sleep - } -} - -/// The result of the bounded foreground wait that precedes a server-only -/// routine cue. Keeping the result explicit prevents callers from treating a -/// missing or still-generating cue as a successfully completed silent cue. -nonisolated enum RoutineTTSForegroundPreparationStatus: Equatable, Sendable { - case prepared - case retryablePending - case unavailable - case cancelled -} - -/// Events are intentionally identifier- and URL-free so they can be read from -/// a TestFlight device console without disclosing account or routine content. -nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable { - case cachePlanMissing - case missingGroupBinding - /// No binding AND no createRoutineGroup mutation record exists at all for - /// this local group. Distinguishes "sync never even recorded intent" from - /// the other missingGroupBinding causes below. - case missingGroupBindingNoMutationRecord - /// A createRoutineGroup mutation exists but is in the terminal `.blocked` - /// state, which never resolves automatically. - case missingGroupBindingMutationBlocked - /// A binding record exists but failed identity/shape validation - /// (wrong member, namespace, or remoteID) rather than being absent. - case missingGroupBindingInvalidExistingBinding - case missingRoutineBinding - case remoteFetchFailed - case responseUnavailable - case waitingForBinding - case waitingForGeneration - case foregroundPrepared - case foregroundRetryExhausted - case audioDownloadFailed - case voiceCacheInvalidated - case cachePurgeFailed - case customCueUnavailable - case serverCueUnavailable - /// The done/remind server-voice common cue's plan was not yet prepared - /// when playback needed it. Fails open (silently completes) by design. - case commonCueUnavailableForServerVoice - /// A common cue's local file was already cache-validated but failed to - /// start playback at cue time. Also fails open by design. - case commonCueLateFailure -} - -nonisolated struct RoutineTTSDiagnostics: Sendable { - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru", - category: "RoutineTTS" - ) - - func record(_ event: RoutineTTSDiagnosticEvent) { - logger.notice( - "Routine TTS: \(event.rawValue, privacy: .public)" - ) - } -} - -@MainActor -protocol RoutineTTSWarming: AnyObject { - func prepare(routineGroupLocalID: UUID, routineLocalIDs: [UUID]) - /// Indicates whether this step is synced already or has a pending synced - /// creation on the current account. A step without that intent remains a - /// local cue instead of waiting for server audio. - func expectsServerGeneratedIntro( - routineGroupLocalID: UUID, - routineLocalID: UUID - ) -> Bool - func prepareAndWait( - routineGroupLocalID: UUID, - routineLocalIDs: [UUID] - ) async -> RoutineTTSForegroundPreparationStatus -} - -@MainActor -protocol RoutineTTSVoiceSelectionVersionStoring: AnyObject { - func selectionVersion(forMemberID memberID: Int64) -> Int64? - func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) - func removeSelectionVersion(forMemberID memberID: Int64) - func selectedTTSID(forMemberID memberID: Int64) -> Int64? - func setSelectedTTSID(_ ttsID: Int64, forMemberID memberID: Int64) - func removeSelectedTTSID(forMemberID memberID: Int64) -} - -extension RoutineTTSVoiceSelectionVersionStoring { - func selectedTTSID(forMemberID memberID: Int64) -> Int64? { nil } - func setSelectedTTSID(_ ttsID: Int64, forMemberID memberID: Int64) {} - func removeSelectedTTSID(forMemberID memberID: Int64) {} -} - -extension RoutineTTSWarming { - func expectsServerGeneratedIntro( - routineGroupLocalID: UUID, - routineLocalID: UUID - ) -> Bool { - false - } - - /// Keeps lightweight test and preview doubles source-compatible while the - /// production coordinator supplies the foreground readiness wait. - func prepareAndWait( - routineGroupLocalID: UUID, - routineLocalIDs: [UUID] - ) async -> RoutineTTSForegroundPreparationStatus { - prepare( - routineGroupLocalID: routineGroupLocalID, - routineLocalIDs: routineLocalIDs - ) - return .prepared - } -} - @MainActor final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudioProviding { private struct PreparedPlan { @@ -228,14 +50,10 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio let assets: [RoutineTTSResolvedAsset] } - private enum PreparedPlanBindingValidation { - case valid - case invalid - case unavailable - } private let remoteService: any RoutineTTSRemoteServing private let bindingRepository: any RoutineSyncRepository + private let bindingValidator: RoutineTTSBindingValidator private let routineRepository: (any RoutineRepository)? private let audioCache: RoutineTTSAudioCache? private let downloader: any RoutineTTSAudioDownloading @@ -294,6 +112,10 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) { self.remoteService = remoteService self.bindingRepository = bindingRepository + self.bindingValidator = RoutineTTSBindingValidator( + bindingRepository: bindingRepository, + serverNamespace: serverNamespace + ) self.routineRepository = routineRepository self.audioCache = audioCache self.downloader = downloader @@ -546,13 +368,13 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio entityKind: .routineGroup, localEntityID: routineGroupLocalID ) else { - return try hasServerSyncIntent( + return try bindingValidator.hasServerSyncIntent( memberID: identity.memberID, routineGroupLocalID: routineGroupLocalID, routineLocalID: routineLocalID ) } - guard isValidGroupBinding( + guard bindingValidator.isValidGroupBinding( groupBinding, routineGroupLocalID: routineGroupLocalID, identity: identity @@ -566,13 +388,13 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio entityKind: .routine, localEntityID: routineLocalID ) else { - return try hasServerSyncIntent( + return try bindingValidator.hasServerSyncIntent( memberID: identity.memberID, routineGroupLocalID: routineGroupLocalID, routineLocalID: routineLocalID ) } - guard isValidRoutineBinding( + guard bindingValidator.isValidRoutineBinding( routineBinding, routineGroupLocalID: routineGroupLocalID, routineLocalID: routineLocalID, @@ -800,9 +622,11 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio return nil } - switch validateCurrentBinding( - for: plan, - planKey: planKey, + switch bindingValidator.validateCurrentBinding( + routineGroupLocalID: planKey.routineGroupLocalID, + routineLocalID: planKey.routineLocalID, + expectedGroupRemoteID: plan.routineGroupRemoteID, + expectedRoutineRemoteID: plan.routineRemoteID, identity: identity ) { case .valid, .unavailable: @@ -957,7 +781,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio memberID: identity.memberID, entityKind: .routineGroup, localEntityID: routineGroupLocalID - ), isValidGroupBinding( + ), bindingValidator.isValidGroupBinding( binding, routineGroupLocalID: routineGroupLocalID, identity: identity @@ -1200,7 +1024,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio routineGroupLocalID: routineGroupLocalID, routineLocalIDs: requestedRoutineIDs ) - if try hasPendingGroupBinding( + if try bindingValidator.hasPendingGroupBinding( memberID: identity.memberID, routineGroupLocalID: routineGroupLocalID ) { @@ -1227,7 +1051,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) return .unavailable } - guard isValidGroupBinding( + guard bindingValidator.isValidGroupBinding( binding, routineGroupLocalID: routineGroupLocalID, identity: identity @@ -1294,7 +1118,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) let hasPendingBinding: Bool do { - hasPendingBinding = try hasPendingRoutineBinding( + hasPendingBinding = try bindingValidator.hasPendingRoutineBinding( memberID: identity.memberID, routineGroupLocalID: routineGroupLocalID, routineLocalID: routineLocalID @@ -1750,95 +1574,11 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio } } - private func hasPendingGroupBinding( - memberID: Int64, - routineGroupLocalID: UUID - ) throws -> Bool { - guard let mutation = try bindingRepository.mutation( - memberID: memberID, - operation: .createRoutineGroup, - entityKind: .routineGroup, - localEntityID: routineGroupLocalID - ) else { - return false - } - return Self.isBindingDeliveryPending(mutation.state) - } - - private func hasServerSyncIntent( - memberID: Int64, - routineGroupLocalID: UUID, - routineLocalID: UUID - ) throws -> Bool { - if try bindingRepository.mutation( - memberID: memberID, - operation: .createRoutineGroup, - entityKind: .routineGroup, - localEntityID: routineGroupLocalID - ) != nil { - return true - } - if try bindingRepository.mutation( - memberID: memberID, - operation: .addRoutine, - entityKind: .routine, - localEntityID: routineLocalID - ) != nil { - return true - } - return try bindingRepository.binding( - memberID: memberID, - entityKind: .routine, - localEntityID: routineLocalID - ) != nil - } - - private func hasPendingRoutineBinding( - memberID: Int64, - routineGroupLocalID: UUID, - routineLocalID: UUID - ) throws -> Bool { - if try hasPendingGroupBinding( - memberID: memberID, - routineGroupLocalID: routineGroupLocalID - ) { - return true - } - guard let mutation = try bindingRepository.mutation( - memberID: memberID, - operation: .addRoutine, - entityKind: .routine, - localEntityID: routineLocalID - ) else { - return false - } - return Self.isBindingDeliveryPending(mutation.state) - } - private static func uniqueRoutineIDs(_ ids: [UUID]) -> [UUID] { var seen = Set() return ids.filter { seen.insert($0).inserted } } - private static func isBindingDeliveryPending( - _ state: RoutineSyncMutationState - ) -> Bool { - switch state { - // Newly saved groups start waiting for runtime contract admission, then - // become queued. All three states can still gain a server binding during - // the same bounded first-cue window, so none should fall through - // silently. `needsReconciliation` means the request may already have - // reached the server; `RoutineSyncSender` retries it automatically - // (see its `pendingReplay` branch), so it is not a dead end either. - case .waitingForServerContract, .queued, .attempting, .needsReconciliation: - true - // `blocked` is the only state that requires explicit intervention and - // will never resolve on its own. - case .blocked: - false - } - } - private func invalidatePreparedPlans( routineGroupLocalID: UUID, routineLocalIDs: [UUID] @@ -1881,73 +1621,6 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) } - private func validateCurrentBinding( - for plan: PreparedPlan, - planKey: LocalPlanKey, - identity: AccountSessionIdentity - ) -> PreparedPlanBindingValidation { - do { - guard let groupBinding = try bindingRepository.binding( - memberID: identity.memberID, - entityKind: .routineGroup, - localEntityID: planKey.routineGroupLocalID - ), let routineBinding = try bindingRepository.binding( - memberID: identity.memberID, - entityKind: .routine, - localEntityID: planKey.routineLocalID - ) else { - return .invalid - } - guard isValidGroupBinding( - groupBinding, - routineGroupLocalID: planKey.routineGroupLocalID, - identity: identity - ), groupBinding.remoteID == plan.routineGroupRemoteID, - isValidRoutineBinding( - routineBinding, - routineGroupLocalID: planKey.routineGroupLocalID, - routineLocalID: planKey.routineLocalID, - groupBinding: groupBinding, - identity: identity - ), routineBinding.remoteID == plan.routineRemoteID else { - return .invalid - } - return .valid - } catch { - return .unavailable - } - } - - private func isValidGroupBinding( - _ binding: RoutineServerBinding, - routineGroupLocalID: UUID, - identity: AccountSessionIdentity - ) -> Bool { - binding.entityKind == .routineGroup - && binding.localEntityID == routineGroupLocalID - && binding.remoteID > 0 - && binding.memberID == identity.memberID - && binding.serverNamespace == serverNamespace - } - - private func isValidRoutineBinding( - _ binding: RoutineServerBinding, - routineGroupLocalID: UUID, - routineLocalID: UUID, - groupBinding: RoutineServerBinding, - identity: AccountSessionIdentity - ) -> Bool { - binding.entityKind == .routine - && binding.localEntityID == routineLocalID - && binding.remoteID > 0 - && binding.memberID == identity.memberID - && binding.serverNamespace == serverNamespace - && binding.parentEntityKind == .routineGroup - && binding.parentLocalEntityID == routineGroupLocalID - && binding.memberID == groupBinding.memberID - && binding.serverNamespace == groupBinding.serverNamespace - } - private func hasCurrentIdentity(_ identity: AccountSessionIdentity) -> Bool { sessionIdentityProvider?.currentAccountSessionIdentity == identity && isAudioCacheUsable(for: identity) diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupPolicies.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupPolicies.swift new file mode 100644 index 0000000..931f682 --- /dev/null +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupPolicies.swift @@ -0,0 +1,73 @@ +// +// RoutineTTSWarmupPolicies.swift +// Moru +// + +import Foundation + +/// A bounded retry window used only when a user is about to hear a +/// server-generated routine. Background warming intentionally remains a +/// single request so foregrounding many routines cannot amplify traffic. +nonisolated struct RoutineTTSForegroundPollingPolicy: Equatable, Sendable { + let maximumAttempts: Int + let retryDelay: Duration + let maximumWait: Duration + + init( + maximumAttempts: Int = 31, + retryDelay: Duration = .seconds(1), + maximumWait: Duration = .seconds(30) + ) { + precondition(maximumAttempts > 0) + precondition(maximumWait > .zero) + self.maximumAttempts = maximumAttempts + self.retryDelay = retryDelay + self.maximumWait = maximumWait + } +} + +/// 파일 분리 전에는 `private`이었다. 쓰는 쪽은 여전히 코디네이터 하나뿐이다. +actor RoutineTTSForegroundWaitGate { + private var result: RoutineTTSForegroundPreparationStatus? + private var continuation: + CheckedContinuation? + + func value() async -> RoutineTTSForegroundPreparationStatus { + if let result { + return result + } + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resolve(_ result: RoutineTTSForegroundPreparationStatus) { + guard self.result == nil else { return } + self.result = result + continuation?.resume(returning: result) + continuation = nil + } +} + +nonisolated struct RoutineTTSPrefetchPollingPolicy: Sendable { + let maximumAttempts: Int + let retryDelays: [Duration] + let now: @Sendable () -> Date + let sleep: @Sendable (Duration) async throws -> Void + + init( + maximumAttempts: Int = 4, + retryDelays: [Duration] = [.seconds(1), .seconds(2), .seconds(4)], + now: @escaping @Sendable () -> Date = Date.init, + sleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + } + ) { + precondition(maximumAttempts > 0) + precondition(retryDelays.count >= max(0, maximumAttempts - 1)) + self.maximumAttempts = maximumAttempts + self.retryDelays = retryDelays + self.now = now + self.sleep = sleep + } +} diff --git a/Moru/MoruTests/RoutineTTSBindingValidatorTests.swift b/Moru/MoruTests/RoutineTTSBindingValidatorTests.swift new file mode 100644 index 0000000..3e110cf --- /dev/null +++ b/Moru/MoruTests/RoutineTTSBindingValidatorTests.swift @@ -0,0 +1,171 @@ +// +// RoutineTTSBindingValidatorTests.swift +// MoruTests +// + +import SwiftData +import XCTest +@testable import Moru + +/// 준비해 둔 오디오가 지금도 그 루틴의 것인지 판단하는 규칙. +/// +/// 예전에는 이 규칙이 `RoutineTTSWarmupCoordinator` 안에 있어서, 시험하려면 +/// 원격 서비스·오디오 캐시·다운로더·음성 선택 저장소까지 모두 세워야 했다. +/// 떼어낸 뒤로는 동기화 저장소 하나만 있으면 된다. +/// +/// 여기서 지키는 것은 하나다 — **남의 루틴 음성이 재생되면 안 된다.** +@MainActor +final class RoutineTTSBindingValidatorTests: XCTestCase { + private let memberID: Int64 = 7 + private let groupRemoteID: Int64 = 41 + private let routineRemoteID: Int64 = 51 + + func testValidBindingPairIsAccepted() throws { + let scene = try makeScene() + + XCTAssertEqual( + scene.validator.validateCurrentBinding( + routineGroupLocalID: scene.groupLocalID, + routineLocalID: scene.routineLocalID, + expectedGroupRemoteID: groupRemoteID, + expectedRoutineRemoteID: routineRemoteID, + identity: scene.identity + ), + .valid + ) + } + + /// 서버가 루틴을 다시 만들어 remote ID가 달라졌다면, 캐시된 오디오는 옛 + /// 루틴의 것이다. 틀어 주면 사용자는 지운 루틴의 안내를 듣는다. + func testRemoteIDChangeInvalidatesThePreparedPlan() throws { + let scene = try makeScene() + + XCTAssertEqual( + scene.validator.validateCurrentBinding( + routineGroupLocalID: scene.groupLocalID, + routineLocalID: scene.routineLocalID, + expectedGroupRemoteID: groupRemoteID, + expectedRoutineRemoteID: routineRemoteID + 1, + identity: scene.identity + ), + .invalid + ) + } + + /// 다른 계정으로 갈아탄 뒤에는 이전 계정의 바인딩이 보이면 안 된다. + func testAnotherAccountSeesNoBinding() throws { + let scene = try makeScene() + let otherAccount = AccountSessionIdentity( + memberID: memberID + 1, + sessionID: UUID() + ) + + XCTAssertEqual( + scene.validator.validateCurrentBinding( + routineGroupLocalID: scene.groupLocalID, + routineLocalID: scene.routineLocalID, + expectedGroupRemoteID: groupRemoteID, + expectedRoutineRemoteID: routineRemoteID, + identity: otherAccount + ), + .invalid + ) + } + + func testUnboundRoutineIsInvalid() throws { + let scene = try makeScene() + + XCTAssertEqual( + scene.validator.validateCurrentBinding( + routineGroupLocalID: UUID(), + routineLocalID: UUID(), + expectedGroupRemoteID: groupRemoteID, + expectedRoutineRemoteID: routineRemoteID, + identity: scene.identity + ), + .invalid + ) + } + + /// 바인딩이 이미 있으면 서버가 만든 안내를 기다려야 한다. 로컬 루틴으로 + /// 착각하면 번들 음성을 틀어 버린다. + func testBoundRoutineReportsServerSyncIntent() throws { + let scene = try makeScene() + + XCTAssertTrue( + try scene.validator.hasServerSyncIntent( + memberID: memberID, + routineGroupLocalID: scene.groupLocalID, + routineLocalID: scene.routineLocalID + ) + ) + XCTAssertFalse( + try scene.validator.hasServerSyncIntent( + memberID: memberID, + routineGroupLocalID: UUID(), + routineLocalID: UUID() + ) + ) + } + + /// `blocked`만 스스로 풀리지 않는 상태다. 나머지는 첫 큐를 기다리는 동안 + /// 서버 바인딩을 얻을 수 있으므로 기다릴 값어치가 있다. + func testOnlyBlockedIsTreatedAsANonPendingDelivery() { + let pending: [RoutineSyncMutationState] = [ + .waitingForServerContract, .queued, .attempting, .needsReconciliation, + ] + + for state in pending { + XCTAssertTrue( + RoutineTTSBindingValidator.isDeliveryPending(state), + "\(state)" + ) + } + XCTAssertFalse(RoutineTTSBindingValidator.isDeliveryPending(.blocked)) + } + + // MARK: - 픽스처 + + private struct Scene { + let validator: RoutineTTSBindingValidator + let identity: AccountSessionIdentity + let groupLocalID: UUID + let routineLocalID: UUID + } + + private func makeScene() throws -> Scene { + let groupLocalID = UUID() + let routineLocalID = UUID() + let container = try ModelContainer.moruContainer(isStoredInMemoryOnly: true) + let bindings = SwiftDataRoutineSyncRepository(modelContainer: container) + + _ = try bindings.recordRemoteIDs( + [ + RoutineServerBindingAssignment( + entityKind: .routineGroup, + localEntityID: groupLocalID, + remoteID: groupRemoteID + ), + RoutineServerBindingAssignment( + entityKind: .routine, + localEntityID: routineLocalID, + remoteID: routineRemoteID, + parentEntityKind: .routineGroup, + parentLocalEntityID: groupLocalID + ), + ], + memberID: memberID, + at: .distantPast + ) + + return Scene( + validator: RoutineTTSBindingValidator( + bindingRepository: bindings, + serverNamespace: .production + ), + identity: AccountSessionIdentity(memberID: memberID, sessionID: UUID()), + groupLocalID: groupLocalID, + routineLocalID: routineLocalID + ) + } +} From c1435e5dfb6ef1c50dde26fc6c317294194ed696 Mon Sep 17 00:00:00 2001 From: MinHeokChoi Date: Sat, 12 Sep 2026 20:43:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Test]=20=ED=95=9C=20=EB=B2=88=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=ED=94=BD=EC=8A=A4?= =?UTF-8?q?=EC=B2=98=EC=97=90=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeWarmupFixture가 backgroundTransferManager를 넘기지 않았다. 파라미터조차 없어서 코디네이터는 모든 테스트에서 이 의존성을 nil로 받았고, 그러면 persistPlayableIntroJobIfSupported와 resumePersistedTransfersNow가 첫 guard에서 그대로 반환한다. 즉 프리페치 지속화와 전송 재개 코드는 **한 줄도 실행되지 않은 채** 초록이었다. 다음에 이 근처를 고치면 깨져도 아무도 모른다. BackgroundTransferSpy를 붙이고 여섯 가지를 고정했다. - 씬 활성화가 남은 전송을 재개한다 - 백그라운드로 내려갈 때는 재개하지 않는다 (내려가는 길에 할 일이 아니다) - 로그아웃하면 큐를 전부 버린다 — 남기면 다음 계정이 이전 계정 음성을 받는다 - 계정 전환은 새 계정 것만 재개한다 - BGAppRefresh 진입점이 전송을 정리한다 - 동기화 완료 스캔은 앱이 앞에 있을 때만 돈다 테스트가 실제로 무는지 변이로 확인했다. setSceneActive에서 resumePersistedTransfers() 제거, 로그아웃에서 discardAllTransfers() 제거, routineSyncDidComplete에서 isSceneActive 가드 제거 — 셋 다 각각 해당 테스트가 잡았다. 확인 뒤 제품 코드는 원복했다. 스모크 1,061개 통과. Co-Authored-By: Claude --- ...emoteFirstRoutineGuidancePlayerTests.swift | 161 +++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift index dbeb149..a88e107 100644 --- a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift +++ b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift @@ -812,6 +812,130 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { XCTAssertEqual(remoteCallCount, 1) } + // MARK: - 백그라운드 전송 경로 + // + // 아래 테스트들이 생기기 전까지 이 경로는 **한 번도 실행되지 않았다.** + // 픽스처가 backgroundTransferManager를 넘기지 않아 항상 nil이었고, + // 코디네이터는 매번 조기 반환했다. 초록이었지만 검증한 것이 없었다. + + func testSceneActivationResumesPersistedBackgroundTransfers() async throws { + let spy = BackgroundTransferSpy() + let fixture = try await makeWarmupFixture( + response: [], + backgroundTransferManager: spy + ) + + fixture.coordinator.setSceneActive(true) + await waitUntilWarmup { spy.resumedMemberIDs.contains(7) } + + XCTAssertEqual(spy.resumedMemberIDs, [7]) + } + + /// 백그라운드로 내려갈 때는 전송을 재개하지 않는다. 재개는 앱이 앞에 있을 + /// 때만 할 일이고, 내려가는 길에는 진행 중인 작업을 멈춰야 한다. + func testBackgroundingDoesNotResumeTransfers() async throws { + let spy = BackgroundTransferSpy() + let fixture = try await makeWarmupFixture( + response: [], + backgroundTransferManager: spy + ) + + fixture.coordinator.setSceneActive(false) + await drainWarmupTasks() + + XCTAssertTrue(spy.resumedMemberIDs.isEmpty) + XCTAssertEqual(spy.discardAllCallCount, 0) + } + + /// 로그아웃하면 큐에 남은 전송을 전부 버린다. 남겨 두면 다음 계정이 + /// 로그인했을 때 이전 계정의 음성을 내려받는다. + func testLogoutDiscardsAllBackgroundTransfers() async throws { + let spy = BackgroundTransferSpy() + let fixture = try await makeWarmupFixture( + response: [], + backgroundTransferManager: spy + ) + + fixture.identityProvider.currentAccountSessionIdentity = nil + fixture.coordinator.accountSessionDidChange() + await waitUntilWarmup { spy.discardAllCallCount == 1 } + + XCTAssertEqual(spy.discardAllCallCount, 1) + XCTAssertTrue(spy.resumedMemberIDs.isEmpty) + } + + func testAccountSwitchResumesTransfersForTheNewAccountOnly() async throws { + let spy = BackgroundTransferSpy() + let fixture = try await makeWarmupFixture( + response: [], + backgroundTransferManager: spy + ) + + fixture.identityProvider.currentAccountSessionIdentity = AccountSessionIdentity( + memberID: 9, + sessionID: UUID() + ) + fixture.coordinator.accountSessionDidChange() + await waitUntilWarmup { spy.resumedMemberIDs.contains(9) } + + XCTAssertEqual(spy.resumedMemberIDs, [9]) + XCTAssertEqual(spy.discardAllCallCount, 0) + } + + /// BGAppRefresh 진입점. 읽기 전용 상태 조회와 전송 정리만 한다. + func testBackgroundPrefetchOpportunityResumesTransfers() async throws { + let spy = BackgroundTransferSpy() + let fixture = try await makeWarmupFixture( + response: [], + backgroundTransferManager: spy + ) + + await fixture.coordinator.resumeBackgroundPrefetchOpportunity() + + XCTAssertEqual(spy.resumedMemberIDs, [7]) + } + + /// 동기화가 끝나면 새로 생긴 루틴을 데운다. 단, 앱이 앞에 있을 때만. + func testRoutineSyncCompletionScansActiveRoutinesOnlyWhileSceneIsActive() async throws { + let fixture = try await makeWarmupFixture(response: []) + + fixture.coordinator.routineSyncDidComplete() + await drainWarmupTasks() + XCTAssertEqual( + fixture.routineRepository.fetchActiveCallCount, + 0, + "화면이 뒤에 있으면 스캔하지 않는다" + ) + + fixture.coordinator.setSceneActive(true) + await waitUntilWarmup { fixture.routineRepository.fetchActiveCallCount > 0 } + let afterActivation = fixture.routineRepository.fetchActiveCallCount + + fixture.coordinator.routineSyncDidComplete() + await waitUntilWarmup { + fixture.routineRepository.fetchActiveCallCount > afterActivation + } + + XCTAssertGreaterThan( + fixture.routineRepository.fetchActiveCallCount, + afterActivation + ) + } + + private func waitUntilWarmup( + _ condition: @MainActor () -> Bool + ) async { + for _ in 0..<200 where !condition() { + try? await Task.sleep(for: .milliseconds(5)) + } + } + + private func drainWarmupTasks() async { + for _ in 0..<20 { + await Task.yield() + } + } + func testForegroundRetryCapPersistsRetryScheduledJobAndDisplayState() async throws { let pending = [remoteRoutine(id: 51, stepIDs: [71], includePending: true)] let root = FileManager.default.temporaryDirectory @@ -1339,7 +1463,8 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring = InMemoryVoiceSelectionVersionStore(), prefetchJobStore: (any RoutineTTSPrefetchJobStoring)? = nil, - preparationStatusCenter: RoutineTTSPreparationStatusCenter? = nil + preparationStatusCenter: RoutineTTSPreparationStatusCenter? = nil, + backgroundTransferManager: BackgroundTransferSpy? = nil ) async throws -> WarmupFixture { let container = try ModelContainer.moruContainer(isStoredInMemoryOnly: true) let bindings = SwiftDataRoutineSyncRepository(modelContainer: container) @@ -1391,6 +1516,7 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { foregroundPollingPolicy: foregroundPollingPolicy, voiceSelectionVersionStore: voiceSelectionVersionStore, prefetchJobStore: prefetchJobStore, + backgroundTransferManager: backgroundTransferManager, preparationStatusCenter: preparationStatusCenter ) return WarmupFixture( @@ -1402,7 +1528,8 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { remote: remote, identityProvider: identity, routineRepository: routineRepository, - root: root + root: root, + backgroundTransferManager: backgroundTransferManager ) } @@ -1455,6 +1582,7 @@ private struct WarmupFixture { let identityProvider: MutableIdentityProvider let routineRepository: WarmupRoutineRepository let root: URL + let backgroundTransferManager: BackgroundTransferSpy? func seedCachedFiles(stepIDs: [Int64]) async throws { let source = root.appendingPathComponent("seed.mp3") @@ -1849,3 +1977,32 @@ private final class RemoteTestTrialFinalizer: TrialRoutineFinalizing { ) } } + +/// 백그라운드 전송 관리자 대역. +/// +/// 예전에는 픽스처가 이걸 아예 넘기지 않아 항상 nil이었다. 그러면 코디네이터의 +/// 프리페치 지속화·전송 재개 경로가 **모든 테스트에서** 조기 반환한다 +/// (`persistPlayableIntroJobIfSupported`, `resumePersistedTransfersNow`). +/// 즉 그 코드는 한 번도 실행되지 않은 채 초록이었다. +@MainActor +final class BackgroundTransferSpy: RoutineTTSBackgroundTransferManaging { + private(set) var resumedMemberIDs: [Int64] = [] + private(set) var enqueuedJobIDs: [UUID] = [] + private(set) var discardAllCallCount = 0 + + func resumePendingTransfers( + memberID: Int64, + selectionVersion: Int64?, + selectedTTSID: Int64? + ) async { + resumedMemberIDs.append(memberID) + } + + func enqueue(jobID: UUID) async { + enqueuedJobIDs.append(jobID) + } + + func discardAllTransfers() async { + discardAllCallCount += 1 + } +}