From 1f49fcca29a7fa204d324ca87049c1386056e3a1 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:08:21 +0900 Subject: [PATCH 1/8] feat: implement memo emoji input contracts and conversion pipeline --- .../TextToEmoji/Model/EmojiCharacter.swift | 73 +++- .../TextToEmoji/Model/EmojiString.swift | 308 ++++++++++----- .../MemoInput/MemoEmojiTokenAttachment.swift | 61 +++ .../MemoInput/MemoIMETrackingTextView.swift | 42 ++ .../Memo/MemoInput/MemoInputContracts.swift | 65 ++++ .../Memo/MemoInput/MemoInputTextView.swift | 29 +- .../MemoInputUITextView+Coordinator.swift | 332 ++++++++++++++++ .../MemoInput/MemoInputUITextView+IME.swift | 186 +++++++++ .../MemoInputUITextView+Snapshot.swift | 186 +++++++++ .../MemoInputUITextView+TextViewLayout.swift | 52 +++ .../Memo/MemoInput/MemoInputUITextView.swift | 361 ++++-------------- COMFIE/Resources/EmojiPool/EmojiPool.swift | 7 +- 12 files changed, 1301 insertions(+), 401 deletions(-) create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift create mode 100644 COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift diff --git a/COMFIE/Domain/TextToEmoji/Model/EmojiCharacter.swift b/COMFIE/Domain/TextToEmoji/Model/EmojiCharacter.swift index bd2fb3b..d3a02ad 100644 --- a/COMFIE/Domain/TextToEmoji/Model/EmojiCharacter.swift +++ b/COMFIE/Domain/TextToEmoji/Model/EmojiCharacter.swift @@ -4,26 +4,77 @@ // // Created by zaehorang on 4/15/25. // +import Foundation -/// 각 문자를 하나의 이모지와 매칭하는 구조입니다. -/// 예: 'a' → 🐯, '한' → 🐯 struct EmojiCharacter { var originalCharacter: Character var emojiCharacter: Character? - + mutating func setEmojiCharacter() { guard emojiCharacter == nil else { return } - - if originalCharacter == " " - || originalCharacter == "\n" - || isEmoji(originalCharacter) { - emojiCharacter = originalCharacter - } else { + + if Self.isEmojiConvertibleCharacter(originalCharacter) { emojiCharacter = EmojiPool.getRandomEmoji() + } else { + emojiCharacter = originalCharacter } } - - private func isEmoji(_ char: Character) -> 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/MemoInput/MemoEmojiTokenAttachment.swift b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift new file mode 100644 index 0000000..2543bd3 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift @@ -0,0 +1,61 @@ +// +// MemoEmojiTokenAttachment.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +// 텍스트뷰 안에서 "원문 1글자 + 이모지 1글자"를 같이 들고 다니는 토큰입니다. +final class MemoEmojiTokenAttachment: NSTextAttachment { + // 토큰이 나타내는 원문 글자입니다. + let original: String + // 화면에 보여줄 이모지 글자입니다. + let emoji: String + + // 토큰을 만들 때 원문/이모지 쌍과 폰트를 함께 받아 이미지까지 준비합니다. + init(original: String, emoji: String, font: UIFont) { + // 스냅샷 복원을 위해 원문 값을 보관합니다. + self.original = original + // 스냅샷 복원을 위해 이모지 값을 보관합니다. + self.emoji = emoji + // NSTextAttachment 기본 초기화를 먼저 수행합니다. + super.init(data: nil, ofType: nil) + + // 폰트 기준으로 이모지 렌더 크기를 계산합니다. + let emojiString = NSAttributedString(string: emoji, attributes: [.font: font]) + let textSize = emojiString.size() + // 폭이 0이 되면 토큰이 깨질 수 있으니 최소 1을 보장합니다. + let width = max(1, ceil(textSize.width)) + // 높이는 라인 높이에 맞춰 caret/선택 동작을 안정화합니다. + let height = ceil(font.lineHeight) + let size = CGSize(width: width, height: height) + // 실제 텍스트 대신 표시될 이모지 이미지를 렌더링합니다. + image = Self.renderEmojiImage(emoji, in: size, font: font) + // baseline 정렬을 맞추기 위해 descender를 반영합니다. + 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..cac76a5 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift @@ -0,0 +1,42 @@ +// +// MemoIMETrackingTextView.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +// IME(한글/중국어 조합 입력) 상태 변화를 콜백으로 전달하는 UITextView입니다. +final class MemoIMETrackingTextView: UITextView { + // 조합 입력 구간이 생겼을 때 호출할 콜백입니다. + var onSetMarkedText: ((NSRange) -> Void)? + // 조합 입력이 확정되어 marked 상태가 해제됐을 때 호출할 콜백입니다. + var onUnmarkText: (() -> Void)? + + // 시스템이 marked text를 설정할 때 우리 로직도 함께 실행합니다. + override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + super.setMarkedText(markedText, selectedRange: selectedRange) + // 현재 marked 범위가 있으면 NSRange로 변환해 상위 코디네이터로 보냅니다. + if let range = markedTextRange { + onSetMarkedText?(memoIME_nsRange(from: range)) + } + } + + // 조합 입력이 끝날 때를 감지해 후처리를 트리거합니다. + override func unmarkText() { + super.unmarkText() + onUnmarkText?() + } +} + +extension UITextView { + // UITextRange를 NSRange로 변환해 배열/스토리지 인덱스 연산에 바로 쓰게 해줍니다. + func memoIME_nsRange(from textRange: UITextRange) -> NSRange { + // 문서 시작점부터 시작 위치까지의 오프셋이 NSRange.location입니다. + let location = offset(from: beginningOfDocument, to: textRange.start) + // 시작점부터 끝점까지의 오프셋이 NSRange.length입니다. + 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..0b4d152 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift @@ -0,0 +1,65 @@ +// +// MemoInputContracts.swift +// COMFIE +// +// Created by zaehorang on 2/25/26. +// + +import Foundation + +// Memo 입력창을 다시 그릴 때 사용하는 seed 스냅샷입니다. +struct MemoInputSeed: Equatable { + // seed 변경을 감지하기 위한 증가 토큰입니다. + var token: Int + // seed 기준 원문 문자열입니다. + var originalText: String + // seed 기준 이모지 문자열입니다. + 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 { + // 현재 draft가 비어 있는지 알림 + 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 { + // 이벤트 고유 ID + let id: UUID + // 실행할 실제 명령 + let command: MemoInputUICommand + + init(command: MemoInputUICommand) { + // 이벤트를 새로 만들 때마다 고유 ID를 생성합니다. + 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..40bdd34 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift @@ -0,0 +1,332 @@ +// +// 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? + // 조합이 끝난 뒤(handleUnmark) 안전하게 처리하려고 사용합니다. + 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 + + func applyStateToTextView(force: Bool) { + 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 + ) + 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 { + // true면 한글 IME 조합 중이라는 뜻입니다. + 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 } + // IME 조합 중 선택 변화는 무시합니다. + 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 { + private func handleUICommandIfNeeded() { + guard let commandEvent = parent.uiCommandEvent else { return } + guard commandEvent.id != lastHandledUICommandID else { return } + lastHandledUICommandID = commandEvent.id + handleUICommand(commandEvent.command) + } + + 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..9abb5d2 --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift @@ -0,0 +1,186 @@ +// +// MemoInputUITextView+IME.swift +// COMFIE +// +// Created by zaehorang on 2/5/26. +// + +import UIKit + +extension MemoInputUITextView.Coordinator { + // MARK: - IME Handling + // IME 변환 흐름 지도(요약): + + // 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 + } + + 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 + } + + 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..8fc0abf --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift @@ -0,0 +1,186 @@ +// +// 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 + } + + func syncSnapshotToStore(_ textView: UITextView) { + let currentSnapshot = snapshot(from: textView.textStorage) + let emojiTextCandidate = isEmojiMode ? currentSnapshot.emoji : currentSnapshot.original + + updateDraft( + originalText: currentSnapshot.original, + emojiTextCandidate: emojiTextCandidate + ) + publishDraftAvailability() + } + + func syncDraftFromFallbackIfNeeded() { + // 주로 생명주기 경계(textView=nil)에서 final sync 요청이 들어왔을 때 사용됩니다. + // 이때도 seed 기준으로 일관된 스냅샷을 만들 수 있어 savePhase 고착을 막을 수 있습니다. + 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) + } + + 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..c59938d --- /dev/null +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift @@ -0,0 +1,52 @@ +// +// 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 + } + + 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..0ba09b8 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift @@ -5,81 +5,93 @@ // 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 조합 경계"를 잡기 위한 연결입니다. + // setMarkedText/unmarkText 타이밍을 직접 잡아야 조합 중 글자와 확정 글자를 안전하게 구분할 수 있습니다. + // IME marked text가 생길 때 코디네이터가 즉시 토큰화를 조정할 수 있게 연결합니다. + textView.onSetMarkedText = { [weak textView, weak coordinator = context.coordinator] markedRange in + guard let textView else { return } + coordinator?.handleMarkedRange(in: textView, marked: markedRange) + } + // IME 조합이 끝나는 순간에도 코디네이터가 후처리하도록 연결합니다. + 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 +99,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[.. Character { + // 배열이 비어 있는 예외 상황에서도 앱이 멈추지 않도록 기본값을 둡니다. emojiPool.randomElement() ?? "🐯" } } From 78b00ea4fed4f0729d86c825398c1b568218ab3b Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:08:31 +0900 Subject: [PATCH 2/8] fix: stabilize memo save-phase and final-sync flow --- COMFIE/App/COMFIEApp.swift | 1 + COMFIE/App/UITestBootstrap.swift | 27 ++ .../UserDefaults/UserDefaultsConstants.swift | 1 + COMFIE/Presentation/Memo/MemoCell.swift | 8 +- COMFIE/Presentation/Memo/MemoStore.swift | 359 +++++++++++------- COMFIE/Presentation/Memo/MemoView.swift | 74 +++- .../Retrospection/RetrospectionStore.swift | 125 +++--- 7 files changed, 382 insertions(+), 213 deletions(-) create mode 100644 COMFIE/App/UITestBootstrap.swift diff --git a/COMFIE/App/COMFIEApp.swift b/COMFIE/App/COMFIEApp.swift index 6cd2c5b..d9fa3ed 100644 --- a/COMFIE/App/COMFIEApp.swift +++ b/COMFIE/App/COMFIEApp.swift @@ -14,6 +14,7 @@ struct COMFIEApp: App { init() { let router = Router() + UITestBootstrap.applyIfNeeded(router: router) self.router = router self.diContainer = DIContainer(router: router) } diff --git a/COMFIE/App/UITestBootstrap.swift b/COMFIE/App/UITestBootstrap.swift new file mode 100644 index 0000000..67ac02d --- /dev/null +++ b/COMFIE/App/UITestBootstrap.swift @@ -0,0 +1,27 @@ +// +// UITestBootstrap.swift +// COMFIE +// +// Created by zaehorang on 2/13/26. +// + +import Foundation + +#if DEBUG +enum UITestBootstrap { + private static let uiTestingArgument = "-ui-testing" + + static func applyIfNeeded(router: Router, userDefaults: UserDefaults = .standard) { + guard ProcessInfo.processInfo.arguments.contains(uiTestingArgument) else { return } + + userDefaults.set(true, forKey: UserDefaultsConstants.Keys.hasEverOnboarded.rawValue) + userDefaults.set(true, forKey: UserDefaultsConstants.Keys.hasSeenTutorial.rawValue) + router.hasEverOnboarded = true + router.isLoadingViewFinished = true + } +} +#else +enum UITestBootstrap { + static func applyIfNeeded(router _: Router, userDefaults _: UserDefaults = .standard) {} +} +#endif diff --git a/COMFIE/Data/UserDefaults/UserDefaultsConstants.swift b/COMFIE/Data/UserDefaults/UserDefaultsConstants.swift index e29f57f..31b86be 100644 --- a/COMFIE/Data/UserDefaults/UserDefaultsConstants.swift +++ b/COMFIE/Data/UserDefaults/UserDefaultsConstants.swift @@ -10,5 +10,6 @@ import Foundation struct UserDefaultsConstants { enum Keys: String { case hasEverOnboarded = "hasEverOnboarded" + case hasSeenTutorial = "hasSeenTutorial" } } 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/MemoStore.swift b/COMFIE/Presentation/Memo/MemoStore.swift index 209c49e..8268462 100644 --- a/COMFIE/Presentation/Memo/MemoStore.swift +++ b/COMFIE/Presentation/Memo/MemoStore.swift @@ -11,82 +11,107 @@ import SwiftUI @Observable class MemoStore: IntentStore { private(set) var state: State + // MARK: State struct State { + enum SavePhase: Equatable { + case idle + // UI final sync 응답을 기다리는 상태 + case awaitingFinalSync(requestID: UUID) + case persisting + } + var memos: [Memo] = [] var isInComfieZone: Bool = false - - // createdAt 날짜 문자열(dotYMDFormat 기준)로 메모들을 그룹화하고 정렬한 결과 + var groupedMemos: [(date: String, memos: [Memo])] { let grouped = Dictionary(grouping: memos) { $0.createdAt.yyyyMMddString } - + return grouped - .sorted { - $0.value.first!.createdAt < $1.value.first!.createdAt + .sorted { lhs, rhs in + lhs.value.first!.createdAt < rhs.value.first!.createdAt } - .map { (key, value) in (date: key, memos: value) } + .map { (date: $0.key, memos: $0.value) } + } + + var isEmojiPresentationEnabled: Bool { + !isInComfieZone } - - var emojiString: EmojiString = .init() - - // 사용자가 텍스트 필드에 입력하는 메모 - var inputMemoText: String = "" + + var inputSeed: MemoInputSeed = .empty + + var isInputEmpty: Bool = true + var savePhase: SavePhase = .idle + var editingMemo: Memo? var deletingMemo: Memo? - + func isEditingMemo(_ memo: Memo) -> 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..3613b1c 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,39 +125,81 @@ 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) - + 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) { switch repository.delete(memo: memo) { case .success: @@ -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) } From 68bd895eaec741c3bf37b45689ef153c8739fc5b Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:08:38 +0900 Subject: [PATCH 3/8] test: add regression coverage for memo input and save boundaries --- COMFIETests/EmojiStringTests.swift | 158 ++++ COMFIETests/MemoInputIMEInsertionTests.swift | 345 +++++++ .../MemoStoreComfieZoneMappingTests.swift | 170 ++++ COMFIETests/MemoStoreInputSnapshotTests.swift | 838 ++++++++++++++++++ .../MemoStoreSavePhaseNavigationTests.swift | 161 ++++ .../RetrospectionEmojiMappingTests.swift | 191 ++++ COMFIEUITests/COMFIEUITests.swift | 83 +- 7 files changed, 1933 insertions(+), 13 deletions(-) create mode 100644 COMFIETests/EmojiStringTests.swift create mode 100644 COMFIETests/MemoInputIMEInsertionTests.swift create mode 100644 COMFIETests/MemoStoreComfieZoneMappingTests.swift create mode 100644 COMFIETests/MemoStoreInputSnapshotTests.swift create mode 100644 COMFIETests/MemoStoreSavePhaseNavigationTests.swift create mode 100644 COMFIETests/RetrospectionEmojiMappingTests.swift 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) + } + + // 시나리오: 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() } } } From 5b47e6ddfd2df16071af5a4f31b822ea476c73e5 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:09:04 +0900 Subject: [PATCH 4/8] docs: document memo emoji input decisions and behavior --- ARCHITECTURE.md | 207 +++++++++++++ .../2026-02-15-memo-final-sync-no-timeout.md | 63 ++++ ...2-15-memo-input-boundary-simplification.md | 68 +++++ .../memo-draft-sot-consolidation-execplan.md | 271 ++++++++++++++++++ .../memo-emoji-input-product-behavior.md | 66 +++++ 5 files changed, 675 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 docs/decisions/2026-02-15-memo-final-sync-no-timeout.md create mode 100644 docs/decisions/2026-02-15-memo-input-boundary-simplification.md create mode 100644 docs/execplans/memo-draft-sot-consolidation-execplan.md create mode 100644 docs/product-specs/memo-emoji-input-product-behavior.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9a29583 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,207 @@ +# COMFIE iOS Architecture + +This document is a stable architecture map for recurring contributors and reviewers. +It is based on the current development baseline (`develop` branch). + +## 0) Scope + +### Audience +- Contributors touching multiple features. +- Reviewers validating cross-cutting changes. + +### Non-goals +- File-by-file implementation walkthroughs. +- SwiftUI tutorial material. +- Full API reference for every type. + +Rule of thumb: this is a country map, not a street map. + +## 1) Update policy + +- Update this doc on architecture drift, not on every PR. +- Keep only stable constraints and navigation hints. +- Delete details that track fast-changing behavior. + +## 2) Bird's-eye view + +### 2.1 System overview + +COMFIE is a local-first SwiftUI app for memo capture, retrospection text, and "ComfieZone" location context. +The app applies emoji mapping to text while preserving user input history, and exposes feature flows through a centralized router. + +The runtime model is a layered flow: +`View` -> `Store (Intent/Action/State)` -> `UseCase/Repository` -> `Service/Persistence`. +Data enters from user interactions and device services, then is persisted to local storage and reflected back into derived UI state. + +### 2.2 Inputs -> Outputs (Ground vs Derived) + +Inputs (ground state): +- User input text and UI intents from SwiftUI views. +- Device location and location authorization (`LocationService`). +- Local persisted data in Core Data (`UserRecordModel`) and UserDefaults (`hasEverOnboarded`). + +Outputs (derived state): +- Feature UI state in store `State` structs. +- Navigation stack/path in `Router.path`. +- Emoji-transformed text derived from source text (`EmojiString`, `EmojiCharacter`). +- Rendered views and popups. + +### 2.3 Update model + +- Small changes are feature-local intent handling in one store. +- Stores receive intents, compute new state via actions, and emit side effects via publishers when UI control is required. +- Persistence and OS API calls are delegated to repositories/use cases/services; views do not own IO concerns. + +## 3) Entry points + +- App bootstrap: `COMFIE/App/COMFIEApp.swift` +- Root navigation shell: `COMFIE/App/COMFIERoutingView.swift` +- Routing core: `COMFIE/App/Router/Router.swift`, `COMFIE/App/Router/Route.swift`, `COMFIE/App/Router/Router + navigate.swift` +- Dependency assembly: `COMFIE/App/DIContainer/DIContainer.swift` +- Store protocol contract: `COMFIE/Presentation/Intent/IntentStore.swift` + +## 4) Code map + +### 4.1 Top-level repository map + +- `/COMFIE/App`: App entry, DI setup, route definitions, root navigation logic. +- `/COMFIE/Presentation`: Feature views and stores (Onboarding, Memo, Retrospection, ComfieZoneSetting, More). +- `/COMFIE/Domain`: Domain entities (`Memo`, `ComfieZone`), use case (`LocationUseCase`), emoji mapping model. +- `/COMFIE/Data`: Core Data service, repository implementations, entity mapping extensions, UserDefaults service. +- `/COMFIE/Service`: OS-facing services (`LocationService`, `LocalAuthenticationService`). +- `/COMFIE/Resources`: Design system components, assets, localization strings, shared extensions. +- `/COMFIETests`, `/COMFIEUITests`: Test targets. + +### 4.2 Major components + +#### Component: App bootstrap and routing (`COMFIE/App`) +- Responsibility: Start app, create router and DI container, select root flow, push/pop routes. +- Owns: `Router.path`, loading gate, onboarding completion flag. +- Depends on: SwiftUI navigation primitives, `UserDefaultsService`, `DIContainer`. +- Must not depend on: Feature-specific persistence internals. +- Boundary status: Primary app composition and navigation boundary. +- Key invariants: Route construction goes through `Route` + `DIContainer.makeView`; root flow decision is centralized in `Router.rootView`. + +#### Component: Presentation stores and views (`COMFIE/Presentation`) +- Responsibility: Translate UI intents into state transitions and side effects. +- Owns: Per-feature `State` values and UI side-effect publishers. +- Depends on: `Router`, repository protocols, use cases, and resources. +- Must not depend on: Core Data entities, `CLLocationManager`, or direct storage APIs. +- Boundary status: Main feature behavior boundary. +- Key invariants: Each feature store is the mutation authority for its feature state; views invoke store intents and do not orchestrate persistence directly. + +#### Component: Domain model and behavior (`COMFIE/Domain`) +- Responsibility: Define core app entities and text-to-emoji mapping logic. +- Owns: `Memo`, `ComfieZone`, `EmojiString`, `EmojiCharacter`, `LocationUseCase`. +- Depends on: Foundation/CoreLocation-level types and repository protocols. +- Must not depend on: SwiftUI view types or Core Data entity classes. +- Boundary status: Feature-independent business model boundary. +- Key invariants: `EmojiString` keeps original and emoji character streams aligned; `LocationUseCase` is the location decision API used by stores. + +#### Component: Data persistence (`COMFIE/Data`) +- Responsibility: Persist and fetch domain data from Core Data and UserDefaults. +- Owns: `CoreDataService`, repository implementations, Core Data <-> domain mapping extensions. +- Depends on: Core Data stack and domain entities. +- Must not depend on: SwiftUI view/store types. +- Boundary status: Persistence and storage boundary. +- Key invariants: Core Data entity mapping happens in `COMFIE/Data/CoreData/Extensions` only; store-facing persistence APIs are repository protocols. + +#### Component: System services (`COMFIE/Service`) +- Responsibility: Wrap device and OS framework APIs. +- Owns: Location updates/authorization and local authentication requests. +- Depends on: `CoreLocation`, `MapKit`, `LocalAuthentication`. +- Must not depend on: Feature view logic. +- Boundary status: OS integration boundary. +- Key invariants: Location manager access is centralized in `LocationService`; authentication prompt flow is isolated in `LocalAuthenticationService`. + +#### Component: UI resources and design system (`COMFIE/Resources`) +- Responsibility: Shared UI primitives, assets, typography, localization literals. +- Owns: `CF*` reusable components and string literal namespaces. +- Depends on: SwiftUI/UIKit rendering concerns only. +- Must not depend on: Repositories, use cases, or routing logic. +- Boundary status: Presentation support boundary. +- Key invariants: Feature views should consume shared design/localization components instead of duplicating constants. + +## 5) Architectural invariants + +**Architecture Invariant:** There is no direct Core Data access from presentation stores or views. +- Rationale: Keep persistence details behind repository boundaries. +- Enforced by: Repository protocols and `CoreDataService` ownership in Data layer. +- Violation symptoms: Stores import Core Data types, feature code tightly coupled to schema. + +**Architecture Invariant:** There is no route stack mutation outside `Router` API usage. +- Rationale: Navigation behavior must remain predictable and reviewable. +- Enforced by: `Router.push/pop/popToRoot` and route enum-based flow. +- Violation symptoms: Inconsistent navigation behavior or duplicated navigation state. + +**Architecture Invariant:** There is no domain entity <-> Core Data entity conversion outside mapping extensions. +- Rationale: Avoid scattered serialization logic and schema leak. +- Enforced by: Conversion helpers in `COMFIE/Data/CoreData/Extensions`. +- Violation symptoms: Duplicate conversion code and inconsistent persisted values. + +**Architecture Invariant:** There is no direct OS permission flow in views. +- Rationale: Permissions and device APIs should be testable and centralized. +- Enforced by: `LocationUseCase`, `LocationService`, and popup/store intent flows. +- Violation symptoms: View-level permission branching and hard-to-test UI logic. + +**Architecture Invariant:** There is no mutable shared feature state outside each feature store. +- Rationale: Maintain a single state authority per screen flow. +- Enforced by: `private(set) state` pattern in stores. +- Violation symptoms: Desynchronized UI, race conditions between views and services. + +## 6) Boundaries and API surfaces + +### 6.1 Boundary list + +- Boundary surface: Presentation <-> Domain/Data (`COMFIE/Presentation/*Store.swift`) +- What crosses: Intents, domain entities (`Memo`, `ComfieZone`), repository/use case interfaces. +- Forbidden crossing: Core Data entities, direct `NSManagedObjectContext`, direct `CLLocationManager`. + +- Boundary surface: Domain <-> Data (`COMFIE/Data/Repository/*`, `COMFIE/Domain/*`) +- What crosses: Domain entities and protocol-based operations. +- Forbidden crossing: SwiftUI view concerns and resource/UI component types. + +- Boundary surface: Service <-> OS frameworks (`COMFIE/Service/*`) +- What crosses: Device state (location/auth results) as value/publisher outputs. +- Forbidden crossing: Feature UI mutation logic. + +- Boundary surface: App routing <-> Features (`COMFIE/App/Router/*`, `COMFIE/App/DIContainer/*`) +- What crosses: `Route` values and injected store dependencies. +- Forbidden crossing: Feature code constructing ad-hoc root navigation policies. + +### 6.2 Boundary rules ("only here") + +- Core Data serialization/mapping happens in: `COMFIE/Data/CoreData/Extensions` (only here). +- Persistent IO happens in: `COMFIE/Data/CoreData/CoreDataService.swift` and `COMFIE/Data/UserDefaults/UserDefaultsService.swift` (only here). +- OS framework IO happens in: `COMFIE/Service/LocationService.swift` and `COMFIE/Service/LocalAuthenticationService.swift` (only here). +- Route stack mutation happens through: `COMFIE/App/Router/Router + navigate.swift` (only here). + +## 7) Cross-cutting concerns + +### Emoji transformation pipeline + +- Input text editing is orchestrated by `MemoInputUITextView` delegate events. +- Character-level emoji mapping and synchronization lives in `Domain/TextToEmoji/Model`. +- Features consume emoji mapping as a domain concern, not as view-only string tricks. + +### Location and authorization flow + +- Permission request and location updates are centralized in `LocationService`. +- Stores consume location state through `LocationUseCase` and react in feature state. +- ComfieZone deletion can require authentication when user context requires stricter confirmation. + +### Error handling style + +- Repository APIs return `Result` to keep failure explicit at call sites. +- Store-level handling currently favors user-flow continuity (state update + local fallback/logging). + +### Testing boundary note + +- Test targets exist, but architecture-level boundary enforcement is primarily by module conventions and protocol seams. +- Dependency seams are provided through repository protocols and DI container construction points. + +## 8) Writing constraints + +- Keep this document architecture-first and stable. +- Prefer module/type/path names over implementation details. +- If a section starts tracking rapid UI behavior changes, shorten or remove it. diff --git a/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md b/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md new file mode 100644 index 0000000..b36353c --- /dev/null +++ b/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md @@ -0,0 +1,63 @@ +# 2026-02-15 Memo Final Sync timeout 제거 + +- Status: Accepted +- Date: 2026-02-15 +- Owner: Memo feature team (user + assistant) + +## 기존 정책 + +기존 구현은 저장 버튼 이후 `awaitingFinalSync` 상태에서 timeout을 시작하고, +timeout이 지나면 `idle`로 복귀시키는 방식이었습니다. +또한 late callback 허용/차단을 위한 추가 문맥(`timedOutFinalSyncContext`, draftRevision 비교)이 필요했습니다. + +## 문제 + +- timeout 값 조정 자체가 정책 복잡도를 키웠다. +- timeout과 late callback 규칙이 결합되어 상태 전이 이해가 어려웠다. +- 테스트가 timeout 전제 시나리오에 강하게 묶였다. + +## 최종 결정 + +timeout 기반 복구를 제거하고, 실패를 명시 이벤트로 복구한다. + +- 추가 intent: `finalSyncFailed(requestID:)` +- `requestID`가 일치하는 `awaitingFinalSync`에서만 `idle` 복귀 +- `requestID` 불일치 실패 이벤트는 무시 + +## 대체 설계와 기각 이유 + +1. timeout 유지 + 짧게 조정 +기각 이유: 근본 복잡도는 그대로 남고 지연 환경에서 재현성이 떨어진다. + +2. timeout 유지 + late callback 전면 허용 +기각 이유: stale 응답 반영 위험이 커지고 request 상관관계 의미가 약해진다. + +## 구현 반영 + +- timeout 관련 멤버/로직 제거 +`COMFIE/Presentation/Memo/MemoStore.swift` + +- 실패 복구 이벤트 추가 +`MemoStore.Intent.MemoInputIntent.finalSyncFailed(requestID:)` + +- InputView 실패 콜백 경로 추가 +`onOutputEvent(.finalSnapshotFailed(requestID:))` + +## 운영 리스크 및 관찰 포인트 + +- InputView가 응답을 보내지 못하는 케이스는 timeout이 아니라 실패 이벤트 경로를 반드시 보내야 한다. +- `requestID` 일치 검증이 유일한 상관관계 장치이므로 테스트에서 계속 보호해야 한다. + +## 검증 테스트 + +- `MemoStoreInputSnapshotTests/finalSyncFailedMatchingRequestIDReturnsIdle()` +- `MemoStoreInputSnapshotTests/finalSyncFailedWithMismatchedRequestIDIsIgnored()` +- `MemoStoreInputSnapshotTests/finalSyncCompletedWithMismatchedRequestIDDoesNotPersist()` +- `MemoStoreInputSnapshotTests/saveTappedTwiceBeforeFinalSyncEmitsSingleRequest()` + +## 결과 + +timeout 제거 후에도 저장 파이프라인은 유지된다. + +- `idle -> awaitingFinalSync(requestID) -> persisting -> idle` +- 응답 누락/실패는 `finalSyncFailed(requestID)`로 명시 복구 diff --git a/docs/decisions/2026-02-15-memo-input-boundary-simplification.md b/docs/decisions/2026-02-15-memo-input-boundary-simplification.md new file mode 100644 index 0000000..51c4bdf --- /dev/null +++ b/docs/decisions/2026-02-15-memo-input-boundary-simplification.md @@ -0,0 +1,68 @@ +# 2026-02-15 Memo Input 경계 단순화 + +- Status: Accepted +- Date: 2026-02-15 +- Owner: Memo feature team (user + assistant) + +## 문제 정의 + +기존 메모 입력 구조는 `MemoStore`와 `UITextView` 코디네이터가 동시에 입력 중간값(원문/이모지)을 들고 있었습니다. +이중 소유 때문에 IME 조합(`markedTextRange`) 중간 상태, 커서 이동, 붙여넣기, 편집 취소/재진입에서 동기화 복잡도가 계속 증가했습니다. + +## 고려한 대안 + +1. Store가 입력 중간 텍스트를 계속 소유한다. +장점: Store만 보면 상태를 파악하기 쉽다. +단점: UIKit 입력 엔진 특성(조합중 텍스트, attachment, 커서)에 맞춘 상태 동기화 코드가 Store까지 퍼진다. + +2. InputView(`MemoInputUITextView.Coordinator`)가 입력 중간 텍스트를 소유한다. (채택) +장점: IME/커서/attachment와 붙어 있는 로컬 상태를 한 곳에서 처리한다. +단점: 저장 직전 스냅샷 계약을 명시적으로 설계해야 한다. + +## 최종 결정 + +입력 중간 텍스트 소유권은 InputView로 이동하고, Store는 정책/트랜잭션 상태만 소유한다. +Store가 실시간으로 들고 있는 입력 상태는 `isInputEmpty`만 유지한다. + +## 반대 의견과 기각 이유 + +- 반대: Store에 실시간 텍스트를 남겨야 디버깅이 쉽다. +기각 이유: 디버깅 편의보다 조합 입력 안정성이 우선이며, 실시간 텍스트는 UIKit 상태와 분리될수록 불일치 위험이 커진다. + +- 반대: InputView가 너무 똑똑해진다. +기각 이유: InputView가 똑똑해지는 범위는 입력 엔진 기술 세부(IME/커서)이며, 도메인 정책 결정은 Store가 계속 소유한다. + +## 구현 결과 + +다음 경계를 적용했다. + +- `Store -> View -> InputView` 입력 +`inputSeed`, `isEmojiPresentationEnabled`, `MemoInputUICommand` + +- `InputView -> View -> Store` 출력 +`onOutputEvent(MemoInputOutputEvent)` + +- InputView에서 `MemoStore` 직접 참조 제거 +`COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift` +`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift` +`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift` +`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift` + +- MemoView가 중재자 역할 수행 +`COMFIE/Presentation/Memo/MemoView.swift` + +## 트레이드오프 + +- Store에서 실시간 본문 문자열을 바로 볼 수는 없다. +- 대신 저장 트랜잭션 흐름(`requestID`, `savePhase`)과 입력 엔진 흐름이 분리되어 변경 영향 범위가 줄었다. + +## 회귀 방지 테스트 + +- `COMFIETests/MemoStoreInputSnapshotTests.swift` +- `COMFIETests/MemoInputIMEInsertionTests.swift` +- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift` +- `COMFIETests/MemoStoreComfieZoneMappingTests.swift` + +## 후속 관찰 포인트 + +- `MemoStoreInputSnapshotTests` 파일 길이 증가(리팩토링 후 700+ 라인)는 추후 테스트 모듈 분할 대상이다. diff --git a/docs/execplans/memo-draft-sot-consolidation-execplan.md b/docs/execplans/memo-draft-sot-consolidation-execplan.md new file mode 100644 index 0000000..9cfd837 --- /dev/null +++ b/docs/execplans/memo-draft-sot-consolidation-execplan.md @@ -0,0 +1,271 @@ +# Memo Input 경계 단순화 + 의사결정 근거 문서화 ExecPlan + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +This document follows the repository ExecPlan guidelines and must be maintained accordingly. + +## Purpose / Big Picture + +이 변경의 목적은 메모 입력 책임을 단순하게 분리해, 사용자가 기존과 같은 동작을 안정적으로 사용하도록 만드는 것이다. 사용자는 이전과 동일하게 입력, 이모지 표시 전환, 저장 버튼 활성화, 저장 중 가드, 편집 취소, 한국어 IME 조합 입력을 사용할 수 있어야 한다. + +차이는 내부 구조다. 이제 입력 중간 텍스트의 소유자는 `UITextView` 경계이고, `MemoStore`는 정책과 저장 트랜잭션만 담당한다. 저장은 "최종 스냅샷" 요청-응답 한 번으로만 진행된다. + +## Progress + +- [x] (2026-02-15 09:20Z) 기존 구현과 테스트를 전수 확인하고, 입력 책임 분리와 timeout 제거 범위를 확정했다. +- [x] (2026-02-15 10:00Z) `MemoStore`에 `inputSeed`, `isInputEmpty`, `savePhase` 중심 상태를 유지하고 입력 중간 본문 상시 보관 책임을 제거했다. +- [x] (2026-02-15 10:35Z) `MemoStore.Intent.MemoInputIntent.finalSyncFailed(requestID:)`를 추가하고 timeout 기반 복구 로직을 제거했다. +- [x] (2026-02-15 11:05Z) `MemoView`를 입력 중재자로 고정하고 Store side effect를 `MemoInputUIEvent`로 전달하도록 연결했다. +- [x] (2026-02-15 11:40Z) `MemoInputTextView`/`MemoInputUITextView`/`Coordinator`에서 `MemoStore` 직접 의존을 제거했다. +- [x] (2026-02-15 12:20Z) `Coordinator`가 로컬 draft(`original/emoji`)를 소유하고 final sync 요청 시점에만 스냅샷을 응답하도록 정리했다. +- [x] (2026-02-15 13:00Z) `textView == nil` 경계에서 local draft fallback 또는 실패 이벤트를 보내 저장 파이프라인 고착을 방지했다. +- [x] (2026-02-15 14:05Z) timeout 전제 테스트를 마이그레이션하고 `finalSyncFailed`, requestID mismatch, 빈 스냅샷 저장 금지 케이스를 강화했다. +- [x] (2026-02-15 14:50Z) `xcodebuild test -scheme COMFIE` 전체 테스트를 통과시켰다. +- [x] (2026-02-15 20:50+0900) 전체 회귀를 재실행해 `** TEST SUCCEEDED **`를 재확인하고 최신 xcresult 경로를 기록했다. +- [x] (2026-02-15 20:55+0900) `docs/decisions` 문서 2개를 작성하고 이 ExecPlan의 living 섹션을 최신 구현 결과로 갱신했다. +- [x] (2026-02-15 21:01+0900) 안전 범위 중복 제거 리팩토링을 적용했다(입력 seed 초기화 공통화, idle 가드 predicate 통합, Coordinator flush+sync helper 통합, Snapshot dead code 제거). +- [x] (2026-02-15 23:18+0900) 문서 변경분(`docs/decisions` 2건 + 본 ExecPlan)을 최종 점검하고 최신 상태로 확정했다. + +## Surprises & Discoveries + +- Observation: 시작 시점에 `docs/decisions` 디렉터리가 존재하지 않아 문서화 산출물을 새로 만들 필요가 있었다. + Evidence: 초기 `ls docs/decisions` 실패 후 디렉터리 생성. + +- Observation: 기존 테스트가 timeout/late-completion 정책에 강하게 결합되어 있어, 실패 이벤트 정책으로 바꾸면서 테스트 의도를 함께 재작성해야 했다. + Evidence: 기존 `COMFIETests/MemoStoreInputSnapshotTests.swift`의 timeout 관련 시나리오 다수 교체. + +- Observation: 입력 타입을 별도 파일로 분리했을 때 타깃 포함 누락으로 컴파일 실패가 발생해, 최종적으로 `MemoStore.swift`에 타입을 공존시켰다. + Evidence: `cannot find type 'MemoInputSeed' in scope` 계열 빌드 에러 재현 후 해결. + +- Observation: ComfieZone plain-mode 매핑 테스트는 단순 원문 비교가 아니라 "기존 이모지 매핑 보존"이 핵심이라, final snapshot fixture를 실제 저장 경로 형태로 맞춰야 했다. + Evidence: `MemoStoreComfieZoneMappingTests`의 fixture 수정 전/후 통과 여부 차이. + +- Observation: `textView == nil` 경계는 이미 회귀 테스트로 보호되고 있었고, 이번 구조에서도 fallback 응답 정책이 유효했다. + Evidence: `MemoStoreInputSnapshotTests`의 nil textView final sync 테스트 통과. + +## Decision Log + +- Decision: 입력 중 원문/이모지 중간값은 Input View(`MemoInputUITextView.Coordinator`)가 소유한다. + Rationale: IME 조합 상태(`markedTextRange`), 커서, attachment 변환은 UIKit 로컬 상태와 분리할수록 오류 위험이 커진다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: Store가 실시간으로 소유하는 입력 상태는 `isInputEmpty` Bool로 제한한다. + Rationale: 버튼 UX/가드에는 충분하고, 본문 동기화 복잡도는 크게 감소한다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: 표시 모드는 도메인 상태인 `isInComfieZone`에서 계산하고 View는 Bool만 소비한다. + Rationale: 도메인 정책 일관성과 테스트 가능성을 유지한다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: 저장 요청은 Store side effect에서 시작하고 MemoView가 InputView와 Store 사이를 중재한다. + Rationale: MVI 흐름을 유지하면서 InputView의 Store 비종속성을 보장한다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: final sync 상관관계는 `requestID` 단일 규칙으로 검증한다. + Rationale: 최소 안전장치로 stale 응답 반영을 차단할 수 있다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: timeout을 제거하고 `finalSyncFailed(requestID)` 이벤트로 `idle` 복귀한다. + Rationale: timeout/late-callback 복합 정책보다 상태 전이가 단순하고 명시적이다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: `textView == nil`일 때는 local draft로 즉시 final snapshot 응답하거나 불가능하면 실패 이벤트를 보낸다. + Rationale: 생명주기 경계에서도 저장 파이프라인 정지를 방지한다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: 빈값 저장 금지는 View 가드와 Store 가드를 모두 유지한다. + Rationale: 제품 불변식은 방어를 한 레이어에만 의존하지 않는다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: 저장 실패 시 입력 draft는 유지하고 savePhase만 `idle`로 복귀한다. + Rationale: 사용자가 즉시 재시도할 수 있어야 한다. + Date/Author: 2026-02-15 / user + assistant + +- Decision: 새 입력 계약 타입(`MemoInputSeed`, `MemoInputSnapshot`, `MemoInputUICommand`, `MemoInputUIEvent`)은 현재 `MemoStore.swift`에 둔다. + Rationale: 별도 파일 타깃 누락으로 인한 빌드 불안정성을 즉시 차단하고, 추후 타깃 정리 시 분리 가능하다. + Date/Author: 2026-02-15 / assistant + +- Decision: 중복 제거 리팩토링은 동작 불변 원칙으로 수행하고, 정책/콜백 시맨틱 변경은 분리한다. + Rationale: 유지보수성을 개선하면서도 저장 파이프라인/IME 회귀 위험을 최소화하기 위해서다. + Date/Author: 2026-02-15 / user + assistant + +## Outcomes & Retrospective + +핵심 목표였던 "동작 동일성 유지 + Store 복잡도 감소 + 의사결정 재사용 가능성"을 달성했다. 사용자는 기존과 같은 화면 동작을 유지하고, 내부적으로는 입력 책임 경계가 명확해졌다. + +Store는 더 이상 입력 중간 문자열 동기화를 강제하지 않고, final sync 트랜잭션과 정책 가드에 집중한다. InputView는 IME/커서/attachment 세부를 로컬 상태로 처리한다. timeout 제거로 save 파이프라인 상태 전이가 단순해졌고, 실패 복구는 명시 이벤트로 관찰 가능해졌다. + +남은 학습 과제는 테스트 파일 분할이다. 특히 `MemoStoreInputSnapshotTests`는 범위가 넓어 이후 유지보수 비용을 낮추기 위해 주제별 파일 분할이 유효하다. + +## Context and Orientation + +이 작업에서 "Input View"는 `UITextView`를 감싼 SwiftUI 브리지 계층(`MemoInputTextView`, `MemoInputUITextView`, `Coordinator`)을 뜻한다. 여기서 "draft"는 사용자가 입력 중인 임시 텍스트 상태이고, "final snapshot"은 저장 직전에 확정된 `(originalText, emojiText)` 값이다. + +핵심 파일은 다음과 같다. + +- `COMFIE/Presentation/Memo/MemoStore.swift`: 도메인 상태, 저장 트랜잭션, 가드 정책을 처리한다. +- `COMFIE/Presentation/Memo/MemoView.swift`: Store side effect를 받아 InputView 명령으로 중재한다. +- `COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift`: SwiftUI 입력 컴포넌트 계약. +- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift`: UIKit 브리지 입력/출력 인터페이스. +- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift`: IME/커서/최종 스냅샷 생성 로직. +- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift`: draft 가용성 이벤트와 스냅샷 보조 로직. +- `COMFIETests/MemoStoreInputSnapshotTests.swift`: final sync 트랜잭션 회귀군. +- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift`: savePhase 가드 회귀군. +- `COMFIETests/MemoStoreComfieZoneMappingTests.swift`: plain-mode 이모지 매핑 회귀군. +- `COMFIETests/MemoInputIMEInsertionTests.swift`: 입력/IME 경계 회귀군. + +의사결정 기록은 다음 문서에 남겼다. + +- `docs/decisions/2026-02-15-memo-input-boundary-simplification.md` +- `docs/decisions/2026-02-15-memo-final-sync-no-timeout.md` + +## Plan of Work + +먼저 Store와 Input 경계를 계약 기반으로 분리했다. Store는 입력 본문 실시간 동기화를 버리고, `isInputEmpty`와 `savePhase`를 중심으로 저장 정책만 유지한다. 저장 버튼에서 즉시 저장하지 않고 `requestFinalSyncAndResign(requestID)` 명령을 내리고, matching `requestID`의 `finalSyncCompleted`만 저장에 반영한다. + +그다음 MemoView를 중재자로 고정했다. Store side effect를 수신해 InputView 명령으로 전달하고, InputView의 draft/final/fail 이벤트를 다시 Store intent로 전달한다. 이로써 InputView 파일군의 `MemoStore` 직접 참조를 제거했다. + +이후 timeout 경로를 제거했다. 응답 누락이나 불가능한 상태는 시간 기반 추정이 아니라 `finalSyncFailed(requestID)`로 복구한다. `textView == nil` 경계에서는 가능한 경우 local draft를 즉시 final snapshot으로 응답하고, seed와 draft 모두 비어 있으면 실패 이벤트로 복귀한다. + +마지막으로 테스트를 새 계약으로 마이그레이션하고, 결정 근거를 `docs/decisions`에 분리 문서로 남겼다. + +## Concrete Steps + +작업 디렉터리는 `/Users/zaehorang/Documents/Projects/COMFIE-iOS`다. + +timeout 참조 제거 확인: + + rg -n "finalSyncTimedOut|finalSyncTimeout|startFinalSyncTimeout" COMFIE/Presentation/Memo + +기대 결과는 출력 없음이다. + +InputView의 Store 직접 의존 제거 확인: + + rg -n "MemoStore" COMFIE/Presentation/Memo/MemoInput + +기대 결과는 출력 없음이다. + +전체 테스트 검증: + + xcodebuild test -scheme COMFIE -destination 'id=919596A7-E423-4D2A-86EE-A892781BCC2C' -derivedDataPath /tmp/comfie-codex-derived + +기대 결과 핵심 라인은 다음과 같다. + + ** TEST SUCCEEDED ** + +## Validation and Acceptance + +수용 기준은 다음 사용자 행동으로 판단한다. + +- 빈 입력이면 저장 버튼이 비활성이고 Store 저장 진입이 차단된다. +- 저장 버튼 연타 시 `awaitingFinalSync` 요청은 1회만 처리된다. +- `requestID`가 다른 final 응답은 무시된다. +- 응답 누락/실패 시 `finalSyncFailed`로 `savePhase`가 `idle`로 복귀한다. +- `textView == nil` 경계에서도 저장 파이프라인이 고착되지 않는다. +- 저장 실패 후 입력 draft는 유지된다. +- IME marked/unmarked 변환과 plain-mode 이모지 매핑이 회귀하지 않는다. +- savePhase 진행 중 네비게이션/삭제/배경 탭 차단이 유지된다. + +검증 근거 테스트는 다음 파일에 존재한다. + +- `COMFIETests/MemoStoreInputSnapshotTests.swift` +- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift` +- `COMFIETests/MemoStoreComfieZoneMappingTests.swift` +- `COMFIETests/MemoInputIMEInsertionTests.swift` + +## Idempotence and Recovery + +이 계획은 반복 실행 가능하게 구성되었다. Store 경계 변경, View 중재 연결, timeout 제거, 테스트 마이그레이션을 분리 단계로 적용했기 때문에 문제 발생 시 최근 단계만 되돌려 재적용할 수 있다. + +가장 위험한 변경은 timeout 제거였고, 이를 `finalSyncFailed` 대체 경로와 동시 적용해 상태 고착을 방지했다. 실패 시 복구는 단순하다. `savePhase`를 `idle`로 복귀시키고 draft를 유지하므로 사용자는 입력 손실 없이 재시도할 수 있다. + +## Artifacts and Notes + +주요 산출물: + +- `docs/decisions/2026-02-15-memo-input-boundary-simplification.md` +- `docs/decisions/2026-02-15-memo-final-sync-no-timeout.md` + +최종 검증 로그 요약: + + rg -n "finalSyncTimedOut|finalSyncTimeout|startFinalSyncTimeout" COMFIE/Presentation/Memo + (no output) + + rg -n "MemoStore" COMFIE/Presentation/Memo/MemoInput + (no output) + + rg -n "syncSnapshotToStoreIfPossible\(" COMFIE/Presentation/Memo + (no output) + + rg -n "case finalSyncFailed|case finalSyncCompleted|case requestFinalSyncAndResign" COMFIE/Presentation/Memo/MemoStore.swift + (expected interface cases remain) + + xcodebuild test -scheme COMFIE -destination 'id=919596A7-E423-4D2A-86EE-A892781BCC2C' -derivedDataPath /tmp/comfie-codex-derived + ** TEST SUCCEEDED ** + xcresult: /tmp/comfie-codex-derived/Logs/Test/Test-COMFIE-2026.02.15_20-59-18-+0900.xcresult + +## Interfaces and Dependencies + +최종 인터페이스는 다음 규약을 따른다. + +`COMFIE/Presentation/Memo/MemoStore.swift`에서 입력 계약 타입을 제공한다. + + struct MemoInputSeed: Equatable { + var token: Int + var originalText: String + var emojiText: String + } + + struct MemoInputSnapshot: Equatable { + let originalText: String + let emojiText: String + } + + enum MemoInputUICommand: Equatable { + case resignWithSync + case resignWithoutSync + case requestFinalSyncAndResign(requestID: UUID) + case setFocus + } + + struct MemoInputUIEvent: Equatable { + let id: UUID + let command: MemoInputUICommand + } + +`MemoStore.Intent.MemoInputIntent`는 다음 케이스를 가진다. + + case draftAvailabilityChanged(isEmpty: Bool) + case memoInputButtonTapped + case finalSyncCompleted(requestID: UUID, snapshot: MemoInputSnapshot) + case finalSyncFailed(requestID: UUID) + +`MemoStore.State`는 입력 경계 관련으로 다음 속성을 유지한다. + + var inputSeed: MemoInputSeed + var isInputEmpty: Bool + var savePhase: SavePhase + var isInComfieZone: Bool + var editingMemo: Memo? + var deletingMemo: Memo? + +`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift`의 Input View 계약은 다음이다. + + init( + inputSeed: MemoInputSeed, + isEmojiPresentationEnabled: Bool, + uiCommandEvent: MemoInputUIEvent?, + onOutputEvent: ((MemoInputOutputEvent) -> Void)? = nil + ) + +의존성 경계 규칙: + +- InputView 파일군은 `MemoStore` 타입을 직접 참조하지 않는다. +- MemoView만 Store와 InputView 양쪽을 알고 중재한다. +- 저장 트랜잭션 시작/검증 책임은 Store에 남긴다. +- plain-mode 이모지 보존 계산은 `EmojiString` 도메인 함수에 위임한다. + +Revision Note (2026-02-15 20:50+0900): 전체 테스트를 한 번 더 재실행해 성공 로그와 xcresult 경로를 갱신했다. 문서 기준 검증 증거를 최신 상태로 유지하기 위한 업데이트다. +Revision Note (2026-02-15 20:55+0900): 본 리비전에서 ExecPlan을 2026-02-15 최종 구현 상태로 전면 갱신했다. 기존 timeout/legacy 의존 설명을 제거하고, 실제 반영된 인터페이스, 실패 이벤트 기반 복구, 테스트/문서 산출물 근거를 통합했다. +Revision Note (2026-02-15 21:01+0900): 안전 범위 중복 제거를 코드에 반영하고, 정적 확인(미사용 메서드 제거/인터페이스 유지)과 전체 테스트 성공 증거를 ExecPlan에 추가했다. +Revision Note (2026-02-15 23:18+0900): 문서 변경분을 최종 점검해 결정 문서 2건과 ExecPlan living 섹션을 현재 브랜치 상태와 일치하도록 확정했다. diff --git a/docs/product-specs/memo-emoji-input-product-behavior.md b/docs/product-specs/memo-emoji-input-product-behavior.md new file mode 100644 index 0000000..72aa2cc --- /dev/null +++ b/docs/product-specs/memo-emoji-input-product-behavior.md @@ -0,0 +1,66 @@ +# Memo Emoji Input Product Behavior + +## 목적 +- 메모 입력 중 문자 -> 이모지 변환 규칙을 UX 관점에서 일관되게 정의한다. +- 영문/한글(IME)/붙여넣기/커서 이동/저장에서 동일한 사용자 기대를 제공한다. + +## 적용 범위 +- 대상 화면: 메모 입력(`MemoInputUITextView`) +- 대상 모드: 이모지 모드(ComfieZone 밖), ComfieZone 원문 모드 편집 +- 비대상: 회고/기타 입력 화면의 별도 이모지 정책 + +## 핵심 UX 원칙 +1. 현재 입력 중인 1글자(또는 IME 조합 중 구간)는 즉시 이모지로 바꾸지 않는다. +2. 확정된 이전 글자부터 이모지 변환한다. +3. 저장 시점에는 미변환 글자가 남지 않도록 모두 확정/변환한다. + +## 입력 이벤트별 규칙 +| 이벤트 | 동작 | +| --- | --- | +| 단일 글자 입력(영문/숫자/기호/한글 확정 1자) | 방금 입력한 글자는 보류하고, 직전 확정 글자만 변환 | +| IME 조합 시작(`markedText`) | 조합 범위 바로 앞 글자까지만 변환, 조합 범위는 보류 | +| IME 조합 종료(`unmarkText`) | 보류 중이던 변환을 적용하고 스냅샷 동기화 | +| 붙여넣기/다글자 치환 | 입력 직후 해당 범위를 즉시 변환 | +| 커서 이동 후 입력 | 위치와 무관하게 동일 규칙 적용(현재 글자 보류, 직전 글자 변환) | +| 포커스 해제/편집 종료/저장 직전 | 보류 글자 flush 후 스냅샷 확정 | + +## ComfieZone 편집 동기화 규칙 +1. ComfieZone(원문 모드)에서 텍스트를 삭제하면, 같은 위치의 이모지도 함께 삭제된다. +2. ComfieZone에서 텍스트를 수정(치환/삽입)하면, 수정된 위치의 이모지도 해당 텍스트 기준으로 다시 매핑된다. +3. 수정되지 않은 위치의 기존 이모지 매핑은 유지한다. + +### 예시: ComfieZone에서 삭제/수정 +1. 기존 값: `original=가나다`, `emoji=😀😃😄` +2. 가운데 `나`를 삭제해 `가다`로 수정 +3. 결과: 같은 위치 이모지도 함께 제거되어 길이가 동기화되어야 함 +4. 중간 글자를 다른 글자로 치환하면, 해당 위치 이모지는 새 글자 기준으로 갱신되어야 함 + +## 시나리오 예시 +### 예시 1: `abc` +1. `a` 입력 직후: `a`는 원문 상태 +2. `b` 입력 직후: `a`는 이모지, `b`는 원문 +3. `c` 입력 직후: `a`,`b`는 이모지, `c`는 원문 +4. 저장 시: `c`까지 이모지 확정 후 저장 + +### 예시 2: `밖으로` +1. `밖` 확정 직후: `밖`은 원문 상태 +2. 다음 글자 조합 시작(`ㅇ` 입력) 시점: `밖` 이모지 변환 +3. 마지막 글자 입력 후 저장: 마지막 글자도 변환 확정 + +### 예시 3: 중간 커서 입력 +1. 기존 문자열 중간으로 커서를 이동해 1글자 입력 +2. 방금 입력한 글자는 보류, 그 앞 확정 글자까지 변환 +3. 이후 다음 입력/flush 이벤트에서 보류 글자 변환 + +## 저장 일관성 규칙 +1. 저장 트랜잭션 진입 전, 입력창의 보류 변환을 먼저 flush한다. +2. 최종 스냅샷(`originalText`, `emojiText`)과 `requestID` 검증 기준으로 저장한다. +3. 결과적으로 저장된 `emojiText`에는 미변환 마지막 글자가 남지 않는다. + +## 수용 기준(AC) +1. 단일 입력 연속 타이핑 시 매 단계에서 "현재 글자 보류"가 유지된다. +2. IME 조합 중 텍스트 리셋/커서 점프/조합 깨짐이 발생하지 않는다. +3. 붙여넣기 직후 붙여넣은 구간이 즉시 변환된다. +4. 중간 커서 입력에서도 동일 규칙으로 동작한다. +5. 저장 시 최종 문자열 전체가 변환 규칙을 만족한다. +6. ComfieZone에서 삭제/수정한 위치는 같은 위치의 이모지에 즉시 반영되고, 미수정 위치 매핑은 유지된다. From 5e92624199fec3cf7f9094334624c52dd33da389 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:09:30 +0900 Subject: [PATCH 5/8] chore: clean repository tracking and project metadata --- .gitignore | 6 + COMFIE.xcodeproj/project.pbxproj | 186 ++++++++---------- .../xcshareddata/xcschemes/COMFIE.xcscheme | 2 +- docs/git/git-convention.md | 55 ++++++ docs/git/pr-convention.md | 79 ++++++++ 5 files changed, 226 insertions(+), 102 deletions(-) create mode 100644 docs/git/git-convention.md create mode 100644 docs/git/pr-convention.md diff --git a/.gitignore b/.gitignore index 79c6e2e..eddbcaa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ Package.resolved *.plist *.xcprivacy .DS_Store + +# Documentation artifacts +docs/ + +# Local agent instruction file +AGENTS.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 @@ -` +- `bugfix/-` +- `release/` + +Examples: +- `feature/ABC-123-profile-edit` +- `bugfix/ABC-456-login-crash` +- `release/1.2.0` + +--- + +## 2) Commit Convention + +### Commit Message Format +```text +: + + +``` + +### Allowed Types +- `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci` + +### Rules +- Keep `type` in **English**. +- Write the subject/body in **English**. +- Keep the subject concise and clearly describe the intent of the change. + +Examples: +- `feat: add profile edit` +- `fix: handle missing error message on login failure` +- `refactor: extract profile validation logic` + +--- + +## 3) Related Docs + +- PR preparation, writing, and review rules are defined in: + - `./docs/git/pr-convention.md` diff --git a/docs/git/pr-convention.md b/docs/git/pr-convention.md new file mode 100644 index 0000000..65da16f --- /dev/null +++ b/docs/git/pr-convention.md @@ -0,0 +1,79 @@ +# PR Convention + +## 1) Scope + +- This document defines PR preparation, writing, and review rules. +- Branching and commit rules are defined in: + - `./docs/git/git-convention.md` + +--- + +## 2) PR Preparation Workflow + +### Before Creating a PR +1. Review the entire commit history (do not check only the latest commit). + - `git log --oneline [base-branch]..HEAD` +2. Review all changes. + - `git diff [base-branch]...HEAD` +3. Verify the working tree does not include unintended files. + - `git status --short` + +### Branch Push Rule +- For the first push of a new branch, set upstream: + - `git push -u origin ` + +--- + +## 3) PR Writing Rules + +- Write the PR title and description in **Korean**. +- If `.github/pull_request_template.md` exists, use it as the default PR description format. +- If the template file does not exist, use the lightweight format below. +- Recommended: keep commit `type` in English and write the PR title as `: <한글 요약>`. + - Example: `refactor: 메인 홈 독서 흐름 MVVM 전환` + +### Lightweight PR Description (Only when template is missing) +- `## 작업 내용` + - 핵심 변경 사항 + - 변경 이유 +- `## 테스트` + - 수행한 테스트와 결과 +- `## 후속 작업` (optional) +- `## 리뷰 포인트` (optional) +- `## 스크린샷` (optional) + +--- + +## 4) PR Review Convention + +### Mandatory Review Checklist +1. Confirm the review target is the latest `HEAD`. +2. Re-check latest commit and diff right before posting comments. +3. Review entire commit history and all changed files. +4. Verify bug/regression/risk points and test evidence. +5. Leave each finding as an inline comment with priority. + +### Rules +- Write review comments in **Korean**. +- Leave all code findings (bugs, regressions, risks) as **inline comments** on PR diff lines. +- Do **not** leave only top-level PR comments for code findings. +- Use one finding per comment with a priority tag: `[P0]`, `[P1]`, `[P2]`, `[P3]`. +- If inline is impossible, leave a top-level comment and explicitly state why inline is not possible. +- When leaving a top-level review comment (summary or fallback), start the first line with `[Agent Review]`. +- If another agent is actively updating the same PR/branch, refresh to latest `HEAD` before final review submission to avoid stale comments. + +### Exception: Reviewing Your Own PR +- GitHub does not allow `request-changes` on your own PR. +- In this case, keep all findings as inline comments (same as normal review). +- Use a top-level `--comment` review only as a summary; do not replace inline findings with top-level-only comments. +- If you must use top-level comments for a finding, explicitly explain why inline was not possible. + +### Required Inline Command (`gh`) +```bash +gh api -X POST repos///pulls//comments \ + -f body='[P1] Finding...' \ + -f commit_id= \ + -f path='path/to/file.swift' \ + -F line= \ + -f side=RIGHT +``` From ae7a79e4f9b70c89e2e1495b3a3025610f152148 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Mon, 2 Mar 2026 23:30:43 +0900 Subject: [PATCH 6/8] chore: ignore architecture and stop tracking docs --- .gitignore | 3 + ARCHITECTURE.md | 207 ------------- .../2026-02-15-memo-final-sync-no-timeout.md | 63 ---- ...2-15-memo-input-boundary-simplification.md | 68 ----- .../memo-draft-sot-consolidation-execplan.md | 271 ------------------ docs/git/git-convention.md | 55 ---- docs/git/pr-convention.md | 79 ----- .../memo-emoji-input-product-behavior.md | 66 ----- 8 files changed, 3 insertions(+), 809 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 docs/decisions/2026-02-15-memo-final-sync-no-timeout.md delete mode 100644 docs/decisions/2026-02-15-memo-input-boundary-simplification.md delete mode 100644 docs/execplans/memo-draft-sot-consolidation-execplan.md delete mode 100644 docs/git/git-convention.md delete mode 100644 docs/git/pr-convention.md delete mode 100644 docs/product-specs/memo-emoji-input-product-behavior.md diff --git a/.gitignore b/.gitignore index eddbcaa..6f66bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs/ # Local agent instruction file AGENTS.md + +# Local architecture note +ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 9a29583..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,207 +0,0 @@ -# COMFIE iOS Architecture - -This document is a stable architecture map for recurring contributors and reviewers. -It is based on the current development baseline (`develop` branch). - -## 0) Scope - -### Audience -- Contributors touching multiple features. -- Reviewers validating cross-cutting changes. - -### Non-goals -- File-by-file implementation walkthroughs. -- SwiftUI tutorial material. -- Full API reference for every type. - -Rule of thumb: this is a country map, not a street map. - -## 1) Update policy - -- Update this doc on architecture drift, not on every PR. -- Keep only stable constraints and navigation hints. -- Delete details that track fast-changing behavior. - -## 2) Bird's-eye view - -### 2.1 System overview - -COMFIE is a local-first SwiftUI app for memo capture, retrospection text, and "ComfieZone" location context. -The app applies emoji mapping to text while preserving user input history, and exposes feature flows through a centralized router. - -The runtime model is a layered flow: -`View` -> `Store (Intent/Action/State)` -> `UseCase/Repository` -> `Service/Persistence`. -Data enters from user interactions and device services, then is persisted to local storage and reflected back into derived UI state. - -### 2.2 Inputs -> Outputs (Ground vs Derived) - -Inputs (ground state): -- User input text and UI intents from SwiftUI views. -- Device location and location authorization (`LocationService`). -- Local persisted data in Core Data (`UserRecordModel`) and UserDefaults (`hasEverOnboarded`). - -Outputs (derived state): -- Feature UI state in store `State` structs. -- Navigation stack/path in `Router.path`. -- Emoji-transformed text derived from source text (`EmojiString`, `EmojiCharacter`). -- Rendered views and popups. - -### 2.3 Update model - -- Small changes are feature-local intent handling in one store. -- Stores receive intents, compute new state via actions, and emit side effects via publishers when UI control is required. -- Persistence and OS API calls are delegated to repositories/use cases/services; views do not own IO concerns. - -## 3) Entry points - -- App bootstrap: `COMFIE/App/COMFIEApp.swift` -- Root navigation shell: `COMFIE/App/COMFIERoutingView.swift` -- Routing core: `COMFIE/App/Router/Router.swift`, `COMFIE/App/Router/Route.swift`, `COMFIE/App/Router/Router + navigate.swift` -- Dependency assembly: `COMFIE/App/DIContainer/DIContainer.swift` -- Store protocol contract: `COMFIE/Presentation/Intent/IntentStore.swift` - -## 4) Code map - -### 4.1 Top-level repository map - -- `/COMFIE/App`: App entry, DI setup, route definitions, root navigation logic. -- `/COMFIE/Presentation`: Feature views and stores (Onboarding, Memo, Retrospection, ComfieZoneSetting, More). -- `/COMFIE/Domain`: Domain entities (`Memo`, `ComfieZone`), use case (`LocationUseCase`), emoji mapping model. -- `/COMFIE/Data`: Core Data service, repository implementations, entity mapping extensions, UserDefaults service. -- `/COMFIE/Service`: OS-facing services (`LocationService`, `LocalAuthenticationService`). -- `/COMFIE/Resources`: Design system components, assets, localization strings, shared extensions. -- `/COMFIETests`, `/COMFIEUITests`: Test targets. - -### 4.2 Major components - -#### Component: App bootstrap and routing (`COMFIE/App`) -- Responsibility: Start app, create router and DI container, select root flow, push/pop routes. -- Owns: `Router.path`, loading gate, onboarding completion flag. -- Depends on: SwiftUI navigation primitives, `UserDefaultsService`, `DIContainer`. -- Must not depend on: Feature-specific persistence internals. -- Boundary status: Primary app composition and navigation boundary. -- Key invariants: Route construction goes through `Route` + `DIContainer.makeView`; root flow decision is centralized in `Router.rootView`. - -#### Component: Presentation stores and views (`COMFIE/Presentation`) -- Responsibility: Translate UI intents into state transitions and side effects. -- Owns: Per-feature `State` values and UI side-effect publishers. -- Depends on: `Router`, repository protocols, use cases, and resources. -- Must not depend on: Core Data entities, `CLLocationManager`, or direct storage APIs. -- Boundary status: Main feature behavior boundary. -- Key invariants: Each feature store is the mutation authority for its feature state; views invoke store intents and do not orchestrate persistence directly. - -#### Component: Domain model and behavior (`COMFIE/Domain`) -- Responsibility: Define core app entities and text-to-emoji mapping logic. -- Owns: `Memo`, `ComfieZone`, `EmojiString`, `EmojiCharacter`, `LocationUseCase`. -- Depends on: Foundation/CoreLocation-level types and repository protocols. -- Must not depend on: SwiftUI view types or Core Data entity classes. -- Boundary status: Feature-independent business model boundary. -- Key invariants: `EmojiString` keeps original and emoji character streams aligned; `LocationUseCase` is the location decision API used by stores. - -#### Component: Data persistence (`COMFIE/Data`) -- Responsibility: Persist and fetch domain data from Core Data and UserDefaults. -- Owns: `CoreDataService`, repository implementations, Core Data <-> domain mapping extensions. -- Depends on: Core Data stack and domain entities. -- Must not depend on: SwiftUI view/store types. -- Boundary status: Persistence and storage boundary. -- Key invariants: Core Data entity mapping happens in `COMFIE/Data/CoreData/Extensions` only; store-facing persistence APIs are repository protocols. - -#### Component: System services (`COMFIE/Service`) -- Responsibility: Wrap device and OS framework APIs. -- Owns: Location updates/authorization and local authentication requests. -- Depends on: `CoreLocation`, `MapKit`, `LocalAuthentication`. -- Must not depend on: Feature view logic. -- Boundary status: OS integration boundary. -- Key invariants: Location manager access is centralized in `LocationService`; authentication prompt flow is isolated in `LocalAuthenticationService`. - -#### Component: UI resources and design system (`COMFIE/Resources`) -- Responsibility: Shared UI primitives, assets, typography, localization literals. -- Owns: `CF*` reusable components and string literal namespaces. -- Depends on: SwiftUI/UIKit rendering concerns only. -- Must not depend on: Repositories, use cases, or routing logic. -- Boundary status: Presentation support boundary. -- Key invariants: Feature views should consume shared design/localization components instead of duplicating constants. - -## 5) Architectural invariants - -**Architecture Invariant:** There is no direct Core Data access from presentation stores or views. -- Rationale: Keep persistence details behind repository boundaries. -- Enforced by: Repository protocols and `CoreDataService` ownership in Data layer. -- Violation symptoms: Stores import Core Data types, feature code tightly coupled to schema. - -**Architecture Invariant:** There is no route stack mutation outside `Router` API usage. -- Rationale: Navigation behavior must remain predictable and reviewable. -- Enforced by: `Router.push/pop/popToRoot` and route enum-based flow. -- Violation symptoms: Inconsistent navigation behavior or duplicated navigation state. - -**Architecture Invariant:** There is no domain entity <-> Core Data entity conversion outside mapping extensions. -- Rationale: Avoid scattered serialization logic and schema leak. -- Enforced by: Conversion helpers in `COMFIE/Data/CoreData/Extensions`. -- Violation symptoms: Duplicate conversion code and inconsistent persisted values. - -**Architecture Invariant:** There is no direct OS permission flow in views. -- Rationale: Permissions and device APIs should be testable and centralized. -- Enforced by: `LocationUseCase`, `LocationService`, and popup/store intent flows. -- Violation symptoms: View-level permission branching and hard-to-test UI logic. - -**Architecture Invariant:** There is no mutable shared feature state outside each feature store. -- Rationale: Maintain a single state authority per screen flow. -- Enforced by: `private(set) state` pattern in stores. -- Violation symptoms: Desynchronized UI, race conditions between views and services. - -## 6) Boundaries and API surfaces - -### 6.1 Boundary list - -- Boundary surface: Presentation <-> Domain/Data (`COMFIE/Presentation/*Store.swift`) -- What crosses: Intents, domain entities (`Memo`, `ComfieZone`), repository/use case interfaces. -- Forbidden crossing: Core Data entities, direct `NSManagedObjectContext`, direct `CLLocationManager`. - -- Boundary surface: Domain <-> Data (`COMFIE/Data/Repository/*`, `COMFIE/Domain/*`) -- What crosses: Domain entities and protocol-based operations. -- Forbidden crossing: SwiftUI view concerns and resource/UI component types. - -- Boundary surface: Service <-> OS frameworks (`COMFIE/Service/*`) -- What crosses: Device state (location/auth results) as value/publisher outputs. -- Forbidden crossing: Feature UI mutation logic. - -- Boundary surface: App routing <-> Features (`COMFIE/App/Router/*`, `COMFIE/App/DIContainer/*`) -- What crosses: `Route` values and injected store dependencies. -- Forbidden crossing: Feature code constructing ad-hoc root navigation policies. - -### 6.2 Boundary rules ("only here") - -- Core Data serialization/mapping happens in: `COMFIE/Data/CoreData/Extensions` (only here). -- Persistent IO happens in: `COMFIE/Data/CoreData/CoreDataService.swift` and `COMFIE/Data/UserDefaults/UserDefaultsService.swift` (only here). -- OS framework IO happens in: `COMFIE/Service/LocationService.swift` and `COMFIE/Service/LocalAuthenticationService.swift` (only here). -- Route stack mutation happens through: `COMFIE/App/Router/Router + navigate.swift` (only here). - -## 7) Cross-cutting concerns - -### Emoji transformation pipeline - -- Input text editing is orchestrated by `MemoInputUITextView` delegate events. -- Character-level emoji mapping and synchronization lives in `Domain/TextToEmoji/Model`. -- Features consume emoji mapping as a domain concern, not as view-only string tricks. - -### Location and authorization flow - -- Permission request and location updates are centralized in `LocationService`. -- Stores consume location state through `LocationUseCase` and react in feature state. -- ComfieZone deletion can require authentication when user context requires stricter confirmation. - -### Error handling style - -- Repository APIs return `Result` to keep failure explicit at call sites. -- Store-level handling currently favors user-flow continuity (state update + local fallback/logging). - -### Testing boundary note - -- Test targets exist, but architecture-level boundary enforcement is primarily by module conventions and protocol seams. -- Dependency seams are provided through repository protocols and DI container construction points. - -## 8) Writing constraints - -- Keep this document architecture-first and stable. -- Prefer module/type/path names over implementation details. -- If a section starts tracking rapid UI behavior changes, shorten or remove it. diff --git a/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md b/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md deleted file mode 100644 index b36353c..0000000 --- a/docs/decisions/2026-02-15-memo-final-sync-no-timeout.md +++ /dev/null @@ -1,63 +0,0 @@ -# 2026-02-15 Memo Final Sync timeout 제거 - -- Status: Accepted -- Date: 2026-02-15 -- Owner: Memo feature team (user + assistant) - -## 기존 정책 - -기존 구현은 저장 버튼 이후 `awaitingFinalSync` 상태에서 timeout을 시작하고, -timeout이 지나면 `idle`로 복귀시키는 방식이었습니다. -또한 late callback 허용/차단을 위한 추가 문맥(`timedOutFinalSyncContext`, draftRevision 비교)이 필요했습니다. - -## 문제 - -- timeout 값 조정 자체가 정책 복잡도를 키웠다. -- timeout과 late callback 규칙이 결합되어 상태 전이 이해가 어려웠다. -- 테스트가 timeout 전제 시나리오에 강하게 묶였다. - -## 최종 결정 - -timeout 기반 복구를 제거하고, 실패를 명시 이벤트로 복구한다. - -- 추가 intent: `finalSyncFailed(requestID:)` -- `requestID`가 일치하는 `awaitingFinalSync`에서만 `idle` 복귀 -- `requestID` 불일치 실패 이벤트는 무시 - -## 대체 설계와 기각 이유 - -1. timeout 유지 + 짧게 조정 -기각 이유: 근본 복잡도는 그대로 남고 지연 환경에서 재현성이 떨어진다. - -2. timeout 유지 + late callback 전면 허용 -기각 이유: stale 응답 반영 위험이 커지고 request 상관관계 의미가 약해진다. - -## 구현 반영 - -- timeout 관련 멤버/로직 제거 -`COMFIE/Presentation/Memo/MemoStore.swift` - -- 실패 복구 이벤트 추가 -`MemoStore.Intent.MemoInputIntent.finalSyncFailed(requestID:)` - -- InputView 실패 콜백 경로 추가 -`onOutputEvent(.finalSnapshotFailed(requestID:))` - -## 운영 리스크 및 관찰 포인트 - -- InputView가 응답을 보내지 못하는 케이스는 timeout이 아니라 실패 이벤트 경로를 반드시 보내야 한다. -- `requestID` 일치 검증이 유일한 상관관계 장치이므로 테스트에서 계속 보호해야 한다. - -## 검증 테스트 - -- `MemoStoreInputSnapshotTests/finalSyncFailedMatchingRequestIDReturnsIdle()` -- `MemoStoreInputSnapshotTests/finalSyncFailedWithMismatchedRequestIDIsIgnored()` -- `MemoStoreInputSnapshotTests/finalSyncCompletedWithMismatchedRequestIDDoesNotPersist()` -- `MemoStoreInputSnapshotTests/saveTappedTwiceBeforeFinalSyncEmitsSingleRequest()` - -## 결과 - -timeout 제거 후에도 저장 파이프라인은 유지된다. - -- `idle -> awaitingFinalSync(requestID) -> persisting -> idle` -- 응답 누락/실패는 `finalSyncFailed(requestID)`로 명시 복구 diff --git a/docs/decisions/2026-02-15-memo-input-boundary-simplification.md b/docs/decisions/2026-02-15-memo-input-boundary-simplification.md deleted file mode 100644 index 51c4bdf..0000000 --- a/docs/decisions/2026-02-15-memo-input-boundary-simplification.md +++ /dev/null @@ -1,68 +0,0 @@ -# 2026-02-15 Memo Input 경계 단순화 - -- Status: Accepted -- Date: 2026-02-15 -- Owner: Memo feature team (user + assistant) - -## 문제 정의 - -기존 메모 입력 구조는 `MemoStore`와 `UITextView` 코디네이터가 동시에 입력 중간값(원문/이모지)을 들고 있었습니다. -이중 소유 때문에 IME 조합(`markedTextRange`) 중간 상태, 커서 이동, 붙여넣기, 편집 취소/재진입에서 동기화 복잡도가 계속 증가했습니다. - -## 고려한 대안 - -1. Store가 입력 중간 텍스트를 계속 소유한다. -장점: Store만 보면 상태를 파악하기 쉽다. -단점: UIKit 입력 엔진 특성(조합중 텍스트, attachment, 커서)에 맞춘 상태 동기화 코드가 Store까지 퍼진다. - -2. InputView(`MemoInputUITextView.Coordinator`)가 입력 중간 텍스트를 소유한다. (채택) -장점: IME/커서/attachment와 붙어 있는 로컬 상태를 한 곳에서 처리한다. -단점: 저장 직전 스냅샷 계약을 명시적으로 설계해야 한다. - -## 최종 결정 - -입력 중간 텍스트 소유권은 InputView로 이동하고, Store는 정책/트랜잭션 상태만 소유한다. -Store가 실시간으로 들고 있는 입력 상태는 `isInputEmpty`만 유지한다. - -## 반대 의견과 기각 이유 - -- 반대: Store에 실시간 텍스트를 남겨야 디버깅이 쉽다. -기각 이유: 디버깅 편의보다 조합 입력 안정성이 우선이며, 실시간 텍스트는 UIKit 상태와 분리될수록 불일치 위험이 커진다. - -- 반대: InputView가 너무 똑똑해진다. -기각 이유: InputView가 똑똑해지는 범위는 입력 엔진 기술 세부(IME/커서)이며, 도메인 정책 결정은 Store가 계속 소유한다. - -## 구현 결과 - -다음 경계를 적용했다. - -- `Store -> View -> InputView` 입력 -`inputSeed`, `isEmojiPresentationEnabled`, `MemoInputUICommand` - -- `InputView -> View -> Store` 출력 -`onOutputEvent(MemoInputOutputEvent)` - -- InputView에서 `MemoStore` 직접 참조 제거 -`COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift` -`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift` -`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift` -`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift` - -- MemoView가 중재자 역할 수행 -`COMFIE/Presentation/Memo/MemoView.swift` - -## 트레이드오프 - -- Store에서 실시간 본문 문자열을 바로 볼 수는 없다. -- 대신 저장 트랜잭션 흐름(`requestID`, `savePhase`)과 입력 엔진 흐름이 분리되어 변경 영향 범위가 줄었다. - -## 회귀 방지 테스트 - -- `COMFIETests/MemoStoreInputSnapshotTests.swift` -- `COMFIETests/MemoInputIMEInsertionTests.swift` -- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift` -- `COMFIETests/MemoStoreComfieZoneMappingTests.swift` - -## 후속 관찰 포인트 - -- `MemoStoreInputSnapshotTests` 파일 길이 증가(리팩토링 후 700+ 라인)는 추후 테스트 모듈 분할 대상이다. diff --git a/docs/execplans/memo-draft-sot-consolidation-execplan.md b/docs/execplans/memo-draft-sot-consolidation-execplan.md deleted file mode 100644 index 9cfd837..0000000 --- a/docs/execplans/memo-draft-sot-consolidation-execplan.md +++ /dev/null @@ -1,271 +0,0 @@ -# Memo Input 경계 단순화 + 의사결정 근거 문서화 ExecPlan - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -This document follows the repository ExecPlan guidelines and must be maintained accordingly. - -## Purpose / Big Picture - -이 변경의 목적은 메모 입력 책임을 단순하게 분리해, 사용자가 기존과 같은 동작을 안정적으로 사용하도록 만드는 것이다. 사용자는 이전과 동일하게 입력, 이모지 표시 전환, 저장 버튼 활성화, 저장 중 가드, 편집 취소, 한국어 IME 조합 입력을 사용할 수 있어야 한다. - -차이는 내부 구조다. 이제 입력 중간 텍스트의 소유자는 `UITextView` 경계이고, `MemoStore`는 정책과 저장 트랜잭션만 담당한다. 저장은 "최종 스냅샷" 요청-응답 한 번으로만 진행된다. - -## Progress - -- [x] (2026-02-15 09:20Z) 기존 구현과 테스트를 전수 확인하고, 입력 책임 분리와 timeout 제거 범위를 확정했다. -- [x] (2026-02-15 10:00Z) `MemoStore`에 `inputSeed`, `isInputEmpty`, `savePhase` 중심 상태를 유지하고 입력 중간 본문 상시 보관 책임을 제거했다. -- [x] (2026-02-15 10:35Z) `MemoStore.Intent.MemoInputIntent.finalSyncFailed(requestID:)`를 추가하고 timeout 기반 복구 로직을 제거했다. -- [x] (2026-02-15 11:05Z) `MemoView`를 입력 중재자로 고정하고 Store side effect를 `MemoInputUIEvent`로 전달하도록 연결했다. -- [x] (2026-02-15 11:40Z) `MemoInputTextView`/`MemoInputUITextView`/`Coordinator`에서 `MemoStore` 직접 의존을 제거했다. -- [x] (2026-02-15 12:20Z) `Coordinator`가 로컬 draft(`original/emoji`)를 소유하고 final sync 요청 시점에만 스냅샷을 응답하도록 정리했다. -- [x] (2026-02-15 13:00Z) `textView == nil` 경계에서 local draft fallback 또는 실패 이벤트를 보내 저장 파이프라인 고착을 방지했다. -- [x] (2026-02-15 14:05Z) timeout 전제 테스트를 마이그레이션하고 `finalSyncFailed`, requestID mismatch, 빈 스냅샷 저장 금지 케이스를 강화했다. -- [x] (2026-02-15 14:50Z) `xcodebuild test -scheme COMFIE` 전체 테스트를 통과시켰다. -- [x] (2026-02-15 20:50+0900) 전체 회귀를 재실행해 `** TEST SUCCEEDED **`를 재확인하고 최신 xcresult 경로를 기록했다. -- [x] (2026-02-15 20:55+0900) `docs/decisions` 문서 2개를 작성하고 이 ExecPlan의 living 섹션을 최신 구현 결과로 갱신했다. -- [x] (2026-02-15 21:01+0900) 안전 범위 중복 제거 리팩토링을 적용했다(입력 seed 초기화 공통화, idle 가드 predicate 통합, Coordinator flush+sync helper 통합, Snapshot dead code 제거). -- [x] (2026-02-15 23:18+0900) 문서 변경분(`docs/decisions` 2건 + 본 ExecPlan)을 최종 점검하고 최신 상태로 확정했다. - -## Surprises & Discoveries - -- Observation: 시작 시점에 `docs/decisions` 디렉터리가 존재하지 않아 문서화 산출물을 새로 만들 필요가 있었다. - Evidence: 초기 `ls docs/decisions` 실패 후 디렉터리 생성. - -- Observation: 기존 테스트가 timeout/late-completion 정책에 강하게 결합되어 있어, 실패 이벤트 정책으로 바꾸면서 테스트 의도를 함께 재작성해야 했다. - Evidence: 기존 `COMFIETests/MemoStoreInputSnapshotTests.swift`의 timeout 관련 시나리오 다수 교체. - -- Observation: 입력 타입을 별도 파일로 분리했을 때 타깃 포함 누락으로 컴파일 실패가 발생해, 최종적으로 `MemoStore.swift`에 타입을 공존시켰다. - Evidence: `cannot find type 'MemoInputSeed' in scope` 계열 빌드 에러 재현 후 해결. - -- Observation: ComfieZone plain-mode 매핑 테스트는 단순 원문 비교가 아니라 "기존 이모지 매핑 보존"이 핵심이라, final snapshot fixture를 실제 저장 경로 형태로 맞춰야 했다. - Evidence: `MemoStoreComfieZoneMappingTests`의 fixture 수정 전/후 통과 여부 차이. - -- Observation: `textView == nil` 경계는 이미 회귀 테스트로 보호되고 있었고, 이번 구조에서도 fallback 응답 정책이 유효했다. - Evidence: `MemoStoreInputSnapshotTests`의 nil textView final sync 테스트 통과. - -## Decision Log - -- Decision: 입력 중 원문/이모지 중간값은 Input View(`MemoInputUITextView.Coordinator`)가 소유한다. - Rationale: IME 조합 상태(`markedTextRange`), 커서, attachment 변환은 UIKit 로컬 상태와 분리할수록 오류 위험이 커진다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: Store가 실시간으로 소유하는 입력 상태는 `isInputEmpty` Bool로 제한한다. - Rationale: 버튼 UX/가드에는 충분하고, 본문 동기화 복잡도는 크게 감소한다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: 표시 모드는 도메인 상태인 `isInComfieZone`에서 계산하고 View는 Bool만 소비한다. - Rationale: 도메인 정책 일관성과 테스트 가능성을 유지한다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: 저장 요청은 Store side effect에서 시작하고 MemoView가 InputView와 Store 사이를 중재한다. - Rationale: MVI 흐름을 유지하면서 InputView의 Store 비종속성을 보장한다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: final sync 상관관계는 `requestID` 단일 규칙으로 검증한다. - Rationale: 최소 안전장치로 stale 응답 반영을 차단할 수 있다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: timeout을 제거하고 `finalSyncFailed(requestID)` 이벤트로 `idle` 복귀한다. - Rationale: timeout/late-callback 복합 정책보다 상태 전이가 단순하고 명시적이다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: `textView == nil`일 때는 local draft로 즉시 final snapshot 응답하거나 불가능하면 실패 이벤트를 보낸다. - Rationale: 생명주기 경계에서도 저장 파이프라인 정지를 방지한다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: 빈값 저장 금지는 View 가드와 Store 가드를 모두 유지한다. - Rationale: 제품 불변식은 방어를 한 레이어에만 의존하지 않는다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: 저장 실패 시 입력 draft는 유지하고 savePhase만 `idle`로 복귀한다. - Rationale: 사용자가 즉시 재시도할 수 있어야 한다. - Date/Author: 2026-02-15 / user + assistant - -- Decision: 새 입력 계약 타입(`MemoInputSeed`, `MemoInputSnapshot`, `MemoInputUICommand`, `MemoInputUIEvent`)은 현재 `MemoStore.swift`에 둔다. - Rationale: 별도 파일 타깃 누락으로 인한 빌드 불안정성을 즉시 차단하고, 추후 타깃 정리 시 분리 가능하다. - Date/Author: 2026-02-15 / assistant - -- Decision: 중복 제거 리팩토링은 동작 불변 원칙으로 수행하고, 정책/콜백 시맨틱 변경은 분리한다. - Rationale: 유지보수성을 개선하면서도 저장 파이프라인/IME 회귀 위험을 최소화하기 위해서다. - Date/Author: 2026-02-15 / user + assistant - -## Outcomes & Retrospective - -핵심 목표였던 "동작 동일성 유지 + Store 복잡도 감소 + 의사결정 재사용 가능성"을 달성했다. 사용자는 기존과 같은 화면 동작을 유지하고, 내부적으로는 입력 책임 경계가 명확해졌다. - -Store는 더 이상 입력 중간 문자열 동기화를 강제하지 않고, final sync 트랜잭션과 정책 가드에 집중한다. InputView는 IME/커서/attachment 세부를 로컬 상태로 처리한다. timeout 제거로 save 파이프라인 상태 전이가 단순해졌고, 실패 복구는 명시 이벤트로 관찰 가능해졌다. - -남은 학습 과제는 테스트 파일 분할이다. 특히 `MemoStoreInputSnapshotTests`는 범위가 넓어 이후 유지보수 비용을 낮추기 위해 주제별 파일 분할이 유효하다. - -## Context and Orientation - -이 작업에서 "Input View"는 `UITextView`를 감싼 SwiftUI 브리지 계층(`MemoInputTextView`, `MemoInputUITextView`, `Coordinator`)을 뜻한다. 여기서 "draft"는 사용자가 입력 중인 임시 텍스트 상태이고, "final snapshot"은 저장 직전에 확정된 `(originalText, emojiText)` 값이다. - -핵심 파일은 다음과 같다. - -- `COMFIE/Presentation/Memo/MemoStore.swift`: 도메인 상태, 저장 트랜잭션, 가드 정책을 처리한다. -- `COMFIE/Presentation/Memo/MemoView.swift`: Store side effect를 받아 InputView 명령으로 중재한다. -- `COMFIE/Presentation/Memo/MemoInput/MemoInputTextView.swift`: SwiftUI 입력 컴포넌트 계약. -- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift`: UIKit 브리지 입력/출력 인터페이스. -- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift`: IME/커서/최종 스냅샷 생성 로직. -- `COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift`: draft 가용성 이벤트와 스냅샷 보조 로직. -- `COMFIETests/MemoStoreInputSnapshotTests.swift`: final sync 트랜잭션 회귀군. -- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift`: savePhase 가드 회귀군. -- `COMFIETests/MemoStoreComfieZoneMappingTests.swift`: plain-mode 이모지 매핑 회귀군. -- `COMFIETests/MemoInputIMEInsertionTests.swift`: 입력/IME 경계 회귀군. - -의사결정 기록은 다음 문서에 남겼다. - -- `docs/decisions/2026-02-15-memo-input-boundary-simplification.md` -- `docs/decisions/2026-02-15-memo-final-sync-no-timeout.md` - -## Plan of Work - -먼저 Store와 Input 경계를 계약 기반으로 분리했다. Store는 입력 본문 실시간 동기화를 버리고, `isInputEmpty`와 `savePhase`를 중심으로 저장 정책만 유지한다. 저장 버튼에서 즉시 저장하지 않고 `requestFinalSyncAndResign(requestID)` 명령을 내리고, matching `requestID`의 `finalSyncCompleted`만 저장에 반영한다. - -그다음 MemoView를 중재자로 고정했다. Store side effect를 수신해 InputView 명령으로 전달하고, InputView의 draft/final/fail 이벤트를 다시 Store intent로 전달한다. 이로써 InputView 파일군의 `MemoStore` 직접 참조를 제거했다. - -이후 timeout 경로를 제거했다. 응답 누락이나 불가능한 상태는 시간 기반 추정이 아니라 `finalSyncFailed(requestID)`로 복구한다. `textView == nil` 경계에서는 가능한 경우 local draft를 즉시 final snapshot으로 응답하고, seed와 draft 모두 비어 있으면 실패 이벤트로 복귀한다. - -마지막으로 테스트를 새 계약으로 마이그레이션하고, 결정 근거를 `docs/decisions`에 분리 문서로 남겼다. - -## Concrete Steps - -작업 디렉터리는 `/Users/zaehorang/Documents/Projects/COMFIE-iOS`다. - -timeout 참조 제거 확인: - - rg -n "finalSyncTimedOut|finalSyncTimeout|startFinalSyncTimeout" COMFIE/Presentation/Memo - -기대 결과는 출력 없음이다. - -InputView의 Store 직접 의존 제거 확인: - - rg -n "MemoStore" COMFIE/Presentation/Memo/MemoInput - -기대 결과는 출력 없음이다. - -전체 테스트 검증: - - xcodebuild test -scheme COMFIE -destination 'id=919596A7-E423-4D2A-86EE-A892781BCC2C' -derivedDataPath /tmp/comfie-codex-derived - -기대 결과 핵심 라인은 다음과 같다. - - ** TEST SUCCEEDED ** - -## Validation and Acceptance - -수용 기준은 다음 사용자 행동으로 판단한다. - -- 빈 입력이면 저장 버튼이 비활성이고 Store 저장 진입이 차단된다. -- 저장 버튼 연타 시 `awaitingFinalSync` 요청은 1회만 처리된다. -- `requestID`가 다른 final 응답은 무시된다. -- 응답 누락/실패 시 `finalSyncFailed`로 `savePhase`가 `idle`로 복귀한다. -- `textView == nil` 경계에서도 저장 파이프라인이 고착되지 않는다. -- 저장 실패 후 입력 draft는 유지된다. -- IME marked/unmarked 변환과 plain-mode 이모지 매핑이 회귀하지 않는다. -- savePhase 진행 중 네비게이션/삭제/배경 탭 차단이 유지된다. - -검증 근거 테스트는 다음 파일에 존재한다. - -- `COMFIETests/MemoStoreInputSnapshotTests.swift` -- `COMFIETests/MemoStoreSavePhaseNavigationTests.swift` -- `COMFIETests/MemoStoreComfieZoneMappingTests.swift` -- `COMFIETests/MemoInputIMEInsertionTests.swift` - -## Idempotence and Recovery - -이 계획은 반복 실행 가능하게 구성되었다. Store 경계 변경, View 중재 연결, timeout 제거, 테스트 마이그레이션을 분리 단계로 적용했기 때문에 문제 발생 시 최근 단계만 되돌려 재적용할 수 있다. - -가장 위험한 변경은 timeout 제거였고, 이를 `finalSyncFailed` 대체 경로와 동시 적용해 상태 고착을 방지했다. 실패 시 복구는 단순하다. `savePhase`를 `idle`로 복귀시키고 draft를 유지하므로 사용자는 입력 손실 없이 재시도할 수 있다. - -## Artifacts and Notes - -주요 산출물: - -- `docs/decisions/2026-02-15-memo-input-boundary-simplification.md` -- `docs/decisions/2026-02-15-memo-final-sync-no-timeout.md` - -최종 검증 로그 요약: - - rg -n "finalSyncTimedOut|finalSyncTimeout|startFinalSyncTimeout" COMFIE/Presentation/Memo - (no output) - - rg -n "MemoStore" COMFIE/Presentation/Memo/MemoInput - (no output) - - rg -n "syncSnapshotToStoreIfPossible\(" COMFIE/Presentation/Memo - (no output) - - rg -n "case finalSyncFailed|case finalSyncCompleted|case requestFinalSyncAndResign" COMFIE/Presentation/Memo/MemoStore.swift - (expected interface cases remain) - - xcodebuild test -scheme COMFIE -destination 'id=919596A7-E423-4D2A-86EE-A892781BCC2C' -derivedDataPath /tmp/comfie-codex-derived - ** TEST SUCCEEDED ** - xcresult: /tmp/comfie-codex-derived/Logs/Test/Test-COMFIE-2026.02.15_20-59-18-+0900.xcresult - -## Interfaces and Dependencies - -최종 인터페이스는 다음 규약을 따른다. - -`COMFIE/Presentation/Memo/MemoStore.swift`에서 입력 계약 타입을 제공한다. - - struct MemoInputSeed: Equatable { - var token: Int - var originalText: String - var emojiText: String - } - - struct MemoInputSnapshot: Equatable { - let originalText: String - let emojiText: String - } - - enum MemoInputUICommand: Equatable { - case resignWithSync - case resignWithoutSync - case requestFinalSyncAndResign(requestID: UUID) - case setFocus - } - - struct MemoInputUIEvent: Equatable { - let id: UUID - let command: MemoInputUICommand - } - -`MemoStore.Intent.MemoInputIntent`는 다음 케이스를 가진다. - - case draftAvailabilityChanged(isEmpty: Bool) - case memoInputButtonTapped - case finalSyncCompleted(requestID: UUID, snapshot: MemoInputSnapshot) - case finalSyncFailed(requestID: UUID) - -`MemoStore.State`는 입력 경계 관련으로 다음 속성을 유지한다. - - var inputSeed: MemoInputSeed - var isInputEmpty: Bool - var savePhase: SavePhase - var isInComfieZone: Bool - var editingMemo: Memo? - var deletingMemo: Memo? - -`COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift`의 Input View 계약은 다음이다. - - init( - inputSeed: MemoInputSeed, - isEmojiPresentationEnabled: Bool, - uiCommandEvent: MemoInputUIEvent?, - onOutputEvent: ((MemoInputOutputEvent) -> Void)? = nil - ) - -의존성 경계 규칙: - -- InputView 파일군은 `MemoStore` 타입을 직접 참조하지 않는다. -- MemoView만 Store와 InputView 양쪽을 알고 중재한다. -- 저장 트랜잭션 시작/검증 책임은 Store에 남긴다. -- plain-mode 이모지 보존 계산은 `EmojiString` 도메인 함수에 위임한다. - -Revision Note (2026-02-15 20:50+0900): 전체 테스트를 한 번 더 재실행해 성공 로그와 xcresult 경로를 갱신했다. 문서 기준 검증 증거를 최신 상태로 유지하기 위한 업데이트다. -Revision Note (2026-02-15 20:55+0900): 본 리비전에서 ExecPlan을 2026-02-15 최종 구현 상태로 전면 갱신했다. 기존 timeout/legacy 의존 설명을 제거하고, 실제 반영된 인터페이스, 실패 이벤트 기반 복구, 테스트/문서 산출물 근거를 통합했다. -Revision Note (2026-02-15 21:01+0900): 안전 범위 중복 제거를 코드에 반영하고, 정적 확인(미사용 메서드 제거/인터페이스 유지)과 전체 테스트 성공 증거를 ExecPlan에 추가했다. -Revision Note (2026-02-15 23:18+0900): 문서 변경분을 최종 점검해 결정 문서 2건과 ExecPlan living 섹션을 현재 브랜치 상태와 일치하도록 확정했다. diff --git a/docs/git/git-convention.md b/docs/git/git-convention.md deleted file mode 100644 index 8fddb6b..0000000 --- a/docs/git/git-convention.md +++ /dev/null @@ -1,55 +0,0 @@ -# Git Convention - -## 1) Branch Structure & Rules - -### Default Branches -- `main`: Release (production) -- `develop`: Integration (development baseline) - -### Working Branch Principles -- Always start new work on a **new branch**. -- Do **not** commit directly to `main`, `develop`, or `dev`. -- Merge all working branches into `develop`. -- Merge releases into `main`. -- When starting implementation (e.g., dev work), if you are on a protected branch (`main` / `develop` / `dev`), **create a working branch immediately**. - -### Branch Naming (Recommended) -- `feature/-` -- `bugfix/-` -- `release/` - -Examples: -- `feature/ABC-123-profile-edit` -- `bugfix/ABC-456-login-crash` -- `release/1.2.0` - ---- - -## 2) Commit Convention - -### Commit Message Format -```text -: - - -``` - -### Allowed Types -- `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci` - -### Rules -- Keep `type` in **English**. -- Write the subject/body in **English**. -- Keep the subject concise and clearly describe the intent of the change. - -Examples: -- `feat: add profile edit` -- `fix: handle missing error message on login failure` -- `refactor: extract profile validation logic` - ---- - -## 3) Related Docs - -- PR preparation, writing, and review rules are defined in: - - `./docs/git/pr-convention.md` diff --git a/docs/git/pr-convention.md b/docs/git/pr-convention.md deleted file mode 100644 index 65da16f..0000000 --- a/docs/git/pr-convention.md +++ /dev/null @@ -1,79 +0,0 @@ -# PR Convention - -## 1) Scope - -- This document defines PR preparation, writing, and review rules. -- Branching and commit rules are defined in: - - `./docs/git/git-convention.md` - ---- - -## 2) PR Preparation Workflow - -### Before Creating a PR -1. Review the entire commit history (do not check only the latest commit). - - `git log --oneline [base-branch]..HEAD` -2. Review all changes. - - `git diff [base-branch]...HEAD` -3. Verify the working tree does not include unintended files. - - `git status --short` - -### Branch Push Rule -- For the first push of a new branch, set upstream: - - `git push -u origin ` - ---- - -## 3) PR Writing Rules - -- Write the PR title and description in **Korean**. -- If `.github/pull_request_template.md` exists, use it as the default PR description format. -- If the template file does not exist, use the lightweight format below. -- Recommended: keep commit `type` in English and write the PR title as `: <한글 요약>`. - - Example: `refactor: 메인 홈 독서 흐름 MVVM 전환` - -### Lightweight PR Description (Only when template is missing) -- `## 작업 내용` - - 핵심 변경 사항 - - 변경 이유 -- `## 테스트` - - 수행한 테스트와 결과 -- `## 후속 작업` (optional) -- `## 리뷰 포인트` (optional) -- `## 스크린샷` (optional) - ---- - -## 4) PR Review Convention - -### Mandatory Review Checklist -1. Confirm the review target is the latest `HEAD`. -2. Re-check latest commit and diff right before posting comments. -3. Review entire commit history and all changed files. -4. Verify bug/regression/risk points and test evidence. -5. Leave each finding as an inline comment with priority. - -### Rules -- Write review comments in **Korean**. -- Leave all code findings (bugs, regressions, risks) as **inline comments** on PR diff lines. -- Do **not** leave only top-level PR comments for code findings. -- Use one finding per comment with a priority tag: `[P0]`, `[P1]`, `[P2]`, `[P3]`. -- If inline is impossible, leave a top-level comment and explicitly state why inline is not possible. -- When leaving a top-level review comment (summary or fallback), start the first line with `[Agent Review]`. -- If another agent is actively updating the same PR/branch, refresh to latest `HEAD` before final review submission to avoid stale comments. - -### Exception: Reviewing Your Own PR -- GitHub does not allow `request-changes` on your own PR. -- In this case, keep all findings as inline comments (same as normal review). -- Use a top-level `--comment` review only as a summary; do not replace inline findings with top-level-only comments. -- If you must use top-level comments for a finding, explicitly explain why inline was not possible. - -### Required Inline Command (`gh`) -```bash -gh api -X POST repos///pulls//comments \ - -f body='[P1] Finding...' \ - -f commit_id= \ - -f path='path/to/file.swift' \ - -F line= \ - -f side=RIGHT -``` diff --git a/docs/product-specs/memo-emoji-input-product-behavior.md b/docs/product-specs/memo-emoji-input-product-behavior.md deleted file mode 100644 index 72aa2cc..0000000 --- a/docs/product-specs/memo-emoji-input-product-behavior.md +++ /dev/null @@ -1,66 +0,0 @@ -# Memo Emoji Input Product Behavior - -## 목적 -- 메모 입력 중 문자 -> 이모지 변환 규칙을 UX 관점에서 일관되게 정의한다. -- 영문/한글(IME)/붙여넣기/커서 이동/저장에서 동일한 사용자 기대를 제공한다. - -## 적용 범위 -- 대상 화면: 메모 입력(`MemoInputUITextView`) -- 대상 모드: 이모지 모드(ComfieZone 밖), ComfieZone 원문 모드 편집 -- 비대상: 회고/기타 입력 화면의 별도 이모지 정책 - -## 핵심 UX 원칙 -1. 현재 입력 중인 1글자(또는 IME 조합 중 구간)는 즉시 이모지로 바꾸지 않는다. -2. 확정된 이전 글자부터 이모지 변환한다. -3. 저장 시점에는 미변환 글자가 남지 않도록 모두 확정/변환한다. - -## 입력 이벤트별 규칙 -| 이벤트 | 동작 | -| --- | --- | -| 단일 글자 입력(영문/숫자/기호/한글 확정 1자) | 방금 입력한 글자는 보류하고, 직전 확정 글자만 변환 | -| IME 조합 시작(`markedText`) | 조합 범위 바로 앞 글자까지만 변환, 조합 범위는 보류 | -| IME 조합 종료(`unmarkText`) | 보류 중이던 변환을 적용하고 스냅샷 동기화 | -| 붙여넣기/다글자 치환 | 입력 직후 해당 범위를 즉시 변환 | -| 커서 이동 후 입력 | 위치와 무관하게 동일 규칙 적용(현재 글자 보류, 직전 글자 변환) | -| 포커스 해제/편집 종료/저장 직전 | 보류 글자 flush 후 스냅샷 확정 | - -## ComfieZone 편집 동기화 규칙 -1. ComfieZone(원문 모드)에서 텍스트를 삭제하면, 같은 위치의 이모지도 함께 삭제된다. -2. ComfieZone에서 텍스트를 수정(치환/삽입)하면, 수정된 위치의 이모지도 해당 텍스트 기준으로 다시 매핑된다. -3. 수정되지 않은 위치의 기존 이모지 매핑은 유지한다. - -### 예시: ComfieZone에서 삭제/수정 -1. 기존 값: `original=가나다`, `emoji=😀😃😄` -2. 가운데 `나`를 삭제해 `가다`로 수정 -3. 결과: 같은 위치 이모지도 함께 제거되어 길이가 동기화되어야 함 -4. 중간 글자를 다른 글자로 치환하면, 해당 위치 이모지는 새 글자 기준으로 갱신되어야 함 - -## 시나리오 예시 -### 예시 1: `abc` -1. `a` 입력 직후: `a`는 원문 상태 -2. `b` 입력 직후: `a`는 이모지, `b`는 원문 -3. `c` 입력 직후: `a`,`b`는 이모지, `c`는 원문 -4. 저장 시: `c`까지 이모지 확정 후 저장 - -### 예시 2: `밖으로` -1. `밖` 확정 직후: `밖`은 원문 상태 -2. 다음 글자 조합 시작(`ㅇ` 입력) 시점: `밖` 이모지 변환 -3. 마지막 글자 입력 후 저장: 마지막 글자도 변환 확정 - -### 예시 3: 중간 커서 입력 -1. 기존 문자열 중간으로 커서를 이동해 1글자 입력 -2. 방금 입력한 글자는 보류, 그 앞 확정 글자까지 변환 -3. 이후 다음 입력/flush 이벤트에서 보류 글자 변환 - -## 저장 일관성 규칙 -1. 저장 트랜잭션 진입 전, 입력창의 보류 변환을 먼저 flush한다. -2. 최종 스냅샷(`originalText`, `emojiText`)과 `requestID` 검증 기준으로 저장한다. -3. 결과적으로 저장된 `emojiText`에는 미변환 마지막 글자가 남지 않는다. - -## 수용 기준(AC) -1. 단일 입력 연속 타이핑 시 매 단계에서 "현재 글자 보류"가 유지된다. -2. IME 조합 중 텍스트 리셋/커서 점프/조합 깨짐이 발생하지 않는다. -3. 붙여넣기 직후 붙여넣은 구간이 즉시 변환된다. -4. 중간 커서 입력에서도 동일 규칙으로 동작한다. -5. 저장 시 최종 문자열 전체가 변환 규칙을 만족한다. -6. ComfieZone에서 삭제/수정한 위치는 같은 위치의 이모지에 즉시 반영되고, 미수정 위치 매핑은 유지된다. From f16bdf0b15e005ee3ea38ca518bb36b225e40c10 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Thu, 5 Mar 2026 11:47:01 +0900 Subject: [PATCH 7/8] refactor: normalize memo input comments and remove unused retrospection param --- .../MemoInput/MemoEmojiTokenAttachment.swift | 17 ++--------------- .../MemoInput/MemoIMETrackingTextView.swift | 11 +++-------- .../Memo/MemoInput/MemoInputContracts.swift | 16 ---------------- .../MemoInputUITextView+Coordinator.swift | 10 +++++----- .../MemoInput/MemoInputUITextView+IME.swift | 4 +++- .../MemoInputUITextView+Snapshot.swift | 5 +++-- .../MemoInputUITextView+TextViewLayout.swift | 1 + .../Memo/MemoInput/MemoInputUITextView.swift | 5 +---- .../Retrospection/RetrospectionStore.swift | 4 ++-- COMFIE/Resources/EmojiPool/EmojiPool.swift | 5 +---- 10 files changed, 21 insertions(+), 57 deletions(-) diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift index 2543bd3..8e86ba8 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift @@ -9,48 +9,35 @@ 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 - // NSTextAttachment 기본 초기화를 먼저 수행합니다. super.init(data: nil, ofType: nil) - // 폰트 기준으로 이모지 렌더 크기를 계산합니다. let emojiString = NSAttributedString(string: emoji, attributes: [.font: font]) let textSize = emojiString.size() - // 폭이 0이 되면 토큰이 깨질 수 있으니 최소 1을 보장합니다. let width = max(1, ceil(textSize.width)) - // 높이는 라인 높이에 맞춰 caret/선택 동작을 안정화합니다. let height = ceil(font.lineHeight) let size = CGSize(width: width, height: height) - // 실제 텍스트 대신 표시될 이모지 이미지를 렌더링합니다. image = Self.renderEmojiImage(emoji, in: size, font: font) - // baseline 정렬을 맞추기 위해 descender를 반영합니다. bounds = CGRect(x: 0, y: font.descender, width: width, height: height) } - // 현재 구현에서는 아카이브 복원을 지원하지 않습니다. required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - // 이모지 문자열을 attachment 이미지로 그려서 반환합니다. + // 이모지를 텍스트와 같은 폰트 기준으로 이미지화해 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) diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift index cac76a5..3c58f38 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift @@ -9,21 +9,18 @@ import UIKit // IME(한글/중국어 조합 입력) 상태 변화를 콜백으로 전달하는 UITextView입니다. final class MemoIMETrackingTextView: UITextView { - // 조합 입력 구간이 생겼을 때 호출할 콜백입니다. var onSetMarkedText: ((NSRange) -> Void)? - // 조합 입력이 확정되어 marked 상태가 해제됐을 때 호출할 콜백입니다. var onUnmarkText: (() -> Void)? - // 시스템이 marked text를 설정할 때 우리 로직도 함께 실행합니다. + // marked text가 생기는 즉시 범위를 Coordinator에 전달해 IME 경계를 추적합니다. override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { super.setMarkedText(markedText, selectedRange: selectedRange) - // 현재 marked 범위가 있으면 NSRange로 변환해 상위 코디네이터로 보냅니다. if let range = markedTextRange { onSetMarkedText?(memoIME_nsRange(from: range)) } } - // 조합 입력이 끝날 때를 감지해 후처리를 트리거합니다. + // 조합 입력이 확정되는 시점에 후처리 콜백을 실행합니다. override func unmarkText() { super.unmarkText() onUnmarkText?() @@ -31,11 +28,9 @@ final class MemoIMETrackingTextView: UITextView { } extension UITextView { - // UITextRange를 NSRange로 변환해 배열/스토리지 인덱스 연산에 바로 쓰게 해줍니다. + // UITextRange를 NSRange로 변환해 textStorage 인덱스 연산에 사용합니다. func memoIME_nsRange(from textRange: UITextRange) -> NSRange { - // 문서 시작점부터 시작 위치까지의 오프셋이 NSRange.location입니다. let location = offset(from: beginningOfDocument, to: textRange.start) - // 시작점부터 끝점까지의 오프셋이 NSRange.length입니다. 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 index 0b4d152..7c55f6c 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift @@ -9,56 +9,40 @@ import Foundation // Memo 입력창을 다시 그릴 때 사용하는 seed 스냅샷입니다. struct MemoInputSeed: Equatable { - // seed 변경을 감지하기 위한 증가 토큰입니다. var token: Int - // seed 기준 원문 문자열입니다. var originalText: String - // seed 기준 이모지 문자열입니다. 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 { - // 현재 draft가 비어 있는지 알림 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 { - // 이벤트 고유 ID let id: UUID - // 실행할 실제 명령 let command: MemoInputUICommand init(command: MemoInputUICommand) { - // 이벤트를 새로 만들 때마다 고유 ID를 생성합니다. self.id = UUID() self.command = command } diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift index 40bdd34..51b52a8 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift @@ -31,7 +31,6 @@ extension MemoInputUITextView { var isMutating = false private(set) var pendingChange: PendingChange? - // 조합이 끝난 뒤(handleUnmark) 안전하게 처리하려고 사용합니다. private(set) var deferredChange: PendingChange? var lastSelectionRange = NSRange(location: 0, length: 0) var lastTextChangeTime: TimeInterval = 0 @@ -104,6 +103,7 @@ extension MemoInputUITextView { } #endif + // seed/모드/명령 이벤트를 합쳐 현재 UITextView 상태를 일관되게 재적용합니다. func applyStateToTextView(force: Bool) { handleUICommandIfNeeded() guard let textView else { return } @@ -133,7 +133,7 @@ extension MemoInputUITextView { updateTextViewHeight(textView) return } - // IME 조합 중에는 강제 모드 렌더링으로 조합을 깨지 않도록 대기합니다. + // 조합 중에는 모드 강제 렌더를 미뤄 IME 입력이 깨지지 않게 보호합니다. guard textView.markedTextRange == nil else { return } clearPendingAndDeferredChanges() @@ -176,7 +176,6 @@ extension MemoInputUITextView { } if isEmojiMode { - // true면 한글 IME 조합 중이라는 뜻입니다. let isComposing = (textView.markedTextRange != nil) if isComposing { deferPendingChangeIfNeeded() @@ -227,7 +226,7 @@ extension MemoInputUITextView { guard isEmojiMode else { return } guard !isMutating else { return } - // IME 조합 중 선택 변화는 무시합니다. + // 조합 중 커서 이동 이벤트는 flush 기준이 아니므로 건너뜁니다. guard textView.markedTextRange == nil else { return } guard !NSEqualRanges(lastSelectionRange, textView.selectedRange) else { return } @@ -239,7 +238,7 @@ extension MemoInputUITextView { } } - // MARK: - UI Command +// MARK: - UI Command extension MemoInputUITextView.Coordinator { private func handleUICommandIfNeeded() { guard let commandEvent = parent.uiCommandEvent else { return } @@ -248,6 +247,7 @@ extension MemoInputUITextView.Coordinator { handleUICommand(commandEvent.command) } + // Store에서 내려온 입력 명령을 한 번만 실행하고 sync 정책을 함께 조정합니다. private func handleUICommand(_ command: MemoInputUICommand) { switch command { case .resignWithSync: diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift index 9abb5d2..d4892b6 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift @@ -9,7 +9,6 @@ import UIKit extension MemoInputUITextView.Coordinator { // MARK: - IME Handling - // IME 변환 흐름 지도(요약): // marked 구간이 생겼을 때 조합 시작점 이전 글자만 안전하게 토큰화합니다. func handleMarkedRange(in textView: UITextView, marked: NSRange) { @@ -63,6 +62,7 @@ extension MemoInputUITextView.Coordinator { lastTextLength = storage.length } + // 조합이 끝난 실제 입력 범위를 찾아 attachment 토큰 변환을 적용합니다. func handleNonMarkedChange(in textView: UITextView, change: PendingChange) { guard change.replacementUTF16Length > 0 else { return } @@ -142,6 +142,7 @@ extension MemoInputUITextView.Coordinator { // MARK: - Mode Conversion + // 일반 텍스트 모드를 이모지 표시 모드로 일괄 변환합니다. func convertAllPlainToEmoji(in textView: UITextView) { let storage = textView.textStorage guard storage.length > 0 else { return } @@ -173,6 +174,7 @@ extension MemoInputUITextView.Coordinator { lastTextLength = storage.length } + // attachment 토큰을 원문 문자열로 되돌려 일반 텍스트 모드를 복원합니다. func convertAllToPlain(in textView: UITextView) { let currentSnapshot = snapshot(from: textView.textStorage) let oldSelection = textView.selectedRange diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift index 8fc0abf..df0fc4b 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift @@ -24,6 +24,7 @@ extension MemoInputUITextView.Coordinator { return originalText } + // 현재 textStorage를 draft로 동기화하고 Store에 입력 가능 상태를 알립니다. func syncSnapshotToStore(_ textView: UITextView) { let currentSnapshot = snapshot(from: textView.textStorage) let emojiTextCandidate = isEmojiMode ? currentSnapshot.emoji : currentSnapshot.original @@ -35,9 +36,8 @@ extension MemoInputUITextView.Coordinator { publishDraftAvailability() } + // textView가 없는 생명주기 경계에서도 seed 기반 draft를 복구해 savePhase 고착을 막습니다. func syncDraftFromFallbackIfNeeded() { - // 주로 생명주기 경계(textView=nil)에서 final sync 요청이 들어왔을 때 사용됩니다. - // 이때도 seed 기준으로 일관된 스냅샷을 만들 수 있어 savePhase 고착을 막을 수 있습니다. let originalText = normalizedOriginalText() let emojiText = normalizedEmojiText(with: originalText) @@ -71,6 +71,7 @@ extension MemoInputUITextView.Coordinator { return (original, emoji) } + // 원문/이모지 스냅샷을 현재 모드(UIText/Attachment)에 맞춰 UITextView에 렌더링합니다. func render(_ textView: UITextView, originalText: String, emojiText: String) { let oldSelection = textView.selectedRange isMutating = true diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift index c59938d..8ca9cf9 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift @@ -14,6 +14,7 @@ extension MemoInputUITextView.Coordinator { placeholderLabel.isHidden = !textView.textStorage.string.isEmpty } + // 텍스트 높이를 최대 4줄 범위로 계산하고 스크롤/SwiftUI 높이를 함께 동기화합니다. func updateTextViewHeight(_ textView: UITextView) { let width = textView.bounds.width guard width > 0 else { return } diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift index 0ba09b8..d8710d6 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift @@ -51,14 +51,11 @@ struct MemoInputUITextView: UIViewRepresentable { let heightConstraint = createMaxHeightConstraint(for: textView) textView.delegate = context.coordinator - // 아래 두 콜백은 "일반 delegate만으로는 잡기 어려운 IME 조합 경계"를 잡기 위한 연결입니다. - // setMarkedText/unmarkText 타이밍을 직접 잡아야 조합 중 글자와 확정 글자를 안전하게 구분할 수 있습니다. - // IME marked text가 생길 때 코디네이터가 즉시 토큰화를 조정할 수 있게 연결합니다. + // delegate만으로 놓치기 쉬운 IME 조합 시작/종료 경계를 직접 받아 Coordinator에 전달합니다. textView.onSetMarkedText = { [weak textView, weak coordinator = context.coordinator] markedRange in guard let textView else { return } coordinator?.handleMarkedRange(in: textView, marked: markedRange) } - // IME 조합이 끝나는 순간에도 코디네이터가 후처리하도록 연결합니다. textView.onUnmarkText = { [weak textView, weak coordinator = context.coordinator] in guard let textView else { return } coordinator?.handleUnmark(in: textView) diff --git a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift index 3613b1c..d1877c0 100644 --- a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift +++ b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift @@ -127,7 +127,7 @@ class RetrospectionStore: IntentStore { case .saveRetrospection: persistRetrospection(&newState) case .deleteRetrospection: - deleteRetrospection(newState) + deleteRetrospection() case .showCompleteButton: newState.showCompleteButton = true case .hideCompleteButton: newState.showCompleteButton = false @@ -200,7 +200,7 @@ extension RetrospectionStore { } } - private func deleteRetrospection(_ state: State) { + private func deleteRetrospection() { switch repository.delete(memo: memo) { case .success: print("회고 삭제 성공") diff --git a/COMFIE/Resources/EmojiPool/EmojiPool.swift b/COMFIE/Resources/EmojiPool/EmojiPool.swift index 9c8f307..d4eef37 100644 --- a/COMFIE/Resources/EmojiPool/EmojiPool.swift +++ b/COMFIE/Resources/EmojiPool/EmojiPool.swift @@ -7,8 +7,6 @@ // 앱 전체에서 재사용하는 랜덤 이모지 후보 풀입니다. struct EmojiPool { - // 변환 가능한 글자를 이모지로 치환할 때 뽑아 쓰는 원본 배열입니다. - // 이 배열의 순서 자체에는 의미가 없고, 랜덤 선택만 사용합니다. static let emojiPool: [Character] = [ "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "🥹", "☺️", "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", @@ -127,9 +125,8 @@ struct EmojiPool { "🕐" ] - // 이모지 풀에서 임의의 글자 1개를 뽑습니다. + // 이모지 풀에서 무작위 이모지를 반환하고, 비어 있으면 기본값을 사용합니다. static func getRandomEmoji() -> Character { - // 배열이 비어 있는 예외 상황에서도 앱이 멈추지 않도록 기본값을 둡니다. emojiPool.randomElement() ?? "🐯" } } From 769fdd9094bddc2486c55f82f5bbc300b0b6d2a9 Mon Sep 17 00:00:00 2001 From: zaehorang Date: Thu, 5 Mar 2026 14:11:52 +0900 Subject: [PATCH 8/8] fix: keep memo edit cursor at end on update flow --- .../MemoInputUITextView+Coordinator.swift | 19 ++++++++--- COMFIETests/MemoStoreInputSnapshotTests.swift | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift index 51b52a8..17dd556 100644 --- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift +++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift @@ -105,7 +105,7 @@ extension MemoInputUITextView { // seed/모드/명령 이벤트를 합쳐 현재 UITextView 상태를 일관되게 재적용합니다. func applyStateToTextView(force: Bool) { - handleUICommandIfNeeded() + let handledUICommand = handleUICommandIfNeeded() guard let textView else { return } let modeChanged = lastEmojiMode != isEmojiMode @@ -119,6 +119,9 @@ extension MemoInputUITextView { originalText: seededOriginal, emojiText: seededEmoji ) + if seedTokenChanged, handledUICommand == .setFocus { + placeCursorAtTextEnd(textView) + } syncDraftCache( originalText: seededOriginal, emojiText: seededEmoji @@ -240,11 +243,19 @@ extension MemoInputUITextView { // MARK: - UI Command extension MemoInputUITextView.Coordinator { - private func handleUICommandIfNeeded() { - guard let commandEvent = parent.uiCommandEvent else { return } - guard commandEvent.id != lastHandledUICommandID else { return } + @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 정책을 함께 조정합니다. diff --git a/COMFIETests/MemoStoreInputSnapshotTests.swift b/COMFIETests/MemoStoreInputSnapshotTests.swift index 9c600d3..44102ed 100644 --- a/COMFIETests/MemoStoreInputSnapshotTests.swift +++ b/COMFIETests/MemoStoreInputSnapshotTests.swift @@ -380,6 +380,40 @@ struct MemoStoreInputSnapshotTests { #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()