diff --git a/GAMSS.xcodeproj/project.pbxproj b/GAMSS.xcodeproj/project.pbxproj index 27396be..4137c92 100644 --- a/GAMSS.xcodeproj/project.pbxproj +++ b/GAMSS.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 94A526BCCCD9DF730A39BF97 /* sample_sentences.json in Resources */ = {isa = PBXBuildFile; fileRef = D4BE9D664889F13369325DC9 /* sample_sentences.json */; }; 96AB63E38D6D2755BA3BC839 /* ConversationPreviewTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA0CD379D5E578049585DD1 /* ConversationPreviewTextFormatterTests.swift */; }; 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */; }; + 85725D9CB0A442D0A3F763B7 /* CardResultViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */; }; A2EE79F9D91A740A8F752761 /* ConversationSummaryDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ACAF83D4A9CCBB62EFD5C84 /* ConversationSummaryDTOTests.swift */; }; A8948FBCA4DF4E43F2C582EA /* Pods_GAMSS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB14F9D00BB8B80EFE9A40A4 /* Pods_GAMSS.framework */; }; A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */; }; @@ -107,6 +108,7 @@ 3C15D652D0472A1EBD024674 /* MainTabTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabTests.swift; sourceTree = ""; }; 3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatComposerViewTests.swift; sourceTree = ""; }; 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeViewModelTests.swift; sourceTree = ""; }; + E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CardResultViewModelTests.swift; sourceTree = ""; }; 411F8C36A4F71C3D2F78D616 /* CreateCardRequestDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateCardRequestDTOTests.swift; sourceTree = ""; }; 41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationSummaryPolicyTests.swift; sourceTree = ""; }; 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendMessageUseCaseTests.swift; sourceTree = ""; }; @@ -404,6 +406,7 @@ 02C393632C8A4F12516549D4 /* ConversationHistoryViewModelTests.swift */, B130F9698EB71973DB961378 /* MessageComposerLayoutTests.swift */, AB13C8E422F2136CAAAF20DF /* ChatViewModelRiskDetectionTests.swift */, + E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */, ); path = Presentation; sourceTree = ""; @@ -711,6 +714,7 @@ F29C98F332A3EC127B809DD3 /* ChatViewModelTests.swift in Sources */, FA90ADA39E7A145713881061 /* MainTabTests.swift in Sources */, 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */, + 85725D9CB0A442D0A3F763B7 /* CardResultViewModelTests.swift in Sources */, C63AB33F1424E4237FF76F6B /* SummaryErrorTests.swift in Sources */, 1D8DF6DFC7E7383B9B8C5850 /* DefaultSummaryRepositoryTests.swift in Sources */, F1D4E5A20F2B3C4D5E6F7A8B /* SpacingTests.swift in Sources */, diff --git a/GAMSS/Sources/Presentation/CardResult/CardResultView.swift b/GAMSS/Sources/Presentation/CardResult/CardResultView.swift index 6e3852d..847c264 100644 --- a/GAMSS/Sources/Presentation/CardResult/CardResultView.swift +++ b/GAMSS/Sources/Presentation/CardResult/CardResultView.swift @@ -84,6 +84,8 @@ struct CardResultView: View { private var discardableCard: some View { ZStack { + draggablePaper + trashBin .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea(edges: .bottom) @@ -97,22 +99,23 @@ struct CardResultView: View { .fixedSize() .alignmentGuide(.top) { $0[.bottom] + 29 } } - - Image("cardFoldStepTwo") - .resizable() - .frame(width: foldStepTwoSize.width, height: foldStepTwoSize.height) - .overlay(alignment: .top) { - Image("cardDiscardArrow") - .resizable() - .frame(width: discardArrowSize.width, height: discardArrowSize.height) - .offset(y: 104) - } - .offset(y: viewModel.dragOffset) - .opacity(CardResultViewModel.opacity(forDragOffset: viewModel.dragOffset)) - .gesture(discardDragGesture) } } + private var draggablePaper: some View { + Image("cardFoldStepTwo") + .resizable() + .frame(width: foldStepTwoSize.width, height: foldStepTwoSize.height) + .overlay(alignment: .top) { + Image("cardDiscardArrow") + .resizable() + .frame(width: discardArrowSize.width, height: discardArrowSize.height) + .offset(y: 104) + } + .offset(y: viewModel.dragOffset) + .gesture(discardDragGesture) + } + private var trashBin: some View { Image("cardTrashBin") .resizable() @@ -128,7 +131,11 @@ struct CardResultView: View { } .onEnded { value in if CardResultViewModel.shouldDiscard(dragOffset: max(0, value.translation.height)) { - onComplete() + withAnimation(.easeIn(duration: 0.3)) { + viewModel.drop() + } completion: { + onComplete() + } } else { withAnimation(.spring()) { viewModel.resetDrag() diff --git a/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift b/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift index 238ef8c..8a5e834 100644 --- a/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift +++ b/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift @@ -30,7 +30,7 @@ final class CardResultViewModel: ObservableObject { @Published private(set) var dragOffset: CGFloat = 0 static let discardThreshold: CGFloat = 120 - static let fadeDistance: CGFloat = 240 + static let dropOffset: CGFloat = 900 func advanceStage() { guard let next = stage.next else { return } @@ -45,12 +45,11 @@ final class CardResultViewModel: ObservableObject { dragOffset = 0 } - static func shouldDiscard(dragOffset: CGFloat) -> Bool { - dragOffset > discardThreshold + func drop() { + dragOffset = Self.dropOffset } - static func opacity(forDragOffset dragOffset: CGFloat) -> Double { - let progress = min(max(dragOffset / fadeDistance, 0), 1) - return 1.0 - progress * 0.7 + static func shouldDiscard(dragOffset: CGFloat) -> Bool { + dragOffset > discardThreshold } } diff --git a/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift index eaaa8c4..cffa30d 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift @@ -203,6 +203,7 @@ final class ChatViewModel: ObservableObject { flushPendingComments() let replyTarget = replyTarget + self.replyTarget = nil pendingUserMessage = PendingUserMessage( content: trimmed, sentAt: Date(), @@ -222,9 +223,6 @@ final class ChatViewModel: ObservableObject { excludedCharacters: excludedCharacters ) pendingUserMessage = nil - if self.replyTarget == replyTarget { - self.replyTarget = nil - } seed(with: sent) // 요약기(온디바이스 추론)가 끝날 때까지 다음 입력을 막지 않도록 백그라운드로 돌린다. @@ -233,10 +231,12 @@ final class ChatViewModel: ObservableObject { pendingSummaryUpdateTask = Task { await summaryStore.add(trimmed) } } catch let error as SendMessageValidationError { pendingUserMessage = nil + if self.replyTarget == nil { self.replyTarget = replyTarget } if input.isEmpty { input = trimmed } alertMessage = error.errorDescription } catch { pendingUserMessage = nil + if self.replyTarget == nil { self.replyTarget = replyTarget } if input.isEmpty { input = trimmed } alertMessage = "메시지를 보내지 못했어요" } diff --git a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift index 9a0faae..c1039b6 100644 --- a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift @@ -11,6 +11,7 @@ struct ConversationListHeaderView: View { var currentMode: ConversationMode var onTappedBackButton: (() -> Void)? var onTappedSearchButton: (() -> Void)? + var onTappedSettingButton: (() -> Void)? var body: some View { HStack(spacing: Spacing.spacing200) { @@ -38,8 +39,12 @@ struct ConversationListHeaderView: View { .foregroundStyle(Color.colorGray900) } - Image(.gear) - .foregroundStyle(Color.colorGray900) + Button { + onTappedSettingButton?() + } label: { + Image(.gear) + .foregroundStyle(Color.colorGray900) + } } .padding(.vertical, Spacing.spacing400) } diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift index 5f853cf..b7deff0 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -15,6 +15,7 @@ enum ConversationMode { struct ConversationListView: View { @StateObject private var viewModel: ConversationListViewModel @State private var selectedConversation: ConversationSummary? + @State private var isSettingPresented = false init(viewModel: ConversationListViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -28,6 +29,8 @@ struct ConversationListView: View { withAnimation(.easeInOut(duration: 0.3)) { viewModel.startSearching() } + }, onTappedSettingButton: { + isSettingPresented = true }) .frame(height: 64) @@ -135,6 +138,9 @@ struct ConversationListView: View { ) .toolbar(.hidden, for: .tabBar) } + .navigationDestination(isPresented: $isSettingPresented) { + SettingView().toolbar(.hidden, for: .tabBar) + } .onAppear { Task { await viewModel.load() } } diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index 08e334e..ee23a3e 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -13,6 +13,7 @@ struct HomeView: View { @State private var isSettingPresented = false @SwiftUI.Environment(UserManager.self) private var userManager @State private var loginSession = LoginSession() + @State private var isGreetingReady = false init(viewModel: HomeViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -52,6 +53,7 @@ struct HomeView: View { greeting .padding(.bottom, Spacing.spacing500) + .opacity(isGreetingReady ? 1 : 0) MessageComposerView( input: $viewModel.input, @@ -61,7 +63,9 @@ struct HomeView: View { onToggleEmotion: { viewModel.toggleEmotion($0) }, onCommit: { viewModel.send() }, onInputChange: { viewModel.updateInput($0) }, - isFocused: $isInputFocused + isFocused: $isInputFocused, + isDisabled: viewModel.isTokenExceeded, + disabledPlaceholder: viewModel.composerDisabledPlaceholder ) Spacer() @@ -95,7 +99,18 @@ struct HomeView: View { .navigationDestination(isPresented: $isSettingPresented) { SettingView().toolbar(.hidden, for: .tabBar) } - .task { await viewModel.loadProfileIfNeeded() } + .task { + if userManager.user != nil { isGreetingReady = true } + async let profile: Void = viewModel.loadProfileIfNeeded() + async let tokenUsage: Void = viewModel.loadTokenUsage() + _ = await (profile, tokenUsage) + } + .onChange(of: userManager.user != nil) { _, isReady in + guard isReady, !isGreetingReady else { return } + withAnimation(.easeInOut(duration: 0.3)) { + isGreetingReady = true + } + } .alert(viewModel.alertMessage ?? "", isPresented: Binding( get: { viewModel.alertMessage != nil }, set: { if !$0 { viewModel.alertMessage = nil } } @@ -188,6 +203,9 @@ struct HomeView: View { viewModel: HomeViewModel( fetchMyProfileUseCase: DefaultFetchMyProfileUseCase( memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) + ), + getTokenUsageUseCase: GetTokenUsageUseCase( + memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) ) ) ) diff --git a/GAMSS/Sources/Presentation/Home/HomeViewModel.swift b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift index 4d09e4b..e5ae6d3 100644 --- a/GAMSS/Sources/Presentation/Home/HomeViewModel.swift +++ b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift @@ -15,15 +15,21 @@ final class HomeViewModel: ObservableObject { @Published var pendingFirstMessage: PendingFirstMessage? @Published private(set) var selectedEmotions: Set = Set(EmotionCharacter.allCases) @Published var isEmotionPickerOpen = false + @Published private(set) var isTokenExceeded = false + + let composerDisabledPlaceholder = "오늘의 토큰을 모두 사용했어요" private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let getTokenUsageUseCase: GetTokenUsageUseCase private let userManager: UserManager init( fetchMyProfileUseCase: FetchMyProfileUseCase, + getTokenUsageUseCase: GetTokenUsageUseCase, userManager: UserManager = .shared ) { self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.getTokenUsageUseCase = getTokenUsageUseCase self.userManager = userManager } @@ -46,7 +52,19 @@ final class HomeViewModel: ObservableObject { } var isSendDisabled: Bool { - input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || selectedEmotions.isEmpty + input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || selectedEmotions.isEmpty || isTokenExceeded + } + + /// 화면 진입/재진입마다 호출한다. 채팅에서 토큰을 다 쓰고 돌아왔을 수 있어, 프로필과 + /// 달리 캐싱하지 않고 매번 최신 상태를 확인한다. + func loadTokenUsage() async { + do { + let usage = try await getTokenUsageUseCase.execute() + isTokenExceeded = usage.exceeded + } catch { + // 실패해도 조용히 무시한다 — 입력을 막을지 여부만 결정하는 부가 정보라, 홈 진입 + // 자체를 방해하는 얼럿까지는 띄우지 않는다. + } } /// 감정 선택을 토글한다. 전체 해제(0개)도 허용한다 — 그 경우 `isSendDisabled`가 true가 @@ -63,7 +81,7 @@ final class HomeViewModel: ObservableObject { /// ChatViewModel이 화면 진입 직후 pendingFirstMessage로 자동 시작한다. func send() { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty, !isTokenExceeded else { return } guard trimmed.count <= ConversationSummaryPolicy.maxMessageLength else { alertMessage = SendMessageValidationError.tooLong.errorDescription return diff --git a/GAMSS/Sources/Presentation/Home/MessageComposerView.swift b/GAMSS/Sources/Presentation/Home/MessageComposerView.swift index 5f95439..d9dd8b7 100644 --- a/GAMSS/Sources/Presentation/Home/MessageComposerView.swift +++ b/GAMSS/Sources/Presentation/Home/MessageComposerView.swift @@ -28,6 +28,9 @@ struct MessageComposerView: View { /// 포커스 상태는 상위(HomeView)가 소유한다 — 빈 화면 탭으로 키보드를 내리는 처리가 상위에 /// 있어, 이 뷰가 자체 `@FocusState`를 따로 가지면 상위에서 그 상태를 제어할 수 없다. var isFocused: FocusState.Binding + /// 오늘의 토큰을 모두 썼으면 true — 입력 자체를 막고 감정/전송 컨트롤도 숨긴다. + let isDisabled: Bool + let disabledPlaceholder: String /// 박스 하단 컨트롤 행(감정 트리거/전송 버튼)이 차지하는 높이 — expanded일 때 TextEditor /// 텍스트가 그 밑에 깔리지 않도록 그만큼 하단 여백을 예약한다. @@ -47,7 +50,14 @@ struct MessageComposerView: View { .strokeBorder(Color.colorGray950, lineWidth: 1) ) - if input.isEmpty { + if isDisabled { + Text(disabledPlaceholder) + .typography(.subtitle3) + .foregroundStyle(Color.colorGray500) + .lineLimit(1) + .padding(.leading, Spacing.spacing300) + .padding(.top, Spacing.spacing300) + } else if input.isEmpty { Text("무슨 이야기를 버려볼까요?") .typography(.subtitle3) .foregroundStyle(Color.colorGray500) @@ -67,6 +77,7 @@ struct MessageComposerView: View { .padding(.top, 8) .padding(.bottom, controlsRowHeight) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .disabled(isDisabled) .focused(isFocused) .onChange(of: isFocused.wrappedValue) { _, isFocusedNow in if isFocusedNow { isEmotionPickerOpen = false } @@ -88,9 +99,9 @@ struct MessageComposerView: View { private var controlsRow: some View { HStack { - if isEmotionSelectionEnabled { emotionTrigger } + if !isDisabled, isEmotionSelectionEnabled { emotionTrigger } Spacer() - submitButton + if !isDisabled { submitButton } } .padding(.horizontal, Spacing.spacing300) .padding(.bottom, Spacing.spacing300) @@ -175,6 +186,7 @@ private struct MessageComposerPreviewContainer: View { @State var input: String @State var selectedEmotions: Set @State var isEmotionPickerOpen: Bool + var isDisabled: Bool = false @FocusState private var isFocused: Bool var body: some View { @@ -195,7 +207,9 @@ private struct MessageComposerPreviewContainer: View { input = newValue return false }, - isFocused: $isFocused + isFocused: $isFocused, + isDisabled: isDisabled, + disabledPlaceholder: "오늘의 토큰을 모두 사용했어요" ) .padding(Spacing.spacing400) } @@ -224,3 +238,12 @@ private struct MessageComposerPreviewContainer: View { isEmotionPickerOpen: true ) } + +#Preview("token exceeded") { + MessageComposerPreviewContainer( + input: "", + selectedEmotions: Set(EmotionCharacter.allCases), + isEmotionPickerOpen: false, + isDisabled: true + ) +} diff --git a/GAMSS/Sources/Presentation/Root/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift index 050f224..511efc6 100644 --- a/GAMSS/Sources/Presentation/Root/MainTabView.swift +++ b/GAMSS/Sources/Presentation/Root/MainTabView.swift @@ -43,6 +43,9 @@ struct MainTabView: View { viewModel: HomeViewModel( fetchMyProfileUseCase: DefaultFetchMyProfileUseCase( memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) + ), + getTokenUsageUseCase: GetTokenUsageUseCase( + memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) ) ) ) diff --git a/GAMSSTests/Presentation/CardResultViewModelTests.swift b/GAMSSTests/Presentation/CardResultViewModelTests.swift index c8ec012..18309c3 100644 --- a/GAMSSTests/Presentation/CardResultViewModelTests.swift +++ b/GAMSSTests/Presentation/CardResultViewModelTests.swift @@ -68,6 +68,15 @@ final class CardResultViewModelTests: XCTestCase { XCTAssertEqual(viewModel.dragOffset, 0) } + func test_drop_setsDragOffsetToDropOffset() { + let viewModel = CardResultViewModel() + viewModel.updateDrag(translationHeight: 130) + + viewModel.drop() + + XCTAssertEqual(viewModel.dragOffset, CardResultViewModel.dropOffset) + } + func test_shouldDiscard_belowThreshold_returnsFalse() { XCTAssertFalse(CardResultViewModel.shouldDiscard(dragOffset: 0)) XCTAssertFalse(CardResultViewModel.shouldDiscard(dragOffset: 119)) @@ -76,24 +85,4 @@ final class CardResultViewModelTests: XCTestCase { func test_shouldDiscard_aboveThreshold_returnsTrue() { XCTAssertTrue(CardResultViewModel.shouldDiscard(dragOffset: 121)) } - - func test_opacity_noOffset_isFullyOpaque() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 0), 1.0) - } - - func test_opacity_atDiscardThreshold_isNotYetFullyFaded() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 120), 0.65, accuracy: 0.0001) - } - - func test_opacity_atFadeDistance_isMinimumOpacity() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 240), 0.3, accuracy: 0.0001) - } - - func test_opacity_beyondFadeDistance_staysClampedAtMinimum() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 500), 0.3, accuracy: 0.0001) - } - - func test_opacity_negativeOffset_staysFullyOpaque() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: -50), 1.0) - } } diff --git a/GAMSSTests/Presentation/ChatViewModelTests.swift b/GAMSSTests/Presentation/ChatViewModelTests.swift index 1fc10eb..06e7e22 100644 --- a/GAMSSTests/Presentation/ChatViewModelTests.swift +++ b/GAMSSTests/Presentation/ChatViewModelTests.swift @@ -425,6 +425,52 @@ final class ChatViewModelTests: XCTestCase { XCTAssertEqual(viewModel.replyTarget, newTarget, "전송 성공 처리가 그 사이에 새로 고른 답장 대상을 지우면 안 됨") } + func test_send_withReplyTarget_clearsReplyTargetImmediatelyBeforeNetworkResponds() async { + let repository = MockConversationRepository() + let gate = SendGate() + repository.sendGate = gate + repository.stubbedSendResult = .success(SentMessage( + message: Message(id: 2, conversationId: 10, sender: .user, content: "고마워", repliesToMessageId: 5, createdAt: Date(timeIntervalSince1970: 0)), + commentStatus: .done, comments: [] + )) + let viewModel = makeViewModel(repository: repository) + viewModel.startReply(to: Message(id: 5, conversationId: 10, sender: .character(.anxiety), content: "안녕하세용", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0))) + viewModel.input = "고마워" + + let sendTask = Task { await viewModel.send() } + while viewModel.pendingUserMessage == nil { + await Task.yield() + } + + XCTAssertNil(viewModel.replyTarget, "감정 캐릭터의 응답을 기다리지 않고 답장 모드가 바로 꺼져야 함") + + await gate.open() + await sendTask.value + } + + func test_send_onFailure_doesNotRestoreReplyTargetIfUserStartedNewReplyMidFlight() async { + let repository = MockConversationRepository() + let gate = SendGate() + repository.sendGate = gate + repository.stubbedSendResult = .failure(SummaryError.inferenceFailed()) + let viewModel = makeViewModel(repository: repository) + viewModel.startReply(to: Message(id: 5, conversationId: 10, sender: .character(.anxiety), content: "안녕하세용", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0))) + viewModel.input = "고마워" + + let sendTask = Task { await viewModel.send() } + while viewModel.pendingUserMessage == nil { + await Task.yield() + } + + let newTarget = Message(id: 6, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) + viewModel.startReply(to: newTarget) + + await gate.open() + await sendTask.value + + XCTAssertEqual(viewModel.replyTarget, newTarget, "전송 실패 복원이 그 사이에 새로 고른 답장 대상을 덮어쓰면 안 됨") + } + func test_send_withoutReplyTarget_pendingUserMessageHasNoQuote() async { let repository = MockConversationRepository() let gate = SendGate() diff --git a/GAMSSTests/Presentation/HomeViewModelTests.swift b/GAMSSTests/Presentation/HomeViewModelTests.swift index 73019e3..d6e6fb2 100644 --- a/GAMSSTests/Presentation/HomeViewModelTests.swift +++ b/GAMSSTests/Presentation/HomeViewModelTests.swift @@ -18,14 +18,30 @@ private final class MockFetchMyProfileUseCase: FetchMyProfileUseCase { } } +private final class MockMemberRepository: MemberRepository { + var stubbedTokenUsageResult: Result = .failure(SummaryError.inferenceFailed()) + private(set) var fetchTokenUsageCallCount = 0 + + func deleteMember() async throws { fatalError("not used in this test") } + func fetchMyProfile() async throws -> User { fatalError("not used in this test") } + func updateNickname(_ nickname: String) async throws -> User { fatalError("not used in this test") } + + func fetchTokenUsage() async throws -> TokenUsage { + fetchTokenUsageCallCount += 1 + return try stubbedTokenUsageResult.get() + } +} + @MainActor final class HomeViewModelTests: XCTestCase { private func makeViewModel( fetchMyProfileUseCase: MockFetchMyProfileUseCase = MockFetchMyProfileUseCase(), + memberRepository: MockMemberRepository = MockMemberRepository(), userManager: UserManager = UserManager() ) -> HomeViewModel { HomeViewModel( fetchMyProfileUseCase: fetchMyProfileUseCase, + getTokenUsageUseCase: GetTokenUsageUseCase(memberRepository: memberRepository), userManager: userManager ) } @@ -183,4 +199,50 @@ final class HomeViewModelTests: XCTestCase { XCTAssertTrue(viewModel.selectedEmotions.isEmpty, "사전 조건: 전체 해제 상태여야 함") XCTAssertTrue(viewModel.isSendDisabled, "감정을 전체 제외하면 입력이 있어도 전송은 막혀야 함") } + + func test_loadTokenUsage_whenNotExceeded_doesNotDisableSend() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 10, dailyLimit: 100, exceeded: false)) + let viewModel = makeViewModel(memberRepository: memberRepository) + viewModel.input = "안녕" + + await viewModel.loadTokenUsage() + + XCTAssertFalse(viewModel.isTokenExceeded) + XCTAssertFalse(viewModel.isSendDisabled) + } + + func test_loadTokenUsage_whenExceeded_disablesSend() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 100, dailyLimit: 100, exceeded: true)) + let viewModel = makeViewModel(memberRepository: memberRepository) + viewModel.input = "안녕" + + await viewModel.loadTokenUsage() + + XCTAssertTrue(viewModel.isTokenExceeded) + XCTAssertTrue(viewModel.isSendDisabled, "토큰을 다 쓰면 입력이 있어도 전송이 막혀야 함") + } + + func test_loadTokenUsage_onFailure_leavesExceededStateUnchanged() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .failure(SummaryError.inferenceFailed()) + let viewModel = makeViewModel(memberRepository: memberRepository) + + await viewModel.loadTokenUsage() + + XCTAssertFalse(viewModel.isTokenExceeded, "조회 실패 시 조용히 무시하고 입력을 막지 않아야 함") + } + + func test_send_whenTokenExceeded_doesNotSetPendingFirstMessage() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 100, dailyLimit: 100, exceeded: true)) + let viewModel = makeViewModel(memberRepository: memberRepository) + await viewModel.loadTokenUsage() + viewModel.input = "안녕" + + viewModel.send() + + XCTAssertNil(viewModel.pendingFirstMessage, "토큰을 다 쓰면 전송 자체가 막혀야 함") + } }