diff --git a/GAMSS.xcodeproj/project.pbxproj b/GAMSS.xcodeproj/project.pbxproj index c59227f..1237ad4 100644 --- a/GAMSS.xcodeproj/project.pbxproj +++ b/GAMSS.xcodeproj/project.pbxproj @@ -17,16 +17,18 @@ 3D1B542CB60C3958D3F16702 /* EmotionCharacterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */; }; 3EF2F6DB34A2276C2C9FD746 /* diary_samples.json in Resources */ = {isa = PBXBuildFile; fileRef = ED231F0A80BD3A4AF9E50BBC /* diary_samples.json */; }; 573CFD0A1638F27B889EF865 /* SendMessageUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */; }; + 58652E08F4413DEA2016C082 /* MessageBubbleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */; }; 5E6EF3C5D7CE4FF3EE4392EC /* ConversationMessageDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */; }; 5F8A6F3F851E20BCFE3F805D /* CommentRevealPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */; }; + 66BC114B170E43E9C900F517 /* QuotedReplyHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */; }; 70248D85300794C800857369 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 70248D84300794C800857369 /* FirebaseCrashlytics */; }; 70248DCD300D429100857369 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 70248DCC300D429100857369 /* FirebaseAuth */; }; 8F4E518B79C7B76223B098BA /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = 8E8487F478AF703D394C5EB6 /* Tokenizers */; }; 94A526BCCCD9DF730A39BF97 /* sample_sentences.json in Resources */ = {isa = PBXBuildFile; fileRef = D4BE9D664889F13369325DC9 /* sample_sentences.json */; }; 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */; }; - FA90ADA39E7A145713881061 /* MainTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C15D652D0472A1EBD024674 /* MainTabTests.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 */; }; + A9E97BA8DEC5EE7635B77D1F /* ChatComposerViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */; }; ABF5C91182F3B409C1AA9E8E /* GetMessagesUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A67D7BECCB85A88C969AD7 /* GetMessagesUseCaseTests.swift */; }; B32ACCF04698A89364EDDECE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 316EA002FAC5D3328796EF90 /* Foundation.framework */; }; BA7748118B0D2759E0F6A207 /* Pods_GAMSSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F1CC8FA289575E4B98C41539 /* Pods_GAMSSTests.framework */; }; @@ -36,10 +38,12 @@ CC604C7607382102024E0FA1 /* ConversationSummaryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */; }; DC1369FEAE93D794107D5373 /* Color_GAMSSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596EE0F239D37460A7BA389D /* Color_GAMSSTests.swift */; }; E3A3BE7330A1CCAB9E144B41 /* DefaultEmotionAnalysisRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B816EE4A02D0B23C50F35FF9 /* DefaultEmotionAnalysisRepositoryTests.swift */; }; + EA9C48AD6033E92B4734D506 /* MessageTimestampFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5370238416571DC93C29121 /* MessageTimestampFormatterTests.swift */; }; EE5555555555555555555555 /* TypographyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4444444444444444444444 /* TypographyTests.swift */; }; F1D4E5A20F2B3C4D5E6F7A8B /* SpacingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D4E5A30F2B3C4D5E6F7A8C /* SpacingTests.swift */; }; F29C98F332A3EC127B809DD3 /* ChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5C85E76690E41AE394895D /* ChatViewModelTests.swift */; }; F2D4E5A40F2B3C4D5E6F7A9D /* RadiusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5A50F2B3C4D5E6F7A9E /* RadiusTests.swift */; }; + FA90ADA39E7A145713881061 /* MainTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C15D652D0472A1EBD024674 /* MainTabTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,13 +60,16 @@ 032B0D990023EAA1E72E1E82 /* DefaultConversationSummaryStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultConversationSummaryStoreTests.swift; sourceTree = ""; }; 162D28B313B7D3B6D79FE039 /* SummaryErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SummaryErrorTests.swift; sourceTree = ""; }; 1A8F630A15A80F5FAB52F2C1 /* Pods-GAMSSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GAMSSTests.debug.xcconfig"; path = "Target Support Files/Pods-GAMSSTests/Pods-GAMSSTests.debug.xcconfig"; sourceTree = ""; }; + 1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QuotedReplyHeaderTests.swift; sourceTree = ""; }; 316EA002FAC5D3328796EF90 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeViewModelTests.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 596EE0F239D37460A7BA389D /* Color_GAMSSTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Color_GAMSSTests.swift; sourceTree = ""; }; 5B4F594F8D1059D3DF30DE92 /* GAMSSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GAMSSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageBubbleLayoutTests.swift; sourceTree = ""; }; 708F57F33003C651008DF433 /* GAMSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GAMSS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7674D757C326ABEDB6302AA6 /* GAMSSTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GAMSSTests.swift; sourceTree = ""; }; 79F056320618BF2744EAC909 /* EmotionTestViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionTestViewModelTests.swift; sourceTree = ""; }; @@ -83,6 +90,7 @@ CEB4219C48B0B2A51FA9457C /* EmotionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionTests.swift; sourceTree = ""; }; D4BE9D664889F13369325DC9 /* sample_sentences.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = sample_sentences.json; sourceTree = ""; }; DD4444444444444444444444 /* TypographyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TypographyTests.swift; sourceTree = ""; }; + E5370238416571DC93C29121 /* MessageTimestampFormatterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageTimestampFormatterTests.swift; sourceTree = ""; }; E7BE3E28AC7871B572DD4B7E /* Pods-GAMSSTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GAMSSTests.release.xcconfig"; path = "Target Support Files/Pods-GAMSSTests/Pods-GAMSSTests.release.xcconfig"; sourceTree = ""; }; EAD22D6FD3BB2E8E689A52BE /* Pods-GAMSS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GAMSS.debug.xcconfig"; path = "Target Support Files/Pods-GAMSS/Pods-GAMSS.debug.xcconfig"; sourceTree = ""; }; EB14F9D00BB8B80EFE9A40A4 /* Pods_GAMSS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GAMSS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -280,6 +288,10 @@ BE5C85E76690E41AE394895D /* ChatViewModelTests.swift */, 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */, 3C15D652D0472A1EBD024674 /* MainTabTests.swift */, + E5370238416571DC93C29121 /* MessageTimestampFormatterTests.swift */, + 1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */, + 6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */, + 3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */, ); path = Presentation; sourceTree = ""; @@ -588,6 +600,10 @@ A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */, 5E6EF3C5D7CE4FF3EE4392EC /* ConversationMessageDTOTests.swift in Sources */, C8DE812C64C04B4EE47E3636 /* DefaultConversationRepositoryTests.swift in Sources */, + EA9C48AD6033E92B4734D506 /* MessageTimestampFormatterTests.swift in Sources */, + 66BC114B170E43E9C900F517 /* QuotedReplyHeaderTests.swift in Sources */, + 58652E08F4413DEA2016C082 /* MessageBubbleLayoutTests.swift in Sources */, + A9E97BA8DEC5EE7635B77D1F /* ChatComposerViewTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/GAMSS/Sources/App/GAMSSApp.swift b/GAMSS/Sources/App/GAMSSApp.swift index c5d4914..b443764 100644 --- a/GAMSS/Sources/App/GAMSSApp.swift +++ b/GAMSS/Sources/App/GAMSSApp.swift @@ -24,6 +24,10 @@ struct GAMSSApp: App { var body: some Scene { WindowGroup { MainTabView() + .preferredColorScheme(.light) } + // 디자인 토큰이 다크모드 값을 갖고 있지만 디자이너가 아직 다크모드 화면을 설계하지 + // 않았다 — 시스템 다크모드를 그대로 따라가면 색상이 라이트/다크 목적과 반대로 + // 뒤집혀 보이므로 앱 전체를 라이트 모드로 고정한다. } } diff --git a/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift b/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift index c53b119..bb53ac8 100644 --- a/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift +++ b/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift @@ -9,7 +9,8 @@ import Foundation /// 서버 필드명과 1:1 (Android ConversationMessage.kt 기준). senderType이 "CHARACTER"일 때만 /// emotionType이 채워진다. senderType이 알 수 없거나 emotionType을 못 알아보면 toDomain()이 -/// nil을 반환 — 잘못된 주체로 그리는 것보다 목록에서 제외하는 쪽을 택한다. +/// nil을 반환 — 잘못된 주체로 그리는 것보다 목록에서 제외하는 쪽을 택한다. createdAt 파싱 +/// 실패도 같은 이유로 nil을 반환한다. struct ConversationMessageDTO: Decodable { let id: Int let conversationId: Int @@ -25,6 +26,25 @@ struct ConversationMessageDTO: Decodable { "GRUMPY": .prickly, "WARM": .sadness, "QUIRKY": .quirky, ] + /// 화면에는 어차피 분 단위까지만 표시하므로(MessageTimestampFormatter), 소수초 정밀도는 + /// 필요 없다. 서버가 소수초 포함 타임스탬프(예: "2026-08-07T00:00:00.123Z")를 보내더라도 + /// 포매터를 두 개 두고 두 번 시도하는 대신, 소수초 부분만 잘라내고 포매터 하나로 파싱한다. + /// ISO8601DateFormatter는 동시 접근 안전성이 문서화되어 있지 않아 인스턴스를 static으로 + /// 공유하지 않고 호출마다 새로 만든다 — 히스토리 로드와 메시지 전송 응답이 동시에 디코딩될 + /// 수 있기 때문. + private static func parseCreatedAt(_ value: String) -> Date? { + ISO8601DateFormatter().date(from: stripFractionalSeconds(value)) + } + + /// 소수초 부분(".123" 등)만 제거한다. 뒤에 타임존 지정자(Z/+/-)가 없어도 소수초 자체는 + /// 잘라내야 하므로, 그 존재를 전제하지 않고 "."부터 이어지는 숫자만 건너뛴다. + private static func stripFractionalSeconds(_ value: String) -> String { + guard let dotIndex = value.firstIndex(of: ".") else { return value } + let afterDot = value[value.index(after: dotIndex)...] + let suffixStart = afterDot.firstIndex(where: { !$0.isNumber }) ?? afterDot.endIndex + return String(value[.. Message? { let sender: MessageSender switch senderType { @@ -36,11 +56,21 @@ struct ConversationMessageDTO: Decodable { default: return nil } - return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId) + guard let createdAtDate = Self.parseCreatedAt(createdAt) else { return nil } + return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId, createdAt: createdAtDate) } - /// 방금 보낸 사용자 메시지 응답 전용 — 항상 .user로 간주한다. + /// 방금 보낸 사용자 메시지 응답 전용 — 항상 .user로 간주한다. createdAt 파싱에 실패해도 + /// (반환 타입이 옵셔널이 아니라) 크래시 없이 현재 시각으로 대체한다 — 서버가 방금 만든 + /// 응답이라 실제로 실패할 일은 거의 없는 안전망일 뿐이다. func toSentUserMessage() -> Message { - Message(id: id, conversationId: conversationId, sender: .user, content: content, repliesToMessageId: repliesToMessageId) + Message( + id: id, + conversationId: conversationId, + sender: .user, + content: content, + repliesToMessageId: repliesToMessageId, + createdAt: Self.parseCreatedAt(createdAt) ?? Date() + ) } } diff --git a/GAMSS/Sources/Domain/Conversation/Message.swift b/GAMSS/Sources/Domain/Conversation/Message.swift index a52a466..b6ff565 100644 --- a/GAMSS/Sources/Domain/Conversation/Message.swift +++ b/GAMSS/Sources/Domain/Conversation/Message.swift @@ -5,6 +5,8 @@ // Created by cchanmi on 8/7/26. // +import Foundation + enum MessageSender: Equatable { case user case character(EmotionCharacter) @@ -16,6 +18,7 @@ struct Message: Identifiable, Equatable { let sender: MessageSender let content: String let repliesToMessageId: Int? + let createdAt: Date } enum CommentGenerationStatus: Equatable { diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift index 3764764..82c3ce5 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -15,28 +15,45 @@ struct ChatView: View { _viewModel = StateObject(wrappedValue: viewModel) } + /// ScrollView 콘텐츠(LazyVStack)에 적용하는 좌우 패딩. `maxBubbleWidth` 계산에 쓰는 + /// containerPadding이 아래 `.padding(containerPadding)`과 같은 값을 참조하도록 상수 하나로 + /// 묶어서, 패딩을 바꿀 때 폭 계산이 따로 놀지 않게 한다. + private let containerPadding = Spacing.spacing300 + var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.messages) { message in - MessageRow( - message: message, - quotedMessage: viewModel.quotedMessage(for: message) - ) - .id(message.id) + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: Spacing.spacing200) { + ForEach(viewModel.messages) { message in + MessageBubbleView( + message: message, + quotedMessage: viewModel.quotedMessage(for: message), + maxWidth: MessageBubbleLayout.maxBubbleWidth( + availableWidth: geometry.size.width, + containerPadding: containerPadding * 2 + ) + ) + .id(message.id) + } } + .padding(containerPadding) + } + // ScrollView가 키보드에 의해 축소/복원될 때 + // SwiftUI가 키보드 dismiss를 자연스럽게 처리하도록 한다. + .scrollDismissesKeyboard(.interactively) + .onChange(of: viewModel.messages) { _, newMessages in + scrollToBottom(proxy, messages: newMessages) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + ChatComposerView( + text: $viewModel.input, + isSendDisabled: viewModel.isSendDisabled, + onSend: { Task { await viewModel.send() } }, + onTextChange: { viewModel.updateInput($0) }, + isFocused: $isInputFocused + ) } - .padding() - } - // ScrollView가 키보드에 의해 축소/복원될 때 - // SwiftUI가 키보드 dismiss를 자연스럽게 처리하도록 한다. - .scrollDismissesKeyboard(.interactively) - .onChange(of: viewModel.messages) { _, newMessages in - scrollToBottom(proxy, messages: newMessages) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - composer } } .task { @@ -59,36 +76,6 @@ struct ChatView: View { } } - private var composer: some View { - HStack(alignment: .bottom, spacing: 8) { - TextField( - "메시지를 입력하세요", - text: $viewModel.input, - axis: .vertical - ) - .textFieldStyle(.roundedBorder) - .focused($isInputFocused) - .lineLimit(1...5) - .onChange(of: viewModel.input) { _, newValue in - if viewModel.updateInput(newValue) { - // 별도의 animation 없이 포커스만 해제한다. - // keyboard dismiss와 layout animation이 충돌하는 것을 방지한다. - isInputFocused = false - } - } - - Button("전송") { - Task { - await viewModel.send() - } - } - .disabled(viewModel.isSendDisabled) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(.background) - } - private func scrollToBottom( _ proxy: ScrollViewProxy, messages: [Message] @@ -106,69 +93,6 @@ struct ChatView: View { } } -private struct MessageRow: View { - let message: Message - let quotedMessage: Message? - - var body: some View { - HStack(alignment: .top, spacing: 8) { - switch message.sender { - case .user: - Spacer() - - bubble - - case let .character(emotion): - Circle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 28, height: 28) - - VStack(alignment: .leading, spacing: 4) { - Text(emotion.displayName) - .font(.caption) - .foregroundStyle(.secondary) - - bubble - } - - Spacer() - } - } - } - - private var bubble: some View { - VStack(alignment: .leading, spacing: 6) { - if let quotedMessage { - VStack(alignment: .leading, spacing: 4) { - Text(quotedMessage.content) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - - Divider() - } - } - - Text(message.content) - } - .padding(10) - .background(background) - .clipShape( - RoundedRectangle(cornerRadius: 12) - ) - } - - private var background: Color { - switch message.sender { - case .user: - .blue.opacity(0.2) - - case .character: - .gray.opacity(0.2) - } - } -} - #Preview { ChatView( viewModel: ChatViewModel( diff --git a/GAMSS/Sources/Presentation/Chat/Components/BubbleWidthLayout.swift b/GAMSS/Sources/Presentation/Chat/Components/BubbleWidthLayout.swift new file mode 100644 index 0000000..7dbe447 --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/BubbleWidthLayout.swift @@ -0,0 +1,53 @@ +// +// BubbleWidthLayout.swift +// GAMSS +// +// Created by cchanmi on 8/13/26. +// + +import SwiftUI + +/// 콘텐츠의 intrinsic 너비와 `maxWidth` 중 작은 값을 버블의 크기로 사용한다. +/// +/// `.frame(maxWidth:)`만으로는 부모 HStack이 공간을 분배하는 방식에 따라 +/// 짧은 메시지에서도 버블이 `maxWidth`까지 늘어날 수 있다. 이 Layout은 +/// 부모가 어떤 크기를 제안하든 `proposal`을 무시하고 항상 +/// `min(intrinsicWidth, maxWidth)`를 스스로 계산해 보고하므로, 부모 HStack의 +/// Spacer가 남는 공간을 온전히 가져가고 버블은 콘텐츠 크기만큼만 차지한다. +struct BubbleWidthLayout: Layout { + let maxWidth: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + guard let subview = subviews.first else { + return .zero + } + + let idealWidth = subview.sizeThatFits(.unspecified).width + let targetWidth = min(idealWidth, maxWidth) + + // 결정된 targetWidth로 다시 측정한다. maxWidth를 초과했던 콘텐츠는 + // 여기서 targetWidth 폭에 맞춰 wrapping된다. + return subview.sizeThatFits(ProposedViewSize(width: targetWidth, height: nil)) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + guard let subview = subviews.first else { + return + } + + subview.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) + } +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/ChatComposerView.swift b/GAMSS/Sources/Presentation/Chat/Components/ChatComposerView.swift new file mode 100644 index 0000000..e29d473 --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/ChatComposerView.swift @@ -0,0 +1,96 @@ +// +// ChatComposerView.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import SwiftUI + +/// 채팅 입력창. 텍스트가 늘어나면서 최대 5줄까지 커지고 그 이후 내부 스크롤되는 동작은 +/// TextField(axis: .vertical) + lineLimit(1...5)가 iOS 16+에서 기본 제공하므로 새로 구현하지 +/// 않는다 — 이 뷰는 배경/테두리/텍스트 스타일과 전송 버튼 노출 조건만 담당한다. +struct ChatComposerView: View { + @Binding var text: String + let isSendDisabled: Bool + let onSend: () -> Void + let onTextChange: (String) -> Bool + @FocusState.Binding var isFocused: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: Spacing.spacing100) { + TextField( + "메시지 입력", + text: $text, + // TextField(prompt:)는 Text 타입을 요구해서 `.typography(_:)`(some View 반환)를 + // 쓸 수 없다 — Text 자체의 font/tracking으로 직접 맞춘다. lineSpacing은 Text에 + // 없는 API라 여기선 적용 대상이 아니다. + prompt: Text("메시지 입력") + .font(.custom(Typography.body4Medium.metrics.weight.postScriptName(), size: Typography.body4Medium.metrics.fontSize)) + .tracking(Typography.body4Medium.metrics.letterSpacing) + .foregroundColor(Color.colorGray400), + axis: .vertical + ) + .typography(.body4Medium) + .foregroundStyle(Color.colorGray950) + .tint(Color.colorGray950) + .focused($isFocused) + .lineLimit(1...5) + .onChange(of: text) { _, newValue in + if onTextChange(newValue) { + isFocused = false + } + } + .padding(.vertical, Spacing.spacing150) + .padding(.horizontal, Spacing.spacing200) + .background(Color.colorGray025) + .overlay( + Rectangle() + .strokeBorder(Color.colorGray950, lineWidth: 1) + ) + + if Self.hasText(text) { + sendButton + } + } + .padding(.horizontal, 18) + .padding(.vertical, Spacing.spacing200) + .background(Color.colorWhite) + } + + static func hasText(_ text: String) -> Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var sendButton: some View { + Button(action: onSend) { + Image(systemName: "arrow.up") + .foregroundStyle(Color.colorGray025) + .frame(width: 24, height: 24) + .padding(Spacing.spacing100) + .background( + RoundedRectangle(cornerRadius: Radius.radius200) + .fill(Color.colorGray900) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.radius200) + .strokeBorder(Color.colorGray950, lineWidth: 1) + ) + } + .disabled(isSendDisabled) + .accessibilityLabel("전송") + } +} + +#Preview { + @Previewable @State var text = "" + @Previewable @FocusState var isFocused: Bool + + return ChatComposerView( + text: $text, + isSendDisabled: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onSend: {}, + onTextChange: { _ in false }, + isFocused: $isFocused + ) +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/EmotionAvatarView.swift b/GAMSS/Sources/Presentation/Chat/Components/EmotionAvatarView.swift new file mode 100644 index 0000000..3b2fcfe --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/EmotionAvatarView.swift @@ -0,0 +1,32 @@ +// +// EmotionAvatarView.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import SwiftUI + +/// 캐릭터 아바타 placeholder. 실제 캐릭터 이미지 에셋이 전달되기 전까지는 빈 원으로 표시한다. +/// emotion을 받아두는 이유는 이미지가 준비되면 캐릭터별 이미지로 교체하기 위함. +struct EmotionAvatarView: View { + let emotion: EmotionCharacter + + var body: some View { + Circle() + .fill(Color.colorWhite) + .overlay( + Circle().strokeBorder(Color.colorGray200, lineWidth: 1) + ) + .frame(width: 24, height: 24) + } +} + +#Preview { + HStack(spacing: Spacing.spacing200) { + ForEach(Array(EmotionCharacter.allCases.enumerated()), id: \.offset) { _, emotion in + EmotionAvatarView(emotion: emotion) + } + } + .padding(Spacing.spacing300) +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleLayout.swift b/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleLayout.swift new file mode 100644 index 0000000..0aa9d0d --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleLayout.swift @@ -0,0 +1,31 @@ +// +// MessageBubbleLayout.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import CoreGraphics + +/// 메시지 버블의 최대 너비를 계산한다. 버블이 붙는 쪽 반대편에 최소 84px 여백을 +/// 확보해야 한다는 디자인 규칙(Figma "bubble > maximum 영역")을 그대로 옮긴 것. +enum MessageBubbleLayout { + static let oppositeMargin: CGFloat = 84 + + /// 수신 버블 행을 오른쪽으로 밀어내는 들여쓰기. 두 곳에서 함께 써야 값이 어긋나지 않는다: + /// MessageBubbleView가 `.padding(.leading:)`로 행을 실제로 밀어내는 곳, 그리고 + /// `receivedBubbleMaxWidth`가 그만큼을 버블 최대 너비에서 미리 빼는 곳. 이 값을 바꾸면 + /// 두 사용처 모두 자동으로 갱신되지만, 들여쓰기를 적용하는 방식 자체를 바꿀 때는(예: + /// 아바타 폭 기반으로 변경) 두 곳을 함께 확인해야 한다. + static let receivedIndent: CGFloat = Spacing.spacing600 + + static func maxBubbleWidth(availableWidth: CGFloat, containerPadding: CGFloat) -> CGFloat { + // 첫 SwiftUI 레이아웃 패스에서 geometry.size.width가 0일 수 있어 결과가 음수가 + // 될 수 있다 — 음수 프레임 폭을 방지하기 위해 0으로 클램프한다. + max(0, availableWidth - oppositeMargin - containerPadding) + } + + static func receivedBubbleMaxWidth(maxWidth: CGFloat) -> CGFloat { + max(0, maxWidth - receivedIndent) + } +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleView.swift b/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleView.swift new file mode 100644 index 0000000..4e5c2f3 --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/MessageBubbleView.swift @@ -0,0 +1,109 @@ +// +// MessageBubbleView.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import SwiftUI + +/// 채팅 메시지 한 건을 sent(사용자)/received(캐릭터)/receivedReply(캐릭터의 인용 답장) +/// 스타일로 렌더링한다. +struct MessageBubbleView: View { + let message: Message + let quotedMessage: Message? + let maxWidth: CGFloat + + var body: some View { + HStack(alignment: .bottom, spacing: Spacing.spacing100) { + switch message.sender { + case .user: + Spacer(minLength: 0) + timestamp + bubble + + case let .character(emotion): + VStack(alignment: .leading, spacing: Spacing.spacing050) { + header(for: emotion) + HStack(alignment: .bottom, spacing: Spacing.spacing100) { + bubble + timestamp + } + .padding(.leading, MessageBubbleLayout.receivedIndent) + } + Spacer(minLength: 0) + } + } + } + + private func header(for emotion: EmotionCharacter) -> some View { + HStack(spacing: Spacing.spacing075) { + EmotionAvatarView(emotion: emotion) + Text(emotion.displayName) + .typography(.body4Medium) + .foregroundStyle(Color.colorGray800) + } + } + + private var quotedHeaderLabel: String? { + guard let quotedMessage else { return nil } + return QuotedReplyHeader.label(forQuotedSender: quotedMessage.sender) + } + + private var bubble: some View { + BubbleWidthLayout(maxWidth: bubbleMaxWidth) { + VStack(alignment: .leading, spacing: Spacing.spacing050) { + if let quotedHeaderLabel, let quotedMessage { + Text(quotedHeaderLabel) + .typography(.body5Medium) + .foregroundStyle(Color.colorGray950) + Text(quotedMessage.content) + .typography(.body4Medium) + .foregroundStyle(Color.colorGray500) + .lineLimit(1) + Rectangle() + .fill(Color.colorGray200) + .frame(height: 1) + } + + Text(message.content) + .typography(.body4Medium) + .foregroundStyle(bodyTextColor) + } + .padding(.vertical, Spacing.spacing100) + .padding(.horizontal, Spacing.spacing200) + } + .background(bubbleBackground) + .overlay( + Rectangle() + .strokeBorder(Color.colorGray950, lineWidth: 1) + ) + } + + private var bubbleMaxWidth: CGFloat { + switch message.sender { + case .user: maxWidth + case .character: MessageBubbleLayout.receivedBubbleMaxWidth(maxWidth: maxWidth) + } + } + + private var timestamp: some View { + Text(MessageTimestampFormatter.string(from: message.createdAt)) + .typography(.body6Regular) + .foregroundStyle(Color.colorGray600) + } + + private var bodyTextColor: Color { + switch message.sender { + case .user: Color.colorGray025 + case .character: Color.colorGray950 + } + } + + private var bubbleBackground: Color { + switch message.sender { + case .user: Color.colorGray900 + case .character: Color.colorGray025 + } + } +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/MessageTimestampFormatter.swift b/GAMSS/Sources/Presentation/Chat/Components/MessageTimestampFormatter.swift new file mode 100644 index 0000000..e13fe16 --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/MessageTimestampFormatter.swift @@ -0,0 +1,29 @@ +// +// MessageTimestampFormatter.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import Foundation + +/// 채팅 메시지 타임스탬프를 "오후 1:37" 형식으로 포맷한다. 기기 로케일/타임존과 무관하게 +/// 한국어 표기로 고정 — 앱 UI가 전부 한국어이기 때문. DateFormatter는 생성 비용이 커서 +/// static으로 캐싱해 메시지 행마다 새로 만들지 않는다. +enum MessageTimestampFormatter { + private static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + // 타임존을 한국으로 고정하는 것도 의도적인 선택이다 — 현재 앱 사용자층과 콘텐츠가 + // 전부 한국 기준이라 실기기 타임존과 무관하게 고정한다. 일반적인 국제화 전략이 + // 아니라 지금 단계의 단순화라는 점을 남겨둔다. + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} diff --git a/GAMSS/Sources/Presentation/Chat/Components/QuotedReplyHeader.swift b/GAMSS/Sources/Presentation/Chat/Components/QuotedReplyHeader.swift new file mode 100644 index 0000000..858876b --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/Components/QuotedReplyHeader.swift @@ -0,0 +1,22 @@ +// +// QuotedReplyHeader.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +/// 인용 답장 버블 상단의 "OOO에게 답장" 라벨을 계산한다. +/// +/// 캐릭터의 1차 댓글은 repliesToMessageId가 거의 항상 사용자 메시지를 가리키는데 +/// (대화가 어디서 시작됐는지 나타내는 앵커 포인터), 이 경우는 헤더를 보여주지 않는 게 +/// 기본 경로다. 헤더는 캐릭터가 다른 캐릭터의 메시지를 인용하는 실제 스레드 답장에서만 보인다. +enum QuotedReplyHeader { + static func label(forQuotedSender sender: MessageSender) -> String? { + switch sender { + case let .character(emotion): + return "\(emotion.displayName)에게 답장" + case .user: + return nil + } + } +} diff --git a/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift b/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift index 82cf4c6..f51998b 100644 --- a/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift +++ b/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift @@ -16,6 +16,44 @@ final class ConversationMessageDTOTests: XCTestCase { ) } + func test_toDomain_parsesCreatedAtAsISO8601Date() throws { + let dto = makeDTO(senderType: "USER", emotionType: nil) + + let createdAt = try XCTUnwrap(dto.toDomain()?.createdAt) + + let formatter = ISO8601DateFormatter() + XCTAssertEqual(createdAt, formatter.date(from: "2026-08-07T00:00:00Z")) + } + + func test_toDomain_fractionalSecondsCreatedAt_parsesSuccessfullyIgnoringFraction() throws { + let dto = ConversationMessageDTO( + id: 1, conversationId: 10, senderType: "USER", emotionType: nil, + content: "내용", repliesToMessageId: nil, rootMessageId: nil, createdAt: "2026-08-07T00:00:00.500Z" + ) + + let createdAt = try XCTUnwrap(dto.toDomain()?.createdAt) + + // 소수초는 화면 표시에 필요 없어 잘라내고 파싱한다 — 초 단위까지만 일치하면 된다. + let formatter = ISO8601DateFormatter() + XCTAssertEqual(createdAt, formatter.date(from: "2026-08-07T00:00:00Z")) + } + + func test_toDomain_invalidCreatedAt_returnsNil() { + let dto = ConversationMessageDTO( + id: 1, conversationId: 10, senderType: "USER", emotionType: nil, + content: "내용", repliesToMessageId: nil, rootMessageId: nil, createdAt: "이상한값" + ) + + XCTAssertNil(dto.toDomain(), "createdAt 파싱에 실패하면 잘못된 시간으로 표시하는 대신 목록에서 제외되어야 함") + } + + func test_toSentUserMessage_parsesCreatedAt() throws { + let dto = makeDTO(senderType: "CHARACTER", emotionType: "JOY") + + let formatter = ISO8601DateFormatter() + XCTAssertEqual(dto.toSentUserMessage().createdAt, formatter.date(from: "2026-08-07T00:00:00Z")) + } + func test_toDomain_userSender_mapsToUserMessage() { let dto = makeDTO(senderType: "USER", emotionType: nil) XCTAssertEqual(dto.toDomain()?.sender, .user) diff --git a/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift b/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift index 1e5acb7..c400803 100644 --- a/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift +++ b/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift @@ -30,8 +30,8 @@ final class GetMessagesUseCaseTests: XCTestCase { func test_execute_returnsMessagesFromRepository() async throws { let repository = MockConversationRepository() repository.stubbedMessages = [ - Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil), - Message(id: 2, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: 1), + Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)), + Message(id: 2, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: 1, createdAt: Date(timeIntervalSince1970: 0)), ] let useCase = GetMessagesUseCase(conversationRepository: repository) diff --git a/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift b/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift index d69f86c..2d7da0e 100644 --- a/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift +++ b/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift @@ -38,7 +38,7 @@ final class SendMessageUseCaseTests: XCTestCase { func test_execute_passesAllParametersThrough() async throws { let repository = MockConversationRepository() let sent = SentMessage( - message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil), + message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)), commentStatus: .done, comments: [] ) @@ -97,7 +97,7 @@ final class SendMessageUseCaseTests: XCTestCase { func test_execute_contentAtMaxLength_isAllowed() async throws { let repository = MockConversationRepository() let sent = SentMessage( - message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil), + message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)), commentStatus: .done, comments: [] ) diff --git a/GAMSSTests/Presentation/ChatComposerViewTests.swift b/GAMSSTests/Presentation/ChatComposerViewTests.swift new file mode 100644 index 0000000..55eae18 --- /dev/null +++ b/GAMSSTests/Presentation/ChatComposerViewTests.swift @@ -0,0 +1,23 @@ +// +// ChatComposerViewTests.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import XCTest +@testable import GAMSS + +final class ChatComposerViewTests: XCTestCase { + func test_hasText_blankInput_returnsFalse() { + XCTAssertFalse(ChatComposerView.hasText(" ")) + } + + func test_hasText_emptyInput_returnsFalse() { + XCTAssertFalse(ChatComposerView.hasText("")) + } + + func test_hasText_nonBlankInput_returnsTrue() { + XCTAssertTrue(ChatComposerView.hasText("안녕")) + } +} diff --git a/GAMSSTests/Presentation/ChatViewModelTests.swift b/GAMSSTests/Presentation/ChatViewModelTests.swift index 14d4fef..ad47cac 100644 --- a/GAMSSTests/Presentation/ChatViewModelTests.swift +++ b/GAMSSTests/Presentation/ChatViewModelTests.swift @@ -61,9 +61,9 @@ final class ChatViewModelTests: XCTestCase { func test_send_onSuccess_appendsSentMessageAndFirstCommentImmediately() async { let repository = MockConversationRepository() - let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil) - let comment1 = Message(id: 2, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: 1) - let comment2 = Message(id: 3, conversationId: 10, sender: .character(.joy), content: "오늘 어때?", repliesToMessageId: 1) + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) + let comment1 = Message(id: 2, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: 1, createdAt: Date(timeIntervalSince1970: 0)) + let comment2 = Message(id: 3, conversationId: 10, sender: .character(.joy), content: "오늘 어때?", repliesToMessageId: 1, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [comment1, comment2])) let viewModel = makeViewModel(repository: repository) viewModel.input = "안녕" @@ -77,7 +77,7 @@ final class ChatViewModelTests: XCTestCase { func test_send_onSuccess_addsContentToSummaryStoreAfterUpdatingMessages() async { let repository = MockConversationRepository() - let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "오늘 힘들었어", repliesToMessageId: nil) + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "오늘 힘들었어", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [])) let summaryStore = MockConversationSummaryStore() let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) @@ -106,7 +106,7 @@ final class ChatViewModelTests: XCTestCase { func test_send_usesCurrentContextSummaryFromStore() async { let repository = MockConversationRepository() repository.stubbedSendResult = .success(SentMessage( - message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil), + message: Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)), commentStatus: .done, comments: [] )) let summaryStore = MockConversationSummaryStore() @@ -121,7 +121,7 @@ final class ChatViewModelTests: XCTestCase { func test_send_commentStatusNotDone_keepsMessageAndSetsAlert() async { let repository = MockConversationRepository() - let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil) + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .limitExceeded, comments: [])) let viewModel = makeViewModel(repository: repository) viewModel.input = "안녕" @@ -145,8 +145,8 @@ final class ChatViewModelTests: XCTestCase { func test_load_populatesMessagesAndRestoresSummaryStoreWithUserUtterancesOnly() async { let repository = MockConversationRepository() - let userMessage = Message(id: 1, conversationId: 10, sender: .user, content: "사용자 발화", repliesToMessageId: nil) - let characterMessage = Message(id: 2, conversationId: 10, sender: .character(.sadness), content: "캐릭터 답장", repliesToMessageId: 1) + let userMessage = Message(id: 1, conversationId: 10, sender: .user, content: "사용자 발화", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) + let characterMessage = Message(id: 2, conversationId: 10, sender: .character(.sadness), content: "캐릭터 답장", repliesToMessageId: 1, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedMessages = [userMessage, characterMessage] let summaryStore = MockConversationSummaryStore() let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) @@ -160,7 +160,7 @@ final class ChatViewModelTests: XCTestCase { func test_start_withInitialSentMessage_seedsWithoutLoadingHistory() async { let repository = MockConversationRepository() - let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil) + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) let initialSentMessage = SentMessage(message: sentMessage, commentStatus: .done, comments: []) let viewModel = makeViewModel(repository: repository, conversationId: 10, initialSentMessage: initialSentMessage) @@ -172,7 +172,7 @@ final class ChatViewModelTests: XCTestCase { func test_start_withConversationIdOnly_loadsHistory() async { let repository = MockConversationRepository() - let userMessage = Message(id: 1, conversationId: 10, sender: .user, content: "사용자 발화", repliesToMessageId: nil) + let userMessage = Message(id: 1, conversationId: 10, sender: .user, content: "사용자 발화", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedMessages = [userMessage] let viewModel = makeViewModel(repository: repository, conversationId: 10) diff --git a/GAMSSTests/Presentation/HomeViewModelTests.swift b/GAMSSTests/Presentation/HomeViewModelTests.swift index 9da7d7a..45d8e8a 100644 --- a/GAMSSTests/Presentation/HomeViewModelTests.swift +++ b/GAMSSTests/Presentation/HomeViewModelTests.swift @@ -34,7 +34,7 @@ final class HomeViewModelTests: XCTestCase { func test_send_onSuccess_setsCreatedConversationIdAndClearsInput() async { let repository = MockConversationRepository() - let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil) + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "안녕", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [])) let viewModel = makeViewModel(repository: repository) viewModel.input = "안녕" diff --git a/GAMSSTests/Presentation/MessageBubbleLayoutTests.swift b/GAMSSTests/Presentation/MessageBubbleLayoutTests.swift new file mode 100644 index 0000000..6fe7e1e --- /dev/null +++ b/GAMSSTests/Presentation/MessageBubbleLayoutTests.swift @@ -0,0 +1,41 @@ +// +// MessageBubbleLayoutTests.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import XCTest +@testable import GAMSS + +final class MessageBubbleLayoutTests: XCTestCase { + func test_maxBubbleWidth_subtractsOppositeMarginAndContainerPadding() { + let result = MessageBubbleLayout.maxBubbleWidth(availableWidth: 390, containerPadding: 32) + + XCTAssertEqual(result, 390 - 84 - 32) + } + + func test_maxBubbleWidth_zeroContainerPadding_onlySubtractsOppositeMargin() { + let result = MessageBubbleLayout.maxBubbleWidth(availableWidth: 200, containerPadding: 0) + + XCTAssertEqual(result, 200 - 84) + } + + func test_maxBubbleWidth_zeroAvailableWidth_clampsToZeroInsteadOfNegative() { + let result = MessageBubbleLayout.maxBubbleWidth(availableWidth: 0, containerPadding: 0) + + XCTAssertEqual(result, 0) + } + + func test_receivedBubbleMaxWidth_subtractsIndent() { + let result = MessageBubbleLayout.receivedBubbleMaxWidth(maxWidth: 260) + + XCTAssertEqual(result, 260 - 32) + } + + func test_receivedBubbleMaxWidth_clampsToZeroInsteadOfNegative() { + let result = MessageBubbleLayout.receivedBubbleMaxWidth(maxWidth: 10) + + XCTAssertEqual(result, 0) + } +} diff --git a/GAMSSTests/Presentation/MessageTimestampFormatterTests.swift b/GAMSSTests/Presentation/MessageTimestampFormatterTests.swift new file mode 100644 index 0000000..2945139 --- /dev/null +++ b/GAMSSTests/Presentation/MessageTimestampFormatterTests.swift @@ -0,0 +1,30 @@ +// +// MessageTimestampFormatterTests.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import XCTest +@testable import GAMSS + +final class MessageTimestampFormatterTests: XCTestCase { + private func makeDate(hour: Int, minute: Int) -> Date { + var components = DateComponents() + components.timeZone = TimeZone(identifier: "Asia/Seoul") + components.year = 2026 + components.month = 8 + components.day = 7 + components.hour = hour + components.minute = minute + return Calendar(identifier: .gregorian).date(from: components)! + } + + func test_string_afternoonTime_usesPMMarker() { + XCTAssertEqual(MessageTimestampFormatter.string(from: makeDate(hour: 13, minute: 37)), "오후 1:37") + } + + func test_string_morningTime_usesAMMarker() { + XCTAssertEqual(MessageTimestampFormatter.string(from: makeDate(hour: 9, minute: 5)), "오전 9:05") + } +} diff --git a/GAMSSTests/Presentation/QuotedReplyHeaderTests.swift b/GAMSSTests/Presentation/QuotedReplyHeaderTests.swift new file mode 100644 index 0000000..411b594 --- /dev/null +++ b/GAMSSTests/Presentation/QuotedReplyHeaderTests.swift @@ -0,0 +1,25 @@ +// +// QuotedReplyHeaderTests.swift +// GAMSS +// +// Created by cchanmi on 8/12/26. +// + +import XCTest +@testable import GAMSS + +final class QuotedReplyHeaderTests: XCTestCase { + func test_label_characterSender_returnsReplyToCharacterName() { + XCTAssertEqual(QuotedReplyHeader.label(forQuotedSender: .character(.anxiety)), "불안에게 답장") + } + + func test_label_differentCharacterSender_usesThatCharactersName() { + XCTAssertEqual(QuotedReplyHeader.label(forQuotedSender: .character(.prickly)), "까칠이에게 답장") + } + + func test_label_userSender_returnsNil() { + // 캐릭터의 1차 댓글은 대부분 사용자 메시지를 가리키므로(주 경로), + // 이 경우 인용 헤더 없이 일반 버블로 렌더링되어야 한다. + XCTAssertNil(QuotedReplyHeader.label(forQuotedSender: .user)) + } +}