diff --git a/GAMSS.xcodeproj/project.pbxproj b/GAMSS.xcodeproj/project.pbxproj index cd016a4..894cce8 100644 --- a/GAMSS.xcodeproj/project.pbxproj +++ b/GAMSS.xcodeproj/project.pbxproj @@ -10,25 +10,34 @@ 06BCF4ADC30405F292D7B64C /* EmotionTestViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79F056320618BF2744EAC909 /* EmotionTestViewModelTests.swift */; }; 10B076C4CBD5237ECB6B305D /* DefaultAnalyzeEmotionUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E353FF7D86EAC9E125F484 /* DefaultAnalyzeEmotionUseCaseTests.swift */; }; 16FD4F6D002D9911BCDA46DD /* GAMSSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7674D757C326ABEDB6302AA6 /* GAMSSTests.swift */; }; - 1D8DF6DFC7E7383B9B8C5850 /* DefaultDiarySummaryRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17C099A15F7D9B1481998D0 /* DefaultDiarySummaryRepositoryTests.swift */; }; + 1CE64E3A4D746FFBE391D949 /* DefaultConversationSummaryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032B0D990023EAA1E72E1E82 /* DefaultConversationSummaryStoreTests.swift */; }; + 1D8DF6DFC7E7383B9B8C5850 /* DefaultSummaryRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17C099A15F7D9B1481998D0 /* DefaultSummaryRepositoryTests.swift */; }; 24EED49AAB4C574B02E9C077 /* EmotionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4219C48B0B2A51FA9457C /* EmotionTests.swift */; }; 26A84A0255BB5BB7139414C5 /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = 4F92A4F86371ED0E8E85D78F /* Tokenizers */; }; + 3D1B542CB60C3958D3F16702 /* EmotionCharacterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */; }; 3EF2F6DB34A2276C2C9FD746 /* diary_samples.json in Resources */ = {isa = PBXBuildFile; fileRef = ED231F0A80BD3A4AF9E50BBC /* diary_samples.json */; }; - 44668B62F14343DDBC28C579 /* SummaryTestViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9F903541DFC66E33484B0E /* SummaryTestViewModelTests.swift */; }; + 573CFD0A1638F27B889EF865 /* SendMessageUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */; }; + 5E6EF3C5D7CE4FF3EE4392EC /* ConversationMessageDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */; }; + 5F8A6F3F851E20BCFE3F805D /* CommentRevealPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */; }; 70248D85300794C800857369 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 70248D84300794C800857369 /* FirebaseCrashlytics */; }; 70248DCD300D429100857369 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 70248DCC300D429100857369 /* FirebaseAuth */; }; - 7171BAFFE141BFB241321110 /* DefaultSummarizeDiaryUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEBF9C5ED345443E4A4BED7 /* DefaultSummarizeDiaryUseCaseTests.swift */; }; 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 */; }; A8948FBCA4DF4E43F2C582EA /* Pods_GAMSS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB14F9D00BB8B80EFE9A40A4 /* Pods_GAMSS.framework */; }; + 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 */; }; - C63AB33F1424E4237FF76F6B /* DiarySummaryErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162D28B313B7D3B6D79FE039 /* DiarySummaryErrorTests.swift */; }; + C63AB33F1424E4237FF76F6B /* SummaryErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162D28B313B7D3B6D79FE039 /* SummaryErrorTests.swift */; }; + C8DE812C64C04B4EE47E3636 /* DefaultConversationRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBFC0FF1B12BFFA1F771ADA /* DefaultConversationRepositoryTests.swift */; }; CC3333333333333333333333 /* FontWeightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2222222222222222222222 /* FontWeightTests.swift */; }; + 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 */; }; + A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.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 */; }; /* End PBXBuildFile section */ @@ -43,25 +52,32 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 0D9F903541DFC66E33484B0E /* SummaryTestViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SummaryTestViewModelTests.swift; sourceTree = ""; }; - 162D28B313B7D3B6D79FE039 /* DiarySummaryErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiarySummaryErrorTests.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + 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; }; 708F57F33003C651008DF433 /* GAMSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GAMSS.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 70A259383022EBB100043BC0 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = /Users/igeonjun/Desktop/GAMSS/GAMSS/Config/Debug.xcconfig; sourceTree = ""; }; - 70A259393022EBB100043BC0 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = /Users/igeonjun/Desktop/GAMSS/GAMSS/Config/Release.xcconfig; sourceTree = ""; }; 7674D757C326ABEDB6302AA6 /* GAMSSTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GAMSSTests.swift; sourceTree = ""; }; + 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateMessageRequestDTOTests.swift; sourceTree = ""; }; 79F056320618BF2744EAC909 /* EmotionTestViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionTestViewModelTests.swift; sourceTree = ""; }; - A17C099A15F7D9B1481998D0 /* DefaultDiarySummaryRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultDiarySummaryRepositoryTests.swift; sourceTree = ""; }; + 7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionCharacterTests.swift; sourceTree = ""; }; + 83A67D7BECCB85A88C969AD7 /* GetMessagesUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GetMessagesUseCaseTests.swift; sourceTree = ""; }; + 86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommentRevealPolicyTests.swift; sourceTree = ""; }; + A17C099A15F7D9B1481998D0 /* DefaultSummaryRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultSummaryRepositoryTests.swift; sourceTree = ""; }; + B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationMessageDTOTests.swift; sourceTree = ""; }; B816EE4A02D0B23C50F35FF9 /* DefaultEmotionAnalysisRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultEmotionAnalysisRepositoryTests.swift; sourceTree = ""; }; - BAEBF9C5ED345443E4A4BED7 /* DefaultSummarizeDiaryUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultSummarizeDiaryUseCaseTests.swift; sourceTree = ""; }; BB2222222222222222222222 /* FontWeightTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FontWeightTests.swift; sourceTree = ""; }; + BE5C85E76690E41AE394895D /* ChatViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatViewModelTests.swift; sourceTree = ""; }; BECB25622D5F9ADA8FA5E5B6 /* Pods-GAMSS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GAMSS.release.xcconfig"; path = "Target Support Files/Pods-GAMSS/Pods-GAMSS.release.xcconfig"; sourceTree = ""; }; C2D4E5A30F2B3C4D5E6F7A8C /* SpacingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpacingTests.swift; sourceTree = ""; }; C3D4E5A50F2B3C4D5E6F7A9E /* RadiusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RadiusTests.swift; sourceTree = ""; }; C6E353FF7D86EAC9E125F484 /* DefaultAnalyzeEmotionUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultAnalyzeEmotionUseCaseTests.swift; sourceTree = ""; }; + CDBFC0FF1B12BFFA1F771ADA /* DefaultConversationRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultConversationRepositoryTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -182,11 +198,21 @@ isa = PBXGroup; children = ( C6E353FF7D86EAC9E125F484 /* DefaultAnalyzeEmotionUseCaseTests.swift */, - BAEBF9C5ED345443E4A4BED7 /* DefaultSummarizeDiaryUseCaseTests.swift */, + 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */, + 83A67D7BECCB85A88C969AD7 /* GetMessagesUseCaseTests.swift */, ); path = UseCase; sourceTree = ""; }; + 5BFD0AF9307B5D7A4F8263C8 /* DTO */ = { + isa = PBXGroup; + children = ( + 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */, + B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */, + ); + path = DTO; + sourceTree = ""; + }; 708F57EA3003C651008DF433 = { isa = PBXGroup; children = ( @@ -212,7 +238,8 @@ isa = PBXGroup; children = ( B816EE4A02D0B23C50F35FF9 /* DefaultEmotionAnalysisRepositoryTests.swift */, - A17C099A15F7D9B1481998D0 /* DefaultDiarySummaryRepositoryTests.swift */, + A17C099A15F7D9B1481998D0 /* DefaultSummaryRepositoryTests.swift */, + CDBFC0FF1B12BFFA1F771ADA /* DefaultConversationRepositoryTests.swift */, ); path = Repository; sourceTree = ""; @@ -248,7 +275,8 @@ isa = PBXGroup; children = ( 79F056320618BF2744EAC909 /* EmotionTestViewModelTests.swift */, - 0D9F903541DFC66E33484B0E /* SummaryTestViewModelTests.swift */, + BE5C85E76690E41AE394895D /* ChatViewModelTests.swift */, + 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */, ); path = Presentation; sourceTree = ""; @@ -272,7 +300,7 @@ isa = PBXGroup; children = ( CEB4219C48B0B2A51FA9457C /* EmotionTests.swift */, - 162D28B313B7D3B6D79FE039 /* DiarySummaryErrorTests.swift */, + 162D28B313B7D3B6D79FE039 /* SummaryErrorTests.swift */, ); path = Entity; sourceTree = ""; @@ -297,6 +325,7 @@ isa = PBXGroup; children = ( 7A27C7CF1F9DFCE4338AD7FC /* Repository */, + 5BFD0AF9307B5D7A4F8263C8 /* DTO */, ); path = Data; sourceTree = ""; @@ -306,10 +335,22 @@ children = ( DC46D39DE9FC00DCA99B4FFD /* Entity */, 314DAF5013EE9BEFD1FC9A6B /* UseCase */, + FA252C1182C38998DC0EC012 /* Conversation */, ); path = Domain; sourceTree = ""; }; + FA252C1182C38998DC0EC012 /* Conversation */ = { + isa = PBXGroup; + children = ( + 7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */, + 86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */, + 032B0D990023EAA1E72E1E82 /* DefaultConversationSummaryStoreTests.swift */, + 41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */, + ); + path = Conversation; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -529,15 +570,24 @@ 10B076C4CBD5237ECB6B305D /* DefaultAnalyzeEmotionUseCaseTests.swift in Sources */, E3A3BE7330A1CCAB9E144B41 /* DefaultEmotionAnalysisRepositoryTests.swift in Sources */, 06BCF4ADC30405F292D7B64C /* EmotionTestViewModelTests.swift in Sources */, - C63AB33F1424E4237FF76F6B /* DiarySummaryErrorTests.swift in Sources */, - 7171BAFFE141BFB241321110 /* DefaultSummarizeDiaryUseCaseTests.swift in Sources */, - 1D8DF6DFC7E7383B9B8C5850 /* DefaultDiarySummaryRepositoryTests.swift in Sources */, - 44668B62F14343DDBC28C579 /* SummaryTestViewModelTests.swift in Sources */, + F29C98F332A3EC127B809DD3 /* ChatViewModelTests.swift in Sources */, + 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */, + C63AB33F1424E4237FF76F6B /* SummaryErrorTests.swift in Sources */, + 1D8DF6DFC7E7383B9B8C5850 /* DefaultSummaryRepositoryTests.swift in Sources */, F1D4E5A20F2B3C4D5E6F7A8B /* SpacingTests.swift in Sources */, F2D4E5A40F2B3C4D5E6F7A9D /* RadiusTests.swift in Sources */, DC1369FEAE93D794107D5373 /* Color_GAMSSTests.swift in Sources */, CC3333333333333333333333 /* FontWeightTests.swift in Sources */, EE5555555555555555555555 /* TypographyTests.swift in Sources */, + 3D1B542CB60C3958D3F16702 /* EmotionCharacterTests.swift in Sources */, + CC604C7607382102024E0FA1 /* ConversationSummaryPolicyTests.swift in Sources */, + 5F8A6F3F851E20BCFE3F805D /* CommentRevealPolicyTests.swift in Sources */, + 1CE64E3A4D746FFBE391D949 /* DefaultConversationSummaryStoreTests.swift in Sources */, + 573CFD0A1638F27B889EF865 /* SendMessageUseCaseTests.swift in Sources */, + ABF5C91182F3B409C1AA9E8E /* GetMessagesUseCaseTests.swift in Sources */, + A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */, + 5E6EF3C5D7CE4FF3EE4392EC /* ConversationMessageDTOTests.swift in Sources */, + C8DE812C64C04B4EE47E3636 /* DefaultConversationRepositoryTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -678,8 +728,7 @@ }; 708F57FF3003C653008DF433 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 708F57F53003C651008DF433 /* GAMSS */; - baseConfigurationReferenceRelativePath = Config/Debug.xcconfig; + baseConfigurationReference = EAD22D6FD3BB2E8E689A52BE /* Pods-GAMSS.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -720,8 +769,7 @@ }; 708F58003003C653008DF433 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 708F57F53003C651008DF433 /* GAMSS */; - baseConfigurationReferenceRelativePath = Config/Release.xcconfig; + baseConfigurationReference = BECB25622D5F9ADA8FA5E5B6 /* Pods-GAMSS.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -762,8 +810,7 @@ }; 8A305D6E137D5FF38853FE59 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 708F57F53003C651008DF433 /* GAMSS */; - baseConfigurationReferenceRelativePath = Config/Debug.xcconfig; + baseConfigurationReference = 1A8F630A15A80F5FAB52F2C1 /* Pods-GAMSSTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; @@ -779,8 +826,7 @@ }; AE0A9E514EEAD6687005E404 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 708F57F53003C651008DF433 /* GAMSS */; - baseConfigurationReferenceRelativePath = Config/Release.xcconfig; + baseConfigurationReference = E7BE3E28AC7871B572DD4B7E /* Pods-GAMSSTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; diff --git a/GAMSS/Sources/App/GAMSSApp.swift b/GAMSS/Sources/App/GAMSSApp.swift index 0cc7599..c5d4914 100644 --- a/GAMSS/Sources/App/GAMSSApp.swift +++ b/GAMSS/Sources/App/GAMSSApp.swift @@ -23,7 +23,7 @@ struct GAMSSApp: App { var body: some Scene { WindowGroup { - LoginView(viewModel: LoginViewModel(loginUseCase: DefaultLoginUseCase(authRepository: DefaultAuthRepository(networkManager: NetworkManager.shared, tokenStorage: TokenStorage.shared)))) + MainTabView() } } } diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift index b2cb615..42d441f 100644 --- a/GAMSS/Sources/Core/Network/NetworkManager.swift +++ b/GAMSS/Sources/Core/Network/NetworkManager.swift @@ -34,11 +34,18 @@ final class NetworkManager: NetworkRequesting { ) async throws -> T { let request = try endpoint.asURLRequest() - + + let bodyString = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "-" + Log.debug("๐Ÿ“ค \(endpoint.method.rawValue) \(endpoint.path) body: \(bodyString)") + let (data, response) = try await session.data( for: request ) - + + if let jsonString = String(data: data, encoding: .utf8) { + Log.debug("๐Ÿ“ฆ \(endpoint.path) response: \(jsonString)") + } + guard let response = response as? HTTPURLResponse else { throw NetworkError.invalidResponse } diff --git a/GAMSS/Sources/Data/DTO/Chat/Request/CreateMessageRequestDTO.swift b/GAMSS/Sources/Data/DTO/Chat/Request/CreateMessageRequestDTO.swift index 8f821e5..92b16fc 100644 --- a/GAMSS/Sources/Data/DTO/Chat/Request/CreateMessageRequestDTO.swift +++ b/GAMSS/Sources/Data/DTO/Chat/Request/CreateMessageRequestDTO.swift @@ -8,9 +8,9 @@ import Foundation struct CreateMessageRequestDTO: Encodable { - let conversationId: Int + let conversationId: Int? let content: String - let repliesMessageId: Int - let currentConversationSummary: String + let repliesToMessageId: Int? + let currentConversationSummary: String? let excludeCharacters: [String] } diff --git a/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift b/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift new file mode 100644 index 0000000..c53b119 --- /dev/null +++ b/GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift @@ -0,0 +1,46 @@ +// +// ConversationMessageDTO.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import Foundation + +/// ์„œ๋ฒ„ ํ•„๋“œ๋ช…๊ณผ 1:1 (Android ConversationMessage.kt ๊ธฐ์ค€). senderType์ด "CHARACTER"์ผ ๋•Œ๋งŒ +/// emotionType์ด ์ฑ„์›Œ์ง„๋‹ค. senderType์ด ์•Œ ์ˆ˜ ์—†๊ฑฐ๋‚˜ emotionType์„ ๋ชป ์•Œ์•„๋ณด๋ฉด toDomain()์ด +/// nil์„ ๋ฐ˜ํ™˜ โ€” ์ž˜๋ชป๋œ ์ฃผ์ฒด๋กœ ๊ทธ๋ฆฌ๋Š” ๊ฒƒ๋ณด๋‹ค ๋ชฉ๋ก์—์„œ ์ œ์™ธํ•˜๋Š” ์ชฝ์„ ํƒํ•œ๋‹ค. +struct ConversationMessageDTO: Decodable { + let id: Int + let conversationId: Int + let senderType: String + let emotionType: String? + let content: String + let repliesToMessageId: Int? + let rootMessageId: Int? + let createdAt: String + + private static let serverTypeToCharacter: [String: EmotionCharacter] = [ + "JOY": .joy, "ANGER": .anger, "ANXIETY": .anxiety, + "GRUMPY": .prickly, "WARM": .sadness, "QUIRKY": .quirky, + ] + + func toDomain() -> Message? { + let sender: MessageSender + switch senderType { + case "USER": + sender = .user + case "CHARACTER": + guard let character = Self.serverTypeToCharacter[emotionType ?? ""] else { return nil } + sender = .character(character) + default: + return nil + } + return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId) + } + + /// ๋ฐฉ๊ธˆ ๋ณด๋‚ธ ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ์‘๋‹ต ์ „์šฉ โ€” ํ•ญ์ƒ .user๋กœ ๊ฐ„์ฃผํ•œ๋‹ค. + func toSentUserMessage() -> Message { + Message(id: id, conversationId: conversationId, sender: .user, content: content, repliesToMessageId: repliesToMessageId) + } +} diff --git a/GAMSS/Sources/Data/DTO/ConversationSummaryDTO.swift b/GAMSS/Sources/Data/DTO/ConversationSummaryDTO.swift new file mode 100644 index 0000000..125f5ec --- /dev/null +++ b/GAMSS/Sources/Data/DTO/ConversationSummaryDTO.swift @@ -0,0 +1,20 @@ +// +// ConversationSummaryDTO.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import Foundation + +struct ConversationSummaryDTO: Decodable { + let id: Int + let title: String? + let status: String + let createdAt: String + let updatedAt: String + + func toDomain() -> ConversationSummary { + ConversationSummary(id: id, title: title, status: status, createdAt: createdAt) + } +} diff --git a/GAMSS/Sources/Data/DTO/SaveMessageResponseDTO.swift b/GAMSS/Sources/Data/DTO/SaveMessageResponseDTO.swift new file mode 100644 index 0000000..3a36819 --- /dev/null +++ b/GAMSS/Sources/Data/DTO/SaveMessageResponseDTO.swift @@ -0,0 +1,32 @@ +// +// SaveMessageResponseDTO.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import Foundation + +struct SaveMessageResponseDTO: Decodable { + let message: ConversationMessageDTO + let commentStatus: String + let comments: [ConversationMessageDTO] + + func toDomain() -> SentMessage { + SentMessage( + message: message.toSentUserMessage(), + commentStatus: commentStatus.toCommentGenerationStatus(), + comments: comments.compactMap { $0.toDomain() } + ) + } +} + +private extension String { + func toCommentGenerationStatus() -> CommentGenerationStatus { + switch self { + case "DONE": return .done + case "LIMIT_EXCEEDED": return .limitExceeded + default: return .failed + } + } +} diff --git a/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift b/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift new file mode 100644 index 0000000..e186204 --- /dev/null +++ b/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift @@ -0,0 +1,45 @@ +// +// DefaultConversationRepository.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +final class DefaultConversationRepository: ConversationRepository { + private let networkManager: NetworkRequesting + + init(networkManager: NetworkRequesting) { + self.networkManager = networkManager + } + + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + let request = CreateMessageRequestDTO( + conversationId: conversationId, + content: content, + repliesToMessageId: repliesToMessageId, + currentConversationSummary: contextSummary, + excludeCharacters: [] + ) + let response = try await networkManager.request( + ChatEndpoint.createMessage(request), + responseType: APIResponse.self + ) + return response.data.toDomain() + } + + func getMessages(conversationId: Int) async throws -> [Message] { + let response = try await networkManager.request( + ChatEndpoint.fetchMessages(chatId: String(conversationId)), + responseType: APIResponse<[ConversationMessageDTO]>.self + ) + return response.data.compactMap { $0.toDomain() } + } + + func getConversations(date: String) async throws -> [ConversationSummary] { + let response = try await networkManager.request( + ChatEndpoint.fetchChats(date: date), + responseType: APIResponse<[ConversationSummaryDTO]>.self + ) + return response.data.map { $0.toDomain() } + } +} diff --git a/GAMSS/Sources/Data/Repository/DefaultDiarySummaryRepository.swift b/GAMSS/Sources/Data/Repository/DefaultSummaryRepository.swift similarity index 89% rename from GAMSS/Sources/Data/Repository/DefaultDiarySummaryRepository.swift rename to GAMSS/Sources/Data/Repository/DefaultSummaryRepository.swift index 340482c..32844a1 100644 --- a/GAMSS/Sources/Data/Repository/DefaultDiarySummaryRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultSummaryRepository.swift @@ -1,8 +1,8 @@ // -// DefaultDiarySummaryRepository.swift +// DefaultSummaryRepository.swift // GAMSS // -// Created by cchanmi on 7/31/26. +// Created by cchanmi on 8/7/26. // import Foundation @@ -12,7 +12,7 @@ import Tokenizers // actor๋กœ ์„ ์–ธํ•ด encoder/decoder ORTSession๊ณผ tokenizer์— ๋Œ€ํ•œ ๋™์‹œ ์ ‘๊ทผ์„ ์ง๋ ฌํ™”ํ•œ๋‹ค. // ONNX Runtime ์„ธ์…˜์€ ์Šค๋ ˆ๋“œ ์„ธ์ดํ”„ํ•˜์ง€ ์•Š์•„์„œ, summarize๊ฐ€ ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ๋™์‹œ์— // ํ˜ธ์ถœ๋˜๋ฉด run() ํ˜ธ์ถœ๋“ค์ด ์„œ๋กœ ๋ ˆ์ด์Šคํ•  ์ˆ˜ ์žˆ๋‹ค. -actor DefaultDiarySummaryRepository: DiarySummaryRepository { +actor DefaultSummaryRepository: SummaryRepository { // ๋ชจ๋ธ์ด ์ด ๊ฐ’๋“ค์— ๋งž์ถฐ ํ•™์Šต/export๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ์ž„์˜ ๋ณ€๊ฒฝ ๊ธˆ์ง€. private static let decoderStartToken = 1 private static let eosToken = 1 @@ -40,12 +40,12 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { self.tokenizer = tokenizer } - static func make() async throws -> DefaultDiarySummaryRepository { + static func make() async throws -> DefaultSummaryRepository { guard let encoderPath = Bundle.main.path(forResource: "kobart_encoder_int8", ofType: "onnx"), let decoderPath = Bundle.main.path(forResource: "kobart_decoder_int8", ofType: "onnx") else { - throw DiarySummaryError.modelLoadFailed() + throw SummaryError.modelLoadFailed() } let encoder: ORTSession @@ -55,11 +55,11 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { encoder = try ORTSession(env: env, modelPath: encoderPath, sessionOptions: nil) decoder = try ORTSession(env: env, modelPath: decoderPath, sessionOptions: nil) } catch { - throw DiarySummaryError.modelLoadFailed(underlying: error) + throw SummaryError.modelLoadFailed(underlying: error) } let tokenizer = try await Self.loadTokenizer() - return DefaultDiarySummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer) + return DefaultSummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer) } // AutoTokenizer.from(modelFolder:)๋Š” ํด๋” ์•ˆ์—์„œ ํ‘œ์ค€ ํŒŒ์ผ๋ช…("tokenizer.json"/"tokenizer_config.json")์„ @@ -68,7 +68,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { // ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด ํ‘œ์ค€ ํŒŒ์ผ๋ช…์œผ๋กœ๋งŒ ๊ตฌ์„ฑ๋œ ๊ฒฉ๋ฆฌ๋œ ์ž„์‹œ ํด๋”๋ฅผ ๋งŒ๋“ค์–ด ๊ทธ ์•ˆ์—์„œ ๋กœ๋“œํ•œ๋‹ค. private static func loadTokenizer() async throws -> Tokenizer { guard let tokenizerURL = Bundle.main.url(forResource: "kobart_tokenizer", withExtension: "json") else { - throw DiarySummaryError.modelLoadFailed() + throw SummaryError.modelLoadFailed() } let isolatedFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -87,7 +87,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { return try await AutoTokenizer.from(modelFolder: isolatedFolder) } catch { - throw DiarySummaryError.modelLoadFailed(underlying: error) + throw SummaryError.modelLoadFailed(underlying: error) } } @@ -108,20 +108,26 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { runOptions: nil ) guard let encoderHidden = encoderOutputs[Self.encoderOutputKey] else { - throw DiarySummaryError.inferenceFailed() + throw SummaryError.inferenceFailed() } let generatedTokens = try greedyDecode(encoderHidden: encoderHidden, encoderAttentionMask: maskTensor) let summary = tokenizer.decode(tokens: generatedTokens, skipSpecialTokens: true) return summary.trimmingCharacters(in: .whitespacesAndNewlines) - } catch let error as DiarySummaryError { + } catch let error as SummaryError { throw error } catch { Log.error("์š”์•ฝ ์ถ”๋ก  ์‹คํŒจ: \(error)") - throw DiarySummaryError.inferenceFailed(underlying: error) + throw SummaryError.inferenceFailed(underlying: error) } } + /// ์ ˆ๋‹จ ์—†๋Š” ์‹ค์ œ ํ† ํฐ ์ˆ˜. swift-transformers์˜ encode()๋Š” ์ž์ฒด์ ์œผ๋กœ truncation์„ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ + /// (๊ทธ truncation์€ summarize()์˜ buildEncoderInputs์—์„œ๋งŒ ์ˆ˜๋™์œผ๋กœ ํ•จ) ๊ทธ๋Œ€๋กœ ๊ฐœ์ˆ˜๋ฅผ ์„ธ๋ฉด ๋œ๋‹ค. + func countTokens(text: String) async throws -> Int { + tokenizer.encode(text: text).count + } + // ์ธ์ฝ”๋” 1ํšŒ ์‹คํ–‰ ๊ฒฐ๊ณผ(encoderHidden)๋ฅผ ๋งค ์Šคํ… ์žฌ์‚ฌ์šฉํ•˜๋ฉฐ, ์ง€๊ธˆ๊นŒ์ง€ ์ƒ์„ฑ๋œ ์ „์ฒด ์‹œํ€€์Šค๋ฅผ // ๋‹ค์‹œ ๋””์ฝ”๋”์— ํ†ต์งธ๋กœ ๋„ฃ๋Š” cache-free ๊ทธ๋ฆฌ๋”” ๋””์ฝ”๋”ฉ(๋””์ฝ”๋”๊ฐ€ KV ์บ์‹œ๋ฅผ ์•ˆ ์“ฐ๋ฏ€๋กœ). private func greedyDecode(encoderHidden: ORTValue, encoderAttentionMask: ORTValue) throws -> [Int] { @@ -140,7 +146,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { runOptions: nil ) guard let logitsValue = decoderOutputs[Self.decoderOutputKey] else { - throw DiarySummaryError.inferenceFailed() + throw SummaryError.inferenceFailed() } let nextToken = try Self.argmaxLastPosition( @@ -189,7 +195,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository { let shapeInfo = try logits.tensorTypeAndShapeInfo() let shape = shapeInfo.shape.map(\.intValue) guard shape.count == 3, shape[1] == sequenceLength, shape[2] == vocabSize else { - throw DiarySummaryError.inferenceFailed() + throw SummaryError.inferenceFailed() } let data = try logits.tensorData() as Data diff --git a/GAMSS/Sources/Data/Repository/LazyConversationSummaryStore.swift b/GAMSS/Sources/Data/Repository/LazyConversationSummaryStore.swift new file mode 100644 index 0000000..a1db951 --- /dev/null +++ b/GAMSS/Sources/Data/Repository/LazyConversationSummaryStore.swift @@ -0,0 +1,48 @@ +// +// LazyConversationSummaryStore.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import Foundation + +/// DefaultSummaryRepository.make()๊ฐ€ async throws๋ผ ํ™”๋ฉด ์ƒ์„ฑ ์‹œ์ ์— ๋ฐ”๋กœ ๋งŒ๋“ค ์ˆ˜ ์—†๋‹ค. +/// ์‹ค์ œ๋กœ ์ฒ˜์Œ ์“ฐ์ผ ๋•Œ(add/current/restore ์ตœ์ดˆ ํ˜ธ์ถœ) ํ•œ ๋ฒˆ๋งŒ ๋งŒ๋“ค์–ด ์บ์‹ฑํ•œ๋‹ค. +/// ์ƒ์„ฑ์— ์‹คํŒจํ•ด๋„(์˜ˆ: ๋ชจ๋ธ ๋กœ๋“œ ์‹คํŒจ) ์••์ถ•์€ ๋ถ€๊ฐ€ ๊ธฐ๋Šฅ์ด๋ฏ€๋กœ ์กฐ์šฉํžˆ ๋ฌด์‹œํ•œ๋‹ค. +actor LazyConversationSummaryStore: ConversationSummaryStore { + private let makeRepository: () async throws -> SummaryRepository + private var underlying: DefaultConversationSummaryStore? + private var resolutionFailed = false + + init(makeRepository: @escaping () async throws -> SummaryRepository = { try await DefaultSummaryRepository.make() }) { + self.makeRepository = makeRepository + } + + func add(_ utterance: String) async { + await resolve()?.add(utterance) + } + + func current() async -> String? { + await resolve()?.current() + } + + func reset() async { + await resolve()?.reset() + } + + func restore(historicalUtterances: [String]) async { + await resolve()?.restore(historicalUtterances: historicalUtterances) + } + + private func resolve() async -> DefaultConversationSummaryStore? { + if let underlying { return underlying } + guard !resolutionFailed, let repository = try? await makeRepository() else { + resolutionFailed = true + return nil + } + let store = DefaultConversationSummaryStore(summaryRepository: repository) + underlying = store + return store + } +} diff --git a/GAMSS/Sources/Domain/Conversation/CommentRevealPolicy.swift b/GAMSS/Sources/Domain/Conversation/CommentRevealPolicy.swift new file mode 100644 index 0000000..6726d47 --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/CommentRevealPolicy.swift @@ -0,0 +1,15 @@ +// +// CommentRevealPolicy.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +enum CommentRevealPolicy { + static let minSeconds = 1.0 + static let maxSeconds = 3.0 + + static func nextGapSeconds() -> Double { + .random(in: minSeconds...maxSeconds) + } +} diff --git a/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift b/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift new file mode 100644 index 0000000..2f99844 --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift @@ -0,0 +1,18 @@ +// +// ConversationRepository.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +protocol ConversationRepository { + /// conversationId๊ฐ€ nil์ด๋ฉด ์„œ๋ฒ„๊ฐ€ ์ƒˆ ์ฑ„ํŒ…๋ฐฉ์„ ๋งŒ๋“ ๋‹ค. + /// contextSummary๋Š” ์ €์žฅ๋˜์ง€ ์•Š๊ณ  ์บ๋ฆญํ„ฐ ์‘๋‹ต ์ƒ์„ฑ ์ปจํ…์ŠคํŠธ๋กœ๋งŒ ์“ฐ์ธ๋‹ค. + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage + + /// ์ž‘์„ฑ์ˆœ์œผ๋กœ ์˜จ๋‹ค. ํ™”๋ฉด์ด ์ด ์ˆœ์„œ์— ์˜์กดํ•œ๋‹ค. + func getMessages(conversationId: Int) async throws -> [Message] + + /// ์ง€์ •ํ•œ ๋‚ ์งœ(yyyy-MM-dd, KST 00:00~24:00)์— ์ƒ์„ฑ๋œ ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + func getConversations(date: String) async throws -> [ConversationSummary] +} diff --git a/GAMSS/Sources/Domain/Conversation/ConversationSummary.swift b/GAMSS/Sources/Domain/Conversation/ConversationSummary.swift new file mode 100644 index 0000000..fb83eec --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/ConversationSummary.swift @@ -0,0 +1,13 @@ +// +// ConversationSummary.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +struct ConversationSummary: Identifiable, Hashable { + let id: Int + let title: String? + let status: String + let createdAt: String +} diff --git a/GAMSS/Sources/Domain/Conversation/ConversationSummaryPolicy.swift b/GAMSS/Sources/Domain/Conversation/ConversationSummaryPolicy.swift new file mode 100644 index 0000000..ba0fe02 --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/ConversationSummaryPolicy.swift @@ -0,0 +1,39 @@ +// +// ConversationSummaryPolicy.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +enum ConversationSummaryPolicy { + /// ์„œ๋ฒ„ ์ œ์•ฝ. ๋ฉ”์‹œ์ง€ ์ž…๋ ฅ UI๋„ ์ด ๊ฐ’์œผ๋กœ ๊ธธ์ด๋ฅผ ์ œํ•œํ•œ๋‹ค. + static let maxMessageLength = 140 + /// ์„œ๋ฒ„๊ฐ€ ๋ฐ›๋Š” ์ปจํ…์ŠคํŠธ ์••์ถ•๋ณธ์˜ ์ตœ๋Œ€ ๊ธธ์ด. ๋„˜๊ธฐ๋ฉด ์„œ๋ฒ„๊ฐ€ ์ „์†ก ์ž์ฒด๋ฅผ ๊ฑฐ์ ˆํ•œ๋‹ค. + static let maxContextSummaryLength = 2000 + /// ์›๋ฌธ์œผ๋กœ ๋‚จ๊ธฐ๋Š” ์ตœ๊ทผ ๋ฐœํ™” ์ˆ˜. ์ง์ „ ๋งฅ๋ฝ์ด ๋‹ต์žฅ ํ’ˆ์งˆ์— ๊ฐ€์žฅ ํฌ๊ฒŒ ๊ธฐ์—ฌํ•œ๋‹ค. + static let recentRawUtterances = 3 + /// ์š”์•ฝ๊ธฐ(KoBART) ์ธ์ฝ”๋” ์ž…๋ ฅ ํ•œ๊ณ„. ์ด ํฌ๊ธฐ๋กœ ์ฒญํฌ๋ฅผ ๋Š์–ด ํ•œ ๋ฒˆ์”ฉ๋งŒ ์š”์•ฝํ•œ๋‹ค. + static let summaryChunkTokenBudget = 512 + + struct InputNormalizationResult: Equatable { + let value: String + let shouldDismissKeyboard: Bool + } + + /// ๋ฉ”์‹œ์ง€ ์ž…๋ ฅ์ฐฝ์˜ ์›์‹œ ์ž…๋ ฅ๊ฐ’์„ ์ •์ฑ…์— ๋งž๊ฒŒ ์ •๊ทœํ™”ํ•œ๋‹ค. + /// iOS ํ‚ค๋ณด๋“œ์˜ return ํ‚ค๋Š” ํ…์ŠคํŠธ ํ•„๋“œ์—์„œ ์ค„๋ฐ”๊ฟˆ์œผ๋กœ ๋“ค์–ด์˜ค๋ฏ€๋กœ, ์ค„๋ฐ”๊ฟˆ์œผ๋กœ ๋๋‚˜๋ฉด + /// ๊ทธ ์ค„๋ฐ”๊ฟˆ์„ ์ œ๊ฑฐํ•˜๊ณ  ํ‚ค๋ณด๋“œ๋ฅผ ๋‚ด๋ฆฌ๋ผ๋Š” ์‹ ํ˜ธ๋ฅผ ํ•จ๊ป˜ ๋Œ๋ ค์ค€๋‹ค. + /// ๊ทธ ์™ธ์—๋Š” `maxMessageLength`๋ฅผ ๋„˜์ง€ ์•Š๋„๋ก ์ž˜๋ผ๋‚ธ๋‹ค(UX์šฉ ์ œํ•œ์ด๋ฉฐ, ์ตœ์ข… ๊ฒ€์ฆ์€ + /// `SendMessageUseCase`๊ฐ€ ํ•œ๋‹ค). + static func normalizeInput(_ raw: String) -> InputNormalizationResult { + if raw.hasSuffix("\n") { + return InputNormalizationResult(value: String(raw.dropLast()), shouldDismissKeyboard: true) + } + + guard raw.count > maxMessageLength else { + return InputNormalizationResult(value: raw, shouldDismissKeyboard: false) + } + + return InputNormalizationResult(value: String(raw.prefix(maxMessageLength)), shouldDismissKeyboard: false) + } +} diff --git a/GAMSS/Sources/Domain/Conversation/ConversationSummaryStore.swift b/GAMSS/Sources/Domain/Conversation/ConversationSummaryStore.swift new file mode 100644 index 0000000..32c5a1b --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/ConversationSummaryStore.swift @@ -0,0 +1,155 @@ +// +// ConversationSummaryStore.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import Foundation + +protocol ConversationSummaryStore { + /// ์ „์†ก ์„ฑ๊ณต ํ›„์—๋งŒ ํ˜ธ์ถœํ•œ๋‹ค. ํ™”๋ฉด ๊ฐฑ์‹  ๋’ค, ๋น„๋™๊ธฐ๋กœ(๋ธ”๋กœํ‚น ์—†์ด) ํ˜ธ์ถœํ•ด์•ผ ํ•œ๋‹ค. + func add(_ utterance: String) async + /// ๋‹ค์Œ ์ „์†ก ์ง์ „์— ํ˜ธ์ถœํ•œ๋‹ค. ์•„์ง ์••์ถ•ํ•  ๊ฒŒ ์—†์œผ๋ฉด nil(= ์š”์ฒญ ํ•„๋“œ ์ƒ๋žต). + func current() async -> String? + func reset() async + /// ์žฌ์ง„์ž… ๋ณต์› ์ „์šฉ. ํžˆ์Šคํ† ๋ฆฌ ๋ฐœํ™”๋ฅผ ์š”์•ฝ ์—†์ด ์›๋ฌธ ๊ทธ๋Œ€๋กœ ์ฑ„์›Œ ๋„ฃ๋Š”๋‹ค โ€” ์ ˆ๋Œ€ ์ด ๋ฐœํ™”๋“ค์— + /// ๋Œ€ํ•ด summarize()๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค(๋ฐœํ™” ์ˆ˜์— ๋น„๋ก€ํ•ด ๋А๋ ค์ง€๋Š” ๊ฒƒ ๋ฐฉ์ง€). + func restore(historicalUtterances: [String]) async +} + +/// ์••์ถ•๋ณธ = ์ฒซ ๋ฐœํ™” ์›๋ฌธ + ํ™•์ • ์ฒญํฌ๋“ค + ์ตœ๊ทผ ๋ฐœํ™” ์›๋ฌธ. ์ฒซ ๋ฐœํ™”๋Š” ์ฃผ์ œ๋ฅผ, ์ตœ๊ทผ ๋ฐœํ™”๋Š” ๋‹ต๊ธ€์ด +/// ์ด์–ด๋ฐ›๋Š” ๋งฅ๋ฝ์„ ๋ถ™๋“ค๊ณ  ์žˆ์–ด ์›๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. ๊ทธ ์‚ฌ์ด๋งŒ ์š”์•ฝํ•˜๊ณ , ํ•œ ๋ฒˆ ํ™•์ •ํ•œ ์ฒญํฌ๋Š” ๋‹ค์‹œ +/// ์š”์•ฝํ•˜์ง€ ์•Š๋Š”๋‹ค(์š”์•ฝ์˜ ์š”์•ฝ์„ ํ”ผํ•œ๋‹ค). +/// +/// ์••์ถ•์€ ๋ถ€๊ฐ€ ๊ธฐ๋Šฅ์ด๋ผ ์‹คํŒจํ•ด๋„ ์ „์†กยท์กฐํšŒ๋ฅผ ๋ง‰์ง€ ์•Š๋Š”๋‹ค. +/// +/// ๊ฐ€๋ณ€ ์ƒํƒœ๋ฅผ ๋“ค๊ณ  ์žˆ์–ด ๋Œ€ํ™” ํ•˜๋‚˜์— ์ธ์Šคํ„ด์Šค ํ•˜๋‚˜์—ฌ์•ผ ํ•œ๋‹ค. ์—ฌ๋Ÿฌ ๋Œ€ํ™”์— ๊ณต์œ ํ•˜๋ฉด ๋‚ด์šฉ์ด ์„ž์ธ๋‹ค. +actor DefaultConversationSummaryStore: ConversationSummaryStore { + private let summaryRepository: SummaryRepository + + private var utterances: [String] = [] + /// ์š”์•ฝ๋ณธ์ด๊ฑฐ๋‚˜, ์š”์•ฝ์ด ์‹คํŒจํ–ˆ์œผ๋ฉด ์›๋ฌธ์ด๋‹ค. + private var closedChunks: [String] = [] + private var pendingChunk: [String] = [] + private var pendingChunkTokens = 0 + /// ์ฒซ ๋ฐœํ™”๋Š” ์›๋ฌธ์œผ๋กœ ๋‚จ์œผ๋ฏ€๋กœ ์ฒญํฌ ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•œ๋‹ค. + private var nextChunkCandidate = 1 + + init(summaryRepository: SummaryRepository) { + self.summaryRepository = summaryRepository + } + + func add(_ utterance: String) async { + let trimmed = utterance.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + utterances.append(trimmed) + await closeChunksIfNeeded() + } + + /// ๋ณด๋‚ผ ์••์ถ•๋ณธ. ์•„์ง ์••์ถ•ํ•  ๊ฒŒ ์—†์œผ๋ฉด nil. + func current() async -> String? { + guard !utterances.isEmpty else { return nil } + + let recentStart = recentStart() + // ์ฒซ ๋ฐœํ™”์™€ ์ตœ๊ทผ ๋ฐœํ™”๋Š” ์ƒํ•œ ์•ˆ์—์„œ ํ•ญ์ƒ ์‚ด๋ฆฐ๋‹ค. + let anchorHead = recentStart > 0 ? [utterances[0]] : [] + let anchorTail = Array(utterances[recentStart...]) + let middle = closedChunks + pendingChunk + + let anchorLength = joinedLength(anchorHead + anchorTail) + let kept = takeNewestFitting(middle, budget: ConversationSummaryPolicy.maxContextSummaryLength - anchorLength) + + let result = (anchorHead + kept + anchorTail).joined(separator: Self.separator) + return String(result.prefix(ConversationSummaryPolicy.maxContextSummaryLength)) + } + + func reset() async { + utterances.removeAll() + closedChunks.removeAll() + pendingChunk.removeAll() + pendingChunkTokens = 0 + nextChunkCandidate = 1 + } + + /// ์žฌ์ง„์ž… ๋ณต์›: ์š”์•ฝ์„ ์ ˆ๋Œ€ ๋Œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค. ์ค‘๊ฐ„ ๊ตฌ๊ฐ„(์ฒซ ๋ฐœํ™” ๋‹ค์Œ๋ถ€ํ„ฐ ์ตœ๊ทผ์ฐฝ ์‹œ์ž‘ ์ง์ „๊นŒ์ง€)์„ + /// ์›๋ฌธ ๊ทธ๋Œ€๋กœ ํ™•์ • ์ฒญํฌ ํ•˜๋‚˜๋กœ ๋„ฃ๊ณ , ๋‹ค์Œ ํ›„๋ณด ์ธ๋ฑ์Šค๋ฅผ ์ตœ๊ทผ์ฐฝ ์‹œ์ž‘์œผ๋กœ ์˜ฎ๊ฒจ ์žฌ์ฒญํ‚น์„ ๋ง‰๋Š”๋‹ค. + func restore(historicalUtterances: [String]) async { + await reset() + guard !historicalUtterances.isEmpty else { return } + + utterances = historicalUtterances + let recentStart = recentStart() + if recentStart > 1 { + closedChunks = [utterances[1.. Int { + max(utterances.count - ConversationSummaryPolicy.recentRawUtterances, 0) + } + + private func closeChunksIfNeeded() async { + let recentStart = recentStart() + while nextChunkCandidate < recentStart { + let utterance = utterances[nextChunkCandidate] + let tokens = await countTokens(utterance) + + // (a) ๋„˜๊ธฐ๊ธฐ ์ „์— ๋Š๋Š”๋‹ค โ€” ์˜ˆ์‚ฐ์„ ๋„˜๊ฒจ์„œ ์š”์•ฝํ•˜๋ฉด ์š”์•ฝ๊ธฐ ์ž…๋ ฅ ํ•œ๊ณ„์—์„œ ์ดˆ๊ณผ๋ถ„์ด + // ์ž˜๋ ค ์‚ฌ๋ผ์ง„๋‹ค. + if !pendingChunk.isEmpty && pendingChunkTokens + tokens > ConversationSummaryPolicy.summaryChunkTokenBudget { + await closePendingChunk() + } + + pendingChunk.append(utterance) + pendingChunkTokens += tokens + nextChunkCandidate += 1 + + // (b) ์ •ํ™•ํžˆ ์ฐผ์œผ๋ฉด ์ฆ‰์‹œ ํ™•์ • + if pendingChunkTokens >= ConversationSummaryPolicy.summaryChunkTokenBudget { + await closePendingChunk() + } + } + } + + /// ์š”์•ฝ ์‹คํŒจ ์‹œ ์›๋ฌธ์œผ๋กœ ํ™•์ •ํ•œ๋‹ค. ์—ด์–ด ๋‘๋ฉด ์ „์†ก๋งˆ๋‹ค ๊ฐ™์€ ์š”์•ฝ์„ ์žฌ์‹œ๋„ํ•ด ์ง€์—ฐ์ด ๋ˆ„์ ๋œ๋‹ค. + private func closePendingChunk() async { + guard !pendingChunk.isEmpty else { return } + let text = pendingChunk.joined(separator: Self.separator) + // ๋ฐœํ™” ํ•˜๋‚˜๊ฐ€ ์˜ˆ์‚ฐ๋ณด๋‹ค ํฌ๋ฉด ์š”์•ฝํ•ด๋„ ์ž˜๋ฆฌ๋ฏ€๋กœ ์›๋ฌธ์œผ๋กœ ๋‘”๋‹ค. + let overBudget = pendingChunkTokens > ConversationSummaryPolicy.summaryChunkTokenBudget + let summarized = overBudget ? nil : (try? await summaryRepository.summarize(text: text)) + closedChunks.append(summarized ?? text) + pendingChunk.removeAll() + pendingChunkTokens = 0 + } + + /// ํ† ํฌ๋‚˜์ด์ €๊ฐ€ ์ฃฝ์œผ๋ฉด ๊ธ€์ž ์ˆ˜๋กœ ์„ผ๋‹ค. ์‹ค์ œ ํ† ํฐ ์ˆ˜์˜ ์ƒํ•œ์ด๋ผ ์˜ˆ์‚ฐ์„ ๋„˜๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + private func countTokens(_ utterance: String) async -> Int { + (try? await summaryRepository.countTokens(text: utterance)) ?? utterance.count + } + + /// ์˜ˆ์‚ฐ์— ๋งž์„ ๋•Œ๊นŒ์ง€ ์˜ค๋ž˜๋œ ํ•ญ๋ชฉ๋ถ€ํ„ฐ ๋ฒ„๋ฆฌ๊ณ  ์›๋ž˜ ์ˆœ์„œ๋กœ ๋Œ๋ ค์ค€๋‹ค. + private func takeNewestFitting(_ parts: [String], budget: Int) -> [String] { + guard budget > 0 else { return [] } + var remaining = budget + var kept: [String] = [] + for part in parts.reversed() { + let cost = part.count + Self.separator.count + guard cost <= remaining else { break } + remaining -= cost + kept.insert(part, at: 0) + } + return kept + } + + private func joinedLength(_ parts: [String]) -> Int { + parts.reduce(0) { $0 + $1.count + Self.separator.count } + } + + private static let separator = " " +} diff --git a/GAMSS/Sources/Domain/Conversation/EmotionCharacter.swift b/GAMSS/Sources/Domain/Conversation/EmotionCharacter.swift new file mode 100644 index 0000000..a8965d3 --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/EmotionCharacter.swift @@ -0,0 +1,24 @@ +// +// EmotionCharacter.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +/// ๋Œ€ํ™” ์ƒ๋Œ€๋กœ ๋“ฑ์žฅํ•˜๋Š” ํŽ˜๋ฅด์†Œ๋‚˜ ์บ๋ฆญํ„ฐ 6์ข…. +/// EmotionCharacter`๋Š” ์„œ๋ฒ„๊ฐ€ ๋Œ€ํ™” ๋ฉ”์‹œ์ง€์˜ ๋ฐœ์‹ ์ž๋กœ ๋‚ด๋ ค์ฃผ๋Š” +/// UI/๋Œ€ํ™” ๋„๋ฉ”์ธ์˜ ์บ๋ฆญํ„ฐ ์‹๋ณ„์ž๋‹ค. +enum EmotionCharacter: CaseIterable, Equatable { + case joy, sadness, anger, anxiety, prickly, quirky + + var displayName: String { + switch self { + case .joy: "๊ธฐ์จ์ด" + case .sadness: "์Šฌํ””์ด" + case .anger: "๋ถ„๋…ธ" + case .anxiety: "๋ถˆ์•ˆ" + case .prickly: "๊นŒ์น ์ด" + case .quirky: "์—‰๋šฑ์ด" + } + } +} diff --git a/GAMSS/Sources/Domain/Conversation/Message.swift b/GAMSS/Sources/Domain/Conversation/Message.swift new file mode 100644 index 0000000..a52a466 --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/Message.swift @@ -0,0 +1,29 @@ +// +// Message.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +enum MessageSender: Equatable { + case user + case character(EmotionCharacter) +} + +struct Message: Identifiable, Equatable { + let id: Int + let conversationId: Int + let sender: MessageSender + let content: String + let repliesToMessageId: Int? +} + +enum CommentGenerationStatus: Equatable { + case done, failed, limitExceeded +} + +struct SentMessage: Equatable { + let message: Message + let commentStatus: CommentGenerationStatus + let comments: [Message] +} diff --git a/GAMSS/Sources/Domain/Entity/DiarySummaryError.swift b/GAMSS/Sources/Domain/Entity/SummaryError.swift similarity index 71% rename from GAMSS/Sources/Domain/Entity/DiarySummaryError.swift rename to GAMSS/Sources/Domain/Entity/SummaryError.swift index 174ba17..225a102 100644 --- a/GAMSS/Sources/Domain/Entity/DiarySummaryError.swift +++ b/GAMSS/Sources/Domain/Entity/SummaryError.swift @@ -1,19 +1,19 @@ // -// DiarySummaryError.swift +// SummaryError.swift // GAMSS // -// Created by cchanmi on 7/29/26. +// Created by cchanmi on 8/7/26. // import Foundation -enum DiarySummaryError: Error { +enum SummaryError: Error { case modelLoadFailed(underlying: Error? = nil) case inferenceFailed(underlying: Error? = nil) } -extension DiarySummaryError: Equatable { - static func == (lhs: DiarySummaryError, rhs: DiarySummaryError) -> Bool { +extension SummaryError: Equatable { + static func == (lhs: SummaryError, rhs: SummaryError) -> Bool { switch (lhs, rhs) { case (.modelLoadFailed, .modelLoadFailed): return true @@ -25,7 +25,7 @@ extension DiarySummaryError: Equatable { } } -extension DiarySummaryError: LocalizedError { +extension SummaryError: LocalizedError { var errorDescription: String? { switch self { case .modelLoadFailed: diff --git a/GAMSS/Sources/Domain/Repository/DiarySummaryRepository.swift b/GAMSS/Sources/Domain/Repository/DiarySummaryRepository.swift deleted file mode 100644 index 5d2fa3c..0000000 --- a/GAMSS/Sources/Domain/Repository/DiarySummaryRepository.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// DiarySummaryRepository.swift -// GAMSS -// -// Created by cchanmi on 7/29/26. -// - -protocol DiarySummaryRepository { - func summarize(text: String) async throws -> String -} diff --git a/GAMSS/Sources/Domain/Repository/SummaryRepository.swift b/GAMSS/Sources/Domain/Repository/SummaryRepository.swift new file mode 100644 index 0000000..d35d54f --- /dev/null +++ b/GAMSS/Sources/Domain/Repository/SummaryRepository.swift @@ -0,0 +1,12 @@ +// +// SummaryRepository.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +protocol SummaryRepository { + func summarize(text: String) async throws -> String + /// ์ ˆ๋‹จ ์—†์ด ์‹ค์ œ ํ† ํฐ ์ˆ˜๋ฅผ ๋Œ๋ ค์ค€๋‹ค โ€” ์ฒญํฌ ๊ฒฝ๊ณ„๋ฅผ ์ •ํ™•ํžˆ ์žฌ๋Š” ์šฉ๋„. + func countTokens(text: String) async throws -> Int +} diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/GetConversationsUseCase.swift b/GAMSS/Sources/Domain/UseCase/Conversation/GetConversationsUseCase.swift new file mode 100644 index 0000000..e0f9a83 --- /dev/null +++ b/GAMSS/Sources/Domain/UseCase/Conversation/GetConversationsUseCase.swift @@ -0,0 +1,18 @@ +// +// GetConversationsUseCase.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +struct GetConversationsUseCase { + private let conversationRepository: ConversationRepository + + init(conversationRepository: ConversationRepository) { + self.conversationRepository = conversationRepository + } + + func execute(date: String) async throws -> [ConversationSummary] { + try await conversationRepository.getConversations(date: date) + } +} diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/GetMessagesUseCase.swift b/GAMSS/Sources/Domain/UseCase/Conversation/GetMessagesUseCase.swift new file mode 100644 index 0000000..6ba97bb --- /dev/null +++ b/GAMSS/Sources/Domain/UseCase/Conversation/GetMessagesUseCase.swift @@ -0,0 +1,18 @@ +// +// GetMessagesUseCase.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +struct GetMessagesUseCase { + private let conversationRepository: ConversationRepository + + init(conversationRepository: ConversationRepository) { + self.conversationRepository = conversationRepository + } + + func execute(conversationId: Int) async throws -> [Message] { + try await conversationRepository.getMessages(conversationId: conversationId) + } +} diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageUseCase.swift b/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageUseCase.swift new file mode 100644 index 0000000..89e6414 --- /dev/null +++ b/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageUseCase.swift @@ -0,0 +1,33 @@ +// +// SendMessageUseCase.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import Foundation + +struct SendMessageUseCase { + private let conversationRepository: ConversationRepository + + init(conversationRepository: ConversationRepository) { + self.conversationRepository = conversationRepository + } + + func execute(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw SendMessageValidationError.empty + } + guard trimmed.count <= ConversationSummaryPolicy.maxMessageLength else { + throw SendMessageValidationError.tooLong + } + + return try await conversationRepository.sendMessage( + conversationId: conversationId, + content: content, + repliesToMessageId: repliesToMessageId, + contextSummary: contextSummary + ) + } +} diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageValidationError.swift b/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageValidationError.swift new file mode 100644 index 0000000..012bf4e --- /dev/null +++ b/GAMSS/Sources/Domain/UseCase/Conversation/SendMessageValidationError.swift @@ -0,0 +1,28 @@ +// +// SendMessageValidationError.swift +// GAMSS +// +// Created by cchanmi on 8/10/26. +// + +import Foundation + +/// `SendMessageUseCase`๊ฐ€ ๋„คํŠธ์›Œํฌ๋กœ ๋‚˜๊ฐ€๊ธฐ ์ง์ „์— ๊ฒ€์ฆํ•˜๋Š” ์ตœ์ข… ๊ทœ์น™ ์œ„๋ฐ˜. +/// ViewModel์˜ ์ž…๋ ฅ ์ œํ•œ์€ UX์šฉ์ด๊ณ , ์‹ค์ œ๋กœ ์‹ ๋ขฐํ•ด์•ผ ํ•  ๊ธฐ์ค€์€ ์—ฌ๊ธฐ๋‹ค. +enum SendMessageValidationError: Error { + case empty + case tooLong +} + +extension SendMessageValidationError: Equatable {} + +extension SendMessageValidationError: LocalizedError { + var errorDescription: String? { + switch self { + case .empty: + return "๋ฉ”์‹œ์ง€๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." + case .tooLong: + return "๋ฉ”์‹œ์ง€๊ฐ€ ๋„ˆ๋ฌด ๊ธธ์–ด์š”." + } + } +} diff --git a/GAMSS/Sources/Domain/UseCase/Summary/DefaultSummarizeDiaryUseCase.swift b/GAMSS/Sources/Domain/UseCase/Summary/DefaultSummarizeDiaryUseCase.swift deleted file mode 100644 index 5b29124..0000000 --- a/GAMSS/Sources/Domain/UseCase/Summary/DefaultSummarizeDiaryUseCase.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// DefaultSummarizeDiaryUseCase.swift -// GAMSS -// -// Created by cchanmi on 7/29/26. -// - -import Foundation - -actor DefaultSummarizeDiaryUseCase: SummarizeDiaryUseCase { - // ๋ˆ„์ ๋œ USER ๋ฐœํ™”๊ฐ€ ์ด ๋ฏธ๋งŒ์ด๋ฉด ์š”์•ฝํ•ด๋„ ์›๋ฌธ๊ณผ ํฐ ์ฐจ์ด๊ฐ€ ์—†์–ด ์˜๋ฏธ๊ฐ€ ์—†์œผ๋ฏ€๋กœ, - // ๋ชจ๋ธ์„ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ๋ˆ„์  ์›๋ฌธ์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•œ๋‹ค. - private static let minCharsForSummary = 140 - - private let diarySummaryRepository: DiarySummaryRepository - private var utterances: [String] = [] - - init(diarySummaryRepository: DiarySummaryRepository) { - self.diarySummaryRepository = diarySummaryRepository - } - - func addUtterance(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - utterances.append(trimmed) - } - - func finalize() async throws -> String? { - let combined = utterances.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - utterances = [] - - guard !combined.isEmpty else { return nil } - guard combined.count >= Self.minCharsForSummary else { return combined } - return try await diarySummaryRepository.summarize(text: combined) - } -} diff --git a/GAMSS/Sources/Domain/UseCase/Summary/SummarizeDiaryUseCase.swift b/GAMSS/Sources/Domain/UseCase/Summary/SummarizeDiaryUseCase.swift deleted file mode 100644 index 2e97ba4..0000000 --- a/GAMSS/Sources/Domain/UseCase/Summary/SummarizeDiaryUseCase.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// SummarizeDiaryUseCase.swift -// GAMSS -// -// Created by cchanmi on 7/29/26. -// - -protocol SummarizeDiaryUseCase { - func addUtterance(_ text: String) async - func finalize() async throws -> String? -} diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift new file mode 100644 index 0000000..3764764 --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -0,0 +1,185 @@ +// +// ChatView.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import SwiftUI + +struct ChatView: View { + @StateObject private var viewModel: ChatViewModel + @FocusState private var isInputFocused: Bool + + init(viewModel: ChatViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + 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) + } + } + .padding() + } + // ScrollView๊ฐ€ ํ‚ค๋ณด๋“œ์— ์˜ํ•ด ์ถ•์†Œ/๋ณต์›๋  ๋•Œ + // SwiftUI๊ฐ€ ํ‚ค๋ณด๋“œ dismiss๋ฅผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋„๋ก ํ•œ๋‹ค. + .scrollDismissesKeyboard(.interactively) + .onChange(of: viewModel.messages) { _, newMessages in + scrollToBottom(proxy, messages: newMessages) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + composer + } + } + .task { + await viewModel.start() + } + .alert( + viewModel.alertMessage ?? "", + isPresented: Binding( + get: { + viewModel.alertMessage != nil + }, + set: { + if !$0 { + viewModel.alertMessage = nil + } + } + ) + ) { + Button("ํ™•์ธ", role: .cancel) {} + } + } + + 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] + ) { + guard let lastMessage = messages.last else { + return + } + + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo( + lastMessage.id, + anchor: .bottom + ) + } + } +} + +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( + sendMessageUseCase: SendMessageUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ), + getMessagesUseCase: GetMessagesUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ), + summaryStore: LazyConversationSummaryStore(), + conversationId: 1 + ) + ) +} diff --git a/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift new file mode 100644 index 0000000..225a21a --- /dev/null +++ b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift @@ -0,0 +1,171 @@ +// +// ChatViewModel.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import Combine +import Foundation + +@MainActor +final class ChatViewModel: ObservableObject { + @Published private(set) var messages: [Message] = [] + @Published private(set) var pendingComments: [Message] = [] + @Published var input: String = "" + @Published private(set) var isSending = false + @Published var alertMessage: String? + + private var conversationId: Int? + private let initialSentMessage: SentMessage? + private let sendMessageUseCase: SendMessageUseCase + private let getMessagesUseCase: GetMessagesUseCase + private let summaryStore: ConversationSummaryStore + private var revealTask: Task? + /// ํ…Œ์ŠคํŠธ์—์„œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์š”์•ฝ ์ €์žฅ์ด ๋๋‚˜๋Š” ์‹œ์ ์„ ๊ฒฐ์ •์ ์œผ๋กœ ๊ธฐ๋‹ค๋ฆฌ๊ธฐ ์œ„ํ•œ ํ•ธ๋“ค. + private(set) var pendingSummaryUpdateTask: Task? + + init( + sendMessageUseCase: SendMessageUseCase, + getMessagesUseCase: GetMessagesUseCase, + summaryStore: ConversationSummaryStore, + conversationId: Int? = nil, + initialSentMessage: SentMessage? = nil + ) { + self.sendMessageUseCase = sendMessageUseCase + self.getMessagesUseCase = getMessagesUseCase + self.summaryStore = summaryStore + self.conversationId = conversationId + self.initialSentMessage = initialSentMessage + } + + /// ํ™”๋ฉด ์ง„์ž… ์‹œ ํ•œ ๋ฒˆ ํ˜ธ์ถœํ•œ๋‹ค. ๋‹ค๋ฅธ ํ™”๋ฉด์—์„œ ์ด๋ฏธ ๋ฐ›์•„์˜จ ์‘๋‹ต์ด ์žˆ์œผ๋ฉด ๊ทธ๊ฑธ๋กœ ์ฑ„์šฐ๊ณ , + /// ์—†์œผ๋ฉด ๊ธฐ์กด ๋Œ€ํ™”์˜ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค. + func start() async { + if let initialSentMessage { + seed(with: initialSentMessage) + } else if let conversationId { + await load(conversationId: conversationId) + } + } + + /// ์žฌ์ง„์ž… ์‹œ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค. ์ˆœ์ฐจ ๋…ธ์ถœ์€ ์ ์šฉํ•˜์ง€ ์•Š๊ณ  ํ•œ ๋ฒˆ์— ํ‘œ์‹œํ•œ๋‹ค. + func load(conversationId: Int) async { + self.conversationId = conversationId + do { + let history = try await getMessagesUseCase.execute(conversationId: conversationId) + messages = history + let userUtterances = history.compactMap { message -> String? in + guard message.sender == .user else { return nil } + return message.content + } + await summaryStore.restore(historicalUtterances: userUtterances) + } catch { + alertMessage = "๋Œ€ํ™”๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์–ด์š”" + } + } + + /// ์ž…๋ ฅ์ฐฝ์˜ ์›์‹œ ์ž…๋ ฅ๊ฐ’์„ ๋ฐ›์•„ ์ •์ฑ…์— ๋งž๊ฒŒ ์ •๊ทœํ™”ํ•˜๊ณ , ํ‚ค๋ณด๋“œ๋ฅผ ๋‚ด๋ ค์•ผ ํ•˜๋Š”์ง€ ๋Œ๋ ค์ค€๋‹ค. + func updateInput(_ rawValue: String) -> Bool { + let result = ConversationSummaryPolicy.normalizeInput(rawValue) + input = result.value + return result.shouldDismissKeyboard + } + + var isSendDisabled: Bool { + input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending + } + + func send() async { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isSending else { return } + + flushPendingComments() + isSending = true + defer { isSending = false } + + let contextSummary = await summaryStore.current() + + do { + let sent = try await sendMessageUseCase.execute( + conversationId: conversationId, + content: trimmed, + repliesToMessageId: nil, + contextSummary: contextSummary + ) + input = "" + seed(with: sent) + + // ์š”์•ฝ๊ธฐ(์˜จ๋””๋ฐ”์ด์Šค ์ถ”๋ก )๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋‹ค์Œ ์ž…๋ ฅ์„ ๋ง‰์ง€ ์•Š๋„๋ก ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๋Œ๋ฆฐ๋‹ค. + // self๊ฐ€ ์•„๋‹ˆ๋ผ summaryStore๋ฅผ ์ง์ ‘ ์บก์ฒ˜ํ•ด ํ™”๋ฉด์„ ๋‚˜๊ฐ€๋„ ์ €์žฅ์€ ๋๊นŒ์ง€ ์™„๋ฃŒ๋˜๊ฒŒ ํ•œ๋‹ค. + let summaryStore = summaryStore + pendingSummaryUpdateTask = Task { await summaryStore.add(trimmed) } + } catch let error as SendMessageValidationError { + alertMessage = error.errorDescription + } catch { + alertMessage = "๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด์ง€ ๋ชปํ–ˆ์–ด์š”" + } + } + + /// ๋‹ค๋ฅธ ํ™”๋ฉด(ํ™ˆ)์—์„œ ์ด๋ฏธ ๋ฐ›์•„์˜จ ์‘๋‹ต์œผ๋กœ ํ™”๋ฉด์„ ์ฑ„์šด๋‹ค โ€” ๋ฐฉ๊ธˆ ๋ฐ›์€ ์‘๋‹ต์„ ๋‹ค์‹œ + /// getMessages๋กœ ์กฐํšŒํ•˜์ง€ ์•Š๊ธฐ ์œ„ํ•œ ์šฉ๋„. + func seed(with sent: SentMessage) { + conversationId = sent.message.conversationId + + // ์ฒซ ๋Œ“๊ธ€์€ ์ฆ‰์‹œ, ๋‚˜๋จธ์ง€๋Š” ์ˆœ์ฐจ ๋…ธ์ถœ ํ๋กœ. + messages.append(sent.message) + if let first = sent.comments.first { + messages.append(first) + } + pendingComments = Array(sent.comments.dropFirst()) + revealRemainingComments() + + if sent.commentStatus != .done { + alertMessage = sent.commentStatus.toUserMessage() + } + } + + /// ๋‹ต์žฅ ๋งํ’์„ ์— ์ธ์šฉํ•  ์›๋ณธ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ๋Š”๋‹ค. ์„œ๋ฒ„๋Š” repliesToMessageId๋งŒ ๋‚ด๋ ค์ฃผ๋ฏ€๋กœ + /// ์ด๋ฏธ ๋กœ๋“œ๋œ messages์—์„œ ์ง์ ‘ ์ฐพ์•„์•ผ ํ•œ๋‹ค. + func quotedMessage(for message: Message) -> Message? { + guard let repliesToMessageId = message.repliesToMessageId else { return nil } + return messages.first { $0.id == repliesToMessageId } + } + + private func flushPendingComments() { + revealTask?.cancel() + revealTask = nil + guard !pendingComments.isEmpty else { return } + messages.append(contentsOf: pendingComments) + pendingComments.removeAll() + } + + private func revealRemainingComments() { + guard !pendingComments.isEmpty else { return } + revealTask = Task { [weak self] in + guard let self else { return } + while true { + // ์ทจ์†Œ ์‹œ flushPendingComments()๊ฐ€ ๊ทธ๋Œ€๋กœ ์“ธ์–ด๋‹ด์„ ์ˆ˜ ์žˆ๋„๋ก, ์ž๋Š” ๋™์•ˆ์€ + // pendingComments์—์„œ ๋นผ์ง€ ์•Š๊ณ  ๋“ค์—ฌ๋‹ค๋ณด๊ธฐ๋งŒ ํ•œ๋‹ค(์ œ๊ฑฐ๋Š” ์ทจ์†Œ ๊ฒ€์‚ฌ ํ†ต๊ณผ ํ›„์—๋งŒ). + let hasNext: Bool = await MainActor.run { !self.pendingComments.isEmpty } + guard hasNext else { break } + try? await Task.sleep(nanoseconds: UInt64(CommentRevealPolicy.nextGapSeconds() * 1_000_000_000)) + guard !Task.isCancelled else { break } + await MainActor.run { + guard !self.pendingComments.isEmpty else { return } + self.messages.append(self.pendingComments.removeFirst()) + } + } + } + } +} + +private extension CommentGenerationStatus { + func toUserMessage() -> String? { + switch self { + case .done: nil + case .failed: "๋‹ต์žฅ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์–ด์š”. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ๋ณด๋‚ด๋ณผ๊นŒ์š”?" + case .limitExceeded: "์˜ค๋Š˜์€ ๋Œ€ํ™”๋ฅผ ๋งŽ์ด ํ–ˆ์–ด์š”. ๋‚ด์ผ ๋‹ค์‹œ ์ด์•ผ๊ธฐํ•ด์š”." + } + } +} diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift new file mode 100644 index 0000000..02da55c --- /dev/null +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -0,0 +1,62 @@ +// +// ConversationListView.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import SwiftUI + +struct ConversationListView: View { + @StateObject private var viewModel: ConversationListViewModel + + init(viewModel: ConversationListViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + NavigationStack { + List(viewModel.conversations) { conversation in + NavigationLink(value: conversation) { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.title ?? "์ œ๋ชฉ ์—†์Œ") + Text(conversation.status) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("์˜ค๋Š˜์˜ ์ฑ„ํŒ…๋ฐฉ") + .navigationDestination(for: ConversationSummary.self) { conversation in + ChatView( + viewModel: ChatViewModel( + sendMessageUseCase: SendMessageUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + getMessagesUseCase: GetMessagesUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + summaryStore: LazyConversationSummaryStore(), + conversationId: conversation.id + ) + ) + .toolbar(.hidden, for: .tabBar) + } + } + .task { + await viewModel.load() + } + .alert(viewModel.alertMessage ?? "", isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } } + )) { + Button("ํ™•์ธ", role: .cancel) {} + } + } +} + +#Preview { + ConversationListView( + viewModel: ConversationListViewModel( + getConversationsUseCase: GetConversationsUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ) + ) + ) +} diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift new file mode 100644 index 0000000..fd049a9 --- /dev/null +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift @@ -0,0 +1,39 @@ +// +// ConversationListViewModel.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import Combine +import Foundation + +@MainActor +final class ConversationListViewModel: ObservableObject { + @Published private(set) var conversations: [ConversationSummary] = [] + @Published var alertMessage: String? + + private let getConversationsUseCase: GetConversationsUseCase + + init(getConversationsUseCase: GetConversationsUseCase) { + self.getConversationsUseCase = getConversationsUseCase + } + + func load() async { + do { + conversations = try await getConversationsUseCase.execute(date: Self.todayDateString()) + } catch { + alertMessage = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์–ด์š”" + } + } + + private static func todayDateString() -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? .current + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } +} diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift new file mode 100644 index 0000000..082c002 --- /dev/null +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -0,0 +1,144 @@ +// +// HomeView.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import SwiftUI + +struct HomeView: View { + @StateObject private var viewModel: HomeViewModel + @FocusState private var isInputFocused: Bool + + init(viewModel: HomeViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + NavigationStack { + ZStack { + // ํ™”๋ฉด์˜ ๋นˆ ์˜์—ญ(๋‹ค๋ฅธ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋ทฐ๊ฐ€ ๊ฐ€๋ฆฌ์ง€ ์•Š๋Š” ๋ถ€๋ถ„)์„ ํƒญํ•˜๋ฉด ํ‚ค๋ณด๋“œ๋ฅผ ๋‚ด๋ฆฐ๋‹ค. + // TextEditor/๋ฒ„ํŠผ์€ ๊ทธ ์œ„์— ๊ทธ๋ ค์ ธ ์ž๊ธฐ ํƒญ์„ ๋จผ์ € ๊ฐ€์ ธ๊ฐ€๋ฏ€๋กœ ์ปค์„œ ์ด๋™ ๋“ฑ์€ ๋ฐฉํ•ด๋ฐ›์ง€ ์•Š๋Š”๋‹ค. + Color.clear + .contentShape(Rectangle()) + .onTapGesture { isInputFocused = false } + + VStack(alignment: .leading, spacing: Spacing.spacing500) { + header + + VStack(alignment: .leading, spacing: Spacing.spacing100) { + Text("OO๋‹˜ ์–ด์„œ์˜ค์„ธ์š”") + .typography(.title3) + .foregroundStyle(Color.colorGray950) + Text("๋งˆ์Œ ์Œ“์•„๋‘” ์ด์•ผ๊ธฐ๊ฐ€ ์žˆ๋‹ค๋ฉด ๋ง์”€ํ•ด๋ณด์„ธ์š”.") + .typography(.body3Regular) + .foregroundStyle(Color.colorGray500) + } + + messageBox + + submitButton + + Spacer() + } + .padding(Spacing.spacing400) + } + .navigationBarHidden(true) + .navigationDestination(item: $viewModel.createdConversationId) { conversationId in + ChatView( + viewModel: ChatViewModel( + sendMessageUseCase: SendMessageUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + getMessagesUseCase: GetMessagesUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + summaryStore: LazyConversationSummaryStore(), + conversationId: conversationId, + initialSentMessage: viewModel.createdSentMessage + ) + ) + .toolbar(.hidden, for: .tabBar) + } + } + .alert(viewModel.alertMessage ?? "", isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } } + )) { + Button("ํ™•์ธ", role: .cancel) {} + } + } + + private var header: some View { + HStack { + Text("GAMSS") + .typography(.subtitle3) + .foregroundStyle(Color.colorGray950) + .padding(.horizontal, Spacing.spacing300) + .padding(.vertical, Spacing.spacing100) + .background(Color.colorGray050) + .clipShape(Capsule()) + + Spacer() + + Image(systemName: "gearshape") + .foregroundStyle(Color.colorGray500) + } + } + + private var messageBox: some View { + VStack(alignment: .trailing, spacing: Spacing.spacing100) { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: Radius.radius300) + .fill(Color.colorGray025) + + if viewModel.input.isEmpty { + Text("์ž„๊ธˆ๋‹˜ ๊ท€๋Š” ๋‹น๋‚˜๊ท€ ๊ท€") + .typography(.body3Regular) + .foregroundStyle(Color.colorGray400) + .padding(.horizontal, Spacing.spacing300) + .padding(.vertical, Spacing.spacing300) + } + + TextEditor(text: $viewModel.input) + .typography(.body3Regular) + .foregroundStyle(Color.colorGray950) + .scrollContentBackground(.hidden) + .padding(Spacing.spacing200) + .focused($isInputFocused) + .onChange(of: viewModel.input) { _, newValue in + if viewModel.updateInput(newValue) { + isInputFocused = false + } + } + } + .frame(height: 220) + + Text("\(viewModel.input.count)/\(ConversationSummaryPolicy.maxMessageLength)") + .typography(.caption2) + .foregroundStyle(Color.colorGray400) + } + } + + private var submitButton: some View { + Button { + Task { await viewModel.send() } + } label: { + Text("์ชฝ์ง€ ๋ณด๋‚ด๊ธฐ") + .typography(.subtitle3) + .foregroundStyle(Color.colorWhite) + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.spacing300) + } + .background(Color.colorGray950) + .clipShape(RoundedRectangle(cornerRadius: Radius.radius200)) + .disabled(viewModel.isSendDisabled) + } +} + +#Preview { + HomeView( + viewModel: HomeViewModel( + sendMessageUseCase: SendMessageUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ) + ) + ) +} diff --git a/GAMSS/Sources/Presentation/Home/HomeViewModel.swift b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift new file mode 100644 index 0000000..8a47bd9 --- /dev/null +++ b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift @@ -0,0 +1,59 @@ +// +// HomeViewModel.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import Combine +import Foundation + +@MainActor +final class HomeViewModel: ObservableObject { + @Published var input: String = "" + @Published private(set) var isSending = false + @Published var alertMessage: String? + @Published var createdConversationId: Int? + @Published private(set) var createdSentMessage: SentMessage? + + private let sendMessageUseCase: SendMessageUseCase + + init(sendMessageUseCase: SendMessageUseCase) { + self.sendMessageUseCase = sendMessageUseCase + } + + /// ์ž…๋ ฅ์ฐฝ์˜ ์›์‹œ ์ž…๋ ฅ๊ฐ’์„ ๋ฐ›์•„ ์ •์ฑ…์— ๋งž๊ฒŒ ์ •๊ทœํ™”ํ•˜๊ณ , ํ‚ค๋ณด๋“œ๋ฅผ ๋‚ด๋ ค์•ผ ํ•˜๋Š”์ง€ ๋Œ๋ ค์ค€๋‹ค. + func updateInput(_ rawValue: String) -> Bool { + let result = ConversationSummaryPolicy.normalizeInput(rawValue) + input = result.value + return result.shouldDismissKeyboard + } + + var isSendDisabled: Bool { + input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending + } + + func send() async { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isSending else { return } + + isSending = true + defer { isSending = false } + + do { + let sent = try await sendMessageUseCase.execute( + conversationId: nil, + content: trimmed, + repliesToMessageId: nil, + contextSummary: nil + ) + input = "" + createdSentMessage = sent + createdConversationId = sent.message.conversationId + } catch let error as SendMessageValidationError { + alertMessage = error.errorDescription + } catch { + alertMessage = "์ชฝ์ง€๋ฅผ ๋ณด๋‚ด์ง€ ๋ชปํ–ˆ์–ด์š”" + } + } +} diff --git a/GAMSS/Sources/Presentation/Root/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift new file mode 100644 index 0000000..2dcbb14 --- /dev/null +++ b/GAMSS/Sources/Presentation/Root/MainTabView.swift @@ -0,0 +1,36 @@ +// +// MainTabView.swift +// GAMSS +// +// Created by cchanmi on 8/8/26. +// + +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + HomeView( + viewModel: HomeViewModel( + sendMessageUseCase: SendMessageUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ) + ) + ) + .tabItem { Label("ํ™ˆ", systemImage: "house") } + + ConversationListView( + viewModel: ConversationListViewModel( + getConversationsUseCase: GetConversationsUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ) + ) + ) + .tabItem { Label("๋Œ€ํ™”", systemImage: "bubble.left.and.bubble.right") } + } + } +} + +#Preview { + MainTabView() +} diff --git a/GAMSS/Sources/Presentation/SummaryTest/SummaryTestView.swift b/GAMSS/Sources/Presentation/SummaryTest/SummaryTestView.swift deleted file mode 100644 index 7493544..0000000 --- a/GAMSS/Sources/Presentation/SummaryTest/SummaryTestView.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// SummaryTestView.swift -// GAMSS -// -// Created by cchanmi on 7/31/26. -// - -import SwiftUI - -struct SummaryTestView: View { - @StateObject private var viewModel: SummaryTestViewModel - @State private var inputText: String = "" - - init(viewModel: SummaryTestViewModel) { - _viewModel = StateObject(wrappedValue: viewModel) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - TextEditor(text: $inputText) - .frame(height: 100) - .border(Color.gray.opacity(0.3)) - - HStack { - Button("์ „์†ก") { - Task { - await viewModel.addUtterance(inputText) - inputText = "" - } - } - .disabled(viewModel.isLoading || inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - Button("๋Œ€ํ™” ์ข…๋ฃŒ") { - Task { - await viewModel.finalizeSession() - } - } - .disabled(viewModel.isLoading) - } - - if !viewModel.utterances.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("๋ˆ„์ ๋œ ๋ฐœํ™”") - .font(.subheadline) - .bold() - ForEach(Array(viewModel.utterances.enumerated()), id: \.offset) { _, utterance in - Text("- \(utterance)") - } - } - } - - if viewModel.isLoading { - ProgressView() - } - - if let result = viewModel.result { - Text("๊ฒฐ๊ณผ: \(result)") - .font(.headline) - } - - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - .foregroundColor(.red) - } - - Spacer() - } - .padding() - } -} diff --git a/GAMSS/Sources/Presentation/SummaryTest/SummaryTestViewModel.swift b/GAMSS/Sources/Presentation/SummaryTest/SummaryTestViewModel.swift deleted file mode 100644 index c1935bf..0000000 --- a/GAMSS/Sources/Presentation/SummaryTest/SummaryTestViewModel.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// SummaryTestViewModel.swift -// GAMSS -// -// Created by cchanmi on 7/31/26. -// - -import Combine -import Foundation - -@MainActor -final class SummaryTestViewModel: ObservableObject { - private let summarizeDiaryUseCase: SummarizeDiaryUseCase - - @Published private(set) var utterances: [String] = [] - @Published private(set) var isLoading = false - @Published private(set) var result: String? - @Published var errorMessage: String? - - init(summarizeDiaryUseCase: SummarizeDiaryUseCase) { - self.summarizeDiaryUseCase = summarizeDiaryUseCase - } - - func addUtterance(_ text: String) async { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - await summarizeDiaryUseCase.addUtterance(trimmed) - utterances.append(trimmed) - } - - func finalizeSession() async { - isLoading = true - errorMessage = nil - - defer { isLoading = false } - - do { - result = try await summarizeDiaryUseCase.finalize() - } catch { - result = nil - errorMessage = error.localizedDescription - } - utterances = [] - } -} diff --git a/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift b/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift new file mode 100644 index 0000000..82cf4c6 --- /dev/null +++ b/GAMSSTests/Data/DTO/ConversationMessageDTOTests.swift @@ -0,0 +1,58 @@ +// +// ConversationMessageDTOTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +final class ConversationMessageDTOTests: XCTestCase { + private func makeDTO(senderType: String, emotionType: String?) -> ConversationMessageDTO { + ConversationMessageDTO( + id: 1, conversationId: 10, senderType: senderType, emotionType: emotionType, + content: "๋‚ด์šฉ", repliesToMessageId: nil, rootMessageId: nil, createdAt: "2026-08-07T00:00:00Z" + ) + } + + func test_toDomain_userSender_mapsToUserMessage() { + let dto = makeDTO(senderType: "USER", emotionType: nil) + XCTAssertEqual(dto.toDomain()?.sender, .user) + } + + func test_toDomain_characterSenderWithKnownEmotionType_mapsToCharacterMessage() { + let cases: [(String, EmotionCharacter)] = [ + ("JOY", .joy), ("ANGER", .anger), ("ANXIETY", .anxiety), + ("GRUMPY", .prickly), ("WARM", .sadness), ("QUIRKY", .quirky), + ] + for (serverValue, expected) in cases { + let dto = makeDTO(senderType: "CHARACTER", emotionType: serverValue) + XCTAssertEqual(dto.toDomain()?.sender, .character(expected), "์„œ๋ฒ„ ๊ฐ’ \(serverValue)") + } + } + + func test_toDomain_characterSenderWithUnknownEmotionType_returnsNil() { + let dto = makeDTO(senderType: "CHARACTER", emotionType: "UNKNOWN_TYPE") + XCTAssertNil(dto.toDomain()) + } + + func test_toDomain_unknownSenderType_returnsNil() { + let dto = makeDTO(senderType: "SYSTEM", emotionType: nil) + XCTAssertNil(dto.toDomain()) + } + + func test_toSentUserMessage_alwaysMapsToUserRegardlessOfSenderType() { + let dto = makeDTO(senderType: "CHARACTER", emotionType: "JOY") + XCTAssertEqual(dto.toSentUserMessage().sender, .user) + } + + func test_decode_fromServerShapedJSON_succeeds() throws { + let json = """ + {"id":1,"conversationId":10,"senderType":"CHARACTER","emotionType":"JOY","content":"๋ฐ˜๊ฐ€์›Œ","repliesToMessageId":5,"rootMessageId":5,"createdAt":"2026-08-07T00:00:00Z"} + """ + let dto = try JSONDecoder().decode(ConversationMessageDTO.self, from: Data(json.utf8)) + XCTAssertEqual(dto.id, 1) + XCTAssertEqual(dto.toDomain()?.sender, .character(.joy)) + } +} diff --git a/GAMSSTests/Data/DTO/CreateMessageRequestDTOTests.swift b/GAMSSTests/Data/DTO/CreateMessageRequestDTOTests.swift new file mode 100644 index 0000000..4f1474c --- /dev/null +++ b/GAMSSTests/Data/DTO/CreateMessageRequestDTOTests.swift @@ -0,0 +1,49 @@ +// +// CreateMessageRequestDTOTests.swift +// GAMSS +// +// Created by cchanmi on 8/10/26. +// + +import XCTest +@testable import GAMSS + +final class CreateMessageRequestDTOTests: XCTestCase { + // Swift์˜ ํ•ฉ์„ฑ Encodable ๊ตฌํ˜„์€ ์˜ต์…”๋„ ํ”„๋กœํผํ‹ฐ๊ฐ€ nil์ด๋ฉด encodeIfPresent๋ฅผ ํ†ตํ•ด ํ‚ค + // ์ž์ฒด๋ฅผ ์ƒ๋žตํ•œ๋‹ค(๋ณ„๋„ ์ปค์Šคํ…€ encode(to:) ์—†์ด๋„ ๋ณด์žฅ๋จ). ์„œ๋ฒ„๋Š” ํ‚ค ๋ถ€์žฌ๋กœ "์ƒˆ ๋Œ€ํ™”"/ + // "์ปจํ…์ŠคํŠธ ์—†์Œ"์„ ํŒ๋‹จํ•˜๋ฏ€๋กœ ์ด ๋™์ž‘์ด ๊นจ์ง€๋ฉด ์•ˆ ๋œ๋‹ค. + func test_encode_withAllOptionalFieldsNil_omitsThoseKeysEntirely() throws { + let dto = CreateMessageRequestDTO( + conversationId: nil, + content: "์•ˆ๋…•", + repliesToMessageId: nil, + currentConversationSummary: nil, + excludeCharacters: [] + ) + + let data = try JSONEncoder().encode(dto) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertFalse(json.contains("conversationId"), "conversationId ํ‚ค๊ฐ€ nil์ผ ๋•Œ ๋น ์ ธ์•ผ ํ•จ") + XCTAssertFalse(json.contains("repliesToMessageId"), "repliesToMessageId ํ‚ค๊ฐ€ nil์ผ ๋•Œ ๋น ์ ธ์•ผ ํ•จ") + XCTAssertFalse(json.contains("currentConversationSummary"), "currentConversationSummary ํ‚ค๊ฐ€ nil์ผ ๋•Œ ๋น ์ ธ์•ผ ํ•จ") + XCTAssertTrue(json.contains("\"content\":\"์•ˆ๋…•\"")) + } + + func test_encode_withAllOptionalFieldsPresent_includesAllKeys() throws { + let dto = CreateMessageRequestDTO( + conversationId: 1, + content: "์•ˆ๋…•", + repliesToMessageId: 2, + currentConversationSummary: "์š”์•ฝ๋ณธ", + excludeCharacters: [] + ) + + let data = try JSONEncoder().encode(dto) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertTrue(json.contains("\"conversationId\":1")) + XCTAssertTrue(json.contains("\"repliesToMessageId\":2")) + XCTAssertTrue(json.contains("\"currentConversationSummary\":\"์š”์•ฝ๋ณธ\"")) + } +} diff --git a/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift new file mode 100644 index 0000000..25b2429 --- /dev/null +++ b/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift @@ -0,0 +1,58 @@ +// +// DefaultConversationRepositoryTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +/// ์‹ค์ œ ๋„คํŠธ์›Œํฌ๋ฅผ ํƒ€์ง€ ์•Š๊ณ , ์„œ๋ฒ„ ์‘๋‹ต ํ˜•ํƒœ์˜ JSON์„ ๊ทธ๋Œ€๋กœ ๋””์ฝ”๋”ฉ์‹œ์ผœ ๋งคํ•‘ ๋กœ์ง์„ ๊ฒ€์ฆํ•œ๋‹ค. +private final class MockNetworkRequesting: NetworkRequesting { + var stubbedData: Data? + var stubbedError: Error? + private(set) var lastEndpoint: Endpoint? + + func request(_ endpoint: Endpoint, responseType: T.Type) async throws -> T { + lastEndpoint = endpoint + if let stubbedError { throw stubbedError } + guard let stubbedData else { fatalError("stubbedData not set") } + return try JSONDecoder().decode(T.self, from: stubbedData) + } +} + +final class DefaultConversationRepositoryTests: XCTestCase { + func test_sendMessage_decodesResponseAndMapsToDomain() async throws { + let network = MockNetworkRequesting() + network.stubbedData = Data(""" + {"success":true,"data":{"message":{"id":1,"conversationId":10,"senderType":"USER","emotionType":null,"content":"์•ˆ๋…•","repliesToMessageId":null,"rootMessageId":null,"createdAt":"2026-08-07T00:00:00Z"},"commentStatus":"DONE","comments":[{"id":2,"conversationId":10,"senderType":"CHARACTER","emotionType":"JOY","content":"๋ฐ˜๊ฐ€์›Œ","repliesToMessageId":1,"rootMessageId":1,"createdAt":"2026-08-07T00:00:01Z"}]},"error":null} + """.utf8) + let repository = DefaultConversationRepository(networkManager: network) + + let result = try await repository.sendMessage(conversationId: nil, content: "์•ˆ๋…•", repliesToMessageId: nil, contextSummary: nil) + + XCTAssertEqual(result.message.content, "์•ˆ๋…•") + XCTAssertEqual(result.message.sender, .user) + XCTAssertEqual(result.commentStatus, .done) + XCTAssertEqual(result.comments.count, 1) + XCTAssertEqual(result.comments.first?.sender, .character(.joy)) + XCTAssertEqual(network.lastEndpoint?.path, "/api/conversations/messages") + XCTAssertEqual(network.lastEndpoint?.method, .post) + } + + func test_getMessages_decodesResponseAndExcludesUnresolvableSenders() async throws { + let network = MockNetworkRequesting() + network.stubbedData = Data(""" + {"success":true,"data":[{"id":1,"conversationId":10,"senderType":"USER","emotionType":null,"content":"์•ˆ๋…•","repliesToMessageId":null,"rootMessageId":null,"createdAt":"2026-08-07T00:00:00Z"},{"id":2,"conversationId":10,"senderType":"CHARACTER","emotionType":"UNKNOWN","content":"???","repliesToMessageId":null,"rootMessageId":null,"createdAt":"2026-08-07T00:00:01Z"}],"error":null} + """.utf8) + let repository = DefaultConversationRepository(networkManager: network) + + let result = try await repository.getMessages(conversationId: 10) + + XCTAssertEqual(result.count, 1, "senderType์„ ์•Œ ์ˆ˜ ์—†๋Š” ๋ฉ”์‹œ์ง€๋Š” ๋ชฉ๋ก์—์„œ ์ œ์™ธ๋˜์–ด์•ผ ํ•จ") + XCTAssertEqual(result.first?.content, "์•ˆ๋…•") + XCTAssertEqual(network.lastEndpoint?.path, "/api/conversations/10/messages") + XCTAssertEqual(network.lastEndpoint?.method, .get) + } +} diff --git a/GAMSSTests/Data/Repository/DefaultDiarySummaryRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultDiarySummaryRepositoryTests.swift deleted file mode 100644 index 297468c..0000000 --- a/GAMSSTests/Data/Repository/DefaultDiarySummaryRepositoryTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// DefaultDiarySummaryRepositoryTests.swift -// GAMSS -// -// Created by cchanmi on 7/31/26. -// - -import XCTest -@testable import GAMSS - -final class DefaultDiarySummaryRepositoryTests: XCTestCase { - private func loadDiaries() throws -> [String] { - let url = Bundle(for: Self.self).url(forResource: "diary_samples", withExtension: "json")! - let data = try Data(contentsOf: url) - return try JSONDecoder().decode([String].self, from: data) - } - - // ๋ชจ๋ธ ํ’ˆ์งˆ์€ ์‚ฌ๋žŒ์ด ๋กœ๊ทธ๋ฅผ ๋ณด๊ณ  ํŒ๋‹จํ•œ๋‹ค (๊ฐ์ • ๋ถ„์„ ์ •ํ™•๋„ ํ…Œ์ŠคํŠธ์™€ ๋™์ผํ•œ ์ฒ ํ•™). - // ์—ฌ๊ธฐ์„œ๋Š” "๋น„์–ด์žˆ์ง€ ์•Š์Œ"๊ณผ "ํฌ๋ž˜์‹œ ์—†์Œ"๋งŒ assertํ•˜๊ณ , ์ž…๋ ฅ/์ถœ๋ ฅ/์†Œ์š”์‹œ๊ฐ„์„ ๋กœ๊ทธ๋กœ ๋‚จ๊ธด๋‹ค. - func test_summarize_sampleDiaries_producesNonEmptySummaryWithoutCrashing() async throws { - let repository = try await DefaultDiarySummaryRepository.make() - let diaries = try loadDiaries() - - for (index, diary) in diaries.enumerated() { - let started = Date() - let summary = try await repository.summarize(text: diary) - let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) - - XCTAssertFalse(summary.isEmpty, "์š”์•ฝ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด์žˆ์Œ: ์ž…๋ ฅ '\(diary)'") - Log.info("SUMMARY_EVAL|\(index)|\(elapsedMs)ms|์ž…๋ ฅ=\(diary)") - Log.info("SUMMARY_EVAL|\(index)|์š”์•ฝ=\(summary)") - } - } - - // ๊ฒŒ์ดํŒ… ํ™•์ธ: ์งง์€ ๋ฌธ์žฅ์€ UseCase๊ฐ€ ์›๋ฌธ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•˜๊ณ (๋ชจ๋ธ ํ˜ธ์ถœ ์—†์Œ), - // ๊ธด ์ผ๊ธฐ๋Š” Repository๋ฅผ ๊ฑฐ์ณ ์‹ค์ œ๋กœ ์š”์•ฝ๋˜์–ด์•ผ ํ•œ๋‹ค. - func test_gating_shortTextReturnsAsIs_longTextIsSummarized() async throws { - let repository = try await DefaultDiarySummaryRepository.make() - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - let diaries = try loadDiaries() - - await useCase.addUtterance("์˜ค๋Š˜ ์–ต์šธํ•œ ์ผ์ด ์žˆ์—ˆ์–ด") - let short = try await useCase.finalize() - XCTAssertEqual(short, "์˜ค๋Š˜ ์–ต์šธํ•œ ์ผ์ด ์žˆ์—ˆ์–ด") - - // ๋ฐ˜๋ณต ์ถ”๊ฐ€๋กœ 140์ž ์ด์ƒ ๋งŒ๋“ค์–ด์„œ ๋ชจ๋ธ ํ˜ธ์ถœ์„ ํŠธ๋ฆฌ๊ฑฐํ•จ - for _ in 0..<3 { - await useCase.addUtterance(diaries[0]) - } - let long = try await useCase.finalize() - XCTAssertNotNil(long) - // 3๊ฐœ๋ฅผ ๋ชจ๋‘ ํ•ฉ์ณ์„œ 140์ž ์ด์ƒ์ด ๋˜๋ฏ€๋กœ ์š”์•ฝ๋˜์–ด์•ผ ํ•จ - let combined = String(repeating: diaries[0] + "\n", count: 3).dropLast() - XCTAssertNotEqual(long, String(combined)) - } -} diff --git a/GAMSSTests/Data/Repository/DefaultSummaryRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultSummaryRepositoryTests.swift new file mode 100644 index 0000000..949cca5 --- /dev/null +++ b/GAMSSTests/Data/Repository/DefaultSummaryRepositoryTests.swift @@ -0,0 +1,47 @@ +// +// DefaultSummaryRepositoryTests.swift +// GAMSS +// +// Created by cchanmi on 7/31/26. +// + +import XCTest +@testable import GAMSS + +final class DefaultSummaryRepositoryTests: XCTestCase { + private func loadDiaries() throws -> [String] { + let url = Bundle(for: Self.self).url(forResource: "diary_samples", withExtension: "json")! + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([String].self, from: data) + } + + // ๋ชจ๋ธ ํ’ˆ์งˆ์€ ์‚ฌ๋žŒ์ด ๋กœ๊ทธ๋ฅผ ๋ณด๊ณ  ํŒ๋‹จํ•œ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” "๋น„์–ด์žˆ์ง€ ์•Š์Œ"๊ณผ "ํฌ๋ž˜์‹œ ์—†์Œ"๋งŒ assertํ•˜๊ณ , + // ์ž…๋ ฅ/์ถœ๋ ฅ/์†Œ์š”์‹œ๊ฐ„์„ ๋กœ๊ทธ๋กœ ๋‚จ๊ธด๋‹ค. + func test_summarize_sampleTexts_producesNonEmptySummaryWithoutCrashing() async throws { + let repository = try await DefaultSummaryRepository.make() + let samples = try loadDiaries() + + for (index, sample) in samples.enumerated() { + let started = Date() + let summary = try await repository.summarize(text: sample) + let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) + + XCTAssertFalse(summary.isEmpty, "์š”์•ฝ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด์žˆ์Œ: ์ž…๋ ฅ '\(sample)'") + Log.info("SUMMARY_EVAL|\(index)|\(elapsedMs)ms|์ž…๋ ฅ=\(sample)") + Log.info("SUMMARY_EVAL|\(index)|์š”์•ฝ=\(summary)") + } + } + + func test_countTokens_returnsPositiveCountForNonEmptyText() async throws { + let repository = try await DefaultSummaryRepository.make() + let count = try await repository.countTokens(text: "์˜ค๋Š˜ ํ•˜๋ฃจ๋„ ๊ณ ์ƒํ–ˆ์–ด") + XCTAssertGreaterThan(count, 0) + } + + func test_countTokens_longerTextHasMoreTokensThanShorterText() async throws { + let repository = try await DefaultSummaryRepository.make() + let short = try await repository.countTokens(text: "์•ˆ๋…•") + let long = try await repository.countTokens(text: String(repeating: "์•ˆ๋…•ํ•˜์„ธ์š” ์˜ค๋Š˜ ๋‚ ์”จ๊ฐ€ ์ข‹๋„ค์š” ", count: 20)) + XCTAssertGreaterThan(long, short) + } +} diff --git a/GAMSSTests/Domain/Conversation/CommentRevealPolicyTests.swift b/GAMSSTests/Domain/Conversation/CommentRevealPolicyTests.swift new file mode 100644 index 0000000..aa7a1b7 --- /dev/null +++ b/GAMSSTests/Domain/Conversation/CommentRevealPolicyTests.swift @@ -0,0 +1,19 @@ +// +// CommentRevealPolicyTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +final class CommentRevealPolicyTests: XCTestCase { + func test_nextGapSeconds_isAlwaysWithinOneToThreeSeconds() { + for _ in 0..<200 { + let gap = CommentRevealPolicy.nextGapSeconds() + XCTAssertGreaterThanOrEqual(gap, CommentRevealPolicy.minSeconds) + XCTAssertLessThanOrEqual(gap, CommentRevealPolicy.maxSeconds) + } + } +} diff --git a/GAMSSTests/Domain/Conversation/ConversationSummaryPolicyTests.swift b/GAMSSTests/Domain/Conversation/ConversationSummaryPolicyTests.swift new file mode 100644 index 0000000..4f6bc91 --- /dev/null +++ b/GAMSSTests/Domain/Conversation/ConversationSummaryPolicyTests.swift @@ -0,0 +1,36 @@ +// +// ConversationSummaryPolicyTests.swift +// GAMSS +// +// Created by cchanmi on 8/10/26. +// + +import XCTest +@testable import GAMSS + +final class ConversationSummaryPolicyTests: XCTestCase { + func test_normalizeInput_trailingNewline_stripsAndSignalsKeyboardDismiss() { + let result = ConversationSummaryPolicy.normalizeInput("์•ˆ๋…•\n") + + XCTAssertEqual(result.value, "์•ˆ๋…•") + XCTAssertTrue(result.shouldDismissKeyboard) + } + + func test_normalizeInput_withinMaxLength_returnsUnchanged() { + let raw = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength) + + let result = ConversationSummaryPolicy.normalizeInput(raw) + + XCTAssertEqual(result.value, raw) + XCTAssertFalse(result.shouldDismissKeyboard) + } + + func test_normalizeInput_overMaxLength_truncatesToMaxLength() { + let raw = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 10) + + let result = ConversationSummaryPolicy.normalizeInput(raw) + + XCTAssertEqual(result.value.count, ConversationSummaryPolicy.maxMessageLength) + XCTAssertFalse(result.shouldDismissKeyboard) + } +} diff --git a/GAMSSTests/Domain/Conversation/DefaultConversationSummaryStoreTests.swift b/GAMSSTests/Domain/Conversation/DefaultConversationSummaryStoreTests.swift new file mode 100644 index 0000000..15eaecd --- /dev/null +++ b/GAMSSTests/Domain/Conversation/DefaultConversationSummaryStoreTests.swift @@ -0,0 +1,225 @@ +// +// DefaultConversationSummaryStoreTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +/// countTokens๋Š” text.count(1๊ธ€์ž=1ํ† ํฐ)๋กœ ๊ณ ์ •ํ•ด์„œ, ๋ฐœํ™” ๊ธธ์ด๋งŒ์œผ๋กœ ์ฒญํฌ ๊ฒฝ๊ณ„๋ฅผ ์ •ํ™•ํžˆ ํ†ต์ œํ•œ๋‹ค. +private actor MockSummaryRepository: SummaryRepository { + private(set) var summarizeCallCount = 0 + private(set) var summarizedTexts: [String] = [] + var stubbedSummary = "์š”์•ฝ๋จ" + var shouldThrowOnSummarize = false + + func summarize(text: String) async throws -> String { + summarizeCallCount += 1 + summarizedTexts.append(text) + if shouldThrowOnSummarize { throw SummaryError.inferenceFailed() } + return stubbedSummary + } + + func countTokens(text: String) async throws -> Int { + text.count + } +} + +final class DefaultConversationSummaryStoreTests: XCTestCase { + private func makeStore(repository: MockSummaryRepository = MockSummaryRepository()) -> (DefaultConversationSummaryStore, MockSummaryRepository) { + (DefaultConversationSummaryStore(summaryRepository: repository), repository) + } + + func test_current_whenEmpty_returnsNil() async { + let (store, _) = makeStore() + let result = await store.current() + XCTAssertNil(result) + } + + func test_current_withFewerThanRecentWindow_returnsAllRawWithNoDuplication() async { + let (store, repository) = makeStore() + await store.add("๋ฐœํ™”1") + await store.add("๋ฐœํ™”2") + + let result = await store.current() + + XCTAssertEqual(result, "๋ฐœํ™”1 ๋ฐœํ™”2") + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 0) + } + + func test_current_firstUtterance_staysRawAsConversationGrows() async { + let (store, _) = makeStore() + // 100์ž์งœ๋ฆฌ ๋ฐœํ™” 10๊ฐœ: ์ฒซ ๋ฐœํ™”๋Š” ์ ˆ๋Œ€ ํ›„๋ณด์— ์•ˆ ๋“ค์–ด๊ฐ€๋ฏ€๋กœ ์›๋ฌธ ๊ทธ๋Œ€๋กœ ๋งจ ์•ž์— ๋‚จ์•„์•ผ ํ•œ๋‹ค. + let first = String(repeating: "๊ฐ€", count: 100) + await store.add(first) + for i in 1..<10 { + await store.add(String(repeating: "\(i % 10)", count: 100)) + } + + let result = await store.current() + + XCTAssertTrue(result?.hasPrefix(first) ?? false, "์ฒซ ๋ฐœํ™”๊ฐ€ ์••์ถ•๋ณธ ๋งจ ์•ž์— ์›๋ฌธ์œผ๋กœ ๋‚จ์•„์žˆ์–ด์•ผ ํ•จ") + } + + func test_add_closesChunkExactlyOnceWhenBudgetExceeded() async { + let (store, repository) = makeStore() + // 100ํ† ํฐ์งœ๋ฆฌ ๋ฐœํ™”๋ฅผ 10๊ฐœ ์ถ”๊ฐ€ํ•˜๋ฉด: recentStart๋Š” ๋งค add๋งˆ๋‹ค ๊ฐฑ์‹ ๋˜๊ณ , + // 10๋ฒˆ์งธ ๋ฐœํ™” ์ถ”๊ฐ€ ์‹œ์ ์— pending(๋ฐœํ™”1~5, 500ํ† ํฐ) + ๋ฐœํ™”6(100ํ† ํฐ) = 600 > 512๋ผ + // ๋ฐœํ™”6์„ ๋„ฃ๊ธฐ ์ „์— ๋จผ์ € ํ™•์ •(summarize 1ํšŒ ํ˜ธ์ถœ)๋œ๋‹ค. + for i in 0..<10 { + await store.add(String(repeating: "\(i)", count: 100)) + } + + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 1, "์ •ํ™•ํžˆ ํ•œ ๋ฒˆ๋งŒ ์š”์•ฝ์ด ํ™•์ •๋˜์–ด์•ผ ํ•จ") + } + + func test_add_closedChunkIsNeverReSummarized() async { + let (store, repository) = makeStore() + for i in 0..<10 { + await store.add(String(repeating: "\(i)", count: 100)) + } + let callCountAfterFirstClose = await repository.summarizeCallCount + XCTAssertEqual(callCountAfterFirstClose, 1) + + // ๋ฐœํ™”๋ฅผ ๋” ์ถ”๊ฐ€ํ•ด๋„ ์ด๋ฏธ ํ™•์ •๋œ ์ฒญํฌ๋Š” ๋‹ค์‹œ ์š”์•ฝ๋˜์ง€ ์•Š๋Š”๋‹ค. + await store.add(String(repeating: "x", count: 50)) + await store.add(String(repeating: "y", count: 50)) + + let callCountAfterMore = await repository.summarizeCallCount + XCTAssertEqual(callCountAfterMore, 1, "์ด๋ฏธ ํ™•์ •๋œ ์ฒญํฌ๊ฐ€ ์žฌ์š”์•ฝ๋˜๋ฉด ์•ˆ ๋จ") + } + + func test_add_singleUtteranceOverBudget_confirmsAsRawWithoutSummarizing() async { + let (store, repository) = makeStore() + let huge = String(repeating: "๊ฐ€", count: 600) // ๋‹จ์ผ ๋ฐœํ™”๊ฐ€ ์ด๋ฏธ 512ํ† ํฐ ์ดˆ๊ณผ + // ์ฒซ ๋ฐœํ™”(์งง๊ฒŒ) + ๊ฑฐ๋Œ€ ๋ฐœํ™” + ์ฑ„์›€์šฉ ๋ฐœํ™” 3๊ฐœ๋กœ ๊ฑฐ๋Œ€ ๋ฐœํ™”๋ฅผ ํ›„๋ณด ๊ตฌ๊ฐ„์— ๋“ค์–ด์˜ค๊ฒŒ ๋งŒ๋“ ๋‹ค. + await store.add("์ฒซ๋ฐœํ™”") + await store.add(huge) + await store.add("์ฑ„์›€1") + await store.add("์ฑ„์›€2") + await store.add("์ฑ„์›€3") + + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 0, "์˜ˆ์‚ฐ์„ ์ดˆ๊ณผํ•œ ๋‹จ์ผ ๋ฐœํ™”๋Š” ์š”์•ฝํ•˜์ง€ ์•Š๊ณ  ์›๋ฌธ์œผ๋กœ ํ™•์ •ํ•ด์•ผ ํ•จ") + let result = await store.current() + XCTAssertTrue(result?.contains(huge) ?? false, "์˜ˆ์‚ฐ ์ดˆ๊ณผ ๋ฐœํ™”๋Š” ์›๋ฌธ ๊ทธ๋Œ€๋กœ ์••์ถ•๋ณธ์— ํฌํ•จ๋˜์–ด์•ผ ํ•จ") + } + + func test_current_recentThreeUtterances_alwaysRawRegardlessOfChunking() async { + let (store, _) = makeStore() + for i in 0..<12 { + await store.add(String(repeating: "\(i)", count: 100)) + } + let last = String(repeating: "9", count: 100) + await store.add(last) + + let result = await store.current() + + XCTAssertTrue(result?.hasSuffix(last) ?? false, "๊ฐ€์žฅ ์ตœ๊ทผ ๋ฐœํ™”๊ฐ€ ์••์ถ•๋ณธ ๋งจ ๋’ค์— ์›๋ฌธ์œผ๋กœ ๋‚จ์•„์žˆ์–ด์•ผ ํ•จ") + } + + func test_current_exceedsMaxLength_dropsOldestMiddleContentFirst() async { + let (store, _) = makeStore() + let first = String(repeating: "์ฒซ", count: 100) + await store.add(first) + // ์ค‘๊ฐ„ ๋ฐœํ™”๋ฅผ ์•„์ฃผ ๋งŽ์ด ๋„ฃ์–ด์„œ(2000์ž ์ƒํ•œ์„ ํ™•์‹คํžˆ ๋„˜๊ธฐ๋„๋ก) ์˜ค๋ž˜๋œ ํ™•์ • ์ฒญํฌ๊ฐ€ ๋ฒ„๋ ค์ง€๋Š”์ง€ ํ™•์ธ. + for i in 0..<30 { + await store.add(String(repeating: "\(i % 10)", count: 100)) + } + let recent = ["์ตœ๊ทผ1", "์ตœ๊ทผ2", "์ตœ๊ทผ3"] + for utterance in recent { + await store.add(utterance) + } + + let result = await store.current() + + XCTAssertNotNil(result) + XCTAssertLessThanOrEqual(result?.count ?? .max, ConversationSummaryPolicy.maxContextSummaryLength) + XCTAssertTrue(result?.hasPrefix(first) ?? false, "์ƒํ•œ์„ ๋„˜๊ฒจ๋„ ์ฒซ ๋ฐœํ™”๋Š” ํ•ญ์ƒ ๋ณด์กด๋˜์–ด์•ผ ํ•จ") + for utterance in recent { + XCTAssertTrue(result?.contains(utterance) ?? false, "์ƒํ•œ์„ ๋„˜๊ฒจ๋„ ์ตœ๊ทผ ๋ฐœํ™”๋Š” ํ•ญ์ƒ ๋ณด์กด๋˜์–ด์•ผ ํ•จ: \(utterance)") + } + } + + func test_reset_clearsAllState() async { + let (store, _) = makeStore() + await store.add("๋ฐœํ™”1") + await store.add("๋ฐœํ™”2") + + await store.reset() + + let result = await store.current() + XCTAssertNil(result) + } + + func test_restore_neverCallsSummarize() async { + let (store, repository) = makeStore() + // 100์ž์งœ๋ฆฌ ๋ฐœํ™” 20๊ฐœ๋ฅผ ๋ณต์› โ€” ์ผ๋ฐ˜ add()์˜€๋‹ค๋ฉด ์—ฌ๋Ÿฌ ๋ฒˆ ์š”์•ฝ์ด ํŠธ๋ฆฌ๊ฑฐ๋  ์–‘์ด์ง€๋งŒ + // restore()๋Š” ์ ˆ๋Œ€ ์š”์•ฝ์„ ํ˜ธ์ถœํ•˜๋ฉด ์•ˆ ๋œ๋‹ค. + let historical = (0..<20).map { String(repeating: "\($0 % 10)", count: 100) } + + await store.restore(historicalUtterances: historical) + + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 0, "์žฌ์ง„์ž… ๋ณต์›์€ ์ ˆ๋Œ€ ์š”์•ฝ์„ ํ˜ธ์ถœํ•˜๋ฉด ์•ˆ ๋จ") + } + + func test_restore_thenAddingNewUtterance_doesNotReprocessRestoredMiddle() async { + let (store, repository) = makeStore() + let historical = (0..<20).map { String(repeating: "\($0 % 10)", count: 100) } + await store.restore(historicalUtterances: historical) + + // ๋ณต์› ํ›„ ์ƒˆ ๋ฐœํ™”๋ฅผ ์ถ”๊ฐ€ํ•ด๋„, ์ด๋ฏธ ๋ณต์›๋œ "์ค‘๊ฐ„ ๊ตฌ๊ฐ„"์€ ์žฌ์ฒญํ‚น๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค + // (๋‹ค์Œ ํ›„๋ณด ์ธ๋ฑ์Šค๊ฐ€ ์ตœ๊ทผ์ฐฝ ์‹œ์ž‘์œผ๋กœ ์ด๋ฏธ ์˜ฎ๊ฒจ์ ธ ์žˆ์œผ๋ฏ€๋กœ). + await store.add("์ƒˆ๋ฐœํ™”") + + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 0, "๋ณต์›๋œ ์ค‘๊ฐ„ ๊ตฌ๊ฐ„์ด ์žฌ์ฒญํ‚น/์žฌ์š”์•ฝ๋˜๋ฉด ์•ˆ ๋จ") + } + + func test_restore_withEmptyHistory_resultsInEmptyStore() async { + let (store, _) = makeStore() + await store.restore(historicalUtterances: []) + let result = await store.current() + XCTAssertNil(result) + } + + func test_restore_withFewerThanRecentWindow_thenAdd_doesNotDuplicateFirstUtterance() async { + let (store, repository) = makeStore() + // ์ตœ๊ทผ์ฐฝ(3๊ฐœ) ์ดํ•˜๋กœ ๋ณต์›ํ•˜๋ฉด recentStart()๊ฐ€ 0์ด ๋˜์–ด, ๋‹ค์Œ ํ›„๋ณด ์ธ๋ฑ์Šค๊ฐ€ ๋ฐ”๋‹ฅ(1) ์—†์ด + // ๊ทธ๋Œ€๋กœ ๋Œ€์ž…๋˜๋ฉด 0์ด ๋˜๊ณ  ๋งŒ๋‹ค โ€” ๊ทธ ์ƒํƒœ์—์„œ ๋ฐœํ™”๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ์ฒซ ๋ฐœํ™”(์ธ๋ฑ์Šค 0)๊ฐ€ + // ๋‹ค์‹œ ์ฒญํฌ ํ›„๋ณด๋กœ ํŽธ์ž…๋˜์–ด anchor์™€ middle ์–‘์ชฝ์— ์ค‘๋ณต ๋“ฑ์žฅํ•˜๊ฒŒ ๋œ๋‹ค. + await store.restore(historicalUtterances: ["์ฒซ๋ฐœํ™”", "๋‘๋ฒˆ์งธ", "์„ธ๋ฒˆ์งธ"]) + await store.add("๋„ค๋ฒˆ์งธ") + + let result = await store.current() + + XCTAssertEqual(result, "์ฒซ๋ฐœํ™” ๋‘๋ฒˆ์งธ ์„ธ๋ฒˆ์งธ ๋„ค๋ฒˆ์งธ") + let occurrences = result?.components(separatedBy: "์ฒซ๋ฐœํ™”").count ?? 0 + XCTAssertEqual(occurrences - 1, 1, "์ฒซ ๋ฐœํ™”๋Š” ์ •ํ™•ํžˆ ํ•œ ๋ฒˆ๋งŒ ๋‚˜ํƒ€๋‚˜์•ผ ํ•จ") + let callCount = await repository.summarizeCallCount + XCTAssertEqual(callCount, 0) + } + + func test_summarizeFailure_confirmsChunkAsRawText() async { + let (store, repository) = makeStore() + await repository.setShouldThrowOnSummarize(true) + for i in 0..<10 { + await store.add(String(repeating: "\(i)", count: 100)) + } + + let result = await store.current() + + XCTAssertNotNil(result, "์š”์•ฝ ์‹คํŒจํ•ด๋„ ์••์ถ•๋ณธ ์ƒ์„ฑ ์ž์ฒด๋Š” ์‹คํŒจํ•˜๋ฉด ์•ˆ ๋จ(์›๋ฌธ์œผ๋กœ ๋Œ€์ฒด)") + } +} + +private extension MockSummaryRepository { + func setShouldThrowOnSummarize(_ value: Bool) { + shouldThrowOnSummarize = value + } +} diff --git a/GAMSSTests/Domain/Conversation/EmotionCharacterTests.swift b/GAMSSTests/Domain/Conversation/EmotionCharacterTests.swift new file mode 100644 index 0000000..e1b7bde --- /dev/null +++ b/GAMSSTests/Domain/Conversation/EmotionCharacterTests.swift @@ -0,0 +1,24 @@ +// +// EmotionCharacterTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +final class EmotionCharacterTests: XCTestCase { + func test_displayName_isKoreanForEveryCase() { + XCTAssertEqual(EmotionCharacter.joy.displayName, "๊ธฐ์จ์ด") + XCTAssertEqual(EmotionCharacter.sadness.displayName, "์Šฌํ””์ด") + XCTAssertEqual(EmotionCharacter.anger.displayName, "๋ถ„๋…ธ") + XCTAssertEqual(EmotionCharacter.anxiety.displayName, "๋ถˆ์•ˆ") + XCTAssertEqual(EmotionCharacter.prickly.displayName, "๊นŒ์น ์ด") + XCTAssertEqual(EmotionCharacter.quirky.displayName, "์—‰๋šฑ์ด") + } + + func test_allCases_hasExactlySixCharacters() { + XCTAssertEqual(EmotionCharacter.allCases.count, 6) + } +} diff --git a/GAMSSTests/Domain/Entity/DiarySummaryErrorTests.swift b/GAMSSTests/Domain/Entity/DiarySummaryErrorTests.swift deleted file mode 100644 index d690b44..0000000 --- a/GAMSSTests/Domain/Entity/DiarySummaryErrorTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// DiarySummaryErrorTests.swift -// GAMSS -// -// Created by cchanmi on 7/29/26. -// - -import XCTest -@testable import GAMSS - -final class DiarySummaryErrorTests: XCTestCase { - func test_equality_ignoresUnderlyingError() { - struct DummyError: Error {} - - XCTAssertEqual( - DiarySummaryError.modelLoadFailed(underlying: DummyError()), - DiarySummaryError.modelLoadFailed(underlying: nil) - ) - XCTAssertEqual(DiarySummaryError.inferenceFailed(), DiarySummaryError.inferenceFailed()) - XCTAssertNotEqual(DiarySummaryError.modelLoadFailed(), DiarySummaryError.inferenceFailed()) - } - - func test_errorDescription_isKorean() { - XCTAssertEqual(DiarySummaryError.modelLoadFailed().errorDescription, "์š”์•ฝ ๋ชจ๋ธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์–ด์š”.") - XCTAssertEqual(DiarySummaryError.inferenceFailed().errorDescription, "์š”์•ฝ์„ ์ƒ์„ฑํ•˜์ง€ ๋ชปํ–ˆ์–ด์š”.") - } -} diff --git a/GAMSSTests/Domain/Entity/SummaryErrorTests.swift b/GAMSSTests/Domain/Entity/SummaryErrorTests.swift new file mode 100644 index 0000000..2027358 --- /dev/null +++ b/GAMSSTests/Domain/Entity/SummaryErrorTests.swift @@ -0,0 +1,27 @@ +// +// SummaryErrorTests.swift +// GAMSS +// +// Created by cchanmi on 7/29/26. +// + +import XCTest +@testable import GAMSS + +final class SummaryErrorTests: XCTestCase { + func test_equality_ignoresUnderlyingError() { + struct DummyError: Error {} + + XCTAssertEqual( + SummaryError.modelLoadFailed(underlying: DummyError()), + SummaryError.modelLoadFailed(underlying: nil) + ) + XCTAssertEqual(SummaryError.inferenceFailed(), SummaryError.inferenceFailed()) + XCTAssertNotEqual(SummaryError.modelLoadFailed(), SummaryError.inferenceFailed()) + } + + func test_errorDescription_isKorean() { + XCTAssertEqual(SummaryError.modelLoadFailed().errorDescription, "์š”์•ฝ ๋ชจ๋ธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์–ด์š”.") + XCTAssertEqual(SummaryError.inferenceFailed().errorDescription, "์š”์•ฝ์„ ์ƒ์„ฑํ•˜์ง€ ๋ชปํ–ˆ์–ด์š”.") + } +} diff --git a/GAMSSTests/Domain/UseCase/DefaultSummarizeDiaryUseCaseTests.swift b/GAMSSTests/Domain/UseCase/DefaultSummarizeDiaryUseCaseTests.swift deleted file mode 100644 index ddcb33f..0000000 --- a/GAMSSTests/Domain/UseCase/DefaultSummarizeDiaryUseCaseTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// DefaultSummarizeDiaryUseCaseTests.swift -// GAMSS -// -// Created by cchanmi on 7/29/26. -// - -import XCTest -@testable import GAMSS - -private final class MockDiarySummaryRepository: DiarySummaryRepository { - var stubbedResult: Result = .success("์š”์•ฝ๋œ ๋ฌธ์žฅ") - private(set) var receivedText: String? - - func summarize(text: String) async throws -> String { - receivedText = text - return try stubbedResult.get() - } -} - -final class DefaultSummarizeDiaryUseCaseTests: XCTestCase { - func test_finalize_withNoUtterances_returnsNilWithoutCallingRepository() async throws { - let repository = MockDiarySummaryRepository() - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - - let result = try await useCase.finalize() - - XCTAssertNil(result) - XCTAssertNil(repository.receivedText) - } - - func test_finalize_withOnlyBlankUtterances_returnsNilWithoutCallingRepository() async throws { - let repository = MockDiarySummaryRepository() - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - - await useCase.addUtterance(" ") - await useCase.addUtterance("") - - let result = try await useCase.finalize() - - XCTAssertNil(result) - XCTAssertNil(repository.receivedText) - } - - func test_finalize_shortAccumulatedText_returnsJoinedTextWithoutCallingRepository() async throws { - let repository = MockDiarySummaryRepository() - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - - await useCase.addUtterance("์˜ค๋Š˜ ์•„์นจ์— ๋Šฆ์ž  ์žค์–ด") - await useCase.addUtterance("๊ทธ๋ž˜์„œ ํšŒ์˜์— ์ง€๊ฐํ–ˆ์–ด") - - let result = try await useCase.finalize() - - XCTAssertEqual(result, "์˜ค๋Š˜ ์•„์นจ์— ๋Šฆ์ž  ์žค์–ด\n๊ทธ๋ž˜์„œ ํšŒ์˜์— ์ง€๊ฐํ–ˆ์–ด") - XCTAssertNil(repository.receivedText) - } - - func test_finalize_longAccumulatedText_returnsRepositoryResult() async throws { - let repository = MockDiarySummaryRepository() - repository.stubbedResult = .success("์š”์•ฝ๋จ") - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - let firstHalf = String(repeating: "๊ฐ€", count: 70) - let secondHalf = String(repeating: "๋‚˜", count: 70) - - await useCase.addUtterance(firstHalf) - await useCase.addUtterance(secondHalf) - - let result = try await useCase.finalize() - - XCTAssertEqual(result, "์š”์•ฝ๋จ") - XCTAssertEqual(repository.receivedText, "\(firstHalf)\n\(secondHalf)") - } - - func test_finalize_propagatesRepositoryError() async { - let repository = MockDiarySummaryRepository() - repository.stubbedResult = .failure(DiarySummaryError.inferenceFailed()) - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - await useCase.addUtterance(String(repeating: "๊ฐ€", count: 140)) - - do { - _ = try await useCase.finalize() - XCTFail("Expected error to be thrown") - } catch { - XCTAssertEqual(error as? DiarySummaryError, .inferenceFailed()) - } - } - - func test_finalize_resetsAccumulatedUtterancesForNextSession() async throws { - let repository = MockDiarySummaryRepository() - let useCase = DefaultSummarizeDiaryUseCase(diarySummaryRepository: repository) - - await useCase.addUtterance("์ฒซ ๋ฒˆ์งธ ์„ธ์…˜ ๋ฐœํ™”") - _ = try await useCase.finalize() - - await useCase.addUtterance("๋‘ ๋ฒˆ์งธ ์„ธ์…˜ ๋ฐœํ™”") - let result = try await useCase.finalize() - - XCTAssertEqual(result, "๋‘ ๋ฒˆ์งธ ์„ธ์…˜ ๋ฐœํ™”") - } -} diff --git a/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift b/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift new file mode 100644 index 0000000..1e5acb7 --- /dev/null +++ b/GAMSSTests/Domain/UseCase/GetMessagesUseCaseTests.swift @@ -0,0 +1,43 @@ +// +// GetMessagesUseCaseTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +private final class MockConversationRepository: ConversationRepository { + var stubbedMessages: [Message] = [] + private(set) var receivedConversationId: Int? + + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + fatalError("not used in this test") + } + + func getMessages(conversationId: Int) async throws -> [Message] { + receivedConversationId = conversationId + return stubbedMessages + } + + func getConversations(date: String) async throws -> [ConversationSummary] { + [] + } +} + +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), + ] + let useCase = GetMessagesUseCase(conversationRepository: repository) + + let result = try await useCase.execute(conversationId: 10) + + XCTAssertEqual(result, repository.stubbedMessages) + XCTAssertEqual(repository.receivedConversationId, 10) + } +} diff --git a/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift b/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift new file mode 100644 index 0000000..d69f86c --- /dev/null +++ b/GAMSSTests/Domain/UseCase/SendMessageUseCaseTests.swift @@ -0,0 +1,112 @@ +// +// SendMessageUseCaseTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +private final class MockConversationRepository: ConversationRepository { + var stubbedSendResult: Result = .failure(SummaryError.inferenceFailed()) + private(set) var receivedConversationId: Int? + private(set) var receivedContent: String? + private(set) var receivedRepliesToMessageId: Int? + private(set) var receivedContextSummary: String? + private(set) var sendCallCount = 0 + + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + sendCallCount += 1 + receivedConversationId = conversationId + receivedContent = content + receivedRepliesToMessageId = repliesToMessageId + receivedContextSummary = contextSummary + return try stubbedSendResult.get() + } + + func getMessages(conversationId: Int) async throws -> [Message] { + [] + } + + func getConversations(date: String) async throws -> [ConversationSummary] { + [] + } +} + +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), + commentStatus: .done, + comments: [] + ) + repository.stubbedSendResult = .success(sent) + let useCase = SendMessageUseCase(conversationRepository: repository) + + let result = try await useCase.execute(conversationId: 10, content: "์•ˆ๋…•", repliesToMessageId: 5, contextSummary: "์••์ถ•๋ณธ") + + XCTAssertEqual(result, sent) + XCTAssertEqual(repository.receivedConversationId, 10) + XCTAssertEqual(repository.receivedContent, "์•ˆ๋…•") + XCTAssertEqual(repository.receivedRepliesToMessageId, 5) + XCTAssertEqual(repository.receivedContextSummary, "์••์ถ•๋ณธ") + } + + func test_execute_propagatesRepositoryError() async { + let repository = MockConversationRepository() + repository.stubbedSendResult = .failure(SummaryError.inferenceFailed()) + let useCase = SendMessageUseCase(conversationRepository: repository) + + do { + _ = try await useCase.execute(conversationId: nil, content: "์•ˆ๋…•", repliesToMessageId: nil, contextSummary: nil) + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? SummaryError, .inferenceFailed()) + } + } + + func test_execute_blankContent_throwsEmptyWithoutCallingRepository() async { + let repository = MockConversationRepository() + let useCase = SendMessageUseCase(conversationRepository: repository) + + do { + _ = try await useCase.execute(conversationId: nil, content: " ", repliesToMessageId: nil, contextSummary: nil) + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? SendMessageValidationError, .empty) + } + XCTAssertEqual(repository.sendCallCount, 0, "๊ฒ€์ฆ์— ์‹คํŒจํ•˜๋ฉด ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ๊นŒ์ง€ ๊ฐ€๋ฉด ์•ˆ ๋จ") + } + + func test_execute_contentOverMaxLength_throwsTooLongWithoutCallingRepository() async { + let repository = MockConversationRepository() + let useCase = SendMessageUseCase(conversationRepository: repository) + let overLong = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 1) + + do { + _ = try await useCase.execute(conversationId: nil, content: overLong, repliesToMessageId: nil, contextSummary: nil) + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? SendMessageValidationError, .tooLong) + } + XCTAssertEqual(repository.sendCallCount, 0, "๊ฒ€์ฆ์— ์‹คํŒจํ•˜๋ฉด ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ๊นŒ์ง€ ๊ฐ€๋ฉด ์•ˆ ๋จ") + } + + func test_execute_contentAtMaxLength_isAllowed() async throws { + let repository = MockConversationRepository() + let sent = SentMessage( + message: Message(id: 1, conversationId: 10, sender: .user, content: "์•ˆ๋…•", repliesToMessageId: nil), + commentStatus: .done, + comments: [] + ) + repository.stubbedSendResult = .success(sent) + let useCase = SendMessageUseCase(conversationRepository: repository) + let exact = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength) + + _ = try await useCase.execute(conversationId: nil, content: exact, repliesToMessageId: nil, contextSummary: nil) + + XCTAssertEqual(repository.sendCallCount, 1) + } +} diff --git a/GAMSSTests/Presentation/ChatViewModelTests.swift b/GAMSSTests/Presentation/ChatViewModelTests.swift new file mode 100644 index 0000000..14d4fef --- /dev/null +++ b/GAMSSTests/Presentation/ChatViewModelTests.swift @@ -0,0 +1,227 @@ +// +// ChatViewModelTests.swift +// GAMSS +// +// Created by cchanmi on 8/7/26. +// + +import XCTest +@testable import GAMSS + +private final class MockConversationRepository: ConversationRepository { + var stubbedSendResult: Result = .failure(SummaryError.inferenceFailed()) + var stubbedMessages: [Message] = [] + private(set) var sendCallCount = 0 + private(set) var getMessagesCallCount = 0 + private(set) var receivedContextSummary: String? + + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + sendCallCount += 1 + receivedContextSummary = contextSummary + return try stubbedSendResult.get() + } + + func getMessages(conversationId: Int) async throws -> [Message] { + getMessagesCallCount += 1 + return stubbedMessages + } + + func getConversations(date: String) async throws -> [ConversationSummary] { + [] + } +} + +private actor MockConversationSummaryStore: ConversationSummaryStore { + private(set) var addedUtterances: [String] = [] + private(set) var restoredHistories: [[String]] = [] + nonisolated(unsafe) var stubbedCurrent: String? + + func add(_ utterance: String) async { addedUtterances.append(utterance) } + func current() async -> String? { stubbedCurrent } + func reset() async {} + func restore(historicalUtterances: [String]) async { restoredHistories.append(historicalUtterances) } +} + +@MainActor +final class ChatViewModelTests: XCTestCase { + private func makeViewModel( + repository: MockConversationRepository = MockConversationRepository(), + summaryStore: MockConversationSummaryStore = MockConversationSummaryStore(), + conversationId: Int? = nil, + initialSentMessage: SentMessage? = nil + ) -> ChatViewModel { + ChatViewModel( + sendMessageUseCase: SendMessageUseCase(conversationRepository: repository), + getMessagesUseCase: GetMessagesUseCase(conversationRepository: repository), + summaryStore: summaryStore, + conversationId: conversationId, + initialSentMessage: initialSentMessage + ) + } + + 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) + repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [comment1, comment2])) + let viewModel = makeViewModel(repository: repository) + viewModel.input = "์•ˆ๋…•" + + await viewModel.send() + + XCTAssertEqual(viewModel.messages, [sentMessage, comment1]) + XCTAssertEqual(viewModel.pendingComments, [comment2]) + XCTAssertEqual(viewModel.input, "") + } + + func test_send_onSuccess_addsContentToSummaryStoreAfterUpdatingMessages() async { + let repository = MockConversationRepository() + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "์˜ค๋Š˜ ํž˜๋“ค์—ˆ์–ด", repliesToMessageId: nil) + repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [])) + let summaryStore = MockConversationSummaryStore() + let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) + viewModel.input = "์˜ค๋Š˜ ํž˜๋“ค์—ˆ์–ด" + + await viewModel.send() + await viewModel.pendingSummaryUpdateTask?.value + + let added = await summaryStore.addedUtterances + XCTAssertEqual(added, ["์˜ค๋Š˜ ํž˜๋“ค์—ˆ์–ด"], "์ „์†ก ์„ฑ๊ณตํ•œ ์‚ฌ์šฉ์ž ๋ฐœํ™”๋งŒ ์••์ถ• ์ €์žฅ์†Œ์— ์ถ”๊ฐ€๋˜์–ด์•ผ ํ•จ") + } + + func test_send_onFailure_doesNotAddToSummaryStore() async { + let repository = MockConversationRepository() + repository.stubbedSendResult = .failure(SummaryError.inferenceFailed()) + let summaryStore = MockConversationSummaryStore() + let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) + viewModel.input = "์‹คํŒจํ•  ๋ฉ”์‹œ์ง€" + + await viewModel.send() + + let added = await summaryStore.addedUtterances + XCTAssertTrue(added.isEmpty, "์ „์†ก ์‹คํŒจํ•œ ๋ฐœํ™”๋Š” ์••์ถ• ์ €์žฅ์†Œ์— ๋“ค์–ด๊ฐ€๋ฉด ์•ˆ ๋จ") + } + + func test_send_usesCurrentContextSummaryFromStore() async { + let repository = MockConversationRepository() + repository.stubbedSendResult = .success(SentMessage( + message: Message(id: 1, conversationId: 10, sender: .user, content: "์•ˆ๋…•", repliesToMessageId: nil), + commentStatus: .done, comments: [] + )) + let summaryStore = MockConversationSummaryStore() + summaryStore.stubbedCurrent = "์ด์ „ ์••์ถ•๋ณธ" + let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) + viewModel.input = "์•ˆ๋…•" + + await viewModel.send() + + XCTAssertEqual(repository.receivedContextSummary, "์ด์ „ ์••์ถ•๋ณธ") + } + + func test_send_commentStatusNotDone_keepsMessageAndSetsAlert() async { + let repository = MockConversationRepository() + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "์•ˆ๋…•", repliesToMessageId: nil) + repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .limitExceeded, comments: [])) + let viewModel = makeViewModel(repository: repository) + viewModel.input = "์•ˆ๋…•" + + await viewModel.send() + + XCTAssertEqual(viewModel.messages, [sentMessage], "๋Œ“๊ธ€ ์ƒ์„ฑ ์‹คํŒจํ•ด๋„ ๋ณด๋‚ธ ๋ฉ”์‹œ์ง€๋Š” ํ™”๋ฉด์— ์œ ์ง€๋˜์–ด์•ผ ํ•จ") + XCTAssertNotNil(viewModel.alertMessage) + } + + func test_send_contentTooLong_setsValidationAlertAndDoesNotCallRepository() async { + let repository = MockConversationRepository() + let viewModel = makeViewModel(repository: repository) + viewModel.input = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 1) + + await viewModel.send() + + XCTAssertEqual(repository.sendCallCount, 0, "์ตœ์ข… ๊ฒ€์ฆ์— ๊ฑธ๋ฆฌ๋ฉด ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ๊นŒ์ง€ ๊ฐ€๋ฉด ์•ˆ ๋จ") + XCTAssertEqual(viewModel.alertMessage, SendMessageValidationError.tooLong.errorDescription) + } + + 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) + repository.stubbedMessages = [userMessage, characterMessage] + let summaryStore = MockConversationSummaryStore() + let viewModel = makeViewModel(repository: repository, summaryStore: summaryStore) + + await viewModel.load(conversationId: 10) + + XCTAssertEqual(viewModel.messages, [userMessage, characterMessage]) + let restored = await summaryStore.restoredHistories + XCTAssertEqual(restored, [["์‚ฌ์šฉ์ž ๋ฐœํ™”"]], "์žฌ์ง„์ž… ๋ณต์›์€ ์‚ฌ์šฉ์ž ๋ฐœํ™”๋งŒ summaryStore์— ๋„˜๊ฒจ์•ผ ํ•จ") + } + + func test_start_withInitialSentMessage_seedsWithoutLoadingHistory() async { + let repository = MockConversationRepository() + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "์•ˆ๋…•", repliesToMessageId: nil) + let initialSentMessage = SentMessage(message: sentMessage, commentStatus: .done, comments: []) + let viewModel = makeViewModel(repository: repository, conversationId: 10, initialSentMessage: initialSentMessage) + + await viewModel.start() + + XCTAssertEqual(viewModel.messages, [sentMessage]) + XCTAssertEqual(repository.getMessagesCallCount, 0, "initialSentMessage๊ฐ€ ์žˆ์œผ๋ฉด ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๋‹ค์‹œ ์กฐํšŒํ•˜๋ฉด ์•ˆ ๋จ") + } + + func test_start_withConversationIdOnly_loadsHistory() async { + let repository = MockConversationRepository() + let userMessage = Message(id: 1, conversationId: 10, sender: .user, content: "์‚ฌ์šฉ์ž ๋ฐœํ™”", repliesToMessageId: nil) + repository.stubbedMessages = [userMessage] + let viewModel = makeViewModel(repository: repository, conversationId: 10) + + await viewModel.start() + + XCTAssertEqual(viewModel.messages, [userMessage]) + XCTAssertEqual(repository.getMessagesCallCount, 1) + } + + func test_start_withNeitherConversationIdNorInitialSentMessage_doesNothing() async { + let repository = MockConversationRepository() + let viewModel = makeViewModel(repository: repository) + + await viewModel.start() + + XCTAssertTrue(viewModel.messages.isEmpty) + XCTAssertEqual(repository.getMessagesCallCount, 0) + } + + func test_updateInput_trailingNewline_stripsNewlineAndSignalsKeyboardDismiss() { + let viewModel = makeViewModel() + + let shouldDismiss = viewModel.updateInput("์•ˆ๋…•\n") + + XCTAssertTrue(shouldDismiss) + XCTAssertEqual(viewModel.input, "์•ˆ๋…•") + } + + func test_updateInput_overMaxLength_truncatesToMaxLength() { + let viewModel = makeViewModel() + let overLong = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 10) + + let shouldDismiss = viewModel.updateInput(overLong) + + XCTAssertFalse(shouldDismiss) + XCTAssertEqual(viewModel.input.count, ConversationSummaryPolicy.maxMessageLength) + } + + func test_isSendDisabled_trueWhenInputBlank() { + let viewModel = makeViewModel() + viewModel.input = " " + + XCTAssertTrue(viewModel.isSendDisabled) + } + + func test_isSendDisabled_falseWhenInputHasContent() { + let viewModel = makeViewModel() + viewModel.input = "์•ˆ๋…•" + + XCTAssertFalse(viewModel.isSendDisabled) + } +} diff --git a/GAMSSTests/Presentation/HomeViewModelTests.swift b/GAMSSTests/Presentation/HomeViewModelTests.swift new file mode 100644 index 0000000..9da7d7a --- /dev/null +++ b/GAMSSTests/Presentation/HomeViewModelTests.swift @@ -0,0 +1,91 @@ +// +// HomeViewModelTests.swift +// GAMSS +// +// Created by cchanmi on 8/10/26. +// + +import XCTest +@testable import GAMSS + +private final class MockConversationRepository: ConversationRepository { + var stubbedSendResult: Result = .failure(SummaryError.inferenceFailed()) + private(set) var sendCallCount = 0 + + func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage { + sendCallCount += 1 + return try stubbedSendResult.get() + } + + func getMessages(conversationId: Int) async throws -> [Message] { + [] + } + + func getConversations(date: String) async throws -> [ConversationSummary] { + [] + } +} + +@MainActor +final class HomeViewModelTests: XCTestCase { + private func makeViewModel(repository: MockConversationRepository = MockConversationRepository()) -> HomeViewModel { + HomeViewModel(sendMessageUseCase: SendMessageUseCase(conversationRepository: repository)) + } + + func test_send_onSuccess_setsCreatedConversationIdAndClearsInput() async { + let repository = MockConversationRepository() + let sentMessage = Message(id: 1, conversationId: 10, sender: .user, content: "์•ˆ๋…•", repliesToMessageId: nil) + repository.stubbedSendResult = .success(SentMessage(message: sentMessage, commentStatus: .done, comments: [])) + let viewModel = makeViewModel(repository: repository) + viewModel.input = "์•ˆ๋…•" + + await viewModel.send() + + XCTAssertEqual(viewModel.createdConversationId, 10) + XCTAssertEqual(viewModel.input, "") + } + + func test_send_contentTooLong_setsValidationAlertAndDoesNotCallRepository() async { + let repository = MockConversationRepository() + let viewModel = makeViewModel(repository: repository) + viewModel.input = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 1) + + await viewModel.send() + + XCTAssertEqual(repository.sendCallCount, 0, "์ตœ์ข… ๊ฒ€์ฆ์— ๊ฑธ๋ฆฌ๋ฉด ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ๊นŒ์ง€ ๊ฐ€๋ฉด ์•ˆ ๋จ") + XCTAssertEqual(viewModel.alertMessage, SendMessageValidationError.tooLong.errorDescription) + } + + func test_updateInput_trailingNewline_stripsNewlineAndSignalsKeyboardDismiss() { + let viewModel = makeViewModel() + + let shouldDismiss = viewModel.updateInput("์•ˆ๋…•\n") + + XCTAssertTrue(shouldDismiss) + XCTAssertEqual(viewModel.input, "์•ˆ๋…•") + } + + func test_updateInput_overMaxLength_truncatesToMaxLength() { + let viewModel = makeViewModel() + let overLong = String(repeating: "๊ฐ€", count: ConversationSummaryPolicy.maxMessageLength + 10) + + let shouldDismiss = viewModel.updateInput(overLong) + + XCTAssertFalse(shouldDismiss) + XCTAssertEqual(viewModel.input.count, ConversationSummaryPolicy.maxMessageLength) + } + + func test_isSendDisabled_trueWhenInputBlank() { + let viewModel = makeViewModel() + viewModel.input = " " + + XCTAssertTrue(viewModel.isSendDisabled) + } + + func test_isSendDisabled_falseWhenInputHasContent() { + let viewModel = makeViewModel() + viewModel.input = "์•ˆ๋…•" + + XCTAssertFalse(viewModel.isSendDisabled) + } +} diff --git a/GAMSSTests/Presentation/SummaryTestViewModelTests.swift b/GAMSSTests/Presentation/SummaryTestViewModelTests.swift deleted file mode 100644 index 939da77..0000000 --- a/GAMSSTests/Presentation/SummaryTestViewModelTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// SummaryTestViewModelTests.swift -// GAMSS -// -// Created by cchanmi on 7/31/26. -// - -import XCTest -@testable import GAMSS - -private actor MockSummarizeDiaryUseCase: SummarizeDiaryUseCase { - private var stubbedResult: Result = .success("์š”์•ฝ๋œ ๋ฌธ์žฅ") - - func setStubbedResult(_ result: Result) { - stubbedResult = result - } - - func addUtterance(_ text: String) {} - - func finalize() async throws -> String? { - try stubbedResult.get() - } -} - -@MainActor -final class SummaryTestViewModelTests: XCTestCase { - func test_addUtterance_appendsToDisplayedList() async { - let useCase = MockSummarizeDiaryUseCase() - let viewModel = SummaryTestViewModel(summarizeDiaryUseCase: useCase) - - await viewModel.addUtterance("์ฒซ ๋ฌธ์žฅ") - await viewModel.addUtterance("๋‘˜์งธ ๋ฌธ์žฅ") - - XCTAssertEqual(viewModel.utterances, ["์ฒซ ๋ฌธ์žฅ", "๋‘˜์งธ ๋ฌธ์žฅ"]) - } - - func test_addUtterance_blankText_isIgnored() async { - let useCase = MockSummarizeDiaryUseCase() - let viewModel = SummaryTestViewModel(summarizeDiaryUseCase: useCase) - - await viewModel.addUtterance(" ") - - XCTAssertTrue(viewModel.utterances.isEmpty) - } - - func test_finalizeSession_success_updatesResultAndClearsUtterances() async { - let useCase = MockSummarizeDiaryUseCase() - await useCase.setStubbedResult(.success("์งง๊ฒŒ ์š”์•ฝ๋œ ๊ฒฐ๊ณผ")) - let viewModel = SummaryTestViewModel(summarizeDiaryUseCase: useCase) - await viewModel.addUtterance("ํ…Œ์ŠคํŠธ ์ผ๊ธฐ") - - await viewModel.finalizeSession() - - XCTAssertEqual(viewModel.result, "์งง๊ฒŒ ์š”์•ฝ๋œ ๊ฒฐ๊ณผ") - XCTAssertNil(viewModel.errorMessage) - XCTAssertFalse(viewModel.isLoading) - XCTAssertTrue(viewModel.utterances.isEmpty) - } - - func test_finalizeSession_failure_setsErrorMessage() async { - let useCase = MockSummarizeDiaryUseCase() - await useCase.setStubbedResult(.failure(DiarySummaryError.inferenceFailed())) - let viewModel = SummaryTestViewModel(summarizeDiaryUseCase: useCase) - await viewModel.addUtterance("ํ…Œ์ŠคํŠธ ์ผ๊ธฐ") - - await viewModel.finalizeSession() - - XCTAssertNil(viewModel.result) - XCTAssertNotNil(viewModel.errorMessage) - } -}