diff --git a/.gitignore b/.gitignore index 79c6e2e..6f66bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ Package.resolved *.plist *.xcprivacy .DS_Store + +# Documentation artifacts +docs/ + +# Local agent instruction file +AGENTS.md + +# Local architecture note +ARCHITECTURE.md diff --git a/COMFIE.xcodeproj/project.pbxproj b/COMFIE.xcodeproj/project.pbxproj index ae6e4a9..1dbdd5f 100644 --- a/COMFIE.xcodeproj/project.pbxproj +++ b/COMFIE.xcodeproj/project.pbxproj @@ -7,14 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 0CEE0DFE05B04205B1C99B8E /* MemoInputUITextView+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC5FF8697B24E0783BB9F50 /* MemoInputUITextView+Snapshot.swift */; }; + 2593651730AF440880F21A3F /* MemoIMETrackingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1240A38C406B4D70A9B72CCC /* MemoIMETrackingTextView.swift */; }; 267F6B2A2D91B7DD0089BD6D /* MemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B292D91B7DD0089BD6D /* MemoView.swift */; }; - 267F6B2C2D91C2780089BD6D /* StringLiterals+Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B2B2D91C2780089BD6D /* StringLiterals+Memo.swift */; }; 267F6B2D2D91C2780089BD6D /* StringLiterals+Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B2B2D91C2780089BD6D /* StringLiterals+Memo.swift */; }; - 267F6B2E2D91C2780089BD6D /* StringLiterals+Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B2B2D91C2780089BD6D /* StringLiterals+Memo.swift */; }; 267F6B3C2D94E5FA0089BD6D /* MemoListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B3B2D94E5FA0089BD6D /* MemoListView.swift */; }; - 267F6B3E2D94F11A0089BD6D /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B3D2D94F11A0089BD6D /* Date+.swift */; }; - 267F6B3F2D94F11A0089BD6D /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B3D2D94F11A0089BD6D /* Date+.swift */; }; - 267F6B402D94F11A0089BD6D /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B3D2D94F11A0089BD6D /* Date+.swift */; }; 267F6B4D2D969F590089BD6D /* MemoRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B4C2D969F590089BD6D /* MemoRepository.swift */; }; 267F6B592D9CEAEE0089BD6D /* MemoRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B582D9CEAEE0089BD6D /* MemoRepositoryProtocol.swift */; }; 267F6B5B2D9CEBEB0089BD6D /* MockMemoRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F6B5A2D9CEBEB0089BD6D /* MockMemoRepository.swift */; }; @@ -32,6 +29,7 @@ 26FB03752DAD650C00129862 /* EmojiPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FB03742DAD650C00129862 /* EmojiPool.swift */; }; 26FB03772DAD651B00129862 /* EmojiCharacter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FB03762DAD651B00129862 /* EmojiCharacter.swift */; }; 26FB03792DAD654200129862 /* EmojiString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FB03782DAD654200129862 /* EmojiString.swift */; }; + 4F4074A4D6734A29850C61B9 /* MemoInputUITextView+Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F79E96D20504ACFA322F680 /* MemoInputUITextView+Coordinator.swift */; }; 510340EA2D776F5F0050C718 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = 510340E92D776F5F0050C718 /* .gitignore */; }; 510340EE2D777AA40050C718 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 510340ED2D777AA40050C718 /* .swiftlint.yml */; }; 510340F62D777C260050C718 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 510340F02D777C260050C718 /* Preview Assets.xcassets */; }; @@ -59,8 +57,6 @@ 51454C1E2DAD3A47008EAEB0 /* NewComfieZoneTextFieldCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51454C1D2DAD3A47008EAEB0 /* NewComfieZoneTextFieldCell.swift */; }; 5149D3BE2D9FF10E00173C30 /* ComfieZoneSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5149D3BD2D9FF10E00173C30 /* ComfieZoneSettingStore.swift */; }; 5149D3C02DA163A700173C30 /* ComfieZoneInfoPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5149D3BF2DA163A600173C30 /* ComfieZoneInfoPopupView.swift */; }; - 5149D3C22DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5149D3C12DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift */; }; - 5149D3C32DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5149D3C12DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift */; }; 5149D3C42DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5149D3C12DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift */; }; 517278452E12E10500B86333 /* ComfieZoneConstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517278442E12E10500B86333 /* ComfieZoneConstant.swift */; }; 517FCAC32D7AB2B100A250A3 /* Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517FCAC22D7AB2B100A250A3 /* Memo.swift */; }; @@ -71,13 +67,7 @@ 518666F32DC0C3900078C6B4 /* LocationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518666F22DC0C3900078C6B4 /* LocationUseCase.swift */; }; 51895ADC2DA69B2C00AFA569 /* MoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895ADB2DA69B2C00AFA569 /* MoreView.swift */; }; 51895ADF2DA6AE6F00AFA569 /* CFList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895ADE2DA6AE6F00AFA569 /* CFList.swift */; }; - 51895AE02DA6AE6F00AFA569 /* CFList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895ADE2DA6AE6F00AFA569 /* CFList.swift */; }; - 51895AE12DA6AE6F00AFA569 /* CFList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895ADE2DA6AE6F00AFA569 /* CFList.swift */; }; 51895AE32DA6AE8F00AFA569 /* CFListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE22DA6AE8F00AFA569 /* CFListRow.swift */; }; - 51895AE42DA6AE8F00AFA569 /* CFListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE22DA6AE8F00AFA569 /* CFListRow.swift */; }; - 51895AE52DA6AE8F00AFA569 /* CFListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE22DA6AE8F00AFA569 /* CFListRow.swift */; }; - 51895AE82DA6B17700AFA569 /* StringLiterals+More.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE72DA6B17700AFA569 /* StringLiterals+More.swift */; }; - 51895AE92DA6B17700AFA569 /* StringLiterals+More.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE72DA6B17700AFA569 /* StringLiterals+More.swift */; }; 51895AEA2DA6B17700AFA569 /* StringLiterals+More.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AE72DA6B17700AFA569 /* StringLiterals+More.swift */; }; 51895AED2DA6C44700AFA569 /* MoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AEC2DA6C44700AFA569 /* MoreStore.swift */; }; 51895AF02DA6C65100AFA569 /* TermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51895AEF2DA6C65100AFA569 /* TermView.swift */; }; @@ -90,54 +80,34 @@ 51A7AFF22D802B6200B50D1E /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 51A7AFF02D802B6200B50D1E /* InfoPlist.xcstrings */; }; 51A7AFF32D802B6200B50D1E /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 51A7AFF02D802B6200B50D1E /* InfoPlist.xcstrings */; }; 51A7AFF72D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFF62D809DD600B50D1E /* LocalizedStringResource+localized.swift */; }; - 51A7AFF82D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFF62D809DD600B50D1E /* LocalizedStringResource+localized.swift */; }; - 51A7AFF92D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFF62D809DD600B50D1E /* LocalizedStringResource+localized.swift */; }; - 51A7AFFB2D809E7700B50D1E /* StringLiterals+Popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFFA2D809E7700B50D1E /* StringLiterals+Popup.swift */; }; - 51A7AFFC2D809E7700B50D1E /* StringLiterals+Popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFFA2D809E7700B50D1E /* StringLiterals+Popup.swift */; }; 51A7AFFD2D809E7700B50D1E /* StringLiterals+Popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A7AFFA2D809E7700B50D1E /* StringLiterals+Popup.swift */; }; 51A7B0122D81704600B50D1E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 51A7B0112D81704600B50D1E /* LaunchScreen.storyboard */; }; - 51D882C12DAEAE060072E3C0 /* View+popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D882C02DAEAE060072E3C0 /* View+popup.swift */; }; - 51D882C22DAEAE060072E3C0 /* View+popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D882C02DAEAE060072E3C0 /* View+popup.swift */; }; 51D882C32DAEAE060072E3C0 /* View+popup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D882C02DAEAE060072E3C0 /* View+popup.swift */; }; 51D882E42DAFF4150072E3C0 /* LocalAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D882E32DAFF4150072E3C0 /* LocalAuthenticationService.swift */; }; 51D882E62DAFFE070072E3C0 /* ComfieZoneSettingPopupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D882E52DAFFE070072E3C0 /* ComfieZoneSettingPopupStore.swift */; }; 51F8F5AB2D91ACA800DD2E3D /* TextWithDifferentFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AA2D91ACA800DD2E3D /* TextWithDifferentFont.swift */; }; - 51F8F5AC2D91ACA800DD2E3D /* TextWithDifferentFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AA2D91ACA800DD2E3D /* TextWithDifferentFont.swift */; }; - 51F8F5AD2D91ACA800DD2E3D /* TextWithDifferentFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AA2D91ACA800DD2E3D /* TextWithDifferentFont.swift */; }; - 51F8F5AF2D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AE2D91ACF000DD2E3D /* StringLiterals_Onboarding.swift */; }; 51F8F5B02D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AE2D91ACF000DD2E3D /* StringLiterals_Onboarding.swift */; }; - 51F8F5B12D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5AE2D91ACF000DD2E3D /* StringLiterals_Onboarding.swift */; }; 51F8F5B42D92921900DD2E3D /* ComfieZoneSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5B32D92921900DD2E3D /* ComfieZoneSettingView.swift */; }; - 51F8F5B62D95259300DD2E3D /* CFNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5B52D95259300DD2E3D /* CFNavigationBar.swift */; }; 51F8F5B72D95259300DD2E3D /* CFNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5B52D95259300DD2E3D /* CFNavigationBar.swift */; }; - 51F8F5B82D95259300DD2E3D /* CFNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5B52D95259300DD2E3D /* CFNavigationBar.swift */; }; - 51F8F5BF2D9529B600DD2E3D /* View+cfNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5BE2D9529B600DD2E3D /* View+cfNavigationBar.swift */; }; - 51F8F5C02D9529B600DD2E3D /* View+cfNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5BE2D9529B600DD2E3D /* View+cfNavigationBar.swift */; }; 51F8F5C12D9529B600DD2E3D /* View+cfNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5BE2D9529B600DD2E3D /* View+cfNavigationBar.swift */; }; - 51F8F5C32D952B4B00DD2E3D /* UINavigationController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5C22D952B4B00DD2E3D /* UINavigationController+.swift */; }; 51F8F5C42D952B4B00DD2E3D /* UINavigationController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5C22D952B4B00DD2E3D /* UINavigationController+.swift */; }; - 51F8F5C52D952B4B00DD2E3D /* UINavigationController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8F5C22D952B4B00DD2E3D /* UINavigationController+.swift */; }; - B9134CA72E0BDD45001CE11A /* CFWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9134CA62E0BDD40001CE11A /* CFWebView.swift */; }; - B9134CA82E0BDD45001CE11A /* CFWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9134CA62E0BDD40001CE11A /* CFWebView.swift */; }; + 724105E8C4EB45DB80342FF4 /* MemoInputUITextView+IME.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897F6CFBB90640B59332AFB2 /* MemoInputUITextView+IME.swift */; }; + 80BBE641EB024D18BF2D15A7 /* MemoEmojiTokenAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BBFC1CC93364600AB0B2088 /* MemoEmojiTokenAttachment.swift */; }; + A1F001A22F04000000ABC001 /* EmojiStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001A32F04000000ABC001 /* EmojiStringTests.swift */; }; + A1F001A42F04000000ABC001 /* MemoStoreInputSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001A52F04000000ABC001 /* MemoStoreInputSnapshotTests.swift */; }; + A1F001A62F04000000ABC001 /* RetrospectionEmojiMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001A72F04000000ABC001 /* RetrospectionEmojiMappingTests.swift */; }; + A1F001A82F04000000ABC001 /* MemoStoreSavePhaseNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001A92F04000000ABC001 /* MemoStoreSavePhaseNavigationTests.swift */; }; + A1F001AA2F04000000ABC001 /* MemoInputIMEInsertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001AB2F04000000ABC001 /* MemoInputIMEInsertionTests.swift */; }; + A1F001AC2F04000000ABC001 /* MemoStoreComfieZoneMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001AD2F04000000ABC001 /* MemoStoreComfieZoneMappingTests.swift */; }; B9134CA92E0BDD45001CE11A /* CFWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9134CA62E0BDD40001CE11A /* CFWebView.swift */; }; B95C98C12DAC9FEB0057C868 /* Memo+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95C98C02DAC9FE70057C868 /* Memo+with.swift */; }; B96CDB302DA235B5004EA2E9 /* RetrospectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB2F2DA235B1004EA2E9 /* RetrospectionView.swift */; }; - B96CDB322DA236B1004EA2E9 /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB312DA236B0004EA2E9 /* Date+.swift */; }; B96CDB332DA236B1004EA2E9 /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB312DA236B0004EA2E9 /* Date+.swift */; }; - B96CDB342DA236B1004EA2E9 /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB312DA236B0004EA2E9 /* Date+.swift */; }; - B96CDB3A2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB392DA238E7004EA2E9 /* EdgeInsets+.swift */; }; - B96CDB3B2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB392DA238E7004EA2E9 /* EdgeInsets+.swift */; }; B96CDB3C2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB392DA238E7004EA2E9 /* EdgeInsets+.swift */; }; B96CDB3E2DA23D3F004EA2E9 /* StringLiterals+Retrospection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB3D2DA23D36004EA2E9 /* StringLiterals+Retrospection.swift */; }; - B96CDB3F2DA23D3F004EA2E9 /* StringLiterals+Retrospection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB3D2DA23D36004EA2E9 /* StringLiterals+Retrospection.swift */; }; - B96CDB402DA23D3F004EA2E9 /* StringLiterals+Retrospection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB3D2DA23D36004EA2E9 /* StringLiterals+Retrospection.swift */; }; B96CDB422DA240B2004EA2E9 /* RetrospectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96CDB412DA240AC004EA2E9 /* RetrospectionStore.swift */; }; - B97D40302E0D152E00B87108 /* CFToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D402F2E0D152C00B87108 /* CFToast.swift */; }; - B97D40312E0D152E00B87108 /* CFToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D402F2E0D152C00B87108 /* CFToast.swift */; }; B97D40322E0D152E00B87108 /* CFToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D402F2E0D152C00B87108 /* CFToast.swift */; }; - B97D40342E0D15F800B87108 /* View+cfToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D40332E0D15E700B87108 /* View+cfToast.swift */; }; B97D40352E0D15F800B87108 /* View+cfToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D40332E0D15E700B87108 /* View+cfToast.swift */; }; - B97D40362E0D15F800B87108 /* View+cfToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D40332E0D15E700B87108 /* View+cfToast.swift */; }; B99A081C2DABABA80094EECB /* RetrospectionRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99A081B2DABAB9E0094EECB /* RetrospectionRepositoryProtocol.swift */; }; B99A081E2DABAD410094EECB /* RetrospectionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99A081D2DABAD390094EECB /* RetrospectionRepository.swift */; }; B9EA52F52D7C8F8A00A32305 /* Pretendard-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = B9EA52F42D7C8F8A00A32305 /* Pretendard-Regular.otf */; }; @@ -150,17 +120,12 @@ B9EA52FC2D7C8F8A00A32305 /* Pretendard-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = B9EA52F22D7C8F8A00A32305 /* Pretendard-Bold.otf */; }; B9EA52FD2D7C8F8A00A32305 /* Pretendard-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = B9EA52F32D7C8F8A00A32305 /* Pretendard-Medium.otf */; }; B9EA53002D7C907E00A32305 /* View+ComfieFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA52FF2D7C907300A32305 /* View+ComfieFonts.swift */; }; - B9EA53012D7C907E00A32305 /* View+ComfieFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA52FF2D7C907300A32305 /* View+ComfieFonts.swift */; }; - B9EA53022D7C907E00A32305 /* View+ComfieFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA52FF2D7C907300A32305 /* View+ComfieFonts.swift */; }; - B9EA53062D7CA2CA00A32305 /* CFPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53052D7CA2C300A32305 /* CFPopupView.swift */; }; B9EA53072D7CA2CA00A32305 /* CFPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53052D7CA2C300A32305 /* CFPopupView.swift */; }; - B9EA53082D7CA2CA00A32305 /* CFPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53052D7CA2C300A32305 /* CFPopupView.swift */; }; B9EA530A2D7CA51100A32305 /* CFPopupType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53092D7CA50D00A32305 /* CFPopupType.swift */; }; - B9EA530B2D7CA51100A32305 /* CFPopupType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53092D7CA50D00A32305 /* CFPopupType.swift */; }; - B9EA530C2D7CA51100A32305 /* CFPopupType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA53092D7CA50D00A32305 /* CFPopupType.swift */; }; - B9EA530F2D7CA5DC00A32305 /* StringLiterals.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA530E2D7CA5DA00A32305 /* StringLiterals.swift */; }; - B9EA53102D7CA5DC00A32305 /* StringLiterals.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA530E2D7CA5DA00A32305 /* StringLiterals.swift */; }; B9EA53112D7CA5DC00A32305 /* StringLiterals.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA530E2D7CA5DA00A32305 /* StringLiterals.swift */; }; + C7F1A0022F0A000100ABC001 /* MemoInputContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F1A0012F0A000100ABC001 /* MemoInputContracts.swift */; }; + E3A8C1BF44AA4EE3A6F8C2D1 /* UITestBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */; }; + F5DAF3CE9914431E80CE53AD /* MemoInputUITextView+TextViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195B10EA2A6C46CAA670BA4C /* MemoInputUITextView+TextViewLayout.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -181,10 +146,11 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 1240A38C406B4D70A9B72CCC /* MemoIMETrackingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoIMETrackingTextView.swift; sourceTree = ""; }; + 195B10EA2A6C46CAA670BA4C /* MemoInputUITextView+TextViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+TextViewLayout.swift"; sourceTree = ""; }; 267F6B292D91B7DD0089BD6D /* MemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoView.swift; sourceTree = ""; }; 267F6B2B2D91C2780089BD6D /* StringLiterals+Memo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StringLiterals+Memo.swift"; sourceTree = ""; }; 267F6B3B2D94E5FA0089BD6D /* MemoListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoListView.swift; sourceTree = ""; }; - 267F6B3D2D94F11A0089BD6D /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; 267F6B4C2D969F590089BD6D /* MemoRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoRepository.swift; sourceTree = ""; }; 267F6B582D9CEAEE0089BD6D /* MemoRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoRepositoryProtocol.swift; sourceTree = ""; }; 267F6B5A2D9CEBEB0089BD6D /* MockMemoRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMemoRepository.swift; sourceTree = ""; }; @@ -263,6 +229,15 @@ 51F8F5B52D95259300DD2E3D /* CFNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CFNavigationBar.swift; sourceTree = ""; }; 51F8F5BE2D9529B600DD2E3D /* View+cfNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+cfNavigationBar.swift"; sourceTree = ""; }; 51F8F5C22D952B4B00DD2E3D /* UINavigationController+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+.swift"; sourceTree = ""; }; + 6BBFC1CC93364600AB0B2088 /* MemoEmojiTokenAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEmojiTokenAttachment.swift; sourceTree = ""; }; + 897F6CFBB90640B59332AFB2 /* MemoInputUITextView+IME.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+IME.swift"; sourceTree = ""; }; + 9F79E96D20504ACFA322F680 /* MemoInputUITextView+Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+Coordinator.swift"; sourceTree = ""; }; + A1F001A32F04000000ABC001 /* EmojiStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiStringTests.swift; sourceTree = ""; }; + A1F001A52F04000000ABC001 /* MemoStoreInputSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoStoreInputSnapshotTests.swift; sourceTree = ""; }; + A1F001A72F04000000ABC001 /* RetrospectionEmojiMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrospectionEmojiMappingTests.swift; sourceTree = ""; }; + A1F001A92F04000000ABC001 /* MemoStoreSavePhaseNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoStoreSavePhaseNavigationTests.swift; sourceTree = ""; }; + A1F001AB2F04000000ABC001 /* MemoInputIMEInsertionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoInputIMEInsertionTests.swift; sourceTree = ""; }; + A1F001AD2F04000000ABC001 /* MemoStoreComfieZoneMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoStoreComfieZoneMappingTests.swift; sourceTree = ""; }; B9134CA62E0BDD40001CE11A /* CFWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CFWebView.swift; sourceTree = ""; }; B95C98C02DAC9FE70057C868 /* Memo+with.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Memo+with.swift"; sourceTree = ""; }; B96CDB2F2DA235B1004EA2E9 /* RetrospectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrospectionView.swift; sourceTree = ""; }; @@ -282,6 +257,9 @@ B9EA53052D7CA2C300A32305 /* CFPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CFPopupView.swift; sourceTree = ""; }; B9EA53092D7CA50D00A32305 /* CFPopupType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CFPopupType.swift; sourceTree = ""; }; B9EA530E2D7CA5DA00A32305 /* StringLiterals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringLiterals.swift; sourceTree = ""; }; + C7F1A0012F0A000100ABC001 /* MemoInputContracts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoInputContracts.swift; sourceTree = ""; }; + CBC5FF8697B24E0783BB9F50 /* MemoInputUITextView+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+Snapshot.swift"; sourceTree = ""; }; + D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestBootstrap.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -341,7 +319,14 @@ isa = PBXGroup; children = ( 26FB03702DAD63B300129862 /* MemoInputTextView.swift */, + C7F1A0012F0A000100ABC001 /* MemoInputContracts.swift */, 26A9EC832DE802DD0059257F /* MemoInputUITextView.swift */, + 9F79E96D20504ACFA322F680 /* MemoInputUITextView+Coordinator.swift */, + 897F6CFBB90640B59332AFB2 /* MemoInputUITextView+IME.swift */, + CBC5FF8697B24E0783BB9F50 /* MemoInputUITextView+Snapshot.swift */, + 195B10EA2A6C46CAA670BA4C /* MemoInputUITextView+TextViewLayout.swift */, + 1240A38C406B4D70A9B72CCC /* MemoIMETrackingTextView.swift */, + 6BBFC1CC93364600AB0B2088 /* MemoEmojiTokenAttachment.swift */, ); path = MemoInput; sourceTree = ""; @@ -436,6 +421,12 @@ isa = PBXGroup; children = ( 510340FA2D777C290050C718 /* COMFIETests.swift */, + A1F001A32F04000000ABC001 /* EmojiStringTests.swift */, + A1F001A52F04000000ABC001 /* MemoStoreInputSnapshotTests.swift */, + A1F001A72F04000000ABC001 /* RetrospectionEmojiMappingTests.swift */, + A1F001A92F04000000ABC001 /* MemoStoreSavePhaseNavigationTests.swift */, + A1F001AB2F04000000ABC001 /* MemoInputIMEInsertionTests.swift */, + A1F001AD2F04000000ABC001 /* MemoStoreComfieZoneMappingTests.swift */, ); path = COMFIETests; sourceTree = ""; @@ -455,6 +446,7 @@ 5103412A2D79ABB60050C718 /* DIContainer */, 510341122D7993A90050C718 /* Router */, 510340F32D777C260050C718 /* COMFIEApp.swift */, + D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */, 510341132D7993B80050C718 /* COMFIERoutingView.swift */, ); path = App; @@ -744,7 +736,6 @@ B96CDB312DA236B0004EA2E9 /* Date+.swift */, B9EA52FF2D7C907300A32305 /* View+ComfieFonts.swift */, 51F8F5C22D952B4B00DD2E3D /* UINavigationController+.swift */, - 267F6B3D2D94F11A0089BD6D /* Date+.swift */, ); path = Extensions; sourceTree = ""; @@ -847,7 +838,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 2620; TargetAttributes = { 510340AC2D776EE70050C718 = { CreatedOnToolsVersion = 16.2; @@ -996,6 +987,13 @@ 26D697BC2D7F421A00AC200C /* CoreDataError.swift in Sources */, 51454C1A2DAD3A0D008EAEB0 /* AddComfieZoneCell.swift in Sources */, 26A9EC842DE802DD0059257F /* MemoInputUITextView.swift in Sources */, + C7F1A0022F0A000100ABC001 /* MemoInputContracts.swift in Sources */, + 4F4074A4D6734A29850C61B9 /* MemoInputUITextView+Coordinator.swift in Sources */, + 724105E8C4EB45DB80342FF4 /* MemoInputUITextView+IME.swift in Sources */, + 0CEE0DFE05B04205B1C99B8E /* MemoInputUITextView+Snapshot.swift in Sources */, + F5DAF3CE9914431E80CE53AD /* MemoInputUITextView+TextViewLayout.swift in Sources */, + 2593651730AF440880F21A3F /* MemoIMETrackingTextView.swift in Sources */, + 80BBE641EB024D18BF2D15A7 /* MemoEmojiTokenAttachment.swift in Sources */, 26D697B12D7ECA6200AC200C /* UserRecordModel.xcdatamodeld in Sources */, 51895AEA2DA6B17700AFA569 /* StringLiterals+More.swift in Sources */, 267F6B3C2D94E5FA0089BD6D /* MemoListView.swift in Sources */, @@ -1021,9 +1019,9 @@ 51A7AFF72D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */, 26FB03712DAD63B300129862 /* MemoInputTextView.swift in Sources */, B9EA53112D7CA5DC00A32305 /* StringLiterals.swift in Sources */, - 267F6B3F2D94F11A0089BD6D /* Date+.swift in Sources */, B99A081C2DABABA80094EECB /* RetrospectionRepositoryProtocol.swift in Sources */, 510340F82D777C260050C718 /* COMFIEApp.swift in Sources */, + E3A8C1BF44AA4EE3A6F8C2D1 /* UITestBootstrap.swift in Sources */, 51895AF62DA8FBF500AFA569 /* MakersView.swift in Sources */, 51F8F5B02D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */, 51895AF42DA8F74100AFA569 /* MailView.swift in Sources */, @@ -1045,31 +1043,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B96CDB402DA23D3F004EA2E9 /* StringLiterals+Retrospection.swift in Sources */, - B96CDB342DA236B1004EA2E9 /* Date+.swift in Sources */, - 51A7AFF82D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */, - B96CDB3B2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */, - B9EA53012D7C907E00A32305 /* View+ComfieFonts.swift in Sources */, - B9134CA72E0BDD45001CE11A /* CFWebView.swift in Sources */, - 51F8F5B12D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */, - 51895AE12DA6AE6F00AFA569 /* CFList.swift in Sources */, - 51D882C12DAEAE060072E3C0 /* View+popup.swift in Sources */, - 51A7AFFC2D809E7700B50D1E /* StringLiterals+Popup.swift in Sources */, - B9EA530F2D7CA5DC00A32305 /* StringLiterals.swift in Sources */, - 51F8F5C32D952B4B00DD2E3D /* UINavigationController+.swift in Sources */, - B97D40362E0D15F800B87108 /* View+cfToast.swift in Sources */, - 267F6B2E2D91C2780089BD6D /* StringLiterals+Memo.swift in Sources */, - 267F6B3E2D94F11A0089BD6D /* Date+.swift in Sources */, - 51895AE42DA6AE8F00AFA569 /* CFListRow.swift in Sources */, - B9EA53062D7CA2CA00A32305 /* CFPopupView.swift in Sources */, - 51F8F5B62D95259300DD2E3D /* CFNavigationBar.swift in Sources */, - 51F8F5AC2D91ACA800DD2E3D /* TextWithDifferentFont.swift in Sources */, 510340FC2D777C290050C718 /* COMFIETests.swift in Sources */, - 5149D3C32DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift in Sources */, - B97D40312E0D152E00B87108 /* CFToast.swift in Sources */, - 51895AE82DA6B17700AFA569 /* StringLiterals+More.swift in Sources */, - B9EA530C2D7CA51100A32305 /* CFPopupType.swift in Sources */, - 51F8F5C02D9529B600DD2E3D /* View+cfNavigationBar.swift in Sources */, + A1F001A22F04000000ABC001 /* EmojiStringTests.swift in Sources */, + A1F001A42F04000000ABC001 /* MemoStoreInputSnapshotTests.swift in Sources */, + A1F001A62F04000000ABC001 /* RetrospectionEmojiMappingTests.swift in Sources */, + A1F001A82F04000000ABC001 /* MemoStoreSavePhaseNavigationTests.swift in Sources */, + A1F001AA2F04000000ABC001 /* MemoInputIMEInsertionTests.swift in Sources */, + A1F001AC2F04000000ABC001 /* MemoStoreComfieZoneMappingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1077,32 +1057,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B96CDB3F2DA23D3F004EA2E9 /* StringLiterals+Retrospection.swift in Sources */, - B9EA53102D7CA5DC00A32305 /* StringLiterals.swift in Sources */, - 51F8F5BF2D9529B600DD2E3D /* View+cfNavigationBar.swift in Sources */, - 51895AE52DA6AE8F00AFA569 /* CFListRow.swift in Sources */, - B9134CA82E0BDD45001CE11A /* CFWebView.swift in Sources */, - B9EA53022D7C907E00A32305 /* View+ComfieFonts.swift in Sources */, - 51F8F5AD2D91ACA800DD2E3D /* TextWithDifferentFont.swift in Sources */, - 51F8F5AF2D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */, - 51F8F5B82D95259300DD2E3D /* CFNavigationBar.swift in Sources */, - B96CDB3A2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */, - 51A7AFF92D809DD600B50D1E /* LocalizedStringResource+localized.swift in Sources */, - B96CDB322DA236B1004EA2E9 /* Date+.swift in Sources */, - 51A7AFFB2D809E7700B50D1E /* StringLiterals+Popup.swift in Sources */, - 5149D3C22DA1662C00173C30 /* StringLiterals+ComfieZoneSetting.swift in Sources */, - B97D40342E0D15F800B87108 /* View+cfToast.swift in Sources */, - 51895AE92DA6B17700AFA569 /* StringLiterals+More.swift in Sources */, - B9EA53082D7CA2CA00A32305 /* CFPopupView.swift in Sources */, - 51F8F5C52D952B4B00DD2E3D /* UINavigationController+.swift in Sources */, 510341002D777C2B0050C718 /* COMFIEUITests.swift in Sources */, - B97D40302E0D152E00B87108 /* CFToast.swift in Sources */, - B9EA530B2D7CA51100A32305 /* CFPopupType.swift in Sources */, - 267F6B2C2D91C2780089BD6D /* StringLiterals+Memo.swift in Sources */, - 267F6B402D94F11A0089BD6D /* Date+.swift in Sources */, 510341012D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift in Sources */, - 51895AE02DA6AE6F00AFA569 /* CFList.swift in Sources */, - 51D882C22DAEAE060072E3C0 /* View+popup.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1127,6 +1083,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -1180,6 +1137,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1191,6 +1149,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -1237,6 +1196,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; @@ -1273,6 +1233,10 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1309,6 +1273,10 @@ MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1327,6 +1295,10 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1346,6 +1318,10 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1363,6 +1339,10 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1380,6 +1360,10 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.HITBS.COMFIE; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/COMFIE.xcodeproj/xcshareddata/xcschemes/COMFIE.xcscheme b/COMFIE.xcodeproj/xcshareddata/xcschemes/COMFIE.xcscheme index db93e97..eaa593d 100644 --- a/COMFIE.xcodeproj/xcshareddata/xcschemes/COMFIE.xcscheme +++ b/COMFIE.xcodeproj/xcshareddata/xcschemes/COMFIE.xcscheme @@ -1,6 +1,6 @@ Bool { + + static func isEmojiConvertibleCharacter(_ char: Character) -> Bool { + if isWhitespaceOrNewline(char) || isEmoji(char) { + return false + } + + let scalars = char.unicodeScalars + if isLetterCharacter(scalars) { + return true + } + + return isNumberPunctuationOrSymbolCharacter(scalars) + } + + private static func isWhitespaceOrNewline(_ char: Character) -> Bool { + char == " " || char == "\n" + } + + private static func isLetterCharacter(_ scalars: String.UnicodeScalarView) -> Bool { + guard !scalars.isEmpty else { return false } + + var hasLetterCore = false + for scalar in scalars { + switch scalar.properties.generalCategory { + case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, .otherLetter: + hasLetterCore = true + case .nonspacingMark, .spacingMark, .enclosingMark, .format: + continue + default: + return false + } + } + return hasLetterCore + } + + private static func isNumberPunctuationOrSymbolCharacter(_ scalars: String.UnicodeScalarView) -> Bool { + guard !scalars.isEmpty else { return false } + + var hasCoreCategory = false + for scalar in scalars { + switch scalar.properties.generalCategory { + case .decimalNumber, .letterNumber, .otherNumber, + .connectorPunctuation, .dashPunctuation, .openPunctuation, .closePunctuation, + .initialPunctuation, .finalPunctuation, .otherPunctuation, + .mathSymbol, .currencySymbol, .modifierSymbol, .otherSymbol: + hasCoreCategory = true + case .nonspacingMark, .spacingMark, .enclosingMark, .format: + continue + default: + return false + } + } + return hasCoreCategory + } + + private static func isEmoji(_ char: Character) -> Bool { char.unicodeScalars .contains(where: { $0.properties.isEmojiPresentation }) && char.unicodeScalars diff --git a/COMFIE/Domain/TextToEmoji/Model/EmojiString.swift b/COMFIE/Domain/TextToEmoji/Model/EmojiString.swift index d2f6ae2..3d22f4e 100644 --- a/COMFIE/Domain/TextToEmoji/Model/EmojiString.swift +++ b/COMFIE/Domain/TextToEmoji/Model/EmojiString.swift @@ -7,121 +7,251 @@ struct EmojiString { private var emojiCharacters: [EmojiCharacter] - + init() { self.emojiCharacters = [] } - - init(memo: Memo) { - let originalText = memo.originalText - let emojiText = memo.emojiText - - precondition(originalText.count == emojiText.count, "originalText와 emojiText의 길이가 같아야 합니다.") - - self.emojiCharacters = zip(originalText, emojiText) - .map { - EmojiCharacter(originalCharacter: $0.0, emojiCharacter: $0.1) + + init(originalText: String, emojiText: String) { + let originalCharacters = Array(originalText) + let emojiCharacters = Array(emojiText) + + if originalCharacters.count != emojiCharacters.count { +#if DEBUG + print("EmojiString init length mismatch - original: \(originalCharacters.count), emoji: \(emojiCharacters.count)") +#endif + } + + self.emojiCharacters = originalCharacters.enumerated().map { index, originalCharacter in + let emojiCharacter: Character? + if index < emojiCharacters.count { + let candidate = emojiCharacters[index] + emojiCharacter = candidate == originalCharacter ? nil : candidate + } else { + emojiCharacter = nil } + + return EmojiCharacter(originalCharacter: originalCharacter, emojiCharacter: emojiCharacter) + } } - - /// index 위치까지 이모지를 적용한 문자열을 설정합니다. - mutating func applyEmojiString(at index: Int, _ newString: String) { - syncWithNewString(newString) - changeEmoji(upTo: index) + + static func normalizedForPersist(originalText: String, preferredEmojiText: String) -> EmojiString { + if originalText.count == preferredEmojiText.count { + return EmojiString(originalText: originalText, emojiText: preferredEmojiText) + } + + var normalized = EmojiString(originalText: originalText, emojiText: originalText) + applyPreferredEmojiToConvertibleCommonRange( + originalText: originalText, + preferredEmojiText: preferredEmojiText, + target: &normalized + ) + + return normalized } - - /// 변경된 문자열과 emojiCharacters를 동기화합니다. - /// - Note: newString에 문자가 **추가된 경우**에만 실행됩니다. - mutating func syncWithNewString(_ newString: String) { - let originalCharacters = Array(newString) - var originalIndex = 0 - - guard newString.count > emojiCharacters.count else { return} - - var newCharacters: [EmojiCharacter] = [] - - for i in 0.. EmojiString { + var normalized = normalizedForPersist( + originalText: originalText, + preferredEmojiText: preferredEmojiText + ) + normalized.setUnassignedEmojis() + return normalized + } + + static func mergedEmojiTextPreservingUnchanged( + previousOriginalText: String, + previousEmojiText: String, + newOriginalText: String + ) -> String { + let oldOriginal = Array(previousOriginalText) + let oldEmoji = Array(previousEmojiText) + let newOriginal = Array(newOriginalText) + + guard !newOriginal.isEmpty else { return "" } + + if let resolvedOnInitialSeed = resolveMergedEmojiForInitialSeed( + oldOriginal: oldOriginal, + oldEmoji: oldEmoji, + newOriginal: newOriginal, + previousEmojiText: previousEmojiText, + newOriginalText: newOriginalText + ) { + return resolvedOnInitialSeed } - - // 나머지 추가된 문자 반영 - if originalIndex < originalCharacters.count { - originalCharacters[originalIndex...].forEach { - newCharacters.append(EmojiCharacter(originalCharacter: $0)) - } + + let normalizedOldEmoji = normalizedOldEmojiCharacters(oldOriginal: oldOriginal, oldEmoji: oldEmoji) + + if previousOriginalText == newOriginalText { + return String(normalizedOldEmoji) } - emojiCharacters = newCharacters - } - - /// index 위치까지 모든 character에 이모지를 적용합니다. - mutating private func changeEmoji(upTo index: Int) { - guard emojiCharacters.count > index && index > 0 else { return } - (0...index).forEach { emojiCharacters[$0].setEmojiCharacter() } + if let mergedFastPath = mergedEmojiByTailFastPath( + oldOriginal: oldOriginal, + normalizedOldEmoji: normalizedOldEmoji, + newOriginal: newOriginal + ) { + return mergedFastPath + } + + // 그 외 복잡한 삽입/삭제/치환은 LCS 기반 병합으로 처리합니다. + return mergedEmojiByLCS( + oldOriginal: oldOriginal, + normalizedOldEmoji: normalizedOldEmoji, + newOriginal: newOriginal + ) } - - /// 현재 이모지 적용 상태의 문자열을 반환합니다. + func getEmojiString() -> String { emojiCharacters .map { String($0.emojiCharacter ?? $0.originalCharacter) } .joined() } - - /// index 위치까지의 이모지 적용 문자열을 반환합니다. - func getEmojiString(to index: Int) -> String { - var string = "" - guard index >= 0 && index < emojiCharacters.count else { - print("getEmojiString(to:) index out of range.: \(index)") - return string - } - - for i in 0...index { - let chracter = emojiCharacters[i] - - string += String(chracter.emojiCharacter ?? chracter.originalCharacter) - } - return string - } - - /// 원본 문자열을 반환합니다. + func getOriginalString() -> String { emojiCharacters .map { String($0.originalCharacter) } .joined() } - - /// 비워진 이모지를 전체 적용합니다. + mutating func setUnassignedEmojis() { - // 전체 채우기 for i in emojiCharacters.indices { emojiCharacters[i].setEmojiCharacter() } } - - /// 지정된 범위의 이모지 문자열을 삭제합니다. - mutating func deleteEmojiString(from start: Int, to end: Int? = nil) { - let toIndex = end ?? start - - // 삭제 범위가 유효한지 확인합니다. - guard start >= 0, toIndex < emojiCharacters.count, start <= toIndex else { - print("deleteEmojiString(start:end:) 인덱스 문제: \(start), \(String(describing: end))") - return + + private mutating func setEmojiCharacter(_ emoji: Character, at index: Int) { + guard emojiCharacters.indices.contains(index) else { return } + emojiCharacters[index].emojiCharacter = emoji + } + + private static func applyPreferredEmojiToConvertibleCommonRange( + originalText: String, + preferredEmojiText: String, + target: inout EmojiString + ) { + let originalCharacters = Array(originalText) + let preferredCharacters = Array(preferredEmojiText) + let commonCount = min(originalCharacters.count, preferredCharacters.count) + guard commonCount > 0 else { return } + + for index in 0.. String? { + guard oldOriginal.isEmpty else { return nil } + + if oldEmoji.count == newOriginal.count { + return previousEmojiText + } + + return newOriginalText + } + + private static func normalizedOldEmojiCharacters( + oldOriginal: [Character], + oldEmoji: [Character] + ) -> [Character] { + oldOriginal.enumerated().map { index, originalCharacter in + if index < oldEmoji.count { + return oldEmoji[index] + } else { + return originalCharacter + } + } + } + + private static func mergedEmojiByTailFastPath( + oldOriginal: [Character], + normalizedOldEmoji: [Character], + newOriginal: [Character] + ) -> String? { + // 꼬리 편집은 LCS 전체 계산 전에 빠른 경로로 처리합니다. + if newOriginal.count == oldOriginal.count + 1, + oldOriginal.elementsEqual(newOriginal.dropLast()) { + var merged = normalizedOldEmoji + if let appended = newOriginal.last { + merged.append(appended) + } + return String(merged) + } + + if oldOriginal.count == newOriginal.count + 1, + newOriginal.elementsEqual(oldOriginal.dropLast()) { + return String(normalizedOldEmoji.dropLast()) + } + + // 빠른 경로 조건이 아니면 nil을 돌려 LCS 경로로 넘깁니다. + return nil + } + + // 일반 삽입/삭제/치환 케이스를 LCS 매칭으로 병합합니다. + private static func mergedEmojiByLCS( + oldOriginal: [Character], + normalizedOldEmoji: [Character], + newOriginal: [Character] + ) -> String { + var mergedEmoji = newOriginal + let matchedIndexPairs = lcsMatchedIndexPairs(old: oldOriginal, new: newOriginal) + + for (oldIndex, newIndex) in matchedIndexPairs { + guard oldIndex < normalizedOldEmoji.count, newIndex < mergedEmoji.count else { continue } + mergedEmoji[newIndex] = normalizedOldEmoji[oldIndex] + } + + return String(mergedEmoji) + } + + // LCS(Longest Common Subsequence) 매칭 인덱스 쌍을 계산합니다. + private static func lcsMatchedIndexPairs(old: [Character], new: [Character]) -> [(Int, Int)] { + guard !old.isEmpty, !new.isEmpty else { return [] } + + let oldCount = old.count + let newCount = new.count + // dp[i][j] = old[i...]와 new[j...]의 LCS 길이입니다. + var dp = Array(repeating: Array(repeating: 0, count: newCount + 1), count: oldCount + 1) + + for i in stride(from: oldCount - 1, through: 0, by: -1) { + for j in stride(from: newCount - 1, through: 0, by: -1) { + if old[i] == new[j] { + dp[i][j] = dp[i + 1][j + 1] + 1 + } else { + dp[i][j] = max(dp[i + 1][j], dp[i][j + 1]) + } + } + } + + var pairs: [(Int, Int)] = [] + var i = 0 + var j = 0 + + while i < oldCount && j < newCount { + // 현재 문자가 LCS 경로에 포함되면 쌍을 기록하고 둘 다 전진합니다. + if old[i] == new[j], dp[i][j] == dp[i + 1][j + 1] + 1 { + pairs.append((i, j)) + i += 1 + j += 1 + } else if dp[i + 1][j] >= dp[i][j + 1] { + i += 1 + } else { + j += 1 + } } - emojiCharacters.removeSubrange(start...toIndex) + return pairs } } diff --git a/COMFIE/Presentation/Memo/MemoCell.swift b/COMFIE/Presentation/Memo/MemoCell.swift index 5aa87d1..fb1784e 100644 --- a/COMFIE/Presentation/Memo/MemoCell.swift +++ b/COMFIE/Presentation/Memo/MemoCell.swift @@ -16,7 +16,6 @@ struct MemoCell: View { @Binding var intent: MemoStore - // TODO: isUserInComfieZone 변경 필요 let isUserInComfieZone: Bool private var isEditing: Bool { @@ -28,8 +27,6 @@ struct MemoCell: View { } private var shouldShowMemo: Bool { - // 회고가 없으면 무조건 메모를 보여주고, - // 회고가 있는 경우엔 사용자가 펼쳤을 때만 보여준다 !isMemoHidden || !hasRetrospection } @@ -71,7 +68,6 @@ struct MemoCell: View { .foregroundStyle(Color.textBlack) } - // 회고가 있는 경우 if let originalRetrospectionText = memo.originalRetrospectionText, let emojiRetrospectionText = memo.emojiRetrospectionText { HStack { @@ -95,14 +91,12 @@ struct MemoCell: View { private var menuButton: some View { Menu { if !isUserInComfieZone { - // 수정하기 Button { intent(.memoCell(.editButtonTapped(memo))) } label: { Label(strings.editButtonTitle.localized, systemImage: "pencil") } } else { - // 회고하기 Button { intent(.memoCell(.retrospectionButtonTapped(memo))) } label: { @@ -111,7 +105,6 @@ struct MemoCell: View { } } - // 삭제하기 Button(role: .destructive) { intent(.memoCell(.deleteButtonTapped(memo))) } label: { @@ -123,6 +116,7 @@ struct MemoCell: View { .resizable() .frame(width: 19, height: 20) } + .accessibilityIdentifier("memo.cell.menuButton") } } diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift new file mode 100644 index 0000000..8e86ba8 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift @@ -0,0 +1,48 @@ +// +// MemoEmojiTokenAttachment.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +// 텍스트뷰 안에서 "원문 1글자 + 이모지 1글자"를 같이 들고 다니는 토큰입니다. +final class MemoEmojiTokenAttachment: NSTextAttachment { + let original: String + let emoji: String + + // 원문/이모지 쌍으로 attachment를 구성하고 렌더링 크기와 baseline을 고정합니다. + init(original: String, emoji: String, font: UIFont) { + self.original = original + self.emoji = emoji + super.init(data: nil, ofType: nil) + + let emojiString = NSAttributedString(string: emoji, attributes: [.font: font]) + let textSize = emojiString.size() + let width = max(1, ceil(textSize.width)) + let height = ceil(font.lineHeight) + let size = CGSize(width: width, height: height) + image = Self.renderEmojiImage(emoji, in: size, font: font) + bounds = CGRect(x: 0, y: font.descender, width: width, height: height) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // 이모지를 텍스트와 같은 폰트 기준으로 이미지화해 attachment에 맞춰 그립니다. + private static func renderEmojiImage(_ emoji: String, in size: CGSize, font: UIFont) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let attributed = NSAttributedString(string: emoji, attributes: attributes) + let textSize = attributed.size() + let origin = CGPoint( + x: max(0, (size.width - textSize.width) * 0.5), + y: max(0, (size.height - textSize.height) * 0.5) + ) + attributed.draw(at: origin) + } + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift new file mode 100644 index 0000000..3c58f38 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift @@ -0,0 +1,37 @@ +// +// MemoIMETrackingTextView.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +// IME(한글/중국어 조합 입력) 상태 변화를 콜백으로 전달하는 UITextView입니다. +final class MemoIMETrackingTextView: UITextView { + var onSetMarkedText: ((NSRange) -> Void)? + var onUnmarkText: (() -> Void)? + + // marked text가 생기는 즉시 범위를 Coordinator에 전달해 IME 경계를 추적합니다. + override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + super.setMarkedText(markedText, selectedRange: selectedRange) + if let range = markedTextRange { + onSetMarkedText?(memoIME_nsRange(from: range)) + } + } + + // 조합 입력이 확정되는 시점에 후처리 콜백을 실행합니다. + override func unmarkText() { + super.unmarkText() + onUnmarkText?() + } +} + +extension UITextView { + // UITextRange를 NSRange로 변환해 textStorage 인덱스 연산에 사용합니다. + func memoIME_nsRange(from textRange: UITextRange) -> NSRange { + let location = offset(from: beginningOfDocument, to: textRange.start) + let length = offset(from: textRange.start, to: textRange.end) + return NSRange(location: location, length: length) + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift new file mode 100644 index 0000000..7c55f6c --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift @@ -0,0 +1,49 @@ +// +// MemoInputContracts.swift +// COMFIE +// +// Created by zaehorang on 2/25/26. +// + +import Foundation + +// Memo 입력창을 다시 그릴 때 사용하는 seed 스냅샷입니다. +struct MemoInputSeed: Equatable { + var token: Int + var originalText: String + var emojiText: String + + static let empty = MemoInputSeed(token: 0, originalText: "", emojiText: "") +} + +// 저장 직전에 Coordinator가 보내는 최종 입력 스냅샷입니다. +struct MemoInputSnapshot: Equatable { + let originalText: String + let emojiText: String +} + +// InputView -> Store 단방향 출력 이벤트입니다. +enum MemoInputOutputEvent: Equatable { + case draftAvailabilityChanged(isEmpty: Bool) + case finalSnapshotReady(requestID: UUID, snapshot: MemoInputSnapshot) + case finalSnapshotFailed(requestID: UUID) +} + +// Store -> InputView 단방향 UI 명령입니다. +enum MemoInputUICommand: Equatable { + case resignWithSync + case resignWithoutSync + case requestFinalSyncAndResign(requestID: UUID) + case setFocus +} + +// 동일 명령 중복 실행을 막기 위해 UUID를 함께 묶은 이벤트 래퍼입니다. +struct MemoInputUIEvent: Equatable { + let id: UUID + let command: MemoInputUICommand + + init(command: MemoInputUICommand) { + self.id = UUID() + self.command = command + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift index afdd528..d829a36 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift @@ -9,23 +9,36 @@ import SwiftUI struct MemoInputTextView: View { let placeholder: String - + let inputSeed: MemoInputSeed + let isEmojiPresentationEnabled: Bool + let uiCommandEvent: MemoInputUIEvent? + let onOutputEvent: ((MemoInputOutputEvent) -> Void)? + @State private var dynamicHeight: CGFloat = 40 - - @Binding private var intent: MemoStore - init(_ placeholder: String = "", - memoStore: Binding + init( + _ placeholder: String = "", + inputSeed: MemoInputSeed, + isEmojiPresentationEnabled: Bool, + uiCommandEvent: MemoInputUIEvent?, + onOutputEvent: ((MemoInputOutputEvent) -> Void)? = nil ) { self.placeholder = placeholder - self._intent = memoStore + self.inputSeed = inputSeed + self.isEmojiPresentationEnabled = isEmojiPresentationEnabled + self.uiCommandEvent = uiCommandEvent + self.onOutputEvent = onOutputEvent } - + var body: some View { MemoInputUITextView( placeholder, dynamicHeight: $dynamicHeight, - intent: $intent) + inputSeed: inputSeed, + isEmojiPresentationEnabled: isEmojiPresentationEnabled, + uiCommandEvent: uiCommandEvent, + onOutputEvent: onOutputEvent + ) .frame(height: dynamicHeight) } } diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift new file mode 100644 index 0000000..17dd556 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift @@ -0,0 +1,343 @@ +// +// MemoInputUITextView+Coordinator.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import SwiftUI +import UIKit + +extension MemoInputUITextView { + // IME 조합 입력 타이밍 이슈를 줄이기 위해 입력 상태 조율을 Coordinator에 모읍니다. + final class Coordinator: NSObject, UITextViewDelegate { + struct PendingChange { + let range: NSRange + let replacementUTF16Length: Int + let replacementCharacterCount: Int + } + + private enum EndEditingSyncPolicy { + case sync + case skipOnce + } + + var parent: MemoInputUITextView + + weak var textView: UITextView! + weak var placeholderLabel: UILabel! + + var textViewHeightConstraint: NSLayoutConstraint? + + var isMutating = false + private(set) var pendingChange: PendingChange? + private(set) var deferredChange: PendingChange? + var lastSelectionRange = NSRange(location: 0, length: 0) + var lastTextChangeTime: TimeInterval = 0 + var lastTextLength = 0 + var lastEmojiMode: Bool? + var lastAppliedInputSeedToken = 0 + var lastHandledUICommandID: UUID? + + private var endEditingSyncPolicy: EndEditingSyncPolicy = .sync + + private(set) var draftOriginalText = "" + private(set) var draftEmojiText = "" + + var isEmojiMode: Bool { + parent.isEmojiPresentationEnabled + } + + init(parent: MemoInputUITextView) { + self.parent = parent + self.draftOriginalText = parent.inputSeed.originalText + self.draftEmojiText = parent.inputSeed.emojiText + self.lastAppliedInputSeedToken = parent.inputSeed.token + } + + func replaceDraft(original: String, emoji: String) { + draftOriginalText = original + draftEmojiText = emoji + } + + func setPendingChange(_ change: PendingChange?) { + pendingChange = change + } + + func setDeferredChange(_ change: PendingChange?) { + deferredChange = change + } + + func clearPendingAndDeferredChanges() { + pendingChange = nil + deferredChange = nil + } + + func pendingOrDeferredChange() -> PendingChange? { + pendingChange ?? deferredChange + } + +#if DEBUG + // 테스트에서 stale draft 주입 시나리오를 재현하기 위한 훅입니다. + func debugInjectDraftForTesting(original: String, emoji: String) { + replaceDraft(original: original, emoji: emoji) + } + + // 테스트에서 stale deferred/pending 시나리오를 재현하기 위한 훅입니다. + func debugInjectChangeForTesting(range: NSRange, replacementLength: Int, asDeferred: Bool) { + let change = PendingChange( + range: range, + replacementUTF16Length: replacementLength, + replacementCharacterCount: replacementLength + ) + if asDeferred { + setDeferredChange(change) + } else { + setPendingChange(change) + } + } + + // 테스트에서 stale change 큐가 비워졌는지 확인하기 위한 훅입니다. + func debugHasPendingOrDeferredChangeForTesting() -> Bool { + pendingChange != nil || deferredChange != nil + } +#endif + + // seed/모드/명령 이벤트를 합쳐 현재 UITextView 상태를 일관되게 재적용합니다. + func applyStateToTextView(force: Bool) { + let handledUICommand = handleUICommandIfNeeded() + guard let textView else { return } + + let modeChanged = lastEmojiMode != isEmojiMode + let seedTokenChanged = lastAppliedInputSeedToken != parent.inputSeed.token + + if force || seedTokenChanged { + let seededOriginal = normalizedOriginalText() + let seededEmoji = normalizedEmojiText(with: seededOriginal) + render( + textView, + originalText: seededOriginal, + emojiText: seededEmoji + ) + if seedTokenChanged, handledUICommand == .setFocus { + placeCursorAtTextEnd(textView) + } + syncDraftCache( + originalText: seededOriginal, + emojiText: seededEmoji + ) + publishDraftAvailability() + clearPendingAndDeferredChanges() + lastAppliedInputSeedToken = parent.inputSeed.token + lastEmojiMode = isEmojiMode + } + + guard modeChanged else { + updateTextViewHeight(textView) + return + } + // 조합 중에는 모드 강제 렌더를 미뤄 IME 입력이 깨지지 않게 보호합니다. + guard textView.markedTextRange == nil else { return } + + clearPendingAndDeferredChanges() + render( + textView, + originalText: draftOriginalText, + emojiText: draftEmojiText + ) + lastEmojiMode = isEmojiMode + publishDraftAvailability() + } + + private func flushAndSyncIfPossible(_ textView: UITextView?) { + guard let textView else { return } + flushPendingConversionBeforeSync(in: textView) + syncSnapshotToStore(textView) + } + + // MARK: - UITextViewDelegate + + func textViewDidChange(_ textView: UITextView) { + guard !isMutating else { + clearPendingAndDeferredChanges() + return + } + + updatePlaceholderVisibility(textView) + updateTextViewHeight(textView) + + if lastEmojiMode == nil { + lastEmojiMode = isEmojiMode + } else if lastEmojiMode != isEmojiMode, textView.markedTextRange == nil { + clearPendingAndDeferredChanges() + if isEmojiMode { + convertAllPlainToEmoji(in: textView) + } else { + convertAllToPlain(in: textView) + } + lastEmojiMode = isEmojiMode + } + + if isEmojiMode { + let isComposing = (textView.markedTextRange != nil) + if isComposing { + deferPendingChangeIfNeeded() + setPendingChange(nil) + } else { + if let change = pendingOrDeferredChange() { + handleNonMarkedChange(in: textView, change: change) + } else { + handleFallbackInsertion(in: textView) + } + clearPendingAndDeferredChanges() + } + } else { + clearPendingAndDeferredChanges() + } + + syncSnapshotToStore(textView) + lastTextLength = textView.textStorage.length + lastSelectionRange = textView.selectedRange + lastTextChangeTime = Date().timeIntervalSinceReferenceDate + lastEmojiMode = isEmojiMode + } + + func textViewDidEndEditing(_ textView: UITextView) { + if endEditingSyncPolicy == .skipOnce { + endEditingSyncPolicy = .sync + return + } + flushAndSyncIfPossible(textView) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard isEmojiMode else { + clearPendingAndDeferredChanges() + return true + } + + setPendingChange(PendingChange( + range: range, + replacementUTF16Length: (text as NSString).length, + replacementCharacterCount: text.count + )) + return true + } + + func textViewDidChangeSelection(_ textView: UITextView) { + defer { lastSelectionRange = textView.selectedRange } + + guard isEmojiMode else { return } + guard !isMutating else { return } + // 조합 중 커서 이동 이벤트는 flush 기준이 아니므로 건너뜁니다. + guard textView.markedTextRange == nil else { return } + guard !NSEqualRanges(lastSelectionRange, textView.selectedRange) else { return } + + let now = Date().timeIntervalSinceReferenceDate + guard now - lastTextChangeTime > 0.05 else { return } + + flushPendingConversionOnCursorMove(in: textView) + } + } +} + +// MARK: - UI Command +extension MemoInputUITextView.Coordinator { + @discardableResult + private func handleUICommandIfNeeded() -> MemoInputUICommand? { + guard let commandEvent = parent.uiCommandEvent else { return nil } + guard commandEvent.id != lastHandledUICommandID else { return nil } + lastHandledUICommandID = commandEvent.id + handleUICommand(commandEvent.command) + return commandEvent.command + } + + private func placeCursorAtTextEnd(_ textView: UITextView) { + let cursorRange = NSRange(location: textView.textStorage.length, length: 0) + textView.selectedRange = cursorRange + lastSelectionRange = cursorRange + } + + // Store에서 내려온 입력 명령을 한 번만 실행하고 sync 정책을 함께 조정합니다. + private func handleUICommand(_ command: MemoInputUICommand) { + switch command { + case .resignWithSync: + if let textView { + flushAndSyncIfPossible(textView) + unfocusTextView(textView) + } else { + syncDraftFromFallbackIfNeeded() + } + endEditingSyncPolicy = .sync + + case .resignWithoutSync: + endEditingSyncPolicy = .skipOnce + if let textView { + unfocusTextView(textView) + } + + case .requestFinalSyncAndResign(let requestID): + // 조합 강제 확정 + flush/sync를 먼저 수행해 최종 스냅샷 기준을 고정합니다. + guard prepareDraftForFinalSnapshot(textView) else { + endEditingSyncPolicy = .sync + parent.emitOutputEvent(.finalSnapshotFailed(requestID: requestID)) + if let textView { + endEditingSyncPolicy = .skipOnce + unfocusTextView(textView) + } + return + } + + // 이 분기는 빈 스냅샷 저장을 막고 Store savePhase를 정상 복귀시키기 위한 안전장치입니다. + if draftOriginalText.isEmpty, + draftEmojiText.isEmpty, + parent.inputSeed.originalText.isEmpty, + parent.inputSeed.emojiText.isEmpty { + endEditingSyncPolicy = .sync + parent.emitOutputEvent(.finalSnapshotFailed(requestID: requestID)) + return + } + + // requestID를 함께 보내 Store가 같은 저장 요청인지 검증할 수 있게 합니다. + let inputSnapshot = MemoInputSnapshot( + originalText: draftOriginalText, + emojiText: draftEmojiText + ) + parent.emitOutputEvent( + .finalSnapshotReady( + requestID: requestID, + snapshot: inputSnapshot + ) + ) + + if let textView { + endEditingSyncPolicy = .skipOnce + unfocusTextView(textView) + } else { + endEditingSyncPolicy = .sync + } + + case .setFocus: + endEditingSyncPolicy = .sync + if let textView { + focusTextView(textView) + } + } + } + + private func finalizeCompositionIfNeeded(_ textView: UITextView) -> Bool { + guard textView.markedTextRange != nil else { return true } + textView.unmarkText() + return textView.markedTextRange == nil + } + + private func prepareDraftForFinalSnapshot(_ textView: UITextView?) -> Bool { + guard let textView else { + syncDraftFromFallbackIfNeeded() + return true + } + guard finalizeCompositionIfNeeded(textView) else { return false } + flushAndSyncIfPossible(textView) + return true + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift new file mode 100644 index 0000000..d4892b6 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift @@ -0,0 +1,188 @@ +// +// MemoInputUITextView+IME.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +extension MemoInputUITextView.Coordinator { + // MARK: - IME Handling + + // marked 구간이 생겼을 때 조합 시작점 이전 글자만 안전하게 토큰화합니다. + func handleMarkedRange(in textView: UITextView, marked: NSRange) { + guard isEmojiMode else { return } + tokenizeBeforeMarkedStart(textView, targetIndex: marked.location - 1, marked: marked) + } + + // IME 조합이 확정(unmark)될 때 지연된 변경을 마무리합니다. + func handleUnmark(in textView: UITextView) { + guard isEmojiMode else { return } + guard textView.markedTextRange == nil else { return } + guard let change = pendingOrDeferredChange() else { return } + + handleNonMarkedChange(in: textView, change: change) + clearPendingAndDeferredChanges() + syncSnapshotToStore(textView) + lastTextLength = textView.textStorage.length + lastSelectionRange = textView.selectedRange + } + + private func tokenizeBeforeMarkedStart(_ textView: UITextView, targetIndex: Int, marked: NSRange?) { + guard targetIndex >= 0 else { return } + + let storage = textView.textStorage + guard targetIndex < storage.length else { return } + + let composedRange = (storage.string as NSString).rangeOfComposedCharacterSequence(at: targetIndex) + + if let marked, + NSIntersectionRange(composedRange, marked).length > 0 { + return + } + + if storage.attribute(.attachment, at: targetIndex, effectiveRange: nil) is NSTextAttachment { + return + } + + let original = storage.attributedSubstring(from: composedRange).string + guard let character = original.first, original.count == 1 else { return } + guard EmojiCharacter.isEmojiConvertibleCharacter(character) else { return } + + let font = textView.font ?? parent.comfieUIBodyFont + let emoji = String(EmojiPool.getRandomEmoji()) + let token = tokenAttributedString(original: original, emoji: emoji, font: font) + + isMutating = true + storage.beginEditing() + storage.replaceCharacters(in: composedRange, with: token) + storage.endEditing() + isMutating = false + lastTextLength = storage.length + } + + // 조합이 끝난 실제 입력 범위를 찾아 attachment 토큰 변환을 적용합니다. + func handleNonMarkedChange(in textView: UITextView, change: PendingChange) { + guard change.replacementUTF16Length > 0 else { return } + + let storageLength = textView.textStorage.length + guard storageLength > 0 else { return } + + let start = min(max(0, change.range.location), storageLength) + let convertedLength = min(change.replacementUTF16Length, storageLength - start) + guard convertedLength > 0 else { return } + + let insertedRange = NSRange(location: start, length: convertedLength) + tokenizeBeforeMarkedStart(textView, targetIndex: insertedRange.location - 1, marked: nil) + if change.replacementCharacterCount > 1 { + tokenizeRange(textView, range: insertedRange) + } + } + + func deferPendingChangeIfNeeded() { + guard let change = pendingChange else { return } + if change.replacementCharacterCount > 1 { + setDeferredChange(change) + } + } + + func flushPendingConversionOnCursorMove(in textView: UITextView) { + applyPendingConversionIfNeeded(in: textView) + syncSnapshotToStore(textView) + lastTextLength = textView.textStorage.length + } + + func flushPendingConversionBeforeSync(in textView: UITextView) { + guard isEmojiMode else { return } + guard textView.markedTextRange == nil else { return } + applyPendingConversionIfNeeded(in: textView) + lastTextLength = textView.textStorage.length + } + + func handleFallbackInsertion(in textView: UITextView) { + let currentLength = textView.textStorage.length + guard currentLength > lastTextLength else { return } + + let insertedLength = currentLength - lastTextLength + let start = max(0, textView.selectedRange.location - insertedLength) + let safeLength = min(insertedLength, currentLength - start) + guard safeLength > 0 else { return } + + let guessedRange = NSRange(location: start, length: safeLength) + tokenizeBeforeMarkedStart(textView, targetIndex: guessedRange.location - 1, marked: nil) + + if guessedRange.length > 1 { + tokenizeRange(textView, range: guessedRange) + } + } + + private func tokenizeRange(_ textView: UITextView, range: NSRange) { + let storageLength = textView.textStorage.length + guard storageLength > 0 else { return } + + let start = max(0, range.location) + let end = min(range.location + range.length, storageLength) + guard start < end else { return } + + for index in stride(from: end - 1, through: start, by: -1) { + tokenizeBeforeMarkedStart(textView, targetIndex: index, marked: nil) + } + } + + private func applyPendingConversionIfNeeded(in textView: UITextView) { + if let change = pendingOrDeferredChange() { + handleNonMarkedChange(in: textView, change: change) + } else if lastSelectionRange.location > 0 { + tokenizeBeforeMarkedStart(textView, targetIndex: lastSelectionRange.location - 1, marked: nil) + } + + clearPendingAndDeferredChanges() + } + + // MARK: - Mode Conversion + + // 일반 텍스트 모드를 이모지 표시 모드로 일괄 변환합니다. + func convertAllPlainToEmoji(in textView: UITextView) { + let storage = textView.textStorage + guard storage.length > 0 else { return } + + let font = textView.font ?? parent.comfieUIBodyFont + let oldSelection = textView.selectedRange + + isMutating = true + storage.beginEditing() + for index in stride(from: storage.length - 1, through: 0, by: -1) { + if storage.attribute(.attachment, at: index, effectiveRange: nil) is NSTextAttachment { + continue + } + + let range = NSRange(location: index, length: 1) + let original = storage.attributedSubstring(from: range).string + guard let character = original.first, original.count == 1 else { continue } + guard EmojiCharacter.isEmojiConvertibleCharacter(character) else { continue } + + let emoji = String(EmojiPool.getRandomEmoji()) + storage.replaceCharacters( + in: range, + with: tokenAttributedString(original: original, emoji: emoji, font: font) + ) + } + storage.endEditing() + textView.selectedRange = clampedSelection(oldSelection, maxLength: storage.length) + isMutating = false + lastTextLength = storage.length + } + + // attachment 토큰을 원문 문자열로 되돌려 일반 텍스트 모드를 복원합니다. + func convertAllToPlain(in textView: UITextView) { + let currentSnapshot = snapshot(from: textView.textStorage) + let oldSelection = textView.selectedRange + + isMutating = true + textView.text = currentSnapshot.original + textView.selectedRange = clampedSelection(oldSelection, maxLength: textView.textStorage.length) + isMutating = false + lastTextLength = textView.textStorage.length + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift new file mode 100644 index 0000000..df0fc4b --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift @@ -0,0 +1,187 @@ +// +// MemoInputUITextView+Snapshot.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +extension MemoInputUITextView.Coordinator { + // MARK: - Snapshot/Rendering + + func normalizedOriginalText() -> String { + if !parent.inputSeed.originalText.isEmpty { + return parent.inputSeed.originalText + } + return parent.inputSeed.emojiText + } + + func normalizedEmojiText(with originalText: String) -> String { + if !parent.inputSeed.emojiText.isEmpty { + return parent.inputSeed.emojiText + } + return originalText + } + + // 현재 textStorage를 draft로 동기화하고 Store에 입력 가능 상태를 알립니다. + func syncSnapshotToStore(_ textView: UITextView) { + let currentSnapshot = snapshot(from: textView.textStorage) + let emojiTextCandidate = isEmojiMode ? currentSnapshot.emoji : currentSnapshot.original + + updateDraft( + originalText: currentSnapshot.original, + emojiTextCandidate: emojiTextCandidate + ) + publishDraftAvailability() + } + + // textView가 없는 생명주기 경계에서도 seed 기반 draft를 복구해 savePhase 고착을 막습니다. + func syncDraftFromFallbackIfNeeded() { + let originalText = normalizedOriginalText() + let emojiText = normalizedEmojiText(with: originalText) + + syncDraftCache( + originalText: originalText, + emojiText: emojiText + ) + publishDraftAvailability() + } + + func publishDraftAvailability() { + parent.emitOutputEvent(.draftAvailabilityChanged(isEmpty: draftEmojiText.isEmpty)) + } + + func snapshot(from storage: NSAttributedString) -> (original: String, emoji: String) { + var original = "" + var emoji = "" + let fullRange = NSRange(location: 0, length: storage.length) + + storage.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in + if let token = attributes[.attachment] as? MemoEmojiTokenAttachment { + original.append(token.original) + emoji.append(token.emoji) + } else { + let plain = storage.attributedSubstring(from: range).string + original.append(plain) + emoji.append(plain) + } + } + + return (original, emoji) + } + + // 원문/이모지 스냅샷을 현재 모드(UIText/Attachment)에 맞춰 UITextView에 렌더링합니다. + func render(_ textView: UITextView, originalText: String, emojiText: String) { + let oldSelection = textView.selectedRange + isMutating = true + + if isEmojiMode { + textView.attributedText = attributedText( + originalText: originalText, + emojiText: emojiText, + font: parent.comfieUIBodyFont + ) + } else { + textView.text = originalText + } + + textView.selectedRange = clampedSelection(oldSelection, maxLength: textView.textStorage.length) + isMutating = false + + updatePlaceholderVisibility(textView) + updateTextViewHeight(textView) + lastTextLength = textView.textStorage.length + lastSelectionRange = textView.selectedRange + } + + func attributedText(originalText: String, emojiText: String, font: UIFont) -> NSAttributedString { + let originalCharacters = Array(originalText) + let emojiCharacters = Array(emojiText) + let count = min(originalCharacters.count, emojiCharacters.count) + + let result = NSMutableAttributedString() + + if count == 0 { + return NSAttributedString(string: originalText, attributes: [.font: font]) + } + + for index in 0.. count { + for originalCharacter in originalCharacters[count...] { + result.append(NSAttributedString(string: String(originalCharacter), attributes: [.font: font])) + } + } + + return result + } + + func tokenAttributedString(original: String, emoji: String, font: UIFont) -> NSAttributedString { + let attachment = MemoEmojiTokenAttachment(original: original, emoji: emoji, font: font) + let token = NSMutableAttributedString(attachment: attachment) + token.addAttribute(.font, value: font, range: NSRange(location: 0, length: token.length)) + return token + } + + func clampedSelection(_ selection: NSRange, maxLength: Int) -> NSRange { + let location = min(max(0, selection.location), maxLength) + let maxAvailableLength = max(0, maxLength - location) + let length = min(max(0, selection.length), maxAvailableLength) + return NSRange(location: location, length: length) + } + + func updateDraft(originalText: String, emojiTextCandidate: String) { + let resolvedEmojiText = resolveDraftEmojiText( + originalText: originalText, + emojiTextCandidate: emojiTextCandidate + ) + + replaceDraft(original: originalText, emoji: resolvedEmojiText) + } + + func syncDraftCache(originalText: String, emojiText: String) { + replaceDraft(original: originalText, emoji: emojiText) + } + + private func resolveDraftEmojiText(originalText: String, emojiTextCandidate: String) -> String { + if isEmojiMode { + return emojiTextCandidate + } + + let seededPreviousEmojiText = seededPreviousEmojiTextForPlainMode( + originalText: originalText, + emojiTextCandidate: emojiTextCandidate + ) + return EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: draftOriginalText, + previousEmojiText: seededPreviousEmojiText, + newOriginalText: originalText + ) + } + + private func seededPreviousEmojiTextForPlainMode(originalText: String, emojiTextCandidate: String) -> String { + if draftOriginalText.isEmpty, + draftEmojiText.isEmpty, + emojiTextCandidate.count == originalText.count { + return emojiTextCandidate + } + + return draftEmojiText + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift new file mode 100644 index 0000000..8ca9cf9 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift @@ -0,0 +1,53 @@ +// +// MemoInputUITextView+TextViewLayout.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +extension MemoInputUITextView.Coordinator { + // MARK: - TextView Handling + + func updatePlaceholderVisibility(_ textView: UITextView) { + placeholderLabel.isHidden = !textView.textStorage.string.isEmpty + } + + // 텍스트 높이를 최대 4줄 범위로 계산하고 스크롤/SwiftUI 높이를 함께 동기화합니다. + func updateTextViewHeight(_ textView: UITextView) { + let width = textView.bounds.width + guard width > 0 else { return } + + updatePlaceholderVisibility(textView) + + let fittingSize = CGSize( + width: width, + height: .greatestFiniteMagnitude + ) + let estimatedSize = textView.sizeThatFits(fittingSize) + + let maxHeight = parent.comfieUIBodyFont.lineHeight + * parent.maxLineCount + + textView.textContainerInset.top + + textView.textContainerInset.bottom + + let targetHeight = min(estimatedSize.height, maxHeight) + textViewHeightConstraint?.constant = targetHeight + textView.isScrollEnabled = estimatedSize.height >= maxHeight + if parent.dynamicHeight != targetHeight { + Task { @MainActor [weak self] in + guard let self else { return } + self.parent.dynamicHeight = targetHeight + } + } + } + + func focusTextView(_ textView: UITextView) { + textView.becomeFirstResponder() + } + + func unfocusTextView(_ textView: UITextView) { + textView.resignFirstResponder() + } +} diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift index 1006d88..d8710d6 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift @@ -5,81 +5,90 @@ // Created by zaehorang on 5/29/25. // -import Combine import SwiftUI +import UIKit struct MemoInputUITextView: UIViewRepresentable { let placeholder: String - - private var text: String { - intent.state.inputMemoText - } - - @Binding private var dynamicHeight: CGFloat - @Binding private var intent: MemoStore - + + let inputSeed: MemoInputSeed + let isEmojiPresentationEnabled: Bool + let uiCommandEvent: MemoInputUIEvent? + let onOutputEvent: ((MemoInputOutputEvent) -> Void)? + + @Binding var dynamicHeight: CGFloat + let comfieUIBodyFont = UIFont( name: ComfieFontType.body.fontName.rawValue, size: ComfieFontType.body.fontSize)! let maxLineCount: CGFloat = 4 - - init(_ placeholder: String, - dynamicHeight: Binding, - intent: Binding + + init( + _ placeholder: String, + dynamicHeight: Binding, + inputSeed: MemoInputSeed, + isEmojiPresentationEnabled: Bool, + uiCommandEvent: MemoInputUIEvent?, + onOutputEvent: ((MemoInputOutputEvent) -> Void)? = nil ) { self.placeholder = placeholder self._dynamicHeight = dynamicHeight - self._intent = intent + self.inputSeed = inputSeed + self.isEmojiPresentationEnabled = isEmojiPresentationEnabled + self.uiCommandEvent = uiCommandEvent + self.onOutputEvent = onOutputEvent } - + func makeCoordinator() -> Coordinator { - Coordinator(parent: self, intent: $intent) + Coordinator(parent: self) } - + func makeUIView(context: Context) -> UIView { let container = UIView() let textView = createTextView() let placeholderLabel = createPlaceholderLabel() - + let heightConstraint = createMaxHeightConstraint(for: textView) - + textView.delegate = context.coordinator + // delegate만으로 놓치기 쉬운 IME 조합 시작/종료 경계를 직접 받아 Coordinator에 전달합니다. + textView.onSetMarkedText = { [weak textView, weak coordinator = context.coordinator] markedRange in + guard let textView else { return } + coordinator?.handleMarkedRange(in: textView, marked: markedRange) + } + textView.onUnmarkText = { [weak textView, weak coordinator = context.coordinator] in + guard let textView else { return } + coordinator?.handleUnmark(in: textView) + } + context.coordinator.textView = textView context.coordinator.placeholderLabel = placeholderLabel - context.coordinator.textViewHeightConstraint = heightConstraint - - context.coordinator.bindFocusControl() - + context.coordinator.applyStateToTextView(force: true) + container.addSubview(textView) container.addSubview(placeholderLabel) - + NSLayoutConstraint.activate([ - textView.topAnchor - .constraint(equalTo: container.topAnchor), - textView.bottomAnchor - .constraint(equalTo: container.bottomAnchor), - textView.leadingAnchor - .constraint(equalTo: container.leadingAnchor), - textView.trailingAnchor - .constraint(equalTo: container.trailingAnchor), - - placeholderLabel.topAnchor - .constraint(equalTo: textView.topAnchor, constant: 9), - - // textView의 커서 위치와 플레이스홀더의 정렬을 맞추기 위해 오른쪽으로 5pt 추가 - placeholderLabel.leadingAnchor - .constraint(equalTo: textView.leadingAnchor, constant: 17) + textView.topAnchor.constraint(equalTo: container.topAnchor), + textView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + textView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + + placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: 9), + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 17) ]) - + return container } - - func updateUIView(_ uiView: UIView, context: Context) { } - - private func createTextView() -> UITextView { - let textView = UITextView() - + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.parent = self + context.coordinator.applyStateToTextView(force: false) + } + + private func createTextView() -> MemoIMETrackingTextView { + let textView = MemoIMETrackingTextView() textView.font = comfieUIBodyFont textView.isScrollEnabled = false textView.backgroundColor = UIColor.keyBackground @@ -87,268 +96,33 @@ struct MemoInputUITextView: UIViewRepresentable { textView.layer.cornerRadius = 12 textView.clipsToBounds = true textView.translatesAutoresizingMaskIntoConstraints = false - + textView.accessibilityIdentifier = "memo.inputTextView" return textView } - + private func createPlaceholderLabel() -> UILabel { let placeholderLabel = UILabel() - placeholderLabel.text = placeholder placeholderLabel.font = comfieUIBodyFont placeholderLabel.textColor = UIColor.textGray placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - return placeholderLabel } - - /// 텍스트뷰의 최대 줄 수에 따른 높이 제한 제약 생성 + private func createMaxHeightConstraint(for textView: UITextView) -> NSLayoutConstraint { let maxHeight = comfieUIBodyFont.lineHeight - * maxLineCount - + textView.textContainerInset.top - + textView.textContainerInset.bottom - + * maxLineCount + + textView.textContainerInset.top + + textView.textContainerInset.bottom + let heightConstraint = textView.heightAnchor.constraint(lessThanOrEqualToConstant: maxHeight) heightConstraint.priority = .defaultHigh heightConstraint.isActive = true - + return heightConstraint } - - final class Coordinator: NSObject, UITextViewDelegate { - private var parent: MemoInputUITextView - - @Binding private var intent: MemoStore - - private var emojiString: EmojiString { - intent.state.emojiString - } - - weak var textView: UITextView! - weak var placeholderLabel: UILabel! - - var textViewHeightConstraint: NSLayoutConstraint? - - private var cancellables = Set() - - init(parent: MemoInputUITextView, intent: Binding) { - self.parent = parent - self._intent = intent - } - - /// MemoStore에서 전달된 sideEffect를 감지하여 포커스를 제어하거나, 상태 기반으로 입력 뷰를 갱신합니다. - func bindFocusControl() { - intent.uiSideEffectPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] sideEffect in - guard let self = self else { return } - switch sideEffect { - case .resignInputFocusWithSyncInput: - self.unfocusTextView(textView) - case .setMemoInputFocus: - self.focusTextView(textView) - case .updateInputViewWithState: - self.textView.text = intent.state.inputMemoText - - updatePlaceholderVisibility(textView) - updateTextViewHeight(textView) - } - } - .store(in: &cancellables) - } - - // MARK: - UITextViewDelegate - - func textViewDidChange(_ textView: UITextView) { - updatePlaceholderVisibility(textView) - updateTextViewHeight(textView) - } - - /// 해담 메서드에서 최졷 동기화를 해야지 한글이 완전히 완성된 상태에서 동기화가 가능 - func textViewDidEndEditing(_ textView: UITextView) { - // 한글 입력 시 뷰에 보이는 텍스트와 내부 데이터가 다를 수 있어 - // 편집 종료 시 최종 동기화 수행. - intent.handleIntent(.memoInput(.updateNewMemo(textView.text))) - } - - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - guard let currentText = textView.text, - let textRange = Range(range, in: currentText) else { - return true - } - - let updatedText = currentText.replacingCharacters(in: textRange, with: text) - - if intent.state.isInComfieZone { - // 컴피존 내에서는 이모지 변환 없이 사용자가 입력한 텍스트 그대로 반영 - intent.handleIntent(.memoInput(.updateNewMemo(updatedText))) - - return true - } else { - if !text.isEmpty { // 텍스트가 추가되는 경우 - if text == "\n" || text == " " { - handleEmojiTransformTrigger(textView, updatedText: updatedText) - - return false - } - intent.handleIntent(.memoInput(.updateNewMemo(updatedText))) - - return true - } else { // 텍스트가 삭제되는 경우 - // 한글 입력 시 뷰에 보이는 텍스트와 내부 데이터가 다를 수 있어 - // 편집 종료 시 최종 동기화 수행. - intent.handleIntent(.memoInput(.updateNewMemo(textView.text))) - handleTextDeletion(textView) - - return false - } - } - } - - // MARK: - TextView Handling - - private func handleEmojiTransformTrigger(_ textView: UITextView, updatedText: String) { - let index = findEndCursorIndexInString(textView) + 1 - - intent.handleIntent(.memoInput(.transformTriggerDetected(index: index, newMemoText: updatedText))) - updateText(textView) - updateTextViewHeight(textView) - - // 커서 위치 고정 - let leftText = emojiString.getEmojiString(to: index) - moveCursor(toLeftText: leftText) - } - - /// 삭제 로직을 처리하는 메서드 - private func handleTextDeletion(_ textView: UITextView) { - intent.handleIntent(.memoInput(.updateNewMemo(textView.text))) - - let endIndex = findEndCursorIndexInString(textView) - let startIndex = findStartCursorIndexInString(textView) - - if startIndex == endIndex { // 한 글자 삭제 - intent.handleIntent(.memoInput(.deleteTriggerDetected(start: startIndex, end: nil))) - } else { // 범위 잡아서 삭제 - intent.handleIntent(.memoInput( - .deleteTriggerDetected( - start: startIndex + 1, end: endIndex - ) - )) - } - updateText(textView) - updateTextViewHeight(textView) - - // 커서 위치 변경 - let leftText = emojiString.getEmojiString(to: startIndex - 1) - moveCursor(toLeftText: leftText) - } - - private func updateText(_ textView: UITextView) { - let updatedText = emojiString.getEmojiString() - - textView.text = updatedText - intent.handleIntent(.memoInput(.updateNewMemo(updatedText))) - } - - private func updatePlaceholderVisibility(_ textView: UITextView) { - placeholderLabel.isHidden = !textView.text.isEmpty - } - - /// 텍스트 내용에 따라 높이를 계산하고 제한된 높이까지 설정 - private func updateTextViewHeight(_ textView: UITextView) { - let width = textView.bounds.width - guard width > 0 else { return } - - updatePlaceholderVisibility(textView) - - let fittingSize = CGSize( - width: width, - height: .greatestFiniteMagnitude - ) - let estimatedSize = textView.sizeThatFits(fittingSize) - - let maxHeight = parent.comfieUIBodyFont.lineHeight - * parent.maxLineCount - + textView.textContainerInset.top - + textView.textContainerInset.bottom - - let targetHeight = min(estimatedSize.height, maxHeight) - - textViewHeightConstraint?.constant = targetHeight - - textView.isScrollEnabled = estimatedSize.height >= maxHeight - - parent.dynamicHeight = targetHeight - } - - private func focusTextView(_ textView: UITextView) { - textView.becomeFirstResponder() - } - - private func unfocusTextView(_ textView: UITextView) { - textView.resignFirstResponder() - } - - // MARK: - Cursor Handling - - /// 커서를 leftText의 끝(UTF-16 기준 오프셋)으로 이동 - private func moveCursor(toLeftText leftText: String) { - guard let textView = textView, - let position = textView.position(from: textView.beginningOfDocument, offset: leftText.utf16.count) else { return } - textView.selectedTextRange = textView.textRange(from: position, to: position) - } - - /// 커서 끝 위치를 String.Index로 반환 - /// - selectedRange는 UTF-16 기준으로, 복합 문자(이모지 등)와 문자 수(count)가 다를 수 있음 - private func getCursorEndIndex(_ textView: UITextView) -> String.Index? { - guard let selectedRange = textView.selectedTextRange, - let text = textView.text else { return nil } - - let cursor = textView.offset(from: textView.beginningOfDocument, to: selectedRange.end) - let nsRange = NSRange(location: 0, length: cursor) - return Range(nsRange, in: text)?.upperBound - } - - /// 커서 시작 위치를 String.Index로 반환 - /// - selectedRange의 UTF-16 offset을 정확한 String.Index로 변환 - private func getCursorStartIndex(_ textView: UITextView) -> String.Index? { - guard let selectedRange = textView.selectedTextRange, - let text = textView.text else { return nil } - - let cursor = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start) - let nsRange = NSRange(location: 0, length: cursor) - return Range(nsRange, in: text)?.upperBound - } - - /// 커서 끝 위치까지 문자의 개수를 Int 인덱스로 반환 - /// - getCursorEndIndex로 구한 String.Index 기준으로 계산 - /// - 커서가 문자열의 맨 앞(시작) 위치에 있으면 -1을 반환합니다. - /// - 참고: 이 값은 삭제 로직에서 특별한 의미(삭제 안 함)로 사용됩니다. - private func findEndCursorIndexInString(_ textView: UITextView) -> Int { - guard let cursorIndex = getCursorEndIndex(textView), - let text = textView.text else { - print("findEndCursorIndexInString Error") - return 0 - } - let leftText = String(text[.. Int { - guard let cursorIndex = getCursorStartIndex(textView), - let text = textView.text else { - print("findStartCursorIndexInString Error") - return 0 - } - let leftText = String(text[.. Bool { editingMemo?.id == memo.id } - + mutating func setEditingMemo(_ memo: Memo) { editingMemo = memo - inputMemoText = memo.emojiText - emojiString = EmojiString(memo: memo) + inputSeed = MemoInputSeed( + token: inputSeed.token + 1, + originalText: memo.originalText, + emojiText: memo.emojiText + ) + isInputEmpty = memo.emojiText.isEmpty } - + + private mutating func resetInputSeedToEmptyPreservingEditing() { + inputSeed = MemoInputSeed(token: inputSeed.token + 1, originalText: "", emojiText: "") + isInputEmpty = true + } + mutating func resetEditingMemo() { - emojiString = .init() - inputMemoText = "" + resetInputSeedToEmptyPreservingEditing() editingMemo = nil } - + + mutating func resetInputSeedAfterSave() { + resetInputSeedToEmptyPreservingEditing() + } + mutating func setDeletingMemo(_ memo: Memo) { deletingMemo = memo } - + mutating func resetDeletingMemo() { deletingMemo = nil } - - @AppStorage("hasSeenTutorial") var hasSeenTutorial: Bool = false + + @AppStorage(UserDefaultsConstants.Keys.hasSeenTutorial.rawValue) var hasSeenTutorial: Bool = false var showTutorial: Bool = false } - + // MARK: Intent enum Intent { case deletePopup(PopupIntent) case memoInput(MemoInputIntent) case memoCell(MemoCellIntent) - + case onAppear case backgroundTapped case comfieZoneSettingButtonTapped case moreButtonTapped case tutorialTapped - + enum PopupIntent { case confirmDeleteButtonTapped case cancelDeleteButtonTapped } - + enum MemoInputIntent { - case updateNewMemo(String) + case draftAvailabilityChanged(isEmpty: Bool) case memoInputButtonTapped - - case transformTriggerDetected(index: Int, newMemoText: String) - case deleteTriggerDetected(start: Int, end: Int?) + // UI final sync 완료 + case finalSyncCompleted(requestID: UUID, snapshot: MemoInputSnapshot) + // UI final sync 실패 + case finalSyncFailed(requestID: UUID) } - + enum MemoCellIntent { case editButtonTapped(Memo) case retrospectionButtonTapped(Memo) @@ -94,7 +119,7 @@ class MemoStore: IntentStore { case editingCancelButtonTapped } } - + // MARK: Action enum Action { case memo(MemoAction) @@ -102,76 +127,76 @@ class MemoStore: IntentStore { case popup(PopupAction) case navigation(NavigationAction) case tutorial(TutorialAction) - + enum MemoAction { case fetchAll - case save - case update(Memo) + case save(snapshot: MemoInputSnapshot) + case update(memo: Memo, snapshot: MemoInputSnapshot) case delete } - + enum InputAction { - case updateText(String) + case draftAvailabilityChanged(isEmpty: Bool) + case finalSyncCompleted(requestID: UUID, snapshot: MemoInputSnapshot) + case finalSyncFailed(requestID: UUID) case startEditing(Memo) case cancelEditing - - case updateEmojiString(index: Int, newMemoText: String) - case deleteEmojiString(start: Int, end: Int?) } - + enum PopupAction { case showDeletePopup(Memo) case cancelDelete } - + enum NavigationAction { case toRetrospection(Memo) case toComfieZoneSetting case toMore } - + enum TutorialAction { case showTutorial case dismissTutorial } } - + // MARK: Side Effect enum SideEffect { case memoInput(MemoInput) case scroll(Scroll) - + enum MemoInput { case resignInputFocusWithSyncInput + case resignInputFocusWithoutSync + case requestFinalSyncAndResign(requestID: UUID) case setMemoInputFocus - case updateInputViewWithState } - + enum Scroll { case toMemo(memo: Memo) case toBottom } } - + private let router: Router private let memoRepository: MemoRepositoryProtocol private let locationUseCase: LocationUseCase - + private(set) var uiSideEffectPublisher = PassthroughSubject() private(set) var scrollSideEffectPublisher = PassthroughSubject() private var cancellables = Set() - + // MARK: Init init(router: Router, memoRepository: MemoRepositoryProtocol, locationUseCase: LocationUseCase) { self.router = router self.memoRepository = memoRepository self.locationUseCase = locationUseCase - + self.state = .init(isInComfieZone: locationUseCase.isInComfieZone(locationUseCase.getCurrentLocation())) - + observeIsInComfieZone() } - + // MARK: Method func handleIntent(_ intent: Intent) { switch intent { @@ -181,23 +206,26 @@ class MemoStore: IntentStore { state = handleMemoInputIntent(memoInputIntent) case .deletePopup(let popupIntent): state = handleDeletePopupIntent(popupIntent) - + case .onAppear: state = handleAction(state, .memo(.fetchAll)) if !state.hasSeenTutorial { state = handleAction(state, .tutorial(.showTutorial)) } case .backgroundTapped: + guard canHandleIdleOnlyInteraction(state) else { return } performUISideEffect(for: .resignInputFocusWithSyncInput) case .comfieZoneSettingButtonTapped: + guard canHandleIdleOnlyInteraction(state) else { return } state = handleAction(state, .navigation(.toComfieZoneSetting)) case .moreButtonTapped: + guard canHandleIdleOnlyInteraction(state) else { return } state = handleAction(state, .navigation(.toMore)) case .tutorialTapped: state = handleAction(state, .tutorial(.dismissTutorial)) } } - + private func handleAction(_ state: State, _ action: Action) -> State { switch action { case .memo(let action): @@ -216,66 +244,74 @@ class MemoStore: IntentStore { // MARK: - Handle Intent Methods extension MemoStore { + private func canHandleIdleOnlyInteraction(_ state: State) -> Bool { + state.savePhase == .idle + } + private func handleMemoCellIntent(_ intent: Intent.MemoCellIntent) -> State { switch intent { case .deleteButtonTapped(let memo): + guard canHandleIdleOnlyInteraction(state) else { return state } return handleAction(state, .popup(.showDeletePopup(memo))) case .editButtonTapped(let memo): + guard canHandleIdleOnlyInteraction(state) else { return state } let newState = handleAction(state, .input(.startEditing(memo))) - performUISideEffect(for: .updateInputViewWithState) performUISideEffect(for: .setMemoInputFocus) - - // 메모 수정 시, 해당 메모 위치로 스크롤 이동 + performScrollEffect(for: .toMemo(memo: memo)) return newState case .editingCancelButtonTapped: + guard canHandleIdleOnlyInteraction(state) else { return state } let newState = handleAction(state, .input(.cancelEditing)) - performUISideEffect(for: .updateInputViewWithState) - performUISideEffect(for: .resignInputFocusWithSyncInput) + // 취소 시 stale sync를 막기 위해 sync 없이 포커스를 내립니다. + performUISideEffect(for: .resignInputFocusWithoutSync) return newState case .retrospectionButtonTapped(let memo): + guard canHandleIdleOnlyInteraction(state) else { return state } let newState = handleNavigationAction(state, .toRetrospection(memo)) return newState } } - + private func handleMemoInputIntent(_ intent: Intent.MemoInputIntent) -> State { switch intent { + case .draftAvailabilityChanged(let isEmpty): + return handleAction(state, .input(.draftAvailabilityChanged(isEmpty: isEmpty))) case .memoInputButtonTapped: - // ⚠️ 텍스트뷰에 보이는 값과 상태가 불일치하는 문제 방지를 위해, 입력 종료 시 델리게이트 메서드에서 동기화 메서드를 추가로 실행함. - performUISideEffect(for: .resignInputFocusWithSyncInput) - - // resignFirstResponder 호출과 그 후 동작들이 모두 메인 스레드(MainActor)에서 실행되어 순서가 보장됨. - Task { @MainActor in - if let editingMemo = state.editingMemo { - // 메모 수정 중 - self.state = handleAction(state, .memo(.update(editingMemo))) - } else { - // 새로운 메모 작성 중 - self.state = handleAction(state, .memo(.save)) - - // 메모가 추가 시, 해당 메모 위치(bottom)으로 스크롤 이동 - performScrollEffect(for: .toBottom) - } - } - - performUISideEffect(for: .updateInputViewWithState) - // 🥲 여기 리턴 값은 사실상 의미 없는 값 - return state - case .updateNewMemo(let text): - return handleAction(state, .input(.updateText(text))) - case .transformTriggerDetected(index: let index, newMemoText: let newMemoText): - return handleAction(state, .input(.updateEmojiString(index: index, newMemoText: newMemoText))) - case .deleteTriggerDetected(start: let start, end: let end): - return handleAction(state, .input(.deleteEmojiString(start: start, end: end))) + // idle + non-empty일 때만 final sync 트랜잭션을 시작합니다. + guard case .idle = state.savePhase, !state.isInputEmpty else { return state } + + // 이번 저장 요청을 구분할 requestID를 만듭니다. + let requestID = UUID() + var newState = state + // 이제부터는 UI final sync 응답을 기다리는 상태입니다. + newState.savePhase = .awaitingFinalSync(requestID: requestID) + // InputView에 "final sync 후 포커스 해제" 명령을 내려 snapshot 수집을 시작합니다. + performUISideEffect(for: .requestFinalSyncAndResign(requestID: requestID)) + return newState + case .finalSyncCompleted(requestID: let requestID, snapshot: let snapshot): + // final sync 성공 payload는 InputAction으로 위임해 requestID 검증을 한곳에서 처리합니다. + return handleAction( + state, + .input( + .finalSyncCompleted( + requestID: requestID, + snapshot: snapshot + ) + ) + ) + case .finalSyncFailed(let requestID): + // final sync 실패도 같은 경로로 모아 savePhase 복귀 정책을 일관되게 적용합니다. + return handleAction(state, .input(.finalSyncFailed(requestID: requestID))) } } - + private func handleDeletePopupIntent(_ intent: Intent.PopupIntent) -> State { switch intent { case .cancelDeleteButtonTapped: return handleAction(state, .popup(.cancelDelete)) case .confirmDeleteButtonTapped: + guard canHandleIdleOnlyInteraction(state) else { return state } return handleAction(state, .memo(.delete)) } } @@ -284,53 +320,83 @@ extension MemoStore { // MARK: - Handle Action Methods extension MemoStore { private func handleMemoAction(_ state: State, _ action: Action.MemoAction) -> State { - var newState = state - switch action { case .fetchAll: - return fetchMemos(newState) - case .save: - // 동기화 - newState.emojiString.syncWithNewString(newState.inputMemoText) - // 이모지 채우기 - newState.emojiString.setUnassignedEmojis() - return saveMemo(newState) - case .update(let updatedMemo): - // 동기화 - newState.emojiString.syncWithNewString(newState.inputMemoText) - // 이모지 채우기 - newState.emojiString.setUnassignedEmojis() - return updateMemo(newState, updatedMemo) + return fetchMemos(state) + case .save(let snapshot): + return saveMemo(state, snapshot: snapshot) + case .update(let memo, let snapshot): + return updateMemo(state, memo, snapshot: snapshot) case .delete: - if let memo = newState.deletingMemo { - return deleteMemo(newState, memo) + if let memo = state.deletingMemo { + return deleteMemo(state, memo) } } - return newState + return state } - + private func handleInputAction(_ state: State, _ action: Action.InputAction) -> State { - var newState = state switch action { - case .updateText(let text): - newState.inputMemoText = text + case .draftAvailabilityChanged(let isEmpty): + var newState = state + newState.isInputEmpty = isEmpty + return newState + case .finalSyncCompleted(let requestID, let snapshot): + return handleFinalSyncCompleted(state, requestID: requestID, snapshot: snapshot) + case .finalSyncFailed(let requestID): + return handleFinalSyncFailed(state, requestID: requestID) case .startEditing(let memo): - // 작성되고 있던 메모 reset - newState.resetEditingMemo() + var newState = state newState.setEditingMemo(memo) + return newState case .cancelEditing: + var newState = state newState.resetEditingMemo() - case .updateEmojiString(index: let index, newMemoText: let newMemoText): - newState.emojiString.applyEmojiString(at: index, newMemoText) - case .deleteEmojiString(start: let start, end: let end): - // 삭제 전, 지금까지 입력된 문자로 동기화를 먼저 진행 - newState.emojiString.syncWithNewString(newState.inputMemoText) - newState.emojiString.deleteEmojiString(from: start, to: end) - } - + return newState + } + } + + // final sync 성공 이벤트를 저장 플로우로 연결합니다. + private func handleFinalSyncCompleted(_ state: State, requestID: UUID, snapshot: MemoInputSnapshot) -> State { + var newState = state + // 현재 대기중 request와 일치할 때만 처리해 stale 이벤트를 무시합니다. + guard case .awaitingFinalSync(let currentRequestID) = newState.savePhase, + currentRequestID == requestID else { + return newState + } + + guard !snapshot.originalText.isEmpty else { + newState.savePhase = .idle + newState.isInputEmpty = true + return newState + } + + newState.savePhase = .persisting + + if let editingMemo = newState.editingMemo { + return handleAction(newState, .memo(.update(memo: editingMemo, snapshot: snapshot))) + } else { + let persistedState = handleAction(newState, .memo(.save(snapshot: snapshot))) + if persistedState.savePhase == .idle { + performScrollEffect(for: .toBottom) + } + return persistedState + } + } + + // final sync 실패 이벤트 처리기입니다. + private func handleFinalSyncFailed(_ state: State, requestID: UUID) -> State { + var newState = state + // requestID가 다르면 "다른 트랜잭션의 실패"라서 현재 상태를 건드리면 안 됩니다. + guard case .awaitingFinalSync(let currentRequestID) = newState.savePhase, + currentRequestID == requestID else { + return newState + } + + newState.savePhase = .idle return newState } - + private func handlePopupAction(_ state: State, _ action: Action.PopupAction) -> State { var newState = state switch action { @@ -341,7 +407,7 @@ extension MemoStore { } return newState } - + private func handleNavigationAction(_ state: State, _ action: Action.NavigationAction) -> State { switch action { case .toRetrospection(let memo): @@ -354,7 +420,7 @@ extension MemoStore { } return state } - + private func handleTutorialAction(_ state: State, _ action: Action.TutorialAction) -> State { var newState = state switch action { @@ -373,11 +439,11 @@ extension MemoStore { private func performUISideEffect(for action: SideEffect.MemoInput) { uiSideEffectPublisher.send(action) } - + private func performScrollEffect(for action: SideEffect.Scroll) { scrollSideEffectPublisher.send(action) } - + private func observeIsInComfieZone() { locationUseCase.currentLocationPublisher .receive(on: DispatchQueue.main) @@ -402,49 +468,56 @@ extension MemoStore { } return newState } - - private func saveMemo(_ state: State) -> State { + + private func saveMemo(_ state: State, snapshot: MemoInputSnapshot) -> State { var newState = state + let emojiString = EmojiString.finalizedForPersist( + originalText: snapshot.originalText, + preferredEmojiText: snapshot.emojiText + ) let newMemo = Memo( id: UUID(), createdAt: .now, - originalText: - newState.emojiString.getOriginalString(), - emojiText: - newState.emojiString.getEmojiString() + originalText: emojiString.getOriginalString(), + emojiText: emojiString.getEmojiString() ) - + switch memoRepository.save(memo: newMemo) { case .success: newState.memos.append(newMemo) - newState.inputMemoText = "" - newState.emojiString = EmojiString() + newState.resetInputSeedAfterSave() case .failure(let error): print("메모 저장 실패: \(error)") } + newState.savePhase = .idle return newState } - - private func updateMemo(_ state: State, _ memo: Memo) -> State { + + private func updateMemo(_ state: State, _ memo: Memo, snapshot: MemoInputSnapshot) -> State { var newState = state + let emojiString = EmojiString.finalizedForPersist( + originalText: snapshot.originalText, + preferredEmojiText: snapshot.emojiText + ) var updatedMemo = memo - - updatedMemo.originalText = newState.emojiString.getOriginalString() - updatedMemo.emojiText = newState.emojiString.getEmojiString() - + + updatedMemo.originalText = emojiString.getOriginalString() + updatedMemo.emojiText = emojiString.getEmojiString() + switch memoRepository.update(memo: updatedMemo) { case .success: if let index = newState.memos.firstIndex(where: { $0.id == memo.id }) { newState.memos[index] = updatedMemo } - + newState.resetEditingMemo() case .failure(let error): print("메모 업데이트 실패: \(error)") } + newState.savePhase = .idle return newState } - + private func deleteMemo(_ state: State, _ memo: Memo) -> State { var newState = state switch memoRepository.delete(memo: memo) { @@ -452,7 +525,7 @@ extension MemoStore { if let index = newState.memos.firstIndex(where: { $0.id == memo.id }) { newState.memos.remove(at: index) } - + newState.resetDeletingMemo() case .failure(let error): print("메모 삭제 실패: \(error)") diff --git a/COMFIE/Presentation/Memo/MemoView.swift b/COMFIE/Presentation/Memo/MemoView.swift index cb99add..ae0a797 100644 --- a/COMFIE/Presentation/Memo/MemoView.swift +++ b/COMFIE/Presentation/Memo/MemoView.swift @@ -11,18 +11,19 @@ struct MemoView: View { private let strings = StringLiterals.Memo.self @State var intent: MemoStore + @State private var memoInputUIEvent: MemoInputUIEvent? var isUserInComfieZone: Bool { intent.state.isInComfieZone } - + private var isEditingMemo: Bool { intent.state.editingMemo != nil } - + var body: some View { ZStack { Color.keyBackground.ignoresSafeArea() - + VStack(spacing: 0) { ZStack(alignment: .top) { MemoListView(intent: $intent, isUserInComfieZone: isUserInComfieZone) @@ -30,7 +31,7 @@ struct MemoView: View { intent(.backgroundTapped) } .padding(.top, 56) - + if isEditingMemo { VStack { Spacer() @@ -38,17 +39,17 @@ struct MemoView: View { .padding(.bottom, 10) } } - + navigationBarView .onTapGesture { intent(.backgroundTapped) } } - + memoInputView .ignoresSafeArea(.keyboard, edges: .bottom) } - + if intent.state.showTutorial { Image(.tutorial) .resizable() @@ -57,7 +58,7 @@ struct MemoView: View { intent(.tutorialTapped) } } - + if intent.state.deletingMemo != nil { CFPopupView(type: .deleteMemo) { intent(.deletePopup(.confirmDeleteButtonTapped)) @@ -69,17 +70,18 @@ struct MemoView: View { .onAppear { intent(.onAppear) } + .onReceive(intent.uiSideEffectPublisher) { sideEffect in + memoInputUIEvent = MemoInputUIEvent(command: mapMemoInputUICommand(sideEffect)) + } } - + // MARK: - View Property private var navigationBarView: some View { HStack(spacing: 0) { Button { - // 페이지 이동 intent(.comfieZoneSettingButtonTapped) } label: { HStack(spacing: 8) { - // 컴피존 상태에 따라 로고 변경 Image(isUserInComfieZone ? .icComfie : .icUncomfie) .resizable() .frame(width: isUserInComfieZone ? 84 : 115, height: 25) @@ -88,9 +90,10 @@ struct MemoView: View { .frame(width: 24, height: 24) } } - + .accessibilityIdentifier("memo.comfieZoneSettingButton") + Spacer() - + Button { intent(.moreButtonTapped) } label: { @@ -100,6 +103,7 @@ struct MemoView: View { .symbolRenderingMode(.monochrome) .tint(.cfBlack) } + .accessibilityIdentifier("memo.moreButton") } .padding(.horizontal, 19) .padding(.vertical, 16) @@ -109,15 +113,21 @@ struct MemoView: View { x: 0, y: 8) } - + private var memoInputView: some View { HStack(alignment: .top, spacing: 12) { MemoInputTextView( strings.textfieldPlaceholder.localized, - memoStore: $intent + inputSeed: intent.state.inputSeed, + isEmojiPresentationEnabled: intent.state.isEmojiPresentationEnabled, + uiCommandEvent: memoInputUIEvent, + onOutputEvent: { outputEvent in + intent(.memoInput(mapMemoInputOutputEvent(outputEvent))) + } ) - + Button { + // 저장/수정 버튼 탭을 Store로 전달하면, Store가 final sync 트랜잭션을 시작합니다. intent(.memoInput(.memoInputButtonTapped)) } label: { Image(isEditingMemo ? .icCheck : .icSend) @@ -126,13 +136,14 @@ struct MemoView: View { .frame(width: 24, height: 24) .padding(8) .background( - intent.state.inputMemoText.isEmpty + intent.state.isInputEmpty ? .keyDeactivated : .keyPrimary ) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .disabled(intent.state.inputMemoText.isEmpty) + .accessibilityIdentifier("memo.sendButton") + .disabled(intent.state.isInputEmpty) } .padding(16) .background { @@ -146,7 +157,7 @@ struct MemoView: View { .ignoresSafeArea() } } - + private var editingCancelButton: some View { Button { intent(.memoCell(.editingCancelButtonTapped)) @@ -160,6 +171,31 @@ struct MemoView: View { .clipShape(RoundedRectangle(cornerRadius: 212)) .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 0) } + .accessibilityIdentifier("memo.editingCancelButton") + } + + private func mapMemoInputUICommand(_ sideEffect: MemoStore.SideEffect.MemoInput) -> MemoInputUICommand { + switch sideEffect { + case .resignInputFocusWithSyncInput: + return .resignWithSync + case .resignInputFocusWithoutSync: + return .resignWithoutSync + case .requestFinalSyncAndResign(let requestID): + return .requestFinalSyncAndResign(requestID: requestID) + case .setMemoInputFocus: + return .setFocus + } + } + + private func mapMemoInputOutputEvent(_ outputEvent: MemoInputOutputEvent) -> MemoStore.Intent.MemoInputIntent { + switch outputEvent { + case .draftAvailabilityChanged(let isEmpty): + return .draftAvailabilityChanged(isEmpty: isEmpty) + case .finalSnapshotReady(let requestID, let snapshot): + return .finalSyncCompleted(requestID: requestID, snapshot: snapshot) + case .finalSnapshotFailed(let requestID): + return .finalSyncFailed(requestID: requestID) + } } } diff --git a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift index 442f7c2..d1877c0 100644 --- a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift +++ b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift @@ -11,84 +11,80 @@ import SwiftUI @Observable class RetrospectionStore: IntentStore { private(set) var state: State = .init() - + let sideEffectPublisher = PassthroughSubject() - + private let router: Router private let repository: RetrospectionRepositoryProtocol - + let memo: Memo - + private var cancellables = Set() - private let inputContentSubject = CurrentValueSubject("") - + private let inputContentSubject = PassthroughSubject() + init(router: Router, repository: RetrospectionRepositoryProtocol, memo: Memo) { self.router = router self.repository = repository self.memo = memo - + setUpBindingContent() } - + struct State { - // 메모 관련 데이터 var originalMemo: String = "" var inputContent: String? var createdDate: String = "" - + var emojiString: EmojiString = .init() - + var lastSavedOriginal: String? + var lastSavedEmoji: String? + var showCompleteButton: Bool = false var showDeletePopupView: Bool = false } - + enum Intent { case onAppear case backgroundTapped case contentFieldTapped case updateRetrospection(String) - - // 네비게이션바 내 버튼 + case backButtonTapped case deleteMenuButtonTapped case completeButtonTapped - - // 삭제 팝업 내 버튼 + case deleteRetrospectionButtonTapped case cancelDeleteRetrospectionButtonTapped } - + // MARK: - Action - + enum Action { - // retrospection CRUD case fetchMemo case updateRetrospection(String) case saveRetrospection case deleteRetrospection - - // complete Button + case showCompleteButton case hideCompleteButton - - // delete-Popup view + case showDeletePopupView case hideDeletePopupView - + case popToLast } - + // MARK: - Side Effect - + enum SideEffect { case ui(UI) - + enum UI { case setContentFieldFocus case removeContentFieldFocus } } - + func handleIntent(_ intent: Intent) { switch intent { case .onAppear: @@ -111,14 +107,14 @@ class RetrospectionStore: IntentStore { performSideEffect(for: .ui(.removeContentFieldFocus)) state = handleAction(state, .hideCompleteButton) state = handleAction(state, .saveRetrospection) - + case .deleteRetrospectionButtonTapped: state = handleAction(state, .deleteRetrospection) _ = handleAction(state, .popToLast) case .cancelDeleteRetrospectionButtonTapped: state = handleAction(state, .hideDeletePopupView) } } - + private func handleAction(_ state: State, _ action: Action) -> State { var newState = state switch action { @@ -129,40 +125,82 @@ class RetrospectionStore: IntentStore { case .updateRetrospection(let text): newState.inputContent = text case .saveRetrospection: - newState.emojiString.syncWithNewString(newState.inputContent ?? "") - newState.emojiString.setUnassignedEmojis() - saveRetrospection(newState) + persistRetrospection(&newState) case .deleteRetrospection: - deleteRetrospection(newState) - + deleteRetrospection() + case .showCompleteButton: newState.showCompleteButton = true case .hideCompleteButton: newState.showCompleteButton = false - + case .showDeletePopupView: newState.showDeletePopupView = true case .hideDeletePopupView: newState.showDeletePopupView = false case .popToLast: router.pop() } return newState } + + private func persistRetrospection(_ state: inout State) { + let content = state.inputContent ?? "" + let baseline = resolveRetrospectionMergeBaseline(state) + let mergedEmojiText = mergedRetrospectionEmojiText( + baselineOriginal: baseline.original, + baselineEmoji: baseline.emoji, + newOriginal: content + ) + + state.emojiString = EmojiString.finalizedForPersist( + originalText: content, + preferredEmojiText: mergedEmojiText + ) + if saveRetrospection(state) { + state.lastSavedOriginal = content + state.lastSavedEmoji = state.emojiString.getEmojiString() + } + } + + private func resolveRetrospectionMergeBaseline(_ state: State) -> (original: String, emoji: String) { + let previousOriginal = state.lastSavedOriginal ?? memo.originalRetrospectionText ?? "" + let previousEmojiRaw = state.lastSavedEmoji ?? memo.emojiRetrospectionText ?? previousOriginal + let normalizedPrevious = EmojiString.normalizedForPersist( + originalText: previousOriginal, + preferredEmojiText: previousEmojiRaw + ) + return (original: previousOriginal, emoji: normalizedPrevious.getEmojiString()) + } + + private func mergedRetrospectionEmojiText( + baselineOriginal: String, + baselineEmoji: String, + newOriginal: String + ) -> String { + EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: baselineOriginal, + previousEmojiText: baselineEmoji, + newOriginalText: newOriginal + ) + } } // MARK: - Helper Methods extension RetrospectionStore { - private func saveRetrospection(_ state: State) { + private func saveRetrospection(_ state: State) -> Bool { let content = state.inputContent?.isEmpty == true ? nil : state.inputContent - let updatedmemo = memo.with(originalRetrospectionText: content, - emojiRetrospectionText: state.emojiString.getEmojiString()) - - switch repository.save(memo: updatedmemo) { + let emojiContent = content == nil ? nil : state.emojiString.getEmojiString() + let updatedMemo = memo.with(originalRetrospectionText: content, + emojiRetrospectionText: emojiContent) + + switch repository.save(memo: updatedMemo) { case .success: print("회고 저장 성공") + return true case .failure(let error): print("회고 저장 실패: \(error)") + return false } } - - private func deleteRetrospection(_ state: State) { + + private func deleteRetrospection() { switch repository.delete(memo: memo) { case .success: print("회고 삭제 성공") @@ -178,8 +216,7 @@ extension RetrospectionStore { private func performSideEffect(for action: SideEffect) { sideEffectPublisher.send(action) } - - // 입력 데이터를 실시간으로 저장해주는 함수 - 0.5초 후 저장 + private func setUpBindingContent() { inputContentSubject .removeDuplicates() @@ -188,7 +225,7 @@ extension RetrospectionStore { guard let self = self else { return } var updatedState = self.state updatedState.inputContent = content - self.saveRetrospection(updatedState) + self.state = self.handleAction(updatedState, .saveRetrospection) } .store(in: &cancellables) } diff --git a/COMFIE/Resources/EmojiPool/EmojiPool.swift b/COMFIE/Resources/EmojiPool/EmojiPool.swift index 6ff9ed4..d4eef37 100644 --- a/COMFIE/Resources/EmojiPool/EmojiPool.swift +++ b/COMFIE/Resources/EmojiPool/EmojiPool.swift @@ -5,6 +5,7 @@ // Created by zaehorang on 4/15/25. // +// 앱 전체에서 재사용하는 랜덤 이모지 후보 풀입니다. struct EmojiPool { static let emojiPool: [Character] = [ "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "🥹", @@ -123,7 +124,8 @@ struct EmojiPool { "💭", "🗯", "💬", "🕐" ] - + + // 이모지 풀에서 무작위 이모지를 반환하고, 비어 있으면 기본값을 사용합니다. static func getRandomEmoji() -> Character { emojiPool.randomElement() ?? "🐯" } diff --git a/COMFIETests/EmojiStringTests.swift b/COMFIETests/EmojiStringTests.swift new file mode 100644 index 0000000..50cf923 --- /dev/null +++ b/COMFIETests/EmojiStringTests.swift @@ -0,0 +1,158 @@ +@testable import COMFIE +import Testing + +struct EmojiStringTests { + + // 시나리오: snapshotInitPreservesOriginalAndEmojiView 동작을 검증합니다. + @Test func snapshotInitPreservesOriginalAndEmojiView() { + let emojiString = EmojiString(originalText: "ab!", emojiText: "😀b!") + + #expect(emojiString.getOriginalString() == "ab!") + #expect(emojiString.getEmojiString() == "😀b!") + } + + // 시나리오: setUnassignedEmojisSkipsSpaceAndConvertsDigits 동작을 검증합니다. + @Test func setUnassignedEmojisSkipsSpaceAndConvertsDigits() { + var emojiString = EmojiString(originalText: "a 1!", emojiText: "a 1!") + + emojiString.setUnassignedEmojis() + + let result = Array(emojiString.getEmojiString()) + #expect(result.count == 4) + #expect(result[0] != "a") + #expect(result[1] == " ") + #expect(result[2] != "1") + #expect(result[3] != "!") + } + + // 시나리오: setUnassignedEmojisConvertsUnicodeLettersAcrossScripts 동작을 검증합니다. + @Test func setUnassignedEmojisConvertsUnicodeLettersAcrossScripts() { + let original = "éЖ中あ" + var emojiString = EmojiString(originalText: original, emojiText: original) + + emojiString.setUnassignedEmojis() + + let result = Array(emojiString.getEmojiString()) + let expected = Array(original) + #expect(result.count == expected.count) + for index in expected.indices { + #expect(result[index] != expected[index]) + } + } + + // 시나리오: setUnassignedEmojisConvertsUnicodeDigitsAcrossScripts 동작을 검증합니다. + @Test func setUnassignedEmojisConvertsUnicodeDigitsAcrossScripts() { + let original = "١23" + var emojiString = EmojiString(originalText: original, emojiText: original) + + emojiString.setUnassignedEmojis() + + let result = Array(emojiString.getEmojiString()) + let expected = Array(original) + #expect(result.count == expected.count) + for index in expected.indices { + #expect(result[index] != expected[index]) + } + } + + // 시나리오: setUnassignedEmojisConvertsUnicodePunctuationAndSymbols 동작을 검증합니다. + @Test func setUnassignedEmojisConvertsUnicodePunctuationAndSymbols() { + let original = "。!∞₩" + var emojiString = EmojiString(originalText: original, emojiText: original) + + emojiString.setUnassignedEmojis() + + let result = Array(emojiString.getEmojiString()) + let expected = Array(original) + #expect(result.count == expected.count) + for index in expected.indices { + #expect(result[index] != expected[index]) + } + } + + // 시나리오: setUnassignedEmojisConvertsDecomposedAccentAsSingleCharacter 동작을 검증합니다. + @Test func setUnassignedEmojisConvertsDecomposedAccentAsSingleCharacter() { + let decomposed = "e\u{0301}" + var emojiString = EmojiString(originalText: decomposed, emojiText: decomposed) + + emojiString.setUnassignedEmojis() + + let converted = emojiString.getEmojiString() + #expect(Array(decomposed).count == 1) + #expect(Array(converted).count == 1) + #expect(converted != decomposed) + } + + // 시나리오: setUnassignedEmojisPreservesWhitespaceAndExistingEmoji 동작을 검증합니다. + @Test func setUnassignedEmojisPreservesWhitespaceAndExistingEmoji() { + let original = " \n😀" + var emojiString = EmojiString(originalText: original, emojiText: original) + + emojiString.setUnassignedEmojis() + + #expect(emojiString.getEmojiString() == original) + } + + // 시나리오: snapshotInitHandlesLengthMismatchWithoutCrash 동작을 검증합니다. + @Test func snapshotInitHandlesLengthMismatchWithoutCrash() { + let emojiString = EmojiString(originalText: "abc", emojiText: "😀") + + #expect(emojiString.getOriginalString() == "abc") + #expect(emojiString.getEmojiString().count == 3) + } + + // 시나리오: normalizedForPersistKeepsOriginalLengthOnMismatch 동작을 검증합니다. + @Test func normalizedForPersistKeepsOriginalLengthOnMismatch() { + var emojiString = EmojiString.normalizedForPersist(originalText: "ab1", preferredEmojiText: "😀") + emojiString.setUnassignedEmojis() + + let emoji = Array(emojiString.getEmojiString()) + #expect(emojiString.getOriginalString() == "ab1") + #expect(emoji.count == 3) + #expect(emoji[2] != "1") + } + + // 시나리오: mergedEmojiTextPreservingUnchangedSupportsTailAppendFastPath 동작을 검증합니다. + @Test func mergedEmojiTextPreservingUnchangedSupportsTailAppendFastPath() { + let merged = EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: "ab", + previousEmojiText: "😀😃", + newOriginalText: "abc" + ) + + #expect(merged == "😀😃c") + } + + // 시나리오: mergedEmojiTextPreservingUnchangedSupportsMiddleInsert 동작을 검증합니다. + @Test func mergedEmojiTextPreservingUnchangedSupportsMiddleInsert() { + let merged = EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: "abc", + previousEmojiText: "😀😃😄", + newOriginalText: "abxc" + ) + + #expect(merged == "😀😃x😄") + } + + // 시나리오: mergedEmojiTextPreservingUnchangedSupportsMiddleDelete 동작을 검증합니다. + @Test func mergedEmojiTextPreservingUnchangedSupportsMiddleDelete() { + let merged = EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: "abc", + previousEmojiText: "😀😃😄", + newOriginalText: "ac" + ) + + #expect(merged == "😀😄") + } + + // 시나리오: mergedEmojiTextPreservingUnchangedSupportsMiddleReplace 동작을 검증합니다. + @Test func mergedEmojiTextPreservingUnchangedSupportsMiddleReplace() { + let merged = EmojiString.mergedEmojiTextPreservingUnchanged( + previousOriginalText: "abc", + previousEmojiText: "😀😃😄", + newOriginalText: "axc" + ) + + #expect(merged == "😀x😄") + } +} diff --git a/COMFIETests/MemoInputIMEInsertionTests.swift b/COMFIETests/MemoInputIMEInsertionTests.swift new file mode 100644 index 0000000..2bfae2f --- /dev/null +++ b/COMFIETests/MemoInputIMEInsertionTests.swift @@ -0,0 +1,345 @@ +@testable import COMFIE +import CoreLocation +import SwiftUI +import Testing +import UIKit + +private struct EmptyComfieZoneRepositoryForIME: ComfieZoneRepositoryProtocol { + func fetchComfieZone() -> ComfieZone? { nil } + func saveComfieZone(_ comfieZone: ComfieZone) {} + func deleteComfieZone() {} +} + +private final class StaticLocationUseCaseForIME: LocationUseCase { + init() { + super.init(locationService: LocationService(), comfiZoneRepository: EmptyComfieZoneRepositoryForIME()) + } + + override func isInComfieZone(_ location: CLLocation?) -> Bool { false } +} + +@MainActor +struct MemoInputIMEInsertionTests { + // 시나리오: singleCharacterTypingConvertsPreviousCharacterAndDefersCurrentCharacter 동작을 검증합니다. + @Test func singleCharacterTypingConvertsPreviousCharacterAndDefersCurrentCharacter() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: "a" + ) + textView.text = "a" + textView.selectedRange = NSRange(location: 1, length: 0) + coordinator.textViewDidChange(textView) + let firstAfterA = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + #expect(firstAfterA == nil) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 1, length: 0), + replacementText: "b" + ) + textView.text = "ab" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + let firstAfterB = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + let secondAfterB = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(firstAfterB is NSTextAttachment) + #expect(secondAfterB == nil) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 2, length: 0), + replacementText: "c" + ) + textView.text = "abc" + textView.selectedRange = NSRange(location: 3, length: 0) + coordinator.textViewDidChange(textView) + let secondAfterC = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + let thirdAfterC = textView.textStorage.attribute(.attachment, at: 2, effectiveRange: nil) + #expect(secondAfterC is NSTextAttachment) + #expect(thirdAfterC == nil) + _ = placeholderLabel + } + + // 시나리오: hangulCompositionConvertsPreviousSyllableWhenNextInputStarts 동작을 검증합니다. + @Test func hangulCompositionConvertsPreviousSyllableWhenNextInputStarts() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + textView.attributedText = NSAttributedString(string: "밖ㅇ") + textView.selectedRange = NSRange(location: 2, length: 0) + + coordinator.handleMarkedRange(in: textView, marked: NSRange(location: 1, length: 1)) + + let firstAttachment = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + let secondAttachment = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(firstAttachment is NSTextAttachment) + #expect(secondAttachment == nil) + _ = placeholderLabel + } + + // 시나리오: middleCursorSingleTypingUsesSameDeferredRule 동작을 검증합니다. + @Test func middleCursorSingleTypingUsesSameDeferredRule() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + textView.text = "ab" + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 1, length: 0), + replacementText: "x" + ) + textView.text = "axb" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + + let firstAfterX = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + let insertedX = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(firstAfterX is NSTextAttachment) + #expect(insertedX == nil) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 2, length: 0), + replacementText: "y" + ) + textView.text = "axyb" + textView.selectedRange = NSRange(location: 3, length: 0) + coordinator.textViewDidChange(textView) + + let xAfterY = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + let insertedY = textView.textStorage.attribute(.attachment, at: 2, effectiveRange: nil) + #expect(xAfterY is NSTextAttachment) + #expect(insertedY == nil) + _ = placeholderLabel + } + + // 시나리오: singleGraphemeWithMultipleScalarsStillUsesDeferredRule 동작을 검증합니다. + @Test func singleGraphemeWithMultipleScalarsStillUsesDeferredRule() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + let decomposedGrapheme = "e\u{0301}" + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: decomposedGrapheme + ) + textView.text = decomposedGrapheme + textView.selectedRange = NSRange(location: (decomposedGrapheme as NSString).length, length: 0) + coordinator.textViewDidChange(textView) + + #expect(attachmentCount(in: textView.textStorage) == 0) + + let appendedText = decomposedGrapheme + "b" + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: (decomposedGrapheme as NSString).length, length: 0), + replacementText: "b" + ) + textView.text = appendedText + textView.selectedRange = NSRange(location: (appendedText as NSString).length, length: 0) + coordinator.textViewDidChange(textView) + + #expect(attachmentCount(in: textView.textStorage) == 1) + let lastAttribute = textView.textStorage.attribute( + .attachment, + at: textView.textStorage.length - 1, + effectiveRange: nil + ) + #expect(lastAttribute == nil) + _ = placeholderLabel + } + + // 시나리오: unicodePunctuationStillFollowsDeferredConversionRule 동작을 검증합니다. + @Test func unicodePunctuationStillFollowsDeferredConversionRule() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: "。" + ) + textView.text = "。" + textView.selectedRange = NSRange(location: 1, length: 0) + coordinator.textViewDidChange(textView) + let firstAfterPunctuation = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + #expect(firstAfterPunctuation == nil) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 1, length: 0), + replacementText: "a" + ) + textView.text = "。a" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + let punctuationAfterSecondInput = textView.textStorage.attribute(.attachment, at: 0, effectiveRange: nil) + let secondCharacterAfterInput = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(punctuationAfterSecondInput is NSTextAttachment) + #expect(secondCharacterAfterInput == nil) + _ = placeholderLabel + } + + // 시나리오: endEditingFlushesDeferredLastCharacter 동작을 검증합니다. + @Test func endEditingFlushesDeferredLastCharacter() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: "a" + ) + textView.text = "a" + textView.selectedRange = NSRange(location: 1, length: 0) + coordinator.textViewDidChange(textView) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 1, length: 0), + replacementText: "b" + ) + textView.text = "ab" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + + let secondBeforeEndEditing = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(secondBeforeEndEditing == nil) + + coordinator.textViewDidEndEditing(textView) + + let secondAfterEndEditing = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(secondAfterEndEditing is NSTextAttachment) + _ = placeholderLabel + } + + // 시나리오: nilFinalSyncRequestDoesNotLeakSkipOnceToNextEndEditing 동작을 검증합니다. + @Test func nilFinalSyncRequestDoesNotLeakSkipOnceToNextEndEditing() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: "a" + ) + textView.text = "a" + textView.selectedRange = NSRange(location: 1, length: 0) + coordinator.textViewDidChange(textView) + + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 1, length: 0), + replacementText: "b" + ) + textView.text = "ab" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + + let secondBeforeFinalSync = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(secondBeforeFinalSync == nil) + + coordinator.textView = nil + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + coordinator.parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: .empty, + isEmojiPresentationEnabled: true, + uiCommandEvent: MemoInputUIEvent(command: .requestFinalSyncAndResign(requestID: UUID())), + onOutputEvent: { _ in } + ) + coordinator.applyStateToTextView(force: false) + + coordinator.textView = textView + coordinator.textViewDidEndEditing(textView) + + let secondAfterEndEditing = textView.textStorage.attribute(.attachment, at: 1, effectiveRange: nil) + #expect(secondAfterEndEditing is NSTextAttachment) + _ = placeholderLabel + } + + // 시나리오: plainModeShouldChangeClearsDeferredChangeQueue 동작을 검증합니다. + @Test func plainModeShouldChangeClearsDeferredChangeQueue() { + let (coordinator, textView, placeholderLabel) = makeIMECoordinator() + coordinator.debugInjectChangeForTesting( + range: NSRange(location: 0, length: 1), + replacementLength: 2, + asDeferred: true + ) + #expect(coordinator.debugHasPendingOrDeferredChangeForTesting()) + + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + coordinator.parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: .empty, + isEmojiPresentationEnabled: false, + uiCommandEvent: nil, + onOutputEvent: { _ in } + ) + _ = coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 0, length: 0), + replacementText: "x" + ) + #expect(!coordinator.debugHasPendingOrDeferredChangeForTesting()) + + coordinator.parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: .empty, + isEmojiPresentationEnabled: true, + uiCommandEvent: nil, + onOutputEvent: { _ in } + ) + textView.text = "ab" + textView.selectedRange = NSRange(location: 2, length: 0) + coordinator.textViewDidChange(textView) + #expect(!coordinator.debugHasPendingOrDeferredChangeForTesting()) + _ = placeholderLabel + } +} + +@MainActor +private func makeIMECoordinator() -> (MemoInputUITextView.Coordinator, UITextView, UILabel) { + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + let parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: .empty, + isEmojiPresentationEnabled: true, + uiCommandEvent: nil, + onOutputEvent: { _ in } + ) + let coordinator = MemoInputUITextView.Coordinator(parent: parent) + + let textView = UITextView(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) + textView.font = parent.comfieUIBodyFont + textView.textContainerInset = UIEdgeInsets(top: 9, left: 12, bottom: 9, right: 8) + textView.delegate = coordinator + coordinator.textView = textView + let placeholderLabel = UILabel() + coordinator.placeholderLabel = placeholderLabel + + return (coordinator, textView, placeholderLabel) +} + +private func attachmentCount(in storage: NSAttributedString) -> Int { + guard storage.length > 0 else { return 0 } + var count = 0 + for index in 0.. Result { + savedMemos.append(memo) + memos.append(memo) + return .success(()) + } + + func fetchAllMemos() -> Result<[Memo], Error> { + .success(memos) + } + + func update(memo: Memo) -> Result { + updatedMemos.append(memo) + if let index = memos.firstIndex(where: { $0.id == memo.id }) { + memos[index] = memo + } + return .success(()) + } + + func delete(memo: Memo) -> Result { + memos.removeAll { $0.id == memo.id } + return .success(()) + } + + func deleteAll() { + memos.removeAll() + } +} + +private struct MappingComfieZoneRepository: ComfieZoneRepositoryProtocol { + func fetchComfieZone() -> ComfieZone? { nil } + func saveComfieZone(_ comfieZone: ComfieZone) {} + func deleteComfieZone() {} +} + +private final class AlwaysInComfieZoneLocationUseCaseForMapping: LocationUseCase { + override func isInComfieZone(_ location: CLLocation?) -> Bool { true } +} + +@MainActor +struct MemoStoreComfieZoneMappingTests { + // 시나리오: plainModeDeletingCharacterRemovesEmojiAtSamePositionOnUpdate 동작을 검증합니다. + @Test func plainModeDeletingCharacterRemovesEmojiAtSamePositionOnUpdate() async throws { + let repository = MappingMemoRepositorySpy() + let existingMemo = Memo( + id: UUID(), + createdAt: .now, + originalText: "abc", + emojiText: "😀😃😄" + ) + repository.memos = [existingMemo] + + let store = makeMemoStoreForComfieZoneMapping(repository: repository) + store.handleIntent(.onAppear) + store.handleIntent(.memoCell(.editButtonTapped(existingMemo))) + + let requestID = try #require(beginSaveRequestIDForComfieZoneMapping(store)) + completeFinalSyncForComfieZoneMapping( + store, + requestID: requestID, + originalText: "ac", + emojiText: "😀😄" + ) + + try await waitUntilForComfieZoneMapping(timeoutTick: 40) { + repository.updatedMemos.count == 1 + } + + let updatedMemo = try #require(repository.updatedMemos.first) + #expect(updatedMemo.originalText == "ac") + #expect(updatedMemo.emojiText == "😀😄") + } + + // 시나리오: plainModeReplacingCharacterRemapsEmojiAtEditedPositionOnUpdate 동작을 검증합니다. + @Test func plainModeReplacingCharacterRemapsEmojiAtEditedPositionOnUpdate() async throws { + let repository = MappingMemoRepositorySpy() + let existingMemo = Memo( + id: UUID(), + createdAt: .now, + originalText: "abc", + emojiText: "😀😃😄" + ) + repository.memos = [existingMemo] + + let store = makeMemoStoreForComfieZoneMapping(repository: repository) + store.handleIntent(.onAppear) + store.handleIntent(.memoCell(.editButtonTapped(existingMemo))) + + let requestID = try #require(beginSaveRequestIDForComfieZoneMapping(store)) + completeFinalSyncForComfieZoneMapping( + store, + requestID: requestID, + originalText: "axc", + emojiText: "😀x😄" + ) + + try await waitUntilForComfieZoneMapping(timeoutTick: 40) { + repository.updatedMemos.count == 1 + } + + let updatedMemo = try #require(repository.updatedMemos.first) + let emojiCharacters = Array(updatedMemo.emojiText) + + #expect(updatedMemo.originalText == "axc") + #expect(emojiCharacters.count == 3) + #expect(emojiCharacters[0] == "😀") + #expect(emojiCharacters[2] == "😄") + #expect(emojiCharacters[1] != "x") + } +} + +private func makeMemoStoreForComfieZoneMapping(repository: MemoRepositoryProtocol) -> MemoStore { + let locationUseCase = AlwaysInComfieZoneLocationUseCaseForMapping( + locationService: LocationService(), + comfiZoneRepository: MappingComfieZoneRepository() + ) + + return MemoStore( + router: Router(), + memoRepository: repository, + locationUseCase: locationUseCase + ) +} + +private func beginSaveRequestIDForComfieZoneMapping(_ store: MemoStore) -> UUID? { + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync(let requestID) = store.state.savePhase else { + return nil + } + return requestID +} + +private func completeFinalSyncForComfieZoneMapping( + _ store: MemoStore, + requestID: UUID, + originalText: String, + emojiText: String +) { + store.handleIntent( + .memoInput( + .finalSyncCompleted( + requestID: requestID, + snapshot: .init( + originalText: originalText, + emojiText: emojiText + ) + ) + ) + ) +} + +private func waitUntilForComfieZoneMapping( + timeoutTick: Int, + condition: @escaping () -> Bool +) async throws { + for _ in 0.. = .success(()) + var updateResult: Result = .success(()) + + func save(memo: Memo) -> Result { + saveCallCount += 1 + + switch saveResult { + case .success: + savedMemos.append(memo) + memos.append(memo) + return .success(()) + case .failure(let error): + return .failure(error) + } + } + + func fetchAllMemos() -> Result<[Memo], Error> { + .success(memos) + } + + func update(memo: Memo) -> Result { + updateCallCount += 1 + + switch updateResult { + case .success: + updatedMemos.append(memo) + if let index = memos.firstIndex(where: { $0.id == memo.id }) { + memos[index] = memo + } + return .success(()) + case .failure(let error): + return .failure(error) + } + } + + func delete(memo: Memo) -> Result { + memos.removeAll { $0.id == memo.id } + return .success(()) + } + + func deleteAll() { + memos.removeAll() + } +} + +private struct TestComfieZoneRepository: ComfieZoneRepositoryProtocol { + func fetchComfieZone() -> ComfieZone? { nil } + func saveComfieZone(_ comfieZone: ComfieZone) {} + func deleteComfieZone() {} +} + +private final class AlwaysInComfieZoneLocationUseCase: LocationUseCase { + override func isInComfieZone(_ location: CLLocation?) -> Bool { true } +} + +private final class ToggleComfieZoneLocationUseCase: LocationUseCase { + private let locationSubject = PassthroughSubject() + private var isInZone: Bool + + override var currentLocationPublisher: AnyPublisher { + locationSubject.eraseToAnyPublisher() + } + + init(initialInComfieZone: Bool) { + self.isInZone = initialInComfieZone + super.init( + locationService: LocationService(), + comfiZoneRepository: TestComfieZoneRepository() + ) + } + + override func isInComfieZone(_ location: CLLocation?) -> Bool { + isInZone + } + + func setInComfieZone(_ isInComfieZone: Bool) { + isInZone = isInComfieZone + locationSubject.send(CLLocation(latitude: 37.0, longitude: 127.0)) + } +} + +private final class StuckMarkedTextView: UITextView { + private final class StubTextPosition: UITextPosition {} + private final class StubTextRange: UITextRange { + private let startPosition = StubTextPosition() + private let endPosition = StubTextPosition() + override var start: UITextPosition { startPosition } + override var end: UITextPosition { endPosition } + override var isEmpty: Bool { false } + } + + var keepsMarkedRange = false + private let stubRange = StubTextRange() + private(set) var unmarkCallCount = 0 + + override var markedTextRange: UITextRange? { + keepsMarkedRange ? stubRange : super.markedTextRange + } + + override func unmarkText() { + unmarkCallCount += 1 + super.unmarkText() + } +} + +@MainActor +private struct MemoInputCoordinatorHarness { + let coordinator: MemoInputUITextView.Coordinator + let textView: UITextView + let placeholderLabel: UILabel + let dynamicHeightBinding: Binding + let store: MemoStore + let repository: MemoRepositorySpy +} + +@MainActor +struct MemoStoreInputSnapshotTests { + + // 시나리오: saveTappedRequestsFinalSyncAndDefersPersist 동작을 검증합니다. + @Test func saveTappedRequestsFinalSyncAndDefersPersist() throws { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + var cancellables = Set() + var receivedRequestID: UUID? + + store.uiSideEffectPublisher + .sink { sideEffect in + if case .requestFinalSyncAndResign(let requestID) = sideEffect { + receivedRequestID = requestID + } + } + .store(in: &cancellables) + + publishDraftAvailability(store, isEmpty: false) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + + let requestID = try #require(receivedRequestID) + #expect(repository.saveCallCount == 0) + #expect(repository.savedMemos.isEmpty) + assertAwaitingFinalSync(store, requestID: requestID) + _ = cancellables + } + + // 시나리오: saveTappedWithEmptyInputDoesNotStartFinalSync 동작을 검증합니다. + @Test func saveTappedWithEmptyInputDoesNotStartFinalSync() { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + var cancellables = Set() + var requestCount = 0 + + store.uiSideEffectPublisher + .sink { sideEffect in + if case .requestFinalSyncAndResign = sideEffect { + requestCount += 1 + } + } + .store(in: &cancellables) + + store.handleIntent(.memoInput(.memoInputButtonTapped)) + + #expect(requestCount == 0) + #expect(repository.saveCallCount == 0) + #expect(repository.updateCallCount == 0) + #expect(store.state.savePhase == .idle) + _ = cancellables + } + + // 시나리오: finalSyncCompletedWithMismatchedRequestIDDoesNotPersist 동작을 검증합니다. + @Test func finalSyncCompletedWithMismatchedRequestIDDoesNotPersist() throws { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + + publishDraftAvailability(store, isEmpty: false) + let requestID = try #require(beginSaveRequestID(store)) + + completeFinalSync( + store, + requestID: UUID(), + originalText: "a1", + emojiText: "a1" + ) + + #expect(repository.saveCallCount == 0) + #expect(repository.savedMemos.isEmpty) + assertAwaitingFinalSync(store, requestID: requestID) + } + + // 시나리오: finalSyncFailedMatchingRequestIDReturnsIdle 동작을 검증합니다. + @Test func finalSyncFailedMatchingRequestIDReturnsIdle() throws { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + + publishDraftAvailability(store, isEmpty: false) + let requestID = try #require(beginSaveRequestID(store)) + store.handleIntent(.memoInput(.finalSyncFailed(requestID: requestID))) + + #expect(store.state.savePhase == .idle) + #expect(repository.saveCallCount == 0) + } + + // 시나리오: finalSyncFailedWithMismatchedRequestIDIsIgnored 동작을 검증합니다. + @Test func finalSyncFailedWithMismatchedRequestIDIsIgnored() throws { + let store = makeMemoStore(repository: MemoRepositorySpy()) + + publishDraftAvailability(store, isEmpty: false) + let requestID = try #require(beginSaveRequestID(store)) + store.handleIntent(.memoInput(.finalSyncFailed(requestID: UUID()))) + + assertAwaitingFinalSync(store, requestID: requestID) + } + + // 시나리오: emptyFinalSnapshotDoesNotPersist 동작을 검증합니다. + @Test func emptyFinalSnapshotDoesNotPersist() throws { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + + publishDraftAvailability(store, isEmpty: false) + let requestID = try #require(beginSaveRequestID(store)) + completeFinalSync(store, requestID: requestID, originalText: "", emojiText: "") + + #expect(repository.saveCallCount == 0) + #expect(repository.updateCallCount == 0) + #expect(store.state.isInputEmpty) + #expect(store.state.savePhase == .idle) + } + + // 시나리오: saveFinalizesConvertibleCharactersFromSnapshot 동작을 검증합니다. + @Test func saveFinalizesConvertibleCharactersFromSnapshot() async throws { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + + publishDraftAvailability(store, isEmpty: false) + let requestID = try #require(beginSaveRequestID(store)) + completeFinalSync( + store, + requestID: requestID, + originalText: "a1", + emojiText: "a1" + ) + + try await waitUntil(timeoutTick: 40) { + repository.savedMemos.count == 1 + } + + let savedMemo = try #require(repository.savedMemos.first) + let savedEmojiCharacters = Array(savedMemo.emojiText) + + #expect(savedMemo.originalText == "a1") + #expect(savedEmojiCharacters.count == 2) + #expect(savedEmojiCharacters[0] != "a") + #expect(savedEmojiCharacters[1] != "1") + #expect(store.state.inputSeed.originalText.isEmpty) + #expect(store.state.inputSeed.emojiText.isEmpty) + #expect(store.state.savePhase == .idle) + } + + // 시나리오: updateFailureReturnsToIdleAndKeepsEditingContext 동작을 검증합니다. + @Test func updateFailureReturnsToIdleAndKeepsEditingContext() throws { + let repository = MemoRepositorySpy() + repository.updateResult = .failure(MemoRepositorySpyError.forcedFailure) + let existingMemo = Memo(id: UUID(), createdAt: .now, originalText: "ab", emojiText: "😀😃") + repository.memos = [existingMemo] + + let store = makeMemoStore(repository: repository) + store.handleIntent(.onAppear) + store.handleIntent(.memoCell(.editButtonTapped(existingMemo))) + + let requestID = try #require(beginSaveRequestID(store)) + completeFinalSync(store, requestID: requestID, originalText: "abc", emojiText: "abc") + + #expect(repository.updateCallCount == 1) + #expect(store.state.savePhase == .idle) + #expect(store.state.editingMemo?.id == existingMemo.id) + } + + // 시나리오: saveTappedTwiceBeforeFinalSyncEmitsSingleRequest 동작을 검증합니다. + @Test func saveTappedTwiceBeforeFinalSyncEmitsSingleRequest() { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + var cancellables = Set() + var requestIDs: [UUID] = [] + + store.uiSideEffectPublisher + .sink { sideEffect in + guard case .requestFinalSyncAndResign(let requestID) = sideEffect else { return } + requestIDs.append(requestID) + } + .store(in: &cancellables) + + publishDraftAvailability(store, isEmpty: false) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + + #expect(requestIDs.count == 1) + #expect(repository.saveCallCount == 0) + #expect(repository.updateCallCount == 0) + #expect(store.state.savePhase != .idle) + _ = cancellables + } + + // 시나리오: editingCancelResetsInputSeedAndRequestsResignSideEffect 동작을 검증합니다. + @Test func editingCancelResetsInputSeedAndRequestsResignSideEffect() { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "ab", + emojiText: "😀😃" + ) + + var cancellables = Set() + var resignSideEffectCount = 0 + store.uiSideEffectPublisher + .sink { sideEffect in + if case .resignInputFocusWithoutSync = sideEffect { + resignSideEffectCount += 1 + } + } + .store(in: &cancellables) + + store.handleIntent(.memoCell(.editButtonTapped(memo))) + #expect(store.state.editingMemo != nil) + store.handleIntent(.memoCell(.editingCancelButtonTapped)) + + #expect(store.state.editingMemo == nil) + #expect(store.state.inputSeed.originalText.isEmpty) + #expect(store.state.inputSeed.emojiText.isEmpty) + #expect(store.state.savePhase == .idle) + #expect(resignSideEffectCount == 1) + _ = cancellables + } + + // 시나리오: startEditingIncrementsInputSeedTokenOncePerEdit 동작을 검증합니다. + @Test func startEditingIncrementsInputSeedTokenOncePerEdit() { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + let firstMemo = Memo( + id: UUID(), + createdAt: .now, + originalText: "ab", + emojiText: "😀😃" + ) + let secondMemo = Memo( + id: UUID(), + createdAt: .now, + originalText: "cd", + emojiText: "😄😁" + ) + + let initialToken = store.state.inputSeed.token + + store.handleIntent(.memoCell(.editButtonTapped(firstMemo))) + #expect(store.state.inputSeed.token == initialToken + 1) + #expect(store.state.editingMemo?.id == firstMemo.id) + #expect(store.state.inputSeed.originalText == firstMemo.originalText) + #expect(store.state.inputSeed.emojiText == firstMemo.emojiText) + + store.handleIntent(.memoCell(.editButtonTapped(secondMemo))) + #expect(store.state.inputSeed.token == initialToken + 2) + #expect(store.state.editingMemo?.id == secondMemo.id) + #expect(store.state.inputSeed.originalText == secondMemo.originalText) + #expect(store.state.inputSeed.emojiText == secondMemo.emojiText) + } + + // 시나리오: setFocusWithSeedRefreshPlacesCursorAtTextEndWhenEditingMemo 동작을 검증합니다. + @Test func setFocusWithSeedRefreshPlacesCursorAtTextEndWhenEditingMemo() throws { + let repository = MemoRepositorySpy() + let harness = makeMemoInputCoordinator(repository: repository) + let store = harness.store + let textView = harness.textView + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "ab", + emojiText: "😀😃" + ) + + var cancellables = Set() + var latestCommand: MemoInputUICommand? + store.uiSideEffectPublisher + .sink { sideEffect in + latestCommand = mapMemoInputCommand(sideEffect) + } + .store(in: &cancellables) + + textView.selectedRange = NSRange(location: 0, length: 0) + store.handleIntent(.memoCell(.editButtonTapped(memo))) + + let command = try #require(latestCommand) + #expect(command == .setFocus) + + applyMemoInputCommand(harness, command: command) + + #expect(textView.selectedRange.location == textView.textStorage.length) + #expect(textView.selectedRange.length == 0) + _ = cancellables + } + + // 시나리오: requestFinalSyncWithNilTextViewUsesSeedFallback 동작을 검증합니다. + @Test func requestFinalSyncWithNilTextViewUsesSeedFallback() async throws { + let repository = MemoRepositorySpy() + let existingMemo = Memo(id: UUID(), createdAt: .now, originalText: "ab", emojiText: "😀😃") + repository.memos = [existingMemo] + + let harness = makeMemoInputCoordinator(repository: repository) + let coordinator = harness.coordinator + let store = harness.store + + store.handleIntent(.onAppear) + store.handleIntent(.memoCell(.editButtonTapped(existingMemo))) + coordinator.textView = nil + + var cancellables = Set() + var latestCommand: MemoInputUICommand? + store.uiSideEffectPublisher + .sink { sideEffect in + latestCommand = mapMemoInputCommand(sideEffect) + } + .store(in: &cancellables) + + let requestID = try #require(beginSaveRequestID(store)) + let command = try #require(latestCommand) + if case .requestFinalSyncAndResign(let commandRequestID) = command { + #expect(commandRequestID == requestID) + } else { + Issue.record("final sync command was not captured") + } + applyMemoInputCommand(harness, command: command) + + try await waitUntil(timeoutTick: 40) { + repository.updatedMemos.count == 1 + } + + let updatedMemo = try #require(repository.updatedMemos.first) + #expect(updatedMemo.originalText == "ab") + #expect(updatedMemo.emojiText == "😀😃") + #expect(store.state.savePhase == .idle) + _ = requestID + _ = cancellables + } + + // 시나리오: requestFinalSyncWithNilTextViewIgnoresStaleCoordinatorDraft 동작을 검증합니다. + @Test func requestFinalSyncWithNilTextViewIgnoresStaleCoordinatorDraft() async throws { + let repository = MemoRepositorySpy() + let existingMemo = Memo(id: UUID(), createdAt: .now, originalText: "ab", emojiText: "😀😃") + repository.memos = [existingMemo] + + let harness = makeMemoInputCoordinator(repository: repository) + let coordinator = harness.coordinator + let store = harness.store + + store.handleIntent(.onAppear) + store.handleIntent(.memoCell(.editButtonTapped(existingMemo))) + coordinator.debugInjectDraftForTesting(original: "stale", emoji: "🙃🙃🙃🙃🙃") + coordinator.textView = nil + + var cancellables = Set() + var latestCommand: MemoInputUICommand? + store.uiSideEffectPublisher + .sink { sideEffect in + latestCommand = mapMemoInputCommand(sideEffect) + } + .store(in: &cancellables) + _ = try #require(beginSaveRequestID(store)) + let command = try #require(latestCommand) + applyMemoInputCommand(harness, command: command) + + try await waitUntil(timeoutTick: 40) { + repository.updatedMemos.count == 1 + } + + let updatedMemo = try #require(repository.updatedMemos.first) + #expect(updatedMemo.originalText == "ab") + #expect(updatedMemo.emojiText == "😀😃") + #expect(store.state.savePhase == .idle) + _ = cancellables + } + + // 시나리오: requestFinalSyncFailsWhenMarkedTextCannotBeFinalized 동작을 검증합니다. + @Test func requestFinalSyncFailsWhenMarkedTextCannotBeFinalized() { + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + let requestID = UUID() + + var outputEvents: [MemoInputOutputEvent] = [] + let parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: MemoInputSeed(token: 1, originalText: "ab", emojiText: "😀😃"), + isEmojiPresentationEnabled: true, + uiCommandEvent: MemoInputUIEvent(command: .requestFinalSyncAndResign(requestID: requestID)), + onOutputEvent: { outputEvents.append($0) } + ) + let coordinator = MemoInputUITextView.Coordinator(parent: parent) + let textView = StuckMarkedTextView() + textView.font = parent.comfieUIBodyFont + textView.text = "ab" + textView.keepsMarkedRange = true + coordinator.textView = textView + + coordinator.applyStateToTextView(force: false) + + let failedRequestIDs = outputEvents.compactMap { event -> UUID? in + guard case .finalSnapshotFailed(let failedRequestID) = event else { return nil } + return failedRequestID + } + let readyEventsCount = outputEvents.compactMap { event -> MemoInputSnapshot? in + guard case .finalSnapshotReady(_, let snapshot) = event else { return nil } + return snapshot + }.count + + #expect(textView.unmarkCallCount == 1) + #expect(failedRequestIDs == [requestID]) + #expect(readyEventsCount == 0) + } + + // 시나리오: backgroundTappedRequestsResignWithoutPersist 동작을 검증합니다. + @Test func backgroundTappedRequestsResignWithoutPersist() { + let repository = MemoRepositorySpy() + let store = makeMemoStore(repository: repository) + var cancellables = Set() + var resignSideEffectCount = 0 + + store.uiSideEffectPublisher + .sink { sideEffect in + if case .resignInputFocusWithSyncInput = sideEffect { + resignSideEffectCount += 1 + } + } + .store(in: &cancellables) + + publishDraftAvailability(store, isEmpty: false) + store.handleIntent(.backgroundTapped) + + #expect(resignSideEffectCount == 1) + #expect(repository.saveCallCount == 0) + #expect(repository.updateCallCount == 0) + #expect(store.state.savePhase == .idle) + _ = cancellables + } + + // 시나리오: comfieZoneTogglePreservesInputSeed 동작을 검증합니다. + @Test func comfieZoneTogglePreservesInputSeed() async throws { + let repository = MemoRepositorySpy() + let locationUseCase = ToggleComfieZoneLocationUseCase(initialInComfieZone: false) + let store = makeMemoStore(repository: repository, locationUseCase: locationUseCase) + let memo = Memo(id: UUID(), createdAt: .now, originalText: "ab", emojiText: "😀😃") + + store.handleIntent(.memoCell(.editButtonTapped(memo))) + let originalSeed = store.state.inputSeed + + locationUseCase.setInComfieZone(true) + try await waitUntil(timeoutTick: 40) { + store.state.isInComfieZone + } + #expect(store.state.inputSeed == originalSeed) + + locationUseCase.setInComfieZone(false) + try await waitUntil(timeoutTick: 40) { + !store.state.isInComfieZone + } + #expect(store.state.inputSeed == originalSeed) + } + + // 시나리오: finalSyncSideEffectFromStoreIsHandledByCoordinator 동작을 검증합니다. + @Test func finalSyncSideEffectFromStoreIsHandledByCoordinator() async throws { + let harness = makeMemoInputCoordinator() + let coordinator = harness.coordinator + let textView = harness.textView + let store = harness.store + let repository = harness.repository + + var cancellables = Set() + var latestCommand: MemoInputUICommand? + store.uiSideEffectPublisher + .sink { sideEffect in + latestCommand = mapMemoInputCommand(sideEffect) + } + .store(in: &cancellables) + + textView.text = "a1" + coordinator.syncSnapshotToStore(textView) + + store.handleIntent(.memoInput(.memoInputButtonTapped)) + let command = try #require(latestCommand) + applyMemoInputCommand(harness, command: command) + + try await waitUntil(timeoutTick: 40) { + repository.savedMemos.count == 1 + } + + let savedMemo = try #require(repository.savedMemos.first) + let savedEmojiCharacters = Array(savedMemo.emojiText) + + #expect(savedMemo.originalText == "a1") + #expect(savedEmojiCharacters.count == 2) + #expect(savedEmojiCharacters[0] != "a") + #expect(savedEmojiCharacters[1] != "1") + #expect(store.state.savePhase == .idle) + _ = cancellables + } + + // 시나리오: finalSnapshotReadyUsesOnOutputEventAndPreservesRequestID 동작을 검증합니다. + @Test func finalSnapshotReadyUsesOnOutputEventAndPreservesRequestID() { + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + let requestID = UUID() + let seed = MemoInputSeed(token: 1, originalText: "ab", emojiText: "😀😃") + + var outputEvents: [MemoInputOutputEvent] = [] + let parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: seed, + isEmojiPresentationEnabled: true, + uiCommandEvent: MemoInputUIEvent(command: .requestFinalSyncAndResign(requestID: requestID)), + onOutputEvent: { outputEvents.append($0) } + ) + let coordinator = MemoInputUITextView.Coordinator(parent: parent) + coordinator.textView = nil + + coordinator.applyStateToTextView(force: false) + + let readyPayloads = outputEvents.compactMap { event -> (UUID, MemoInputSnapshot)? in + guard case .finalSnapshotReady(let eventRequestID, let snapshot) = event else { return nil } + return (eventRequestID, snapshot) + } + #expect(readyPayloads.count == 1) + #expect(readyPayloads.first?.0 == requestID) + #expect(readyPayloads.first?.1 == MemoInputSnapshot(originalText: "ab", emojiText: "😀😃")) + } + + // 시나리오: finalSnapshotFailedUsesOnOutputEventAndPreservesRequestID 동작을 검증합니다. + @Test func finalSnapshotFailedUsesOnOutputEventAndPreservesRequestID() { + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + let requestID = UUID() + + var outputEvents: [MemoInputOutputEvent] = [] + let parent = MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: .empty, + isEmojiPresentationEnabled: true, + uiCommandEvent: MemoInputUIEvent(command: .requestFinalSyncAndResign(requestID: requestID)), + onOutputEvent: { outputEvents.append($0) } + ) + let coordinator = MemoInputUITextView.Coordinator(parent: parent) + coordinator.textView = nil + + coordinator.applyStateToTextView(force: false) + + let failedRequestIDs = outputEvents.compactMap { event -> UUID? in + guard case .finalSnapshotFailed(let failedRequestID) = event else { return nil } + return failedRequestID + } + #expect(failedRequestIDs == [requestID]) + } +} + +private func publishDraftAvailability( + _ store: MemoStore, + isEmpty: Bool +) { + store.handleIntent( + .memoInput( + .draftAvailabilityChanged(isEmpty: isEmpty) + ) + ) +} + +private func beginSaveRequestID(_ store: MemoStore) -> UUID? { + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync(let requestID) = store.state.savePhase else { + return nil + } + return requestID +} + +private func completeFinalSync( + _ store: MemoStore, + requestID: UUID, + originalText: String, + emojiText: String +) { + store.handleIntent( + .memoInput( + .finalSyncCompleted( + requestID: requestID, + snapshot: .init( + originalText: originalText, + emojiText: emojiText + ) + ) + ) + ) +} + +private func assertAwaitingFinalSync(_ store: MemoStore, requestID: UUID) { + guard case .awaitingFinalSync(let pendingRequestID) = store.state.savePhase else { + Issue.record("savePhase가 awaitingFinalSync 상태가 아닙니다.") + return + } + #expect(pendingRequestID == requestID) +} + +private func makeMemoStore( + repository: MemoRepositoryProtocol, + isInComfieZone: Bool = false, + locationUseCase: LocationUseCase? = nil +) -> MemoStore { + let resolvedLocationUseCase: LocationUseCase + if let locationUseCase { + resolvedLocationUseCase = locationUseCase + } else if isInComfieZone { + resolvedLocationUseCase = AlwaysInComfieZoneLocationUseCase( + locationService: LocationService(), + comfiZoneRepository: TestComfieZoneRepository() + ) + } else { + resolvedLocationUseCase = LocationUseCase( + locationService: LocationService(), + comfiZoneRepository: TestComfieZoneRepository() + ) + } + + return MemoStore( + router: Router(), + memoRepository: repository, + locationUseCase: resolvedLocationUseCase + ) +} + +@MainActor +private func makeMemoInputCoordinator( + repository: MemoRepositorySpy = MemoRepositorySpy(), + isInComfieZone: Bool = false +) -> MemoInputCoordinatorHarness { + let store = makeMemoStore(repository: repository, isInComfieZone: isInComfieZone) + + var dynamicHeight: CGFloat = 40 + let dynamicHeightBinding = Binding( + get: { dynamicHeight }, + set: { dynamicHeight = $0 } + ) + + let parent = makeMemoInputParent( + store: store, + dynamicHeightBinding: dynamicHeightBinding, + uiCommandEvent: nil + ) + let coordinator = MemoInputUITextView.Coordinator(parent: parent) + + let textView = UITextView(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) + textView.font = parent.comfieUIBodyFont + textView.textContainerInset = UIEdgeInsets(top: 9, left: 12, bottom: 9, right: 8) + textView.isScrollEnabled = false + textView.translatesAutoresizingMaskIntoConstraints = false + let placeholderLabel = UILabel() + coordinator.textView = textView + textView.delegate = coordinator + coordinator.placeholderLabel = placeholderLabel + let heightConstraint = textView.heightAnchor.constraint(lessThanOrEqualToConstant: 120) + heightConstraint.isActive = true + coordinator.textViewHeightConstraint = heightConstraint + + return MemoInputCoordinatorHarness( + coordinator: coordinator, + textView: textView, + placeholderLabel: placeholderLabel, + dynamicHeightBinding: dynamicHeightBinding, + store: store, + repository: repository + ) +} + +@MainActor +private func applyMemoInputCommand( + _ harness: MemoInputCoordinatorHarness, + command: MemoInputUICommand +) { + harness.coordinator.parent = makeMemoInputParent( + store: harness.store, + dynamicHeightBinding: harness.dynamicHeightBinding, + uiCommandEvent: MemoInputUIEvent(command: command) + ) + harness.coordinator.applyStateToTextView(force: false) +} + +private func mapMemoInputCommand(_ sideEffect: MemoStore.SideEffect.MemoInput) -> MemoInputUICommand { + switch sideEffect { + case .resignInputFocusWithSyncInput: + return .resignWithSync + case .resignInputFocusWithoutSync: + return .resignWithoutSync + case .requestFinalSyncAndResign(let requestID): + return .requestFinalSyncAndResign(requestID: requestID) + case .setMemoInputFocus: + return .setFocus + } +} + +private func makeMemoInputParent( + store: MemoStore, + dynamicHeightBinding: Binding, + uiCommandEvent: MemoInputUIEvent? + ) -> MemoInputUITextView { + MemoInputUITextView( + "placeholder", + dynamicHeight: dynamicHeightBinding, + inputSeed: store.state.inputSeed, + isEmojiPresentationEnabled: store.state.isEmojiPresentationEnabled, + uiCommandEvent: uiCommandEvent, + onOutputEvent: { outputEvent in + switch outputEvent { + case .draftAvailabilityChanged(let isEmpty): + store.handleIntent( + .memoInput( + .draftAvailabilityChanged(isEmpty: isEmpty) + ) + ) + case .finalSnapshotReady(let requestID, let snapshot): + store.handleIntent( + .memoInput( + .finalSyncCompleted( + requestID: requestID, + snapshot: snapshot + ) + ) + ) + case .finalSnapshotFailed(let requestID): + store.handleIntent(.memoInput(.finalSyncFailed(requestID: requestID))) + } + } + ) +} + +private func waitUntil(timeoutTick: Int, condition: @escaping () -> Bool) async throws { + for _ in 0.. ComfieZone? { nil } + func saveComfieZone(_ comfieZone: ComfieZone) {} + func deleteComfieZone() {} +} + +private final class StaticComfieZoneLocationUseCase: LocationUseCase { + init() { + super.init(locationService: LocationService(), comfiZoneRepository: EmptyComfieZoneRepository()) + } + + override func isInComfieZone(_ location: CLLocation?) -> Bool { true } +} + +@MainActor +struct MemoStoreSavePhaseNavigationTests { + // 시나리오: retrospectionTapIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func retrospectionTapIsIgnoredWhileSaveInProgress() { + let router = Router() + let store = MemoStore( + router: router, + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + let memo = Memo(id: UUID(), createdAt: .now, originalText: "a", emojiText: "😀") + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before navigation guard check") + return + } + + store.handleIntent(.memoCell(.retrospectionButtonTapped(memo))) + + #expect(router.path.isEmpty) + } + + // 시나리오: deleteTapIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func deleteTapIsIgnoredWhileSaveInProgress() { + let store = MemoStore( + router: Router(), + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + let memo = Memo(id: UUID(), createdAt: .now, originalText: "a", emojiText: "😀") + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before delete guard check") + return + } + + store.handleIntent(.memoCell(.deleteButtonTapped(memo))) + + #expect(store.state.deletingMemo == nil) + } + + // 시나리오: comfieZoneSettingTapIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func comfieZoneSettingTapIsIgnoredWhileSaveInProgress() { + let router = Router() + let store = MemoStore( + router: router, + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before comfieZoneSetting guard check") + return + } + + store.handleIntent(.comfieZoneSettingButtonTapped) + + #expect(router.path.isEmpty) + } + + // 시나리오: moreTapIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func moreTapIsIgnoredWhileSaveInProgress() { + let router = Router() + let store = MemoStore( + router: router, + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before more guard check") + return + } + + store.handleIntent(.moreButtonTapped) + + #expect(router.path.isEmpty) + } + + // 시나리오: backgroundTapIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func backgroundTapIsIgnoredWhileSaveInProgress() { + let store = MemoStore( + router: Router(), + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + var cancellables = Set() + var resignSideEffectCount = 0 + + store.uiSideEffectPublisher + .sink { sideEffect in + if case .resignInputFocusWithSyncInput = sideEffect { + resignSideEffectCount += 1 + } + } + .store(in: &cancellables) + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before background tap guard check") + return + } + + store.handleIntent(.backgroundTapped) + + #expect(resignSideEffectCount == 0) + _ = cancellables + } + + // 시나리오: confirmDeletePopupIsIgnoredWhileSaveInProgress 동작을 검증합니다. + @Test func confirmDeletePopupIsIgnoredWhileSaveInProgress() { + let store = MemoStore( + router: Router(), + memoRepository: MockMemoRepository(), + locationUseCase: StaticComfieZoneLocationUseCase() + ) + let memo = Memo(id: UUID(), createdAt: .now, originalText: "a", emojiText: "😀") + + store.handleIntent(.memoCell(.deleteButtonTapped(memo))) + #expect(store.state.deletingMemo?.id == memo.id) + + store.handleIntent(.memoInput(.draftAvailabilityChanged(isEmpty: false))) + store.handleIntent(.memoInput(.memoInputButtonTapped)) + guard case .awaitingFinalSync = store.state.savePhase else { + Issue.record("savePhase should be awaitingFinalSync before popup confirm guard check") + return + } + + store.handleIntent(.deletePopup(.confirmDeleteButtonTapped)) + + #expect(store.state.deletingMemo?.id == memo.id) + } +} diff --git a/COMFIETests/RetrospectionEmojiMappingTests.swift b/COMFIETests/RetrospectionEmojiMappingTests.swift new file mode 100644 index 0000000..06b7fe7 --- /dev/null +++ b/COMFIETests/RetrospectionEmojiMappingTests.swift @@ -0,0 +1,191 @@ +@testable import COMFIE +import Foundation +import Testing + +private final class RetrospectionRepositorySpy: RetrospectionRepositoryProtocol { + var saveCallCount = 0 + var savedMemos: [Memo] = [] + var deletedMemos: [Memo] = [] + + func save(memo: Memo) -> Result { + saveCallCount += 1 + savedMemos.append(memo) + return .success(()) + } + + func delete(memo: Memo) -> Result { + deletedMemos.append(memo) + return .success(()) + } +} + +@MainActor +struct RetrospectionEmojiMappingTests { + @Test func onAppearDoesNotTriggerDebouncedSaveWithoutUserInput() async throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "ab", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + try await Task.sleep(nanoseconds: 700_000_000) + + #expect(repository.saveCallCount == 0) + #expect(repository.savedMemos.isEmpty) + } + + @Test func savePreservesEmojiOnAppend() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "ab", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("abc")) + store.handleIntent(.completeButtonTapped) + + let saved = try #require(repository.savedMemos.last) + let emoji = saved.emojiRetrospectionText ?? "" + + #expect(saved.originalRetrospectionText == "abc") + #expect(emoji.count == 3) + #expect(emoji.hasPrefix("😀😃")) + #expect(Array(emoji)[2] != "c") + } + + @Test func clearingRetrospectionSavesBothOriginalAndEmojiAsNil() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "ab", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("")) + store.handleIntent(.completeButtonTapped) + + let saved = try #require(repository.savedMemos.last) + #expect(saved.originalRetrospectionText == nil) + #expect(saved.emojiRetrospectionText == nil) + } + + @Test func savePreservesEmojiOnDelete() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "abc", + emojiRetrospectionText: "😀😃😄" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("ac")) + store.handleIntent(.completeButtonTapped) + + let saved = try #require(repository.savedMemos.last) + let emoji = saved.emojiRetrospectionText ?? "" + + #expect(saved.originalRetrospectionText == "ac") + #expect(emoji == "😀😄") + } + + @Test func saveKeepsEmojiWhenUnchanged() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "ab", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("ab")) + store.handleIntent(.completeButtonTapped) + + let saved = try #require(repository.savedMemos.last) + let emoji = saved.emojiRetrospectionText ?? "" + + #expect(saved.originalRetrospectionText == "ab") + #expect(emoji == "😀😃") + } + + @Test func saveNormalizesLengthMismatch() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "abcd", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("abcd")) + store.handleIntent(.completeButtonTapped) + + let saved = try #require(repository.savedMemos.last) + let emoji = saved.emojiRetrospectionText ?? "" + + #expect(saved.originalRetrospectionText == "abcd") + #expect(emoji.count == 4) + #expect(emoji.hasPrefix("😀😃")) + #expect(Array(emoji)[2] != "c") + #expect(Array(emoji)[3] != "d") + } + + @Test func saveUsesLatestBaselineAcrossConsecutiveSaves() throws { + let repository = RetrospectionRepositorySpy() + let memo = Memo( + id: UUID(), + createdAt: .now, + originalText: "", + emojiText: "", + originalRetrospectionText: "ab", + emojiRetrospectionText: "😀😃" + ) + let store = RetrospectionStore(router: Router(), repository: repository, memo: memo) + + store.handleIntent(.onAppear) + store.handleIntent(.updateRetrospection("abc")) + store.handleIntent(.completeButtonTapped) + + let firstSaved = try #require(repository.savedMemos.last) + let firstEmoji = firstSaved.emojiRetrospectionText ?? "" + let savedCEmoji = Array(firstEmoji)[2] + + store.handleIntent(.updateRetrospection("abcd")) + store.handleIntent(.completeButtonTapped) + + let secondSaved = try #require(repository.savedMemos.last) + let secondEmoji = secondSaved.emojiRetrospectionText ?? "" + + #expect(secondSaved.originalRetrospectionText == "abcd") + #expect(secondEmoji.count == 4) + #expect(Array(secondEmoji)[2] == savedCEmoji) + } +} diff --git a/COMFIEUITests/COMFIEUITests.swift b/COMFIEUITests/COMFIEUITests.swift index 0264f77..1ff0a3e 100644 --- a/COMFIEUITests/COMFIEUITests.swift +++ b/COMFIEUITests/COMFIEUITests.swift @@ -10,33 +10,90 @@ import XCTest final class COMFIEUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false + } - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + @MainActor + private func launchAppForMemoScenario() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += ["-ui-testing"] + app.launch() + return app } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + private func waitUntil( + timeout: TimeInterval = 8, + pollInterval: TimeInterval = 0.1, + condition: @escaping () -> Bool + ) -> Bool { + let endTime = Date().addingTimeInterval(timeout) + while Date() < endTime { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + return condition() } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() + func testMemoInputSendCreatesNewMemoCell() throws { + let app = launchAppForMemoScenario() + let input = app.textViews["memo.inputTextView"] + let sendButton = app.buttons["memo.sendButton"] + + XCTAssertTrue(input.waitForExistence(timeout: 8)) + XCTAssertTrue(sendButton.waitForExistence(timeout: 3)) + + let beforeCount = app.buttons.matching(identifier: "memo.cell.menuButton").count + + input.tap() + input.typeText("UITEST\(Int(Date().timeIntervalSince1970))") + + let becameEnabled = waitUntil { + sendButton.isEnabled + } + XCTAssertTrue(becameEnabled) + sendButton.tap() + + let countIncreased = waitUntil { + app.buttons.matching(identifier: "memo.cell.menuButton").count >= beforeCount + 1 + } + XCTAssertTrue(countIncreased) + } + + @MainActor + func testHangulTypingAndCursorTapKeepsInputInteractive() throws { + let app = launchAppForMemoScenario() + let input = app.textViews["memo.inputTextView"] + let sendButton = app.buttons["memo.sendButton"] - // Use XCTAssert and related functions to verify your tests produce the correct results. + XCTAssertTrue(input.waitForExistence(timeout: 8)) + XCTAssertTrue(sendButton.waitForExistence(timeout: 3)) + + input.tap() + input.typeText("가나") + + let firstEnable = waitUntil { + sendButton.isEnabled + } + XCTAssertTrue(firstEnable) + + input.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).tap() + input.typeText("다") + + let secondEnable = waitUntil { + sendButton.isEnabled + } + XCTAssertTrue(secondEnable) + XCTAssertTrue(input.exists) } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + let app = XCUIApplication() + app.launchArguments += ["-ui-testing"] + app.launch() } } }