From da1c0f0462e107d5d6f3b8e4550d4caf26ff4253 Mon Sep 17 00:00:00 2001 From: MinHeokChoi Date: Sun, 16 Aug 2026 18:53:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Feat]=20TTS=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B2=84=EC=A0=84=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Moru/Moru/App/AppRouter.swift | 7 +- .../AccountServer/AccountServerDTO.swift | 1 + .../AccountServer/AccountServerTarget.swift | 3 +- .../RemoteAccountServerService.swift | 13 ++- .../RoutineTTS/RemoteRoutineTTSService.swift | 13 +++ .../Remote/RoutineTTS/RoutineTTSDTO.swift | 1 + .../Remote/RoutineTTS/RoutineTTSTarget.swift | 3 +- .../Domain/Models/AccountServerModels.swift | 15 +++ .../Moru/Domain/Models/RoutineTTSModels.swift | 25 +++++ .../Services/RoutineTTSCuePlanResolver.swift | 7 +- .../RoutineTTSWarmupCoordinator.swift | 35 ++++++- .../AccountServerSettingsViewModel.swift | 7 +- .../Features/Profile/ProfileFlowBuilder.swift | 5 +- .../AccountServerRemoteContractTests.swift | 34 ++++++- .../AccountServerSettingsViewModelTests.swift | 19 ++-- ...emoteFirstRoutineGuidancePlayerTests.swift | 92 ++++++++++++++++++- .../RoutineTTSRemoteContractTests.swift | 23 ++++- 17 files changed, 275 insertions(+), 28 deletions(-) diff --git a/Moru/Moru/App/AppRouter.swift b/Moru/Moru/App/AppRouter.swift index 5b4acb76..23f112a6 100644 --- a/Moru/Moru/App/AppRouter.swift +++ b/Moru/Moru/App/AppRouter.swift @@ -497,9 +497,12 @@ struct AppRouter: View { UIApplication.shared.open(url) }, onResetSucceeded: resetToNewUserFlow, - onServerVoiceSelectionDidSucceed: { memberID in + onServerVoiceSelectionDidSucceed: { selection in dependencies.routineTTSWarmupCoordinator? - .serverVoiceSelectionDidChange(memberID: memberID) + .serverVoiceSelectionDidChange( + memberID: selection.memberID, + selectionVersion: selection.selectionVersion + ) } ) let mainTabState = state.mainTabState diff --git a/Moru/Moru/Data/Remote/AccountServer/AccountServerDTO.swift b/Moru/Moru/Data/Remote/AccountServer/AccountServerDTO.swift index b5aeda62..89f34faf 100644 --- a/Moru/Moru/Data/Remote/AccountServer/AccountServerDTO.swift +++ b/Moru/Moru/Data/Remote/AccountServer/AccountServerDTO.swift @@ -75,4 +75,5 @@ nonisolated struct TTSUpdateResponseDTO: let ttsId: Int64? let voiceCode: String? let displayName: String? + let selectionVersion: Int64? } diff --git a/Moru/Moru/Data/Remote/AccountServer/AccountServerTarget.swift b/Moru/Moru/Data/Remote/AccountServer/AccountServerTarget.swift index 437e3d55..ceb562e8 100644 --- a/Moru/Moru/Data/Remote/AccountServer/AccountServerTarget.swift +++ b/Moru/Moru/Data/Remote/AccountServer/AccountServerTarget.swift @@ -124,7 +124,8 @@ nonisolated enum AccountServerTarget: MoruTargetType { "memberId": 98, "ttsId": \(request.ttsId), "voiceCode": "HYEONU", - "displayName": "현우" + "displayName": "현우", + "selectionVersion": 0 } } """.utf8 diff --git a/Moru/Moru/Data/Remote/AccountServer/RemoteAccountServerService.swift b/Moru/Moru/Data/Remote/AccountServer/RemoteAccountServerService.swift index f78c44eb..6e100e8d 100644 --- a/Moru/Moru/Data/Remote/AccountServer/RemoteAccountServerService.swift +++ b/Moru/Moru/Data/Remote/AccountServer/RemoteAccountServerService.swift @@ -209,7 +209,8 @@ nonisolated private extension TTSUpdateResponseDTO { memberID: memberId, ttsID: ttsId, voiceCode: voiceCode, - displayName: displayName + displayName: displayName, + selectionVersion: try validSelectionVersion(selectionVersion) ) } } @@ -256,6 +257,16 @@ nonisolated private func normalizedOptionalText( return try normalizedRequiredText(value) } +nonisolated private func validSelectionVersion( + _ value: Int64? +) throws -> Int64? { + guard let value else { return nil } + guard value >= 0 else { + throw AccountServerRemoteError.invalidResponse + } + return value +} + nonisolated private func previewAudioURL( from value: String? ) throws -> URL? { diff --git a/Moru/Moru/Data/Remote/RoutineTTS/RemoteRoutineTTSService.swift b/Moru/Moru/Data/Remote/RoutineTTS/RemoteRoutineTTSService.swift index 04115d52..729aeca0 100644 --- a/Moru/Moru/Data/Remote/RoutineTTS/RemoteRoutineTTSService.swift +++ b/Moru/Moru/Data/Remote/RoutineTTS/RemoteRoutineTTSService.swift @@ -105,6 +105,9 @@ where Element == RoutineTTSStepResponseDTO { content: try requiredRoutineTTSText(step.content), introText: introText, status: status, + selectionVersion: try validRoutineTTSSelectionVersion( + step.selectionVersion + ), audioURL: playableAudioURL( status: status, introText: introText, @@ -167,6 +170,16 @@ nonisolated private func normalizedOptionalRoutineTTSText( return normalized.isEmpty ? nil : normalized } +nonisolated private func validRoutineTTSSelectionVersion( + _ value: Int64? +) throws -> Int64? { + guard let value else { return nil } + guard value >= 0 else { + throw RoutineTTSRemoteError.invalidResponse + } + return value +} + nonisolated private func playableAudioURL( status: ServerRoutineTTSGenerationStatus, introText: String?, diff --git a/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSDTO.swift b/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSDTO.swift index a9c638f6..b862dcd1 100644 --- a/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSDTO.swift +++ b/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSDTO.swift @@ -24,4 +24,5 @@ nonisolated struct RoutineTTSStepResponseDTO: let ttsIntro: String? let ttsStatus: String? let s3Url: String? + let selectionVersion: Int64? } diff --git a/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSTarget.swift b/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSTarget.swift index 6e371832..c18e5a87 100644 --- a/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSTarget.swift +++ b/Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSTarget.swift @@ -54,7 +54,8 @@ nonisolated enum RoutineTTSTarget: "content": "목 스트레칭", "ttsIntro": "이제 목을 부드럽게 풀어볼까요?", "ttsStatus": "COMPLETED", - "s3Url": "https://moru-tts.s3.ap-northeast-2.amazonaws.com/101.mp3" + "s3Url": "https://moru-tts.s3.ap-northeast-2.amazonaws.com/101.mp3", + "selectionVersion": 0 } ] } diff --git a/Moru/Moru/Domain/Models/AccountServerModels.swift b/Moru/Moru/Domain/Models/AccountServerModels.swift index e52c9658..ec07bd30 100644 --- a/Moru/Moru/Domain/Models/AccountServerModels.swift +++ b/Moru/Moru/Domain/Models/AccountServerModels.swift @@ -61,4 +61,19 @@ nonisolated struct ServerTTSSelection: Equatable, Sendable { let ttsID: Int64 let voiceCode: String let displayName: String + let selectionVersion: Int64? + + init( + memberID: Int64, + ttsID: Int64, + voiceCode: String, + displayName: String, + selectionVersion: Int64? = nil + ) { + self.memberID = memberID + self.ttsID = ttsID + self.voiceCode = voiceCode + self.displayName = displayName + self.selectionVersion = selectionVersion + } } diff --git a/Moru/Moru/Domain/Models/RoutineTTSModels.swift b/Moru/Moru/Domain/Models/RoutineTTSModels.swift index c63b903b..9356e642 100644 --- a/Moru/Moru/Domain/Models/RoutineTTSModels.swift +++ b/Moru/Moru/Domain/Models/RoutineTTSModels.swift @@ -23,15 +23,40 @@ nonisolated struct ServerRoutineTTSStep: Equatable, Sendable { let content: String let introText: String? let status: ServerRoutineTTSGenerationStatus + let selectionVersion: Int64? /// A remote audio URL is exposed only when the complete playable contract /// is satisfied. Callers must still download and validate the asset before /// playback rather than streaming this URL directly. let audioURL: URL? + init( + stepID: Int64, + content: String, + introText: String?, + status: ServerRoutineTTSGenerationStatus, + selectionVersion: Int64? = nil, + audioURL: URL? + ) { + self.stepID = stepID + self.content = content + self.introText = introText + self.status = status + self.selectionVersion = selectionVersion + self.audioURL = audioURL + } + var isPlayable: Bool { audioURL != nil } + + func matchesCurrentSelectionVersion(_ currentSelectionVersion: Int64?) -> Bool { + // A missing side means a legacy server contract, not a mismatch. + guard let currentSelectionVersion, let selectionVersion else { + return true + } + return currentSelectionVersion == selectionVersion + } } nonisolated enum ServerRoutineTTSGenerationStatus: Equatable, Sendable { diff --git a/Moru/Moru/Domain/Services/RoutineTTSCuePlanResolver.swift b/Moru/Moru/Domain/Services/RoutineTTSCuePlanResolver.swift index 62154f08..ee0ab124 100644 --- a/Moru/Moru/Domain/Services/RoutineTTSCuePlanResolver.swift +++ b/Moru/Moru/Domain/Services/RoutineTTSCuePlanResolver.swift @@ -28,7 +28,8 @@ nonisolated struct RoutineTTSCuePlanResolver: Sendable { routineLocalID: UUID, groupBinding: RoutineServerBinding?, routineBinding: RoutineServerBinding?, - response: [ServerRoutineTTSRoutine] + response: [ServerRoutineTTSRoutine], + currentSelectionVersion: Int64? = nil ) -> RoutineTTSCuePlanResolution { guard let groupBinding, groupBinding.entityKind == .routineGroup, @@ -67,6 +68,10 @@ nonisolated struct RoutineTTSCuePlanResolver: Sendable { hasPendingStep = true case .completed: + guard step.matchesCurrentSelectionVersion(currentSelectionVersion) else { + hasPendingStep = true + continue + } guard let audioURL = step.audioURL, audioURL.scheme?.lowercased() == "https", audioURL.host != nil else { diff --git a/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift b/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift index 59d4e5c5..1fa22335 100644 --- a/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift +++ b/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift @@ -87,6 +87,11 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio let keys: [RoutineTTSAudioCacheKey] } + private struct CurrentVoiceSelection { + let identity: AccountSessionIdentity + let version: Int64 + } + private struct RoutineTTSLocalFingerprint: Equatable { let normalizedTitle: String let type: RoutineStepType @@ -144,6 +149,9 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio private var sessionTransitionTask: Task? private var isSceneActive = false private var observedIdentity: AccountSessionIdentity? + /// Trusted only for the exact account session that received the PATCH + /// response. The current backend has no read contract for restoring it. + private var currentVoiceSelection: CurrentVoiceSelection? /// If a purge fails, normalized cache keys can still resolve old bytes. /// Keep the affected account muted until a later purge succeeds instead of /// risking the newly selected voice playing stale audio. @@ -196,6 +204,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio let previousIdentity = observedIdentity let currentIdentity = sessionIdentityProvider?.currentAccountSessionIdentity observedIdentity = currentIdentity + currentVoiceSelection = nil preparedPlans.removeAll() var memberIDsToPurge = Set() if let previousIdentity { @@ -243,12 +252,23 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio /// a new signed URL. The cache key intentionally normalizes URL queries, so /// invalidate this account's namespace before any next cue can use old /// bytes. Rewarming is deferred until the purge completes. - func serverVoiceSelectionDidChange(memberID: Int64) { + func serverVoiceSelectionDidChange( + memberID: Int64, + selectionVersion: Int64? = nil + ) { guard let identity = sessionIdentityProvider?.currentAccountSessionIdentity, identity.memberID == memberID else { return } + if let selectionVersion, selectionVersion >= 0 { + currentVoiceSelection = CurrentVoiceSelection( + identity: identity, + version: selectionVersion + ) + } else { + currentVoiceSelection = nil + } preparedPlans.removeAll() cacheUnavailableMemberIDs.insert(memberID) let previousTransition = sessionTransitionTask @@ -610,7 +630,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio routineLocalID: routineLocalID, groupBinding: groupBinding, routineBinding: routineBinding, - response: response + response: response, + currentSelectionVersion: currentSelectionVersion(for: identity) ) let assets: [RoutineTTSResolvedAsset] switch resolution { @@ -837,7 +858,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio routineLocalID: routineLocalID, groupBinding: groupBinding, routineBinding: routineBinding, - response: response + response: response, + currentSelectionVersion: currentSelectionVersion(for: identity) ) { case .pending: return .pendingGeneration @@ -1077,6 +1099,13 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio !cacheUnavailableMemberIDs.contains(identity.memberID) } + private func currentSelectionVersion( + for identity: AccountSessionIdentity + ) -> Int64? { + guard currentVoiceSelection?.identity == identity else { return nil } + return currentVoiceSelection?.version + } + private func validateCurrentBinding( for plan: PreparedPlan, planKey: LocalPlanKey, diff --git a/Moru/Moru/Features/Profile/AccountServerSettingsViewModel.swift b/Moru/Moru/Features/Profile/AccountServerSettingsViewModel.swift index cad9d2b4..740b0dd9 100644 --- a/Moru/Moru/Features/Profile/AccountServerSettingsViewModel.swift +++ b/Moru/Moru/Features/Profile/AccountServerSettingsViewModel.swift @@ -31,7 +31,8 @@ enum AccountServerResourceState: Equatable { @Observable final class AccountServerSettingsViewModel { private let remoteService: (any AccountServerRemoteServing)? - private let onServerVoiceSelectionDidSucceed: @MainActor (Int64) -> Void + private let onServerVoiceSelectionDidSucceed: + @MainActor (ServerTTSSelection) -> Void private var loadGeneration = 0 private var currentMemberID: Int64? @@ -50,7 +51,7 @@ final class AccountServerSettingsViewModel { init( remoteService: (any AccountServerRemoteServing)? = nil, onServerVoiceSelectionDidSucceed: - @escaping @MainActor (Int64) -> Void = { _ in } + @escaping @MainActor (ServerTTSSelection) -> Void = { _ in } ) { self.remoteService = remoteService self.onServerVoiceSelectionDidSucceed = onServerVoiceSelectionDidSucceed @@ -179,7 +180,7 @@ final class AccountServerSettingsViewModel { latestSelection = selection selectedTTSID = selection.ttsID if previousTTSID != selection.ttsID { - onServerVoiceSelectionDidSucceed(memberID) + onServerVoiceSelectionDidSucceed(selection) } } catch is CancellationError { return diff --git a/Moru/Moru/Features/Profile/ProfileFlowBuilder.swift b/Moru/Moru/Features/Profile/ProfileFlowBuilder.swift index 50008097..1b8afff3 100644 --- a/Moru/Moru/Features/Profile/ProfileFlowBuilder.swift +++ b/Moru/Moru/Features/Profile/ProfileFlowBuilder.swift @@ -32,7 +32,8 @@ final class DefaultProfileFlowBuilder: ProfileFlowBuilding { private let resetAvailability: @MainActor () -> Bool private let onOpenSettings: @MainActor () -> Void private let onResetSucceeded: @MainActor () -> Void - private let onServerVoiceSelectionDidSucceed: @MainActor (Int64) -> Void + private let onServerVoiceSelectionDidSucceed: + @MainActor (ServerTTSSelection) -> Void init( profileSettingsUseCase: any ProfileSettingsUseCaseProtocol, @@ -54,7 +55,7 @@ final class DefaultProfileFlowBuilder: ProfileFlowBuilding { onOpenSettings: @escaping @MainActor () -> Void, onResetSucceeded: @escaping @MainActor () -> Void, onServerVoiceSelectionDidSucceed: - @escaping @MainActor (Int64) -> Void = { _ in } + @escaping @MainActor (ServerTTSSelection) -> Void = { _ in } ) { self.profileSettingsUseCase = profileSettingsUseCase self.voicePreviewPlayer = voicePreviewPlayer diff --git a/Moru/MoruTests/AccountServerRemoteContractTests.swift b/Moru/MoruTests/AccountServerRemoteContractTests.swift index 46f4dbe7..6117a538 100644 --- a/Moru/MoruTests/AccountServerRemoteContractTests.swift +++ b/Moru/MoruTests/AccountServerRemoteContractTests.swift @@ -66,6 +66,7 @@ final class AccountServerRemoteContractTests: XCTestCase { XCTAssertEqual(streakEnvelope.result?.weeklyStatus?.count, 7) XCTAssertEqual(voiceEnvelope.result?.voices?.count, 2) XCTAssertEqual(updateEnvelope.result?.ttsId, 2) + XCTAssertEqual(updateEnvelope.result?.selectionVersion, 0) } func testFetchesSupportedAccountEndpointsWithExactPatchBody() @@ -138,7 +139,8 @@ final class AccountServerRemoteContractTests: XCTestCase { memberID: 98, ttsID: 2, voiceCode: "HYEONU", - displayName: "현우" + displayName: "현우", + selectionVersion: 0 ) ) let requests = capture.requests @@ -196,10 +198,24 @@ final class AccountServerRemoteContractTests: XCTestCase { TTSVoiceListResponseDTO.self, from: Data("{}".utf8) ) + let legacyUpdate = try decoder.decode( + TTSUpdateResponseDTO.self, + from: Data( + """ + { + "memberId": 98, + "ttsId": 2, + "voiceCode": "HYEONU", + "displayName": "현우" + } + """.utf8 + ) + ) XCTAssertNil(profile.memberId) XCTAssertNil(streak.currentStreak) XCTAssertNil(voices.voices) + XCTAssertNil(legacyUpdate.selectionVersion) let client = AccountServerPayloadAPIClient( profile: profile, @@ -217,6 +233,15 @@ final class AccountServerRemoteContractTests: XCTestCase { await assertRemoteError(.invalidResponse) { _ = try await service.fetchVoices(memberID: 98) } + + let legacyService = DefaultAccountServerRemoteService( + apiClient: AccountServerPayloadAPIClient(update: legacyUpdate) + ) + let legacySelection = try await legacyService.updateTTS( + ttsID: 2, + memberID: 98 + ) + XCTAssertNil(legacySelection.selectionVersion) } func testRejectsInvalidMemberAndTTSIDsBeforeTransport() async { @@ -384,6 +409,7 @@ final class AccountServerRemoteContractTests: XCTestCase { ttsUpdateDTO(ttsId: 0), ttsUpdateDTO(voiceCode: " "), ttsUpdateDTO(displayName: "\n"), + ttsUpdateDTO(selectionVersion: -1), ] for update in invalidUpdates { @@ -538,13 +564,15 @@ nonisolated private func ttsUpdateDTO( memberId: Int64? = 98, ttsId: Int64? = 2, voiceCode: String? = "HYEONU", - displayName: String? = "현우" + displayName: String? = "현우", + selectionVersion: Int64? = nil ) -> TTSUpdateResponseDTO { TTSUpdateResponseDTO( memberId: memberId, ttsId: ttsId, voiceCode: voiceCode, - displayName: displayName + displayName: displayName, + selectionVersion: selectionVersion ) } diff --git a/Moru/MoruTests/AccountServerSettingsViewModelTests.swift b/Moru/MoruTests/AccountServerSettingsViewModelTests.swift index e4a02fe7..cdb472dd 100644 --- a/Moru/MoruTests/AccountServerSettingsViewModelTests.swift +++ b/Moru/MoruTests/AccountServerSettingsViewModelTests.swift @@ -97,11 +97,11 @@ final class AccountServerSettingsViewModelTests: XCTestCase { streak: .success(accountSettingsStreak()), voices: .success([voice]) ) - var invalidatedMemberIDs: [Int64] = [] + var invalidatedSelections: [ServerTTSSelection] = [] let viewModel = AccountServerSettingsViewModel( remoteService: service, - onServerVoiceSelectionDidSucceed: { memberID in - invalidatedMemberIDs.append(memberID) + onServerVoiceSelectionDidSucceed: { selection in + invalidatedSelections.append(selection) } ) await viewModel.load(memberID: 98) @@ -115,12 +115,14 @@ final class AccountServerSettingsViewModelTests: XCTestCase { memberID: 98, ttsID: 2, voiceCode: "VOICE_2", - displayName: "서버 음성 2" + displayName: "서버 음성 2", + selectionVersion: 4 ) ) let calls = await service.calls XCTAssertTrue(calls.contains(.update(ttsID: 2, memberID: 98))) - XCTAssertEqual(invalidatedMemberIDs, [98]) + XCTAssertEqual(invalidatedSelections.count, 1) + XCTAssertEqual(invalidatedSelections.first, viewModel.latestSelection) } @MainActor @@ -131,8 +133,8 @@ final class AccountServerSettingsViewModelTests: XCTestCase { var invalidatedMemberIDs: [Int64] = [] let viewModel = AccountServerSettingsViewModel( remoteService: service, - onServerVoiceSelectionDidSucceed: { memberID in - invalidatedMemberIDs.append(memberID) + onServerVoiceSelectionDidSucceed: { selection in + invalidatedMemberIDs.append(selection.memberID) } ) await viewModel.load(memberID: 98) @@ -255,7 +257,8 @@ private actor AccountServerSettingsRemoteStub: memberID: memberID, ttsID: ttsID, voiceCode: "VOICE_\(ttsID)", - displayName: "서버 음성 \(ttsID)" + displayName: "서버 음성 \(ttsID)", + selectionVersion: 4 ) } diff --git a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift index 01cc73f1..8f6225fe 100644 --- a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift +++ b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift @@ -66,6 +66,42 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { ) } + func testResolverAppliesVersionGateOnlyWhenBothVersionsExist() { + let groupID = UUID() + let routineID = UUID() + let group = binding(kind: .routineGroup, localID: groupID, remoteID: 41) + let child = binding( + kind: .routine, + localID: routineID, + remoteID: 51, + parentID: groupID + ) + let expected = RoutineTTSCuePlanResolution.playable([ + asset(routineID: 51, stepID: 71), + ]) + let versionedResponse = [ + remoteRoutine(id: 51, stepIDs: [71], selectionVersion: 0), + ] + let legacyResponse = [remoteRoutine(id: 51, stepIDs: [71])] + let resolve: ([ServerRoutineTTSRoutine], Int64?) -> RoutineTTSCuePlanResolution = { + response, + currentSelectionVersion in + RoutineTTSCuePlanResolver().resolve( + routineGroupLocalID: groupID, + routineLocalID: routineID, + groupBinding: group, + routineBinding: child, + response: response, + currentSelectionVersion: currentSelectionVersion + ) + } + + XCTAssertEqual(resolve(versionedResponse, 0), expected) + XCTAssertEqual(resolve(versionedResponse, 1), .pending) + XCTAssertEqual(resolve(versionedResponse, nil), expected) + XCTAssertEqual(resolve(legacyResponse, 1), expected) + } + func testIntroUsesEveryPreparedLocalAssetInOrder() async { let groupID = UUID() let routineID = UUID() @@ -446,7 +482,10 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { let warmedURLs = await waitForLocalURLs(fixture) XCTAssertNotNil(warmedURLs) - fixture.coordinator.serverVoiceSelectionDidChange(memberID: 7) + fixture.coordinator.serverVoiceSelectionDidChange( + memberID: 7, + selectionVersion: 2 + ) let clearedURLs = await fixture.coordinator.localAudioURLs( for: fixture.localRequest() @@ -473,6 +512,36 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { XCTAssertNotNil(otherAccountURL) } + func testVoiceSelectionVersionBlocksMismatchedCompletedStep() async throws { + let downloader = RecordingWarmupDownloader() + let fixture = try await makeWarmupFixture( + response: [ + remoteRoutine(id: 51, stepIDs: [71], selectionVersion: 1), + ], + downloader: downloader + ) + + fixture.coordinator.serverVoiceSelectionDidChange( + memberID: 7, + selectionVersion: 2 + ) + fixture.coordinator.prepare( + routineGroupLocalID: fixture.groupID, + routineLocalIDs: [fixture.routineID] + ) + for _ in 0..<100 { + if await fixture.remote.callCount > 0 { break } + try? await Task.sleep(for: .milliseconds(5)) + } + + let urls = await fixture.coordinator.localAudioURLs( + for: fixture.localRequest() + ) + let downloadCallCount = await downloader.callCount + XCTAssertNil(urls) + XCTAssertEqual(downloadCallCount, 0) + } + func testWarmupRejectsRemoteTitleMismatch() async throws { let fixture = try await makeWarmupFixture( response: [remoteRoutine(id: 51, stepIDs: [71], title: "이전 제목")] @@ -646,7 +715,8 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { id: Int64, stepIDs: [Int64], includePending: Bool = false, - title: String = "루틴" + title: String = "루틴", + selectionVersion: Int64? = nil ) -> ServerRoutineTTSRoutine { var steps = stepIDs.map { stepID in ServerRoutineTTSStep( @@ -654,6 +724,7 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { content: "안내", introText: "안내", status: .completed, + selectionVersion: selectionVersion, audioURL: URL(string: "https://audio.example.com/\(stepID).mp3")! ) } @@ -864,6 +935,23 @@ private actor WarmupRemoteStub: RoutineTTSRemoteServing { } } +private actor RecordingWarmupDownloader: RoutineTTSAudioDownloading { + private(set) var callCount = 0 + + func download( + _ request: RoutineTTSAudioDownloadRequest, + stagingDirectory: URL + ) async throws -> RoutineTTSAudioDownloadedFile { + callCount += 1 + let file = stagingDirectory.appendingPathComponent("recorded.partial") + try Data("audio".utf8).write( + to: file, + options: .completeFileProtectionUnlessOpen + ) + return RoutineTTSAudioDownloadedFile(fileURL: file, byteCount: 5) + } +} + private actor SuspendedWarmupDownloader: RoutineTTSAudioDownloading { private(set) var callCount = 0 private var continuation: CheckedContinuation? diff --git a/Moru/MoruTests/RoutineTTSRemoteContractTests.swift b/Moru/MoruTests/RoutineTTSRemoteContractTests.swift index 35bc5c29..5962e417 100644 --- a/Moru/MoruTests/RoutineTTSRemoteContractTests.swift +++ b/Moru/MoruTests/RoutineTTSRemoteContractTests.swift @@ -38,6 +38,10 @@ final class RoutineTTSRemoteContractTests: XCTestCase { XCTAssertTrue(envelope.isSuccess) XCTAssertEqual(envelope.result?.first?.routineId, 14) XCTAssertEqual(envelope.result?.first?.steps?.first?.stepId, 101) + XCTAssertEqual( + envelope.result?.first?.steps?.first?.selectionVersion, + 0 + ) } func testFetchUsesExactIdentityAndMapsAllContractValues() @@ -56,7 +60,8 @@ final class RoutineTTSRemoteContractTests: XCTestCase { "content": " 첫 안내 ", "ttsIntro": " 시작해 볼까요? ", "ttsStatus": "COMPLETED", - "s3Url": " https://audio.example.com/a.mp3?signature=secret " + "s3Url": " https://audio.example.com/a.mp3?signature=secret ", + "selectionVersion": 0 }, { "stepId": 102, @@ -117,12 +122,14 @@ final class RoutineTTSRemoteContractTests: XCTestCase { XCTAssertEqual(routines[0].steps[0].content, "첫 안내") XCTAssertEqual(routines[0].steps[0].introText, "시작해 볼까요?") XCTAssertEqual(routines[0].steps[0].status, .completed) + XCTAssertEqual(routines[0].steps[0].selectionVersion, 0) XCTAssertEqual( routines[0].steps[0].audioURL?.absoluteString, "https://audio.example.com/a.mp3?signature=secret" ) XCTAssertTrue(routines[0].steps[0].isPlayable) XCTAssertEqual(routines[0].steps[1].status, .unknown("QUEUED_V2")) + XCTAssertNil(routines[0].steps[1].selectionVersion) XCTAssertNil(routines[0].steps[1].audioURL) XCTAssertEqual(routines[1].steps[0].status, .pending) XCTAssertNil(routines[1].steps[0].audioURL) @@ -146,6 +153,7 @@ final class RoutineTTSRemoteContractTests: XCTestCase { XCTAssertNil(routine.steps) XCTAssertNil(step.stepId) XCTAssertNil(step.ttsStatus) + XCTAssertNil(step.selectionVersion) let service = makeService(resultJSON: "[{}]") await assertRemoteError(.invalidResponse) { @@ -242,6 +250,19 @@ final class RoutineTTSRemoteContractTests: XCTestCase { [{"stepId":1,"content":"A","ttsStatus":" "}] """ ), + routineResult( + stepsJSON: + """ + [ + { + "stepId":1, + "content":"A", + "ttsStatus":"COMPLETED", + "selectionVersion":-1 + } + ] + """ + ), """ [ { From 63f4a57abaed2c127789bdee1151faf6a8a619de Mon Sep 17 00:00:00 2001 From: MinHeokChoi Date: Sun, 16 Aug 2026 23:48:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Feat]=20TTS=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=A1=9C=EC=BB=AC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Moru/Moru/App/DependencyContainer.swift | 3 +- ...RoutineTTSVoiceSelectionVersionStore.swift | 51 +++++++++ .../RoutineTTSWarmupCoordinator.swift | 39 ++++++- ...emoteFirstRoutineGuidancePlayerTests.swift | 105 +++++++++++++++++- 4 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 Moru/Moru/Data/Local/UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift diff --git a/Moru/Moru/App/DependencyContainer.swift b/Moru/Moru/App/DependencyContainer.swift index 5754a2d0..6426eb3f 100644 --- a/Moru/Moru/App/DependencyContainer.swift +++ b/Moru/Moru/App/DependencyContainer.swift @@ -157,7 +157,8 @@ struct DependencyContainer { routineRepository: routineRepository, audioCache: routineTTSAudioCache, downloader: RoutineTTSAudioDownloader(), - sessionIdentityProvider: sessionIdentityProvider + sessionIdentityProvider: sessionIdentityProvider, + voiceSelectionVersionStore: UserDefaultsRoutineTTSVoiceSelectionVersionStore() ) routineTTSWarmupCoordinator = warmupCoordinator let remoteFirstGuidancePlayer = RemoteFirstRoutineGuidancePlayer( diff --git a/Moru/Moru/Data/Local/UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift b/Moru/Moru/Data/Local/UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift new file mode 100644 index 00000000..8f175a8f --- /dev/null +++ b/Moru/Moru/Data/Local/UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift @@ -0,0 +1,51 @@ +// +// UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift +// Moru +// + +import Foundation + +@MainActor +final class UserDefaultsRoutineTTSVoiceSelectionVersionStore: + RoutineTTSVoiceSelectionVersionStoring { + static let defaultKeyPrefix = "routine-tts-voice-selection-version-v1" + + private let userDefaults: UserDefaults + private let keyPrefix: String + + init( + userDefaults: UserDefaults = .standard, + keyPrefix: String = UserDefaultsRoutineTTSVoiceSelectionVersionStore.defaultKeyPrefix + ) { + self.userDefaults = userDefaults + self.keyPrefix = keyPrefix + } + + func selectionVersion(forMemberID memberID: Int64) -> Int64? { + guard let number = userDefaults.object(forKey: key(for: memberID)) as? NSNumber else { + return nil + } + let version = number.int64Value + guard version >= 0 else { + removeSelectionVersion(forMemberID: memberID) + return nil + } + return version + } + + func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) { + guard version >= 0 else { + removeSelectionVersion(forMemberID: memberID) + return + } + userDefaults.set(NSNumber(value: version), forKey: key(for: memberID)) + } + + func removeSelectionVersion(forMemberID memberID: Int64) { + userDefaults.removeObject(forKey: key(for: memberID)) + } + + private func key(for memberID: Int64) -> String { + "\(keyPrefix).\(memberID)" + } +} diff --git a/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift b/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift index 1fa22335..1a719ae3 100644 --- a/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift +++ b/Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift @@ -63,6 +63,13 @@ protocol RoutineTTSWarming: AnyObject { ) async } +@MainActor +protocol RoutineTTSVoiceSelectionVersionStoring: AnyObject { + func selectionVersion(forMemberID memberID: Int64) -> Int64? + func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) + func removeSelectionVersion(forMemberID memberID: Int64) +} + extension RoutineTTSWarming { /// Keeps lightweight test and preview doubles source-compatible while the /// production coordinator supplies the foreground readiness wait. @@ -141,6 +148,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio private let resolver: RoutineTTSCuePlanResolver private let foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy private let diagnostics: RoutineTTSDiagnostics + private let voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring private weak var playbackSessionInvalidator: (any RoutineTTSPlaybackSessionInvalidating)? @@ -149,8 +157,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio private var sessionTransitionTask: Task? private var isSceneActive = false private var observedIdentity: AccountSessionIdentity? - /// Trusted only for the exact account session that received the PATCH - /// response. The current backend has no read contract for restoring it. + /// Restored only from a version previously received from this member's + /// successful PATCH response on this device. private var currentVoiceSelection: CurrentVoiceSelection? /// If a purge fails, normalized cache keys can still resolve old bytes. /// Keep the affected account muted until a later purge succeeds instead of @@ -168,7 +176,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio resolver: RoutineTTSCuePlanResolver = RoutineTTSCuePlanResolver(), foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy = RoutineTTSForegroundPollingPolicy(), - diagnostics: RoutineTTSDiagnostics = RoutineTTSDiagnostics() + diagnostics: RoutineTTSDiagnostics = RoutineTTSDiagnostics(), + voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring ) { self.remoteService = remoteService self.bindingRepository = bindingRepository @@ -180,7 +189,10 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio self.resolver = resolver self.foregroundPollingPolicy = foregroundPollingPolicy self.diagnostics = diagnostics - observedIdentity = sessionIdentityProvider.currentAccountSessionIdentity + self.voiceSelectionVersionStore = voiceSelectionVersionStore + let identity = sessionIdentityProvider.currentAccountSessionIdentity + observedIdentity = identity + currentVoiceSelection = restoredVoiceSelection(for: identity) } func setSceneActive(_ isActive: Bool) { @@ -204,7 +216,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio let previousIdentity = observedIdentity let currentIdentity = sessionIdentityProvider?.currentAccountSessionIdentity observedIdentity = currentIdentity - currentVoiceSelection = nil + currentVoiceSelection = restoredVoiceSelection(for: currentIdentity) preparedPlans.removeAll() var memberIDsToPurge = Set() if let previousIdentity { @@ -262,11 +274,16 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio } if let selectionVersion, selectionVersion >= 0 { + voiceSelectionVersionStore.setSelectionVersion( + selectionVersion, + forMemberID: memberID + ) currentVoiceSelection = CurrentVoiceSelection( identity: identity, version: selectionVersion ) } else { + voiceSelectionVersionStore.removeSelectionVersion(forMemberID: memberID) currentVoiceSelection = nil } preparedPlans.removeAll() @@ -1106,6 +1123,18 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio return currentVoiceSelection?.version } + private func restoredVoiceSelection( + for identity: AccountSessionIdentity? + ) -> CurrentVoiceSelection? { + guard let identity, + let version = voiceSelectionVersionStore.selectionVersion( + forMemberID: identity.memberID + ) else { + return nil + } + return CurrentVoiceSelection(identity: identity, version: version) + } + private func validateCurrentBinding( for plan: PreparedPlan, planKey: LocalPlanKey, diff --git a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift index 8f6225fe..85468923 100644 --- a/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift +++ b/Moru/MoruTests/RemoteFirstRoutineGuidancePlayerTests.swift @@ -542,6 +542,82 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { XCTAssertEqual(downloadCallCount, 0) } + func testPersistedVoiceSelectionVersionBlocksMismatchAfterRelaunch() async throws { + let versionStore = InMemoryVoiceSelectionVersionStore() + let firstLaunch = try await makeWarmupFixture( + response: [], + voiceSelectionVersionStore: versionStore + ) + firstLaunch.coordinator.serverVoiceSelectionDidChange( + memberID: 7, + selectionVersion: 2 + ) + + let downloader = RecordingWarmupDownloader() + let relaunched = try await makeWarmupFixture( + response: [ + remoteRoutine(id: 51, stepIDs: [71], selectionVersion: 1), + ], + downloader: downloader, + voiceSelectionVersionStore: versionStore + ) + relaunched.coordinator.prepare( + routineGroupLocalID: relaunched.groupID, + routineLocalIDs: [relaunched.routineID] + ) + for _ in 0..<100 { + if await relaunched.remote.callCount > 0 { break } + try? await Task.sleep(for: .milliseconds(5)) + } + + let urls = await relaunched.coordinator.localAudioURLs( + for: relaunched.localRequest() + ) + let downloadCallCount = await downloader.callCount + XCTAssertEqual(versionStore.selectionVersion(forMemberID: 7), 2) + XCTAssertNil(urls) + XCTAssertEqual(downloadCallCount, 0) + } + + func testMissingPatchVersionClearsPersistedSelectionVersion() async throws { + let versionStore = InMemoryVoiceSelectionVersionStore() + versionStore.setSelectionVersion(2, forMemberID: 7) + versionStore.setSelectionVersion(8, forMemberID: 8) + let fixture = try await makeWarmupFixture( + response: [], + voiceSelectionVersionStore: versionStore + ) + + fixture.coordinator.serverVoiceSelectionDidChange( + memberID: 7, + selectionVersion: nil + ) + + XCTAssertNil(versionStore.selectionVersion(forMemberID: 7)) + XCTAssertEqual(versionStore.selectionVersion(forMemberID: 8), 8) + } + + func testUserDefaultsVoiceSelectionVersionPersistsZeroPerAccount() throws { + let suiteName = "RoutineTTSVoiceSelectionVersionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let firstStore = UserDefaultsRoutineTTSVoiceSelectionVersionStore( + userDefaults: defaults + ) + firstStore.setSelectionVersion(0, forMemberID: 7) + firstStore.setSelectionVersion(4, forMemberID: 8) + + let restoredStore = UserDefaultsRoutineTTSVoiceSelectionVersionStore( + userDefaults: defaults + ) + + XCTAssertEqual(restoredStore.selectionVersion(forMemberID: 7), 0) + XCTAssertEqual(restoredStore.selectionVersion(forMemberID: 8), 4) + restoredStore.removeSelectionVersion(forMemberID: 7) + XCTAssertNil(restoredStore.selectionVersion(forMemberID: 7)) + XCTAssertEqual(restoredStore.selectionVersion(forMemberID: 8), 4) + } + func testWarmupRejectsRemoteTitleMismatch() async throws { let fixture = try await makeWarmupFixture( response: [remoteRoutine(id: 51, stepIDs: [71], title: "이전 제목")] @@ -762,7 +838,9 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { routines: [Routine] = [], downloader: any RoutineTTSAudioDownloading = RoutineTTSAudioDownloader(), foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy = - RoutineTTSForegroundPollingPolicy() + RoutineTTSForegroundPollingPolicy(), + voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring = + InMemoryVoiceSelectionVersionStore() ) async throws -> WarmupFixture { let container = try ModelContainer.moruContainer(isStoredInMemoryOnly: true) let bindings = SwiftDataRoutineSyncRepository(modelContainer: container) @@ -811,7 +889,8 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { audioCache: cache, downloader: downloader, sessionIdentityProvider: identity, - foregroundPollingPolicy: foregroundPollingPolicy + foregroundPollingPolicy: foregroundPollingPolicy, + voiceSelectionVersionStore: voiceSelectionVersionStore ) return WarmupFixture( groupID: groupID, @@ -842,6 +921,28 @@ final class RemoteFirstRoutineGuidancePlayerTests: XCTestCase { } } +@MainActor +private final class InMemoryVoiceSelectionVersionStore: + RoutineTTSVoiceSelectionVersionStoring { + private var versions: [Int64: Int64] = [:] + + func selectionVersion(forMemberID memberID: Int64) -> Int64? { + versions[memberID] + } + + func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) { + guard version >= 0 else { + removeSelectionVersion(forMemberID: memberID) + return + } + versions[memberID] = version + } + + func removeSelectionVersion(forMemberID memberID: Int64) { + versions[memberID] = nil + } +} + @MainActor private struct WarmupFixture { let groupID: UUID