diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index 67f2c91..d021252 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -3,6 +3,7 @@ import FortuneFeature import Foundation import LuckyActionFeature import MyPageFeature +import TodakFeature @Reducer struct MainTabFeature { @@ -20,7 +21,9 @@ struct MainTabFeature { @ObservableState struct State: Equatable { var selectedTab: Tab + var previousTab: Tab var fortune: FortuneFeature.State + var todak: TodakFeature.State var luckyAction: LuckyActionFeature.State @Presents var pushedLuckyAction: LuckyActionFeature.State? var myPage: MyPageFeature.State @@ -30,13 +33,17 @@ struct MainTabFeature { init( selectedTab: Tab = .fortune, + previousTab: Tab = .fortune, fortune: FortuneFeature.State = .init(), + todak: TodakFeature.State = .init(), luckyAction: LuckyActionFeature.State = .init(), pushedLuckyAction: LuckyActionFeature.State? = nil, myPage: MyPageFeature.State = .init() ) { self.selectedTab = selectedTab + self.previousTab = previousTab self.fortune = fortune + self.todak = todak self.luckyAction = luckyAction self.pushedLuckyAction = pushedLuckyAction self.myPage = myPage @@ -47,6 +54,7 @@ struct MainTabFeature { case selectedTabChanged(Tab) case fortuneNavigationChanged case fortune(FortuneFeature.Action) + case todak(TodakFeature.Action) case luckyAction(LuckyActionFeature.Action) case pushedLuckyAction(PresentationAction) case pushedLuckyActionPresentationChanged(Bool) @@ -62,6 +70,10 @@ struct MainTabFeature { MyPageFeature() } + Scope(state: \.todak, action: \.todak) { + TodakFeature() + } + Scope(state: \.luckyAction, action: \.luckyAction) { LuckyActionFeature() } @@ -69,6 +81,9 @@ struct MainTabFeature { Reduce { state, action in switch action { case let .selectedTabChanged(tab): + if tab == .todak, state.selectedTab != .todak { + state.previousTab = state.selectedTab + } let shouldDiscardEditPresentation = tab != .fortune && state.compatibilityEditSourceID != nil state.selectedTab = tab @@ -91,6 +106,10 @@ struct MainTabFeature { state.selectedTab = .luckyAction return .none + case .todak(.delegate(.closeRequested)): + state.selectedTab = state.previousTab == .todak ? .fortune : state.previousTab + return .none + case .fortune(.delegate(.luckyActionPushRequested)): state.pushedLuckyAction = LuckyActionFeature.State( presentationStyle: .pushed, @@ -135,10 +154,11 @@ struct MainTabFeature { action: .compatibility(.mySajuRefreshRequested) ) ) - ) ) + ) - case .fortune, .luckyAction, .pushedLuckyAction, .pushedLuckyActionPresentationChanged, .myPage: + case .fortune, .todak, .luckyAction, .pushedLuckyAction, + .pushedLuckyActionPresentationChanged, .myPage: return .none } } diff --git a/Projects/App/Sources/MainTab/MainTabView.swift b/Projects/App/Sources/MainTab/MainTabView.swift index 4bfcbc5..4a78ecd 100644 --- a/Projects/App/Sources/MainTab/MainTabView.swift +++ b/Projects/App/Sources/MainTab/MainTabView.swift @@ -5,6 +5,7 @@ import Foundation import LuckyActionFeature import MyPageFeature import SwiftUI +import TodakFeature struct MainTabView: View { @Bindable var store: StoreOf @@ -38,7 +39,9 @@ struct MainTabView: View { fortuneTab case .todak: - UnavailableTabView(title: "토닥이") + TodakView( + store: store.scope(state: \.todak, action: \.todak) + ) case .luckyAction: LuckyActionView( @@ -83,7 +86,8 @@ struct MainTabView: View { && store.myPage.withdrawal == nil && store.myPage.sajuManagement == nil ) - return fortuneShowing + return store.selectedTab != .todak + && fortuneShowing && myPageShowing && !isMyInfoEditPresented && store.pushedLuckyAction == nil diff --git a/Projects/App/Sources/Root/RootApp.swift b/Projects/App/Sources/Root/RootApp.swift index 1a7f564..8a4e8af 100644 --- a/Projects/App/Sources/Root/RootApp.swift +++ b/Projects/App/Sources/Root/RootApp.swift @@ -11,6 +11,7 @@ import MyPageFeature import NetworkCore import OnboardingFeature import SwiftUI +import TodakFeature @main struct TodakunApp: App { @@ -30,6 +31,7 @@ struct TodakunApp: App { let fortuneClient: FortuneClient let luckyActionClient: LuckyActionClient let myPageClient: MyPageClient + let todakClient: TodakClient if let baseURL = configuration.apiBaseURL { let authHTTPClient = HTTPClient(baseURL: baseURL) @@ -52,11 +54,22 @@ struct TodakunApp: App { fortuneClient = FortuneClient.live(httpClient: authenticatedHTTPClient) luckyActionClient = LuckyActionClient.live(httpClient: authenticatedHTTPClient) myPageClient = MyPageClient.live(httpClient: authenticatedHTTPClient) + let authenticatedSSEClient = SSEClient( + baseURL: baseURL, + defaultHeaders: { + await authSession.authorizationHeaders() + } + ) + todakClient = TodakClient.live( + httpClient: authenticatedHTTPClient, + sseClient: authenticatedSSEClient + ) } else { authClient = .unavailable fortuneClient = .unavailable luckyActionClient = .unavailable myPageClient = .unavailable + todakClient = .unavailable } Task { @MainActor in @@ -73,6 +86,7 @@ struct TodakunApp: App { $0.fortuneClient = fortuneClient $0.luckyActionClient = luckyActionClient $0.myPageClient = myPageClient + $0.todakClient = todakClient $0.socialLoginClient = .live(configuration: configuration) } } diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift index c097ad4..2105ee2 100644 --- a/Projects/App/Tests/MainTabFeatureTests.swift +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -3,6 +3,7 @@ import FortuneFeature import Foundation import LuckyActionFeature import Testing +import TodakFeature @testable import Todakun @Suite @@ -229,4 +230,22 @@ struct MainTabFeatureTests { store.state.fortune.viewState == .failed(message: "네트워크 오류가 발생했습니다.") ) } + + @Test + func closingTodakReturnsToPreviousTab() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + await store.send(.selectedTabChanged(.myPage)) { + $0.selectedTab = .myPage + } + await store.send(.selectedTabChanged(.todak)) { + $0.previousTab = .myPage + $0.selectedTab = .todak + } + await store.send(.todak(.delegate(.closeRequested))) { + $0.selectedTab = .myPage + } + } } diff --git a/Projects/Core/DesignSystem/Docs/Components/Chat_Type_box.md b/Projects/Core/DesignSystem/Docs/Components/Chat_Type_box.md index b522423..8650c35 100644 --- a/Projects/Core/DesignSystem/Docs/Components/Chat_Type_box.md +++ b/Projects/Core/DesignSystem/Docs/Components/Chat_Type_box.md @@ -11,6 +11,7 @@ - **긴 텍스트 처리:** 3줄 초과 시 텍스트 박스 내에서 위로 스크롤되며 가려짐. 가려지는 부분에 `#FFFFFF` 60% 레이어 씌움. - **긴 텍스트 페이드:** 상단에 배경색 60% 불투명도 레이어를 16px 높이로 표시. - **그림자:** black 6%, blur 20pt, y 4pt +- **커서·스크롤:** UIKit 기본 커서를 사용한다. 최대 높이 전에는 `UITextView`의 content offset을 직접 조정하지 않으며, 최대 높이 초과 시 기본 caret 스크롤을 사용한다. 입력 중에는 SwiftUI 갱신마다 `UITextView`의 font·typing attributes를 재설정하지 않는다. ## 🏗️ Structure & Layout diff --git a/Projects/Core/DesignSystem/Docs/Components/Conversation_History_List.md b/Projects/Core/DesignSystem/Docs/Components/Conversation_History_List.md index 3285bd1..7530938 100644 --- a/Projects/Core/DesignSystem/Docs/Components/Conversation_History_List.md +++ b/Projects/Core/DesignSystem/Docs/Components/Conversation_History_List.md @@ -18,4 +18,4 @@ - 하단 구분선은 셀 컴포넌트가 아닌 부모 대화 목록의 책임이다. 마지막 셀에는 구분선을 렌더링하지 않도록 목록 구현에서 제어한다. - **대화 제목:** 고정 높이 96pt 행의 레이아웃을 유지하기 위해 한 줄로 표시하고, 초과 텍스트는 말줄임표로 처리한다. -- 제목·읽지 않음 표시 영역은 320pt, 삭제 아이콘은 23pt이며 두 영역 사이 간격은 10pt다. 삭제 아이콘과 읽지 않음 표시는 제목의 상단에 맞춘다. +- Figma 기준 화면(393pt)에서 제목·읽지 않음 표시 영역은 320pt, 삭제 아이콘은 23pt이며 두 영역 사이 간격은 10pt다. 실제 컴포넌트는 좌우 20pt inset만 고정하고 제목 영역은 남은 폭으로 확장하므로, 어떤 화면 폭에서도 삭제 아이콘은 trailing inset에 정렬된다. 삭제 아이콘과 읽지 않음 표시는 제목의 상단에 맞춘다. diff --git a/Projects/Core/DesignSystem/Docs/Components/Todak_Example_Question.md b/Projects/Core/DesignSystem/Docs/Components/Todak_Example_Question.md index 20ef475..b8d882d 100644 --- a/Projects/Core/DesignSystem/Docs/Components/Todak_Example_Question.md +++ b/Projects/Core/DesignSystem/Docs/Components/Todak_Example_Question.md @@ -6,9 +6,15 @@ ## 🏗️ Structure & Layout -- 🖼️ **Todak Example Question** (COMPONENT) `W: 284.0, H: 48.0` - - 🖼️ **User Chat** (INSTANCE) `W: 284.0, H: 48.0` [X: 0.0, Y: 0.0 | Fill: primary50 (#f5f3fe) (op: 1.00) | Radius: 12] - - 📝 **📅 중요한 일정 잡기 좋은 날인지 궁금해** (TEXT) `W: 248.0, H: 24.0` [X: 18.0, Y: 12.0 | Font: dsBody2Regular (Figma LH: 24.0px) | Color: coolGray800] +- 🖼️ **Todak Example Question** (COMPONENT) `H: 48.0 min` + - 🖼️ **User Chat** (INSTANCE) [Fill: primary50 (#f5f3fe) (op: 1.00) | Radius: 12] + - 📝 **📅 중요한 일정 잡기 좋은 날인지 궁금해** [X: 18.0, Y: 12.0 | Font: dsBody2Regular (Figma LH: 24.0px) | Color: coolGray800] + +## 폭 계약 + +- Figma의 `W: 284.0`은 해당 예시 문구가 만든 결과 폭이며, 컴포넌트의 고정 폭이 아니다. +- 질문 말풍선은 텍스트의 intrinsic width와 좌우 padding `18pt`로 폭을 결정한다. +- 부모가 제공하는 너비보다 긴 문구는 부모 폭 안에서 줄바꿈하며, `48pt`는 최소 높이로 유지한다. ## 콘텐츠 API diff --git a/Projects/Core/DesignSystem/Example/Sources/Playgrounds/ConversationHistoryListPlaygroundView.swift b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/ConversationHistoryListPlaygroundView.swift index a6c25e2..0f79d14 100644 --- a/Projects/Core/DesignSystem/Example/Sources/Playgrounds/ConversationHistoryListPlaygroundView.swift +++ b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/ConversationHistoryListPlaygroundView.swift @@ -35,7 +35,6 @@ struct ConversationHistoryListPlaygroundView: View { } Section(header: Text("Figma Specification Check")) { - DSSpecificationRow(title: "Content Width", value: specification.contentWidth.ptDescription) DSSpecificationRow( title: "Horizontal Padding", value: specification.horizontalPadding.ptDescription @@ -49,10 +48,6 @@ struct ConversationHistoryListPlaygroundView: View { title: "Title Truncation Mode", value: String(describing: specification.titleTruncationMode) ) - DSSpecificationRow( - title: "Title Indicator Width", - value: specification.titleIndicatorWidth.ptDescription - ) DSSpecificationRow(title: "Unread Indicator Size", value: specification.indicatorSize.ptDescription) DSSpecificationRow( title: "Unread Indicator Color", diff --git a/Projects/Core/DesignSystem/Example/Sources/Playgrounds/TodakExampleQuestionPlaygroundView.swift b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/TodakExampleQuestionPlaygroundView.swift index 276d38b..bd5f254 100644 --- a/Projects/Core/DesignSystem/Example/Sources/Playgrounds/TodakExampleQuestionPlaygroundView.swift +++ b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/TodakExampleQuestionPlaygroundView.swift @@ -22,7 +22,6 @@ struct TodakExampleQuestionPlaygroundView: View { .init(trailingText) ] ) - .frame(maxWidth: 329) } Form { diff --git a/Projects/Core/DesignSystem/Sources/Components/Chat/DSChatTypeBox.swift b/Projects/Core/DesignSystem/Sources/Components/Chat/DSChatTypeBox.swift index 221e5ae..b829bf8 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Chat/DSChatTypeBox.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Chat/DSChatTypeBox.swift @@ -251,7 +251,7 @@ private struct DSChatTextEditor: UIViewRepresentable { textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.showsVerticalScrollIndicator = false - textView.alwaysBounceVertical = true + textView.alwaysBounceVertical = false textView.onContentHeightChange = { measuredHeight in guard abs(contentHeight - measuredHeight) > 0.5 else { return } contentHeight = measuredHeight @@ -264,8 +264,6 @@ private struct DSChatTextEditor: UIViewRepresentable { if textView.text != text { textView.text = text } - configure(textView) - textView.setNeedsLayout() } private func configure(_ textView: DSChatTextView) { @@ -293,25 +291,6 @@ private struct DSChatTextEditor: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { text = textView.text - textView.setNeedsLayout() - - DispatchQueue.main.async { - guard let chatTextView = textView as? DSChatTextView else { return } - - let textLength = (textView.text as NSString).length - let contentOverflows = chatTextView.resolvedContentHeight() > textView.bounds.height - - if contentOverflows { - textView.scrollRangeToVisible( - NSRange(location: textLength, length: 0) - ) - } else { - textView.setContentOffset( - CGPoint(x: textView.contentOffset.x, y: 0), - animated: false - ) - } - } } func textViewDidBeginEditing(_ textView: UITextView) { @@ -336,13 +315,6 @@ final class DSChatTextView: UITextView { let contentHeight = resolvedContentHeight() - if contentHeight <= bounds.height { - setContentOffset( - CGPoint(x: contentOffset.x, y: 0), - animated: false - ) - } - guard abs(lastReportedContentHeight - contentHeight) > 0.5 else { return } lastReportedContentHeight = contentHeight onContentHeightChange?(contentHeight) diff --git a/Projects/Core/DesignSystem/Sources/Components/Chat/DSConversationHistoryList.swift b/Projects/Core/DesignSystem/Sources/Components/Chat/DSConversationHistoryList.swift index 3615f31..9645726 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Chat/DSConversationHistoryList.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Chat/DSConversationHistoryList.swift @@ -2,7 +2,6 @@ import SwiftUI public struct DSConversationHistoryList: View { public struct Specification: Sendable { - public let contentWidth: CGFloat public let horizontalPadding: CGFloat public let topPadding: CGFloat public let bottomPadding: CGFloat @@ -10,7 +9,6 @@ public struct DSConversationHistoryList: View { public let titleColorAsset: DesignSystemColors public let titleLineLimit: Int public let titleTruncationMode: Text.TruncationMode - public let titleIndicatorWidth: CGFloat public let indicatorSize: CGFloat public let indicatorColorAsset: DesignSystemColors public let titleIndicatorSpacing: CGFloat @@ -24,7 +22,6 @@ public struct DSConversationHistoryList: View { } public static let specification = Specification( - contentWidth: 353, horizontalPadding: 20, topPadding: 20, bottomPadding: 20, @@ -32,7 +29,6 @@ public struct DSConversationHistoryList: View { titleColorAsset: DesignSystemAsset.Colors.black, titleLineLimit: 1, titleTruncationMode: .tail, - titleIndicatorWidth: 320, indicatorSize: 6, indicatorColorAsset: DesignSystemAsset.Colors.red400, titleIndicatorSpacing: 6, @@ -84,7 +80,7 @@ public struct DSConversationHistoryList: View { .dsDebugDetailGeometry("DSConversationHistoryList.UnreadIndicator") } } - .frame(width: specification.titleIndicatorWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) Button(action: onDelete) { DSIcon( @@ -103,12 +99,12 @@ public struct DSConversationHistoryList: View { ) ) } - .frame(width: specification.contentWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) Text(time) .dsFont(specification.timeFont) .foregroundStyle(specification.timeColorAsset.swiftUIColor) - .frame(width: specification.contentWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, specification.horizontalPadding) .padding(.top, specification.topPadding) diff --git a/Projects/Core/DesignSystem/Sources/Components/Chat/DSTodakExampleQuestion.swift b/Projects/Core/DesignSystem/Sources/Components/Chat/DSTodakExampleQuestion.swift index d825e1a..4c5fa8f 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Chat/DSTodakExampleQuestion.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Chat/DSTodakExampleQuestion.swift @@ -50,7 +50,6 @@ public struct DSTodakExampleQuestion: View { questionText(specification: specification) .dsFont(specification.fontStyle) - .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, specification.horizontalPadding) .padding(.vertical, specification.verticalPadding) .frame(minHeight: specification.height) diff --git a/Projects/Core/DesignSystem/Tests/DSConversationHistoryListSpecificationTests.swift b/Projects/Core/DesignSystem/Tests/DSConversationHistoryListSpecificationTests.swift index 34e0ccd..8d54a36 100644 --- a/Projects/Core/DesignSystem/Tests/DSConversationHistoryListSpecificationTests.swift +++ b/Projects/Core/DesignSystem/Tests/DSConversationHistoryListSpecificationTests.swift @@ -6,7 +6,6 @@ struct DSConversationHistoryListSpecTests { func testSpecification() { let specification = DSConversationHistoryList.specification - #expect(specification.contentWidth == 353) #expect(specification.horizontalPadding == 20) #expect(specification.topPadding == 20) #expect(specification.bottomPadding == 20) @@ -14,7 +13,6 @@ struct DSConversationHistoryListSpecTests { expectColorEqual(specification.titleColorAsset, DesignSystemAsset.Colors.black) #expect(specification.titleLineLimit == 1) #expect(specification.titleTruncationMode == .tail) - #expect(specification.titleIndicatorWidth == 320) #expect(specification.indicatorSize == 6) expectColorEqual(specification.indicatorColorAsset, DesignSystemAsset.Colors.red400) #expect(specification.titleIndicatorSpacing == 6) diff --git a/Projects/Core/NetworkCore/Sources/SSEClient.swift b/Projects/Core/NetworkCore/Sources/SSEClient.swift index 9b2f7e7..df60104 100644 --- a/Projects/Core/NetworkCore/Sources/SSEClient.swift +++ b/Projects/Core/NetworkCore/Sources/SSEClient.swift @@ -1,5 +1,9 @@ import Foundation +#if DEBUG +import OSLog +#endif + /// SSE 요청 전송, 응답 검증과 이벤트 스트림 생명주기를 담당합니다. /// /// `SSEClient`는 이벤트의 `data` 값을 Feature DTO로 해석하거나 자동 재연결하지 않습니다. @@ -100,7 +104,9 @@ public struct SSEClient: Sendable { let producerTask = Task { do { let request = try await requestBuilder.makeRequest(for: endpoint) + NetworkCoreSSEDebugLogger.requestStarted(request) let connection = try await transport(request) + NetworkCoreSSEDebugLogger.connectionOpened(connection.response) cancellationRelay.install { connection.cancel() } @@ -110,23 +116,40 @@ public struct SSEClient: Sendable { try Task.checkCancellation() var parser = SSEParser() + var rawLineCount = 0 + var emittedEventCount = 0 for try await line in connection.lines { try Task.checkCancellation() + rawLineCount += 1 + NetworkCoreSSEDebugLogger.rawLineReceived(byteLength: line.utf8.count) if let event = parser.consume(line) { + emittedEventCount += 1 + NetworkCoreSSEDebugLogger.eventCompleted( + name: event.event, + dataLength: event.data?.utf8.count + ) eventContinuation.yield(event) } } try Task.checkCancellation() + NetworkCoreSSEDebugLogger.streamEnded( + rawLineCount: rawLineCount, + emittedEventCount: emittedEventCount + ) eventContinuation.finish() } catch let clientError as SSEClientError { + NetworkCoreSSEDebugLogger.streamFailed(clientError) eventContinuation.finish(throwing: clientError) } catch is CancellationError { + NetworkCoreSSEDebugLogger.streamCancelled() eventContinuation.finish(throwing: CancellationError()) } catch let urlError as URLError where urlError.code == .cancelled { + NetworkCoreSSEDebugLogger.streamCancelled() eventContinuation.finish(throwing: CancellationError()) } catch let urlError as URLError { + NetworkCoreSSEDebugLogger.streamFailed(urlError) eventContinuation.finish( throwing: SSEClientError.transportFailed( code: urlError.code, @@ -134,6 +157,7 @@ public struct SSEClient: Sendable { ) ) } catch { + NetworkCoreSSEDebugLogger.streamFailed(error) eventContinuation.finish( throwing: SSEClientError.transportFailed( code: nil, @@ -151,6 +175,60 @@ public struct SSEClient: Sendable { } } +private enum NetworkCoreSSEDebugLogger { + static func requestStarted(_ request: URLRequest) { + let method = request.httpMethod ?? "none" + let path = request.url?.path ?? "none" + let headerNames = request.allHTTPHeaderFields? + .keys + .sorted() + .joined(separator: ",") ?? "none" + + log( + "request method=\(method) path=\(path) " + + "bodyLength=\(request.httpBody?.count ?? 0) headers=\(headerNames)" + ) + } + + static func connectionOpened(_ response: URLResponse) { + let status = (response as? HTTPURLResponse) + .map { String($0.statusCode) } ?? "non-http" + let mimeType = response.mimeType ?? "none" + log("connection opened status=\(status) mime=\(mimeType)") + } + + static func rawLineReceived(byteLength: Int) { + log("raw line received byteLength=\(byteLength)") + } + + static func eventCompleted(name: String?, dataLength: Int?) { + let eventName = name ?? "none" + log("event completed name=\(eventName) dataLength=\(dataLength ?? 0)") + } + + static func streamEnded(rawLineCount: Int, emittedEventCount: Int) { + log("stream ended rawLineCount=\(rawLineCount) emittedEventCount=\(emittedEventCount)") + } + + static func streamCancelled() { + log("stream cancelled") + } + + static func streamFailed(_ error: Error) { + log("stream failed type=\(String(reflecting: type(of: error))) detail=\(String(describing: error))") + } + + private static func log(_ message: String) { + #if DEBUG + logger.debug("[NetworkCoreSSE] \(message, privacy: .public)") + #endif + } + + #if DEBUG + private static let logger = Logger(subsystem: "com.yapp.todakun.network", category: "SSE") + #endif +} + private extension SSEClient { static func validate(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { @@ -174,9 +252,13 @@ private extension SSEClient { let lines = LineStream { lineContinuation in let forwardingTask = Task { do { - for try await line in bytes.lines { + var lineBuffer = SSELineBuffer() + for try await byte in bytes { try Task.checkCancellation() - lineContinuation.yield(line) + + if let line = try lineBuffer.consume(byte) { + lineContinuation.yield(line) + } } try Task.checkCancellation() @@ -210,3 +292,36 @@ private extension SSEClient { } } } + +struct SSELineBuffer: Sendable { + private var bytes: [UInt8] = [] + private var ignoresNextLineFeed = false + + mutating func consume(_ byte: UInt8) throws -> String? { + if ignoresNextLineFeed { + ignoresNextLineFeed = false + if byte == 0x0A { + return nil + } + } + + switch byte { + case 0x0A: + return try drainLine() + case 0x0D: + ignoresNextLineFeed = true + return try drainLine() + default: + bytes.append(byte) + return nil + } + } + + private mutating func drainLine() throws -> String { + defer { bytes.removeAll(keepingCapacity: true) } + guard let line = String(bytes: bytes, encoding: .utf8) else { + throw SSEClientError.invalidUTF8 + } + return line + } +} diff --git a/Projects/Core/NetworkCore/Sources/SSEClientError.swift b/Projects/Core/NetworkCore/Sources/SSEClientError.swift index 9ea62ee..c622cc9 100644 --- a/Projects/Core/NetworkCore/Sources/SSEClientError.swift +++ b/Projects/Core/NetworkCore/Sources/SSEClientError.swift @@ -15,6 +15,9 @@ public enum SSEClientError: Error, Sendable { /// - Parameter actual: 서버가 반환한 미디어 타입입니다. case invalidContentType(actual: String?) + /// SSE 원문 라인이 UTF-8로 해석되지 않습니다. + case invalidUTF8 + /// 스트림 전송 계층에서 오류가 발생했습니다. /// /// - Parameters: diff --git a/Projects/Core/NetworkCore/Tests/SSEParserTests.swift b/Projects/Core/NetworkCore/Tests/SSEParserTests.swift index 5dcc9b3..2c51e52 100644 --- a/Projects/Core/NetworkCore/Tests/SSEParserTests.swift +++ b/Projects/Core/NetworkCore/Tests/SSEParserTests.swift @@ -2,6 +2,43 @@ import Testing @testable import NetworkCore struct SSEParserTests { + @Test("raw byte line splitter가 빈 줄을 SSE 이벤트 경계로 보존한다") + func preservesBlankLineBoundaries() throws { + var lineBuffer = SSELineBuffer() + var lines: [String] = [] + + for byte in Array("event: start\ndata: first\n\nevent: done\ndata: second\n\n".utf8) { + if let line = try lineBuffer.consume(byte) { + lines.append(line) + } + } + + #expect( + lines == [ + "event: start", + "data: first", + "", + "event: done", + "data: second", + "" + ] + ) + } + + @Test("raw byte line splitter가 CRLF를 하나의 줄바꿈으로 처리한다") + func treatsCRLFAsOneLineEnding() throws { + var lineBuffer = SSELineBuffer() + var lines: [String] = [] + + for byte in Array("data: payload\r\n\r\n".utf8) { + if let line = try lineBuffer.consume(byte) { + lines.append(line) + } + } + + #expect(lines == ["data: payload", ""]) + } + @Test("빈 줄에서 표준 SSE 필드를 이벤트로 완성한다") func dispatchesStandardFieldsOnBlankLine() throws { var parser = SSEParser() diff --git a/Projects/Feature/TodakFeature/Example/Sources/ExampleContentView.swift b/Projects/Feature/TodakFeature/Example/Sources/ExampleContentView.swift index fc1cf1f..0ae3cd7 100644 --- a/Projects/Feature/TodakFeature/Example/Sources/ExampleContentView.swift +++ b/Projects/Feature/TodakFeature/Example/Sources/ExampleContentView.swift @@ -1,19 +1,29 @@ +import ComposableArchitecture import SwiftUI import TodakFeature struct ExampleContentView: View { + private let store = Store( + initialState: TodakFeature.State( + entry: TodakEntry( + greeting: "오늘은 어떤게 궁금해?", + suggestions: [ + TodakSuggestion( + emoji: "💼", + label: "커리어 흐름이나, 목표에 대한 성과가 궁금해", + seedPrompt: "이직할까 말까?", + category: .achievement + ) + ], + quota: TodakQuota(used: 0, limit: 3) + ), + quota: TodakQuota(used: 0, limit: 3) + ) + ) { + TodakFeature() + } + var body: some View { - NavigationStack { - VStack { - Text("TodakFeature Example") - .font(.largeTitle) - .bold() - Spacer() - Text("This is a standalone sample app.") - Spacer() - } - .padding() - .navigationTitle("TodakFeature") - } + TodakView(store: store) } } diff --git a/Projects/Feature/TodakFeature/Project.swift b/Projects/Feature/TodakFeature/Project.swift index 22eb4c8..b117805 100644 --- a/Projects/Feature/TodakFeature/Project.swift +++ b/Projects/Feature/TodakFeature/Project.swift @@ -2,5 +2,6 @@ import ProjectDescription import ProjectDescriptionHelpers let project = Project.makeFeature( - name: "TodakFeature" + name: "TodakFeature", + resources: ["Resources/**"] ) diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/Contents.json new file mode 100644 index 0000000..117f1d7 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_achievement.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/todak_achievement.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/todak_achievement.png new file mode 100644 index 0000000..46c0c60 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_achievement.imageset/todak_achievement.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/Contents.json new file mode 100644 index 0000000..11e49dd --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_default.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/todak_default.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/todak_default.png new file mode 100644 index 0000000..780f583 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_default.imageset/todak_default.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/Contents.json new file mode 100644 index 0000000..48a40d1 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_health.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/todak_health.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/todak_health.png new file mode 100644 index 0000000..baf60c1 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_health.imageset/todak_health.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/Contents.json new file mode 100644 index 0000000..b34e474 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_love.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/todak_love.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/todak_love.png new file mode 100644 index 0000000..5b0f223 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_love.imageset/todak_love.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/Contents.json new file mode 100644 index 0000000..50f93ca --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_money.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/todak_money.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/todak_money.png new file mode 100644 index 0000000..8b70714 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_money.imageset/todak_money.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/Contents.json new file mode 100644 index 0000000..639d05d --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_other.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/todak_other.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/todak_other.png new file mode 100644 index 0000000..eeb9a93 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_other.imageset/todak_other.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/Contents.json new file mode 100644 index 0000000..537b560 --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_relationship.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/todak_relationship.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/todak_relationship.png new file mode 100644 index 0000000..311a04c Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_relationship.imageset/todak_relationship.png differ diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/Contents.json b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/Contents.json new file mode 100644 index 0000000..fcb2fde --- /dev/null +++ b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "todak_splash.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/todak_splash.png b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/todak_splash.png new file mode 100644 index 0000000..a00f135 Binary files /dev/null and b/Projects/Feature/TodakFeature/Resources/Assets.xcassets/Images/todak_splash.imageset/todak_splash.png differ diff --git a/Projects/Feature/TodakFeature/Sources/Domain/TodakModels.swift b/Projects/Feature/TodakFeature/Sources/Domain/TodakModels.swift new file mode 100644 index 0000000..fa9615c --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/Domain/TodakModels.swift @@ -0,0 +1,239 @@ +import Foundation + +// `Identifiable`이 요구하는 표준 프로퍼티 이름이다. +// swiftlint:disable identifier_name + +public struct TodakEntry: Equatable, Sendable { + public let greeting: String + public let suggestions: [TodakSuggestion] + public let quota: TodakQuota + + public init(greeting: String, suggestions: [TodakSuggestion], quota: TodakQuota) { + self.greeting = greeting + self.suggestions = suggestions + self.quota = quota + } + + public static let initial = Self( + greeting: "오늘은 어떤 게 궁금해?", + suggestions: [ + .init( + emoji: "🤝", + label: "관계운에 관하여 궁금해", + seedPrompt: "요즘 관계운이 궁금해.", + category: .relationship + ), + .init( + emoji: "💌", + label: "연애운에 관하여 궁금해", + seedPrompt: "요즘 연애운이 궁금해.", + category: .love + ), + .init( + emoji: "💼", + label: "성취운에 관하여 궁금해", + seedPrompt: "요즘 성취운이 궁금해.", + category: .achievement + ), + .init( + emoji: "🧧", + label: "금전운에 관하여 궁금해", + seedPrompt: "요즘 금전운이 궁금해.", + category: .money + ), + .init( + emoji: "💪", + label: "건강운에 관하여 궁금해", + seedPrompt: "요즘 건강운이 궁금해.", + category: .health + ), + .init( + emoji: "💬", + label: "그 외에 다른 운이 궁금해", + seedPrompt: "요즘 궁금한 게 있어.", + category: nil + ) + ], + quota: .init(used: 0, limit: 3) + ) +} + +public struct TodakSuggestion: Equatable, Identifiable, Sendable { + public let emoji: String + public let label: String + public let seedPrompt: String + public let category: TodakCategory? + + public var id: String { "\(category?.rawValue ?? "OTHER")|\(seedPrompt)" } + + public init(emoji: String, label: String, seedPrompt: String, category: TodakCategory?) { + self.emoji = emoji + self.label = label + self.seedPrompt = seedPrompt + self.category = category + } + + public var displayText: String { + label.hasPrefix(emoji) ? label : "\(emoji) \(label)" + } +} + +public struct TodakQuota: Equatable, Sendable { + public let used: Int + public let limit: Int + + public init(used: Int, limit: Int) { + self.used = used + self.limit = limit + } + + public var remaining: Int { max(0, limit - used) } +} + +public enum TodakCategory: String, Equatable, Sendable { + case relationship = "RELATIONSHIP" + case love = "LOVE" + case achievement = "ACHIEVEMENT" + case money = "MONEY" + case health = "HEALTH" + case other = "OTHER" +} + +public enum TodakInitialReply { + // 기획 확정 전까지 모든 추천 질문은 성취운 고정 답변을 공통으로 사용한다. + // 카테고리별 답변이 전달되면 각 case의 값만 교체한다. + public static func content(for category: TodakCategory) -> String { + switch category { + case .relationship: + return relationshipContent + case .love: + return loveContent + case .achievement: + return achievementContent + case .money: + return moneyContent + case .health: + return healthContent + case .other: + return otherContent + } + } + + private static let relationshipContent = """ + 인간관계에서 궁금한 걸 물어봐! 가족, 친구, 동료와의 관계 등 뭐든 괜찮아. + """ + + private static let loveContent = """ + 그 사람과의 연애, 궁금한 걸 물어봐! 짝사랑, 재회, 갈등 등 뭐든 괜찮아. + """ + + private static let achievementContent = """ + 커리어에서 궁금한 걸 물어봐! 이직, 승진, 목표 달성 등 뭐든 괜찮아. + """ + + private static let moneyContent = """ + 돈 관리에서 궁금한 걸 물어봐! 저축, 지출, 투자 등 뭐든 괜찮아. + """ + + private static let healthContent = """ + 요즘 컨디션에서 궁금한 걸 물어봐! 스트레스, 생활 습관 등 뭐든 괜찮아. + """ + + private static let otherContent = """ + 그 외에 궁금한 게 있으면 물어봐! 사소한 것부터 마음속 얘기까지 다 괜찮아. + """ + +} + +public struct TodakConversationSummary: Equatable, Identifiable, Sendable { + public let id: UUID + public let title: String + public let lastMessageAt: Date? + public let unread: Bool + + public init(id: UUID, title: String, lastMessageAt: Date?, unread: Bool) { + self.id = id + self.title = title + self.lastMessageAt = lastMessageAt + self.unread = unread + } +} + +public struct TodakConversation: Equatable, Sendable { + public let id: UUID + public let title: String + public let messages: [TodakMessage] + + public init(id: UUID, title: String, messages: [TodakMessage]) { + self.id = id + self.title = title + self.messages = messages + } +} + +public struct TodakMessage: Equatable, Identifiable, Sendable { + public enum Role: Equatable, Sendable { + case user + case assistant + case unknown(String) + } + + public enum Status: Equatable, Sendable { + case streaming + case completed + case failed + case unknown(String) + } + + public let id: UUID + public let role: Role + public var content: String + public var status: Status + public var action: TodakMessageAction? + public let createdAt: Date? + + public init( + id: UUID, + role: Role, + content: String, + status: Status, + action: TodakMessageAction? = nil, + createdAt: Date? = nil + ) { + self.id = id + self.role = role + self.content = content + self.status = status + self.action = action + self.createdAt = createdAt + } +} + +public struct TodakMessageAction: Equatable, Sendable { + public let type: String + public let label: String + public let category: TodakCategory + public let date: Date + + public init(type: String, label: String, category: TodakCategory, date: Date) { + self.type = type + self.label = label + self.category = category + self.date = date + } +} + +public enum TodakStreamEvent: Equatable, Sendable { + case start( + conversationID: UUID, + userMessageID: UUID, + assistantMessageID: UUID, + quota: TodakQuota + ) + case delta(String) + case action(TodakMessageAction) + case done(assistantMessageID: UUID) + case error(code: String, message: String) +} + +// swiftlint:enable identifier_name diff --git a/Projects/Feature/TodakFeature/Sources/Dummy.swift b/Projects/Feature/TodakFeature/Sources/Dummy.swift deleted file mode 100644 index ac0a6fc..0000000 --- a/Projects/Feature/TodakFeature/Sources/Dummy.swift +++ /dev/null @@ -1 +0,0 @@ -// Dummy file for TodakFeature diff --git a/Projects/Feature/TodakFeature/Sources/TodakClient+Live.swift b/Projects/Feature/TodakFeature/Sources/TodakClient+Live.swift new file mode 100644 index 0000000..4e08a0d --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakClient+Live.swift @@ -0,0 +1,397 @@ +import Foundation +import NetworkCore + +public extension TodakClient { + static func live(httpClient: HTTPClient, sseClient: SSEClient) -> Self { + Self( + fetchEntry: { + try await performFetchEntry(httpClient: httpClient) + }, + fetchConversations: { + try await performFetchConversations(httpClient: httpClient) + }, + fetchConversation: { conversationID in + try await performFetchConversation(httpClient: httpClient, conversationID: conversationID) + }, + deleteConversation: { conversationID in + try await performDeleteConversation(httpClient: httpClient, conversationID: conversationID) + }, + sendMessage: { conversationID, content in + messageEvents( + sseClient: sseClient, + conversationID: conversationID, + content: content + ) + } + ) + } +} + +private func performFetchEntry(httpClient: HTTPClient) async throws -> TodakEntry { + do { + let response: CommonResponseDTO = try await httpClient.request( + .get("/api/v1/chat/entry") + ) + return try extractData(response).toDomain() + } catch is CancellationError { + throw CancellationError() + } catch { + throw mapClientError(error) + } +} + +private func performFetchConversations(httpClient: HTTPClient) async throws -> [TodakConversationSummary] { + do { + let response: CommonResponseDTO = try await httpClient.request( + .get("/api/v1/chat/conversations") + ) + return try extractData(response).conversations.map { try $0.toDomain() } + } catch is CancellationError { + throw CancellationError() + } catch { + throw mapClientError(error) + } +} + +private func performFetchConversation(httpClient: HTTPClient, conversationID: UUID) async throws -> TodakConversation { + do { + let response: CommonResponseDTO = try await httpClient.request( + .get("/api/v1/chat/conversations/\(conversationID.uuidString.lowercased())") + ) + return try extractData(response).toDomain() + } catch is CancellationError { + throw CancellationError() + } catch { + throw mapClientError(error) + } +} + +private func performDeleteConversation(httpClient: HTTPClient, conversationID: UUID) async throws { + do { + let endpoint = Endpoint( + method: .delete, + path: "/api/v1/chat/conversations/\(conversationID.uuidString.lowercased())" + ) + let response: CommonResponseDTO = try await httpClient.request(endpoint) + try validateSuccess(response) + } catch is CancellationError { + throw CancellationError() + } catch { + throw mapClientError(error) + } +} + +private func messageEvents( + sseClient: SSEClient, + conversationID: UUID?, + content: String +) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = SendMessageRequestDTO( + conversationID: conversationID, + content: content + ) + let endpoint = try Endpoint.post( + "/api/v1/chat/messages", + body: request, + headers: ["Accept": "text/event-stream"] + ) + TodakSSEDebugLogger.sendRequested( + conversationID: conversationID, + contentLength: content.count + ) + + var receivedDone = false + + for try await event in sseClient.events(for: endpoint) { + TodakSSEDebugLogger.rawEventReceived( + name: event.event, + dataLength: event.data?.utf8.count + ) + guard let data = event.data else { + TodakSSEDebugLogger.eventWithoutDataIgnored(name: event.event) + continue + } + + let streamEvent: TodakStreamEvent + if let eventName = event.event { + streamEvent = try decodeStreamEvent(name: eventName, data: data) + } else { + streamEvent = try decodeDataOnlyStreamError(data: data) + } + if case .done = streamEvent { receivedDone = true } + TodakSSEDebugLogger.decoded(streamEvent) + continuation.yield(streamEvent) + } + TodakSSEDebugLogger.streamEnded(receivedDone: receivedDone) + continuation.finish() + } catch is CancellationError { + TodakSSEDebugLogger.streamCancelled() + continuation.finish(throwing: CancellationError()) + } catch let error as TodakClientError { + TodakSSEDebugLogger.streamFailed(error) + continuation.finish(throwing: error) + } catch let error as SSEClientError { + TodakSSEDebugLogger.streamFailed(error) + continuation.finish(throwing: mapSSEClientError(error)) + } catch { + TodakSSEDebugLogger.streamFailed(error) + continuation.finish(throwing: TodakClientError.transport) + } + } + + continuation.onTermination = { @Sendable _ in task.cancel() } + } +} + +func decodeStreamEvent(name: String, data: String) throws -> TodakStreamEvent { + let decoder = JSONDecoder() + guard let dataValue = data.data(using: .utf8) else { + throw TodakClientError.invalidResponse + } + + switch name { + case "start": + let dto = try decoder.decode(StreamStartDTO.self, from: dataValue) + return .start( + conversationID: dto.conversationID, + userMessageID: dto.userMessageID, + assistantMessageID: dto.assistantMessageID, + quota: TodakQuota(used: dto.quotaUsed, limit: dto.quotaLimit) + ) + case "delta": + return .delta(try decoder.decode(StreamDeltaDTO.self, from: dataValue).text) + case "action": + return .action(try decoder.decode(MessageActionDTO.self, from: dataValue).toDomain()) + case "done": + return .done( + assistantMessageID: try decoder.decode(StreamDoneDTO.self, from: dataValue).assistantMessageID + ) + case "error": + let dto = try decoder.decode(StreamErrorDTO.self, from: dataValue) + return .error(code: dto.code, message: dto.message) + default: + throw TodakClientError.invalidResponse + } +} + +func decodeDataOnlyStreamError(data: String) throws -> TodakStreamEvent { + let decoder = JSONDecoder() + guard let dataValue = data.data(using: .utf8) else { + throw TodakClientError.invalidResponse + } + + let dto = try decoder.decode(StreamErrorDTO.self, from: dataValue) + return .error(code: dto.code, message: dto.message) +} + +private func extractData(_ response: CommonResponseDTO) throws -> T { + try validateSuccess(response) + guard let data = response.data else { throw TodakClientError.invalidResponse } + return data +} + +private func validateSuccess(_ response: CommonResponseDTO) throws { + guard response.success else { + guard let code = response.code else { throw TodakClientError.invalidResponse } + throw TodakClientError.server(code: code, message: response.message) + } +} + +private func mapClientError(_ error: Error) -> TodakClientError { + switch error { + case let clientError as TodakClientError: + return clientError + case let httpError as HTTPClientError: + switch httpError { + case let .unacceptableStatusCode(code, _): + return .httpStatus(code) + case .decodingFailed, .emptyResponse, .invalidURL, .invalidResponse: + return .invalidResponse + case .transportFailed: + return .transport + } + default: + return .transport + } +} + +private func mapSSEClientError(_ error: SSEClientError) -> TodakClientError { + switch error { + case let .unacceptableStatusCode(code): + return .httpStatus(code) + case .invalidResponse, .invalidContentType, .invalidUTF8: + return .invalidResponse + case .transportFailed: + return .transport + } +} + +private struct CommonResponseDTO: Decodable, Sendable { + let success: Bool + let code: String? + let message: String? + let data: Data? +} + +private struct EmptyResponseDTO: Decodable, Sendable {} + +private struct ConversationListResponseDTO: Decodable, Sendable { + let conversations: [ConversationSummaryDTO] +} + +private struct ConversationSummaryDTO: Decodable, Sendable { + let conversationID: UUID + let title: String + let lastMessageAt: String? + let unread: Bool + + func toDomain() throws -> TodakConversationSummary { + let date = try lastMessageAt.map(parseDateTime) + return TodakConversationSummary( + id: conversationID, + title: title, + lastMessageAt: date, + unread: unread + ) + } + + enum CodingKeys: String, CodingKey { + case conversationID = "id" + case title + case lastMessageAt + case unread + } +} + +private struct ConversationDetailResponseDTO: Decodable, Sendable { + let conversationID: UUID + let title: String + let messages: [MessageDTO] + + func toDomain() throws -> TodakConversation { + TodakConversation( + id: conversationID, + title: title, + messages: try messages.map { try $0.toDomain() } + ) + } + + enum CodingKeys: String, CodingKey { + case conversationID = "id" + case title + case messages + } +} + +private struct MessageDTO: Decodable, Sendable { + let messageID: UUID + let role: String + let content: String + let status: String + let action: MessageActionDTO? + let createdAt: String? + + func toDomain() throws -> TodakMessage { + TodakMessage( + id: messageID, + role: mapRole(role), + content: content, + status: mapStatus(status), + action: try action?.toDomain(), + createdAt: try createdAt.map(parseDateTime) + ) + } + + enum CodingKeys: String, CodingKey { + case messageID = "id" + case role + case content + case status + case action + case createdAt + } +} + +private struct MessageActionDTO: Decodable, Sendable { + let type: String + let label: String + let category: String + let date: String + + func toDomain() throws -> TodakMessageAction { + TodakMessageAction( + type: type, + label: label, + category: try mapCategory(category), + date: try parseDate(date) + ) + } +} + +private struct SendMessageRequestDTO: Encodable, Sendable { + let conversationID: UUID? + let content: String + + enum CodingKeys: String, CodingKey { + case conversationID = "conversationId" + case content + } +} + +private struct StreamStartDTO: Decodable, Sendable { + let conversationID: UUID + let userMessageID: UUID + let assistantMessageID: UUID + let quotaUsed: Int + let quotaLimit: Int + + enum CodingKeys: String, CodingKey { + case conversationID = "conversationId" + case userMessageID = "userMessageId" + case assistantMessageID = "assistantMessageId" + case quotaUsed + case quotaLimit + } +} + +private struct StreamDeltaDTO: Decodable, Sendable { let text: String } + +private struct StreamDoneDTO: Decodable, Sendable { + let assistantMessageID: UUID + + enum CodingKeys: String, CodingKey { + case assistantMessageID = "assistantMessageId" + } +} + +private struct StreamErrorDTO: Decodable, Sendable { + let code: String + let message: String +} + +func mapCategory(_ value: String) throws -> TodakCategory { + guard let category = TodakCategory(rawValue: value) else { + throw TodakClientError.unsupportedCategory(value) + } + return category +} + +private func mapRole(_ value: String) -> TodakMessage.Role { + switch value.uppercased() { + case "USER": .user + case "ASSISTANT": .assistant + default: .unknown(value) + } +} + +private func mapStatus(_ value: String) -> TodakMessage.Status { + switch value.uppercased() { + case "STREAMING", "IN_PROGRESS": .streaming + case "COMPLETED", "DONE": .completed + case "FAILED", "ERROR": .failed + default: .unknown(value) + } +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakClient.swift b/Projects/Feature/TodakFeature/Sources/TodakClient.swift new file mode 100644 index 0000000..dca2a7c --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakClient.swift @@ -0,0 +1,59 @@ +import ComposableArchitecture +import Foundation + +public struct TodakClient: Sendable { + public var fetchEntry: @Sendable () async throws -> TodakEntry + public var fetchConversations: @Sendable () async throws -> [TodakConversationSummary] + public var fetchConversation: @Sendable (UUID) async throws -> TodakConversation + public var deleteConversation: @Sendable (UUID) async throws -> Void + public var sendMessage: @Sendable (UUID?, String) -> AsyncThrowingStream + + public init( + fetchEntry: @escaping @Sendable () async throws -> TodakEntry, + fetchConversations: @escaping @Sendable () async throws -> [TodakConversationSummary], + fetchConversation: @escaping @Sendable (UUID) async throws -> TodakConversation, + deleteConversation: @escaping @Sendable (UUID) async throws -> Void, + sendMessage: @escaping @Sendable (UUID?, String) -> AsyncThrowingStream + ) { + self.fetchEntry = fetchEntry + self.fetchConversations = fetchConversations + self.fetchConversation = fetchConversation + self.deleteConversation = deleteConversation + self.sendMessage = sendMessage + } +} + +public enum TodakClientError: Error, Equatable, Sendable { + case notConfigured + case server(code: String, message: String?) + case httpStatus(Int) + case invalidResponse + case unsupportedCategory(String) + case transport +} + +extension TodakClient: DependencyKey { + public static let liveValue = TodakClient.unavailable + public static let testValue = TodakClient.unavailable +} + +public extension DependencyValues { + var todakClient: TodakClient { + get { self[TodakClient.self] } + set { self[TodakClient.self] = newValue } + } +} + +public extension TodakClient { + static let unavailable = Self( + fetchEntry: { throw TodakClientError.notConfigured }, + fetchConversations: { throw TodakClientError.notConfigured }, + fetchConversation: { _ in throw TodakClientError.notConfigured }, + deleteConversation: { _ in throw TodakClientError.notConfigured }, + sendMessage: { _, _ in + AsyncThrowingStream { continuation in + continuation.finish(throwing: TodakClientError.notConfigured) + } + } + ) +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakDateParser.swift b/Projects/Feature/TodakFeature/Sources/TodakDateParser.swift new file mode 100644 index 0000000..ee0906a --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakDateParser.swift @@ -0,0 +1,25 @@ +import Foundation + +func parseDateTime(_ value: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value) else { + throw TodakClientError.invalidResponse + } + return date +} + +func parseDate(_ value: String) throws -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.isLenient = false + guard let date = formatter.date(from: value) else { + throw TodakClientError.invalidResponse + } + return date +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakEntryDTO.swift b/Projects/Feature/TodakFeature/Sources/TodakEntryDTO.swift new file mode 100644 index 0000000..25d00e1 --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakEntryDTO.swift @@ -0,0 +1,36 @@ +import Foundation + +struct EntryResponseDTO: Decodable, Sendable { + let greeting: String + let suggestions: [EntrySuggestionDTO] + let quota: EntryQuotaDTO + + func toDomain() throws -> TodakEntry { + TodakEntry( + greeting: greeting, + suggestions: try suggestions.map { try $0.toDomain() }, + quota: TodakQuota(used: quota.used, limit: quota.limit) + ) + } +} + +struct EntrySuggestionDTO: Decodable, Sendable { + let emoji: String + let label: String + let seedPrompt: String + let category: String? + + func toDomain() throws -> TodakSuggestion { + TodakSuggestion( + emoji: emoji, + label: label, + seedPrompt: seedPrompt, + category: try category.map(mapCategory) + ) + } +} + +struct EntryQuotaDTO: Decodable, Sendable { + let used: Int + let limit: Int +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakFeature+Effects.swift b/Projects/Feature/TodakFeature/Sources/TodakFeature+Effects.swift new file mode 100644 index 0000000..32603bc --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakFeature+Effects.swift @@ -0,0 +1,232 @@ +import ComposableArchitecture +import Foundation + +extension TodakFeature { + func fetchEntryEffect() -> Effect { + .run { send in + do { + await send(.entryResponse(.success(try await client.fetchEntry()))) + } catch is CancellationError { + return + } catch let error as TodakClientError { + await send(.entryResponse(.failure(error))) + } catch { + await send(.entryResponse(.failure(.transport))) + } + } + .cancellable(id: CancelID.entry, cancelInFlight: true) + } + + func splashDelayEffect() -> Effect { + .run { send in + try await clock.sleep(for: .milliseconds(1_500)) + await send(.splashElapsed) + } + .cancellable(id: CancelID.splash, cancelInFlight: true) + } + + func sendMessageEffect(state: inout State, content: String) -> Effect { + let content = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return .none } + + state.draft = "" + state.isStreaming = true + state.assistantMessageID = nil + state.messages.append( + TodakMessage( + id: uuid(), + role: .user, + content: content, + status: .completed, + createdAt: now + ) + ) + let conversationID = state.conversationID + + return .run { send in + do { + for try await event in client.sendMessage(conversationID, content) { + await send(.streamEvent(event)) + } + await send(.streamFinished) + } catch is CancellationError { + return + } catch let error as TodakClientError { + await send(.streamFailed(error)) + } catch { + await send(.streamFailed(.transport)) + } + } + .cancellable(id: CancelID.stream, cancelInFlight: true) + } + + func showInitialReply(state: inout State, for suggestion: TodakSuggestion) -> Effect { + let category = suggestion.category ?? .other + + state.messages = [ + TodakMessage( + id: uuid(), + role: .user, + content: suggestion.seedPrompt, + status: .completed, + createdAt: now + ), + TodakMessage( + id: uuid(), + role: .assistant, + content: TodakInitialReply.content(for: category), + status: .completed, + createdAt: now + ) + ] + + guard state.guideCategory != nil else { return .none } + return guideDismissEffect() + } + + func guideDismissEffect() -> Effect { + .run { send in + try await clock.sleep(for: .seconds(2)) + await send(.guideDismissed) + } + .cancellable(id: CancelID.guide, cancelInFlight: true) + } + + func handleStreamEvent(_ event: TodakStreamEvent, state: inout State) -> Effect { + TodakSSEDebugLogger.reducerReceived(event) + switch event { + case let .start(conversationID, _, assistantMessageID, quota): + state.conversationID = conversationID + state.quota = quota + state.assistantMessageID = assistantMessageID + state.messages.append( + TodakMessage( + id: assistantMessageID, + role: .assistant, + content: "", + status: .streaming, + createdAt: now + ) + ) + return .none + + case let .delta(text): + guard let assistantMessageID = state.assistantMessageID, + let index = state.messages.firstIndex(where: { $0.id == assistantMessageID }) else { + return .none + } + state.messages[index].content += text + return .none + + case let .action(action): + state.activeCategory = action.category + guard let assistantMessageID = state.assistantMessageID, + let index = state.messages.firstIndex(where: { $0.id == assistantMessageID }) else { + return .none + } + state.messages[index].action = action + return .none + + case let .done(assistantMessageID): + if let index = state.messages.firstIndex(where: { $0.id == assistantMessageID }) { + state.messages[index].status = .completed + } + state.isStreaming = false + state.assistantMessageID = nil + return .none + + case let .error(code, message): + state.isStreaming = false + if code == "CHAT-429" { + state.quota = TodakQuota(used: state.quota.limit, limit: state.quota.limit) + } + markAssistantMessageFailed(state: &state) + state.toastMessage = message + return toastDismissEffect() + } + } + + func fetchHistoryEffect() -> Effect { + .run { send in + do { + await send(.historyResponse(.success(try await client.fetchConversations()))) + } catch is CancellationError { + return + } catch let error as TodakClientError { + await send(.historyResponse(.failure(error))) + } catch { + await send(.historyResponse(.failure(.transport))) + } + } + .cancellable(id: CancelID.history, cancelInFlight: true) + } + + func fetchConversationEffect(conversationID: UUID) -> Effect { + .run { send in + do { + let conversation = try await client.fetchConversation(conversationID) + await send(.conversationResponse(.success(conversation))) + } catch is CancellationError { + return + } catch let error as TodakClientError { + await send(.conversationResponse(.failure(error))) + } catch { + await send(.conversationResponse(.failure(.transport))) + } + } + .cancellable(id: CancelID.conversation, cancelInFlight: true) + } + + func deleteConversationEffect(conversationID: UUID) -> Effect { + .run { send in + do { + try await client.deleteConversation(conversationID) + await send(.deleteResponse(conversationID, .success(true))) + } catch is CancellationError { + return + } catch let error as TodakClientError { + await send(.deleteResponse(conversationID, .failure(error))) + } catch { + await send(.deleteResponse(conversationID, .failure(.transport))) + } + } + .cancellable(id: CancelID.deletion, cancelInFlight: true) + } + + func toastDismissEffect() -> Effect { + .run { send in + try await clock.sleep(for: .seconds(3)) + await send(.toastDismissed) + } + .cancellable(id: CancelID.toast, cancelInFlight: true) + } + + func resetConversation(state: inout State) { + state.conversationID = nil + state.messages = [] + state.draft = "" + state.activeCategory = nil + state.guideCategory = nil + state.assistantMessageID = nil + } + + func markAssistantMessageFailed(state: inout State) { + guard let assistantMessageID = state.assistantMessageID, + let index = state.messages.firstIndex(where: { $0.id == assistantMessageID }) else { return } + state.messages[index].status = .failed + state.assistantMessageID = nil + } + + func message(for error: TodakClientError) -> String { + switch error { + case .notConfigured: + "토닥이 서비스를 사용할 수 없어요." + case .server, .httpStatus: + "요청을 처리하지 못했어요. 잠시 후 다시 시도해 주세요." + case .invalidResponse, .unsupportedCategory: + "토닥이 응답을 읽는 중 오류가 발생했어요." + case .transport: + "네트워크 상태를 확인해 주세요." + } + } +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakFeature.swift b/Projects/Feature/TodakFeature/Sources/TodakFeature.swift index a6a08c1..0380ea3 100644 --- a/Projects/Feature/TodakFeature/Sources/TodakFeature.swift +++ b/Projects/Feature/TodakFeature/Sources/TodakFeature.swift @@ -1 +1,283 @@ -// Dummy file to ensure Tuist generates the target +import ComposableArchitecture +import Foundation + +@Reducer +public struct TodakFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public enum Screen: Equatable, Sendable { + case chat + case history + } + + public var screen: Screen + public var showsSplash: Bool + public var didPresentSplash: Bool + public var didCompleteSplashDelay: Bool + public var isLoadingEntry: Bool + public var entry: TodakEntry + public var quota: TodakQuota + public var conversationID: UUID? + public var messages: [TodakMessage] + public var draft: String + public var isStreaming: Bool + public var assistantMessageID: UUID? + public var activeCategory: TodakCategory? + public var guideCategory: TodakCategory? + public var conversations: [TodakConversationSummary] + public var isLoadingHistory: Bool + public var pendingDeletionID: UUID? + public var toastMessage: String? + + public init( + screen: Screen = .chat, + showsSplash: Bool = true, + didPresentSplash: Bool = false, + didCompleteSplashDelay: Bool = false, + isLoadingEntry: Bool = false, + entry: TodakEntry = .initial, + quota: TodakQuota = TodakEntry.initial.quota, + conversationID: UUID? = nil, + messages: [TodakMessage] = [], + draft: String = "", + isStreaming: Bool = false, + assistantMessageID: UUID? = nil, + activeCategory: TodakCategory? = nil, + guideCategory: TodakCategory? = nil, + conversations: [TodakConversationSummary] = [], + isLoadingHistory: Bool = false, + pendingDeletionID: UUID? = nil, + toastMessage: String? = nil + ) { + self.screen = screen + self.showsSplash = showsSplash + self.didPresentSplash = didPresentSplash + self.didCompleteSplashDelay = didCompleteSplashDelay + self.isLoadingEntry = isLoadingEntry + self.entry = entry + self.quota = quota + self.conversationID = conversationID + self.messages = messages + self.draft = draft + self.isStreaming = isStreaming + self.assistantMessageID = assistantMessageID + self.activeCategory = activeCategory + self.guideCategory = guideCategory + self.conversations = conversations + self.isLoadingHistory = isLoadingHistory + self.pendingDeletionID = pendingDeletionID + self.toastMessage = toastMessage + } + + public var canSend: Bool { + !isStreaming && quota.remaining > 0 && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + public enum Action: BindableAction, Equatable, Sendable { + case binding(BindingAction) + case task + case splashElapsed + case entryResponse(Result) + case closeButtonTapped + case newChatButtonTapped + case historyButtonTapped + case historyBackButtonTapped + case suggestionTapped(TodakSuggestion) + case sendButtonTapped + case streamEvent(TodakStreamEvent) + case streamFinished + case streamFailed(TodakClientError) + case guideDismissed + case historyResponse(Result<[TodakConversationSummary], TodakClientError>) + case conversationTapped(UUID) + case conversationResponse(Result) + case deleteButtonTapped(UUID) + case deleteCancelled + case deleteConfirmed + case deleteResponse(UUID, Result) + case toastDismissed + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case closeRequested + } + } + + enum CancelID { + case splash + case entry + case guide + case stream + case history + case conversation + case deletion + case toast + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.date.now) var now + @Dependency(\.todakClient) var client + @Dependency(\.uuid) var uuid + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.draft): + if state.draft.count > 500 { + state.draft = String(state.draft.prefix(500)) + } + return .none + + case .binding: + return .none + + case .task: + guard !state.didPresentSplash else { return .none } + state.didPresentSplash = true + state.isLoadingEntry = true + return .merge(splashDelayEffect(), fetchEntryEffect()) + + case .splashElapsed: + state.didCompleteSplashDelay = true + state.showsSplash = state.isLoadingEntry + return .none + + case let .entryResponse(.success(entry)): + state.entry = entry + state.quota = entry.quota + state.isLoadingEntry = false + if state.didCompleteSplashDelay { + state.showsSplash = false + } + return .none + + case .entryResponse(.failure): + state.isLoadingEntry = false + if state.didCompleteSplashDelay { + state.showsSplash = false + } + return .none + + case .closeButtonTapped: + return .send(.delegate(.closeRequested)) + + case .newChatButtonTapped: + guard !state.isStreaming else { return .none } + resetConversation(state: &state) + state.screen = .chat + return .none + + case .historyButtonTapped: + guard !state.isStreaming else { return .none } + state.screen = .history + state.isLoadingHistory = true + return fetchHistoryEffect() + + case .historyBackButtonTapped: + state.screen = .chat + return .cancel(id: CancelID.history) + + case let .suggestionTapped(suggestion): + guard !state.isStreaming, state.quota.remaining > 0 else { return .none } + state.activeCategory = suggestion.category ?? .other + state.guideCategory = suggestion.category + return showInitialReply(state: &state, for: suggestion) + + case .sendButtonTapped: + guard state.canSend else { return .none } + return sendMessageEffect(state: &state, content: state.draft) + + case let .streamEvent(event): + return handleStreamEvent(event, state: &state) + + case .streamFinished: + guard state.isStreaming else { return .none } + TodakSSEDebugLogger.fallbackShown() + state.isStreaming = false + markAssistantMessageFailed(state: &state) + state.toastMessage = "답변을 끝까지 받지 못했어요. 다시 시도해 주세요." + return toastDismissEffect() + + case let .streamFailed(error): + state.isStreaming = false + markAssistantMessageFailed(state: &state) + state.toastMessage = message(for: error) + return toastDismissEffect() + + case .guideDismissed: + state.guideCategory = nil + return .cancel(id: CancelID.guide) + + case let .historyResponse(.success(conversations)): + state.isLoadingHistory = false + state.conversations = conversations.sorted { lhs, rhs in + (lhs.lastMessageAt ?? .distantPast) > (rhs.lastMessageAt ?? .distantPast) + } + return .none + + case let .historyResponse(.failure(error)): + state.isLoadingHistory = false + state.toastMessage = message(for: error) + return toastDismissEffect() + + case let .conversationTapped(conversationID): + return fetchConversationEffect(conversationID: conversationID) + + case let .conversationResponse(.success(conversation)): + state.screen = .chat + state.conversationID = conversation.id + state.messages = conversation.messages + state.conversations = state.conversations.map { summary in + guard summary.id == conversation.id else { return summary } + return TodakConversationSummary( + id: summary.id, + title: summary.title, + lastMessageAt: summary.lastMessageAt, + unread: false + ) + } + return .none + + case let .conversationResponse(.failure(error)): + state.toastMessage = message(for: error) + return toastDismissEffect() + + case let .deleteButtonTapped(conversationID): + state.pendingDeletionID = conversationID + return .none + + case .deleteCancelled: + state.pendingDeletionID = nil + return .none + + case .deleteConfirmed: + guard let conversationID = state.pendingDeletionID else { return .none } + state.pendingDeletionID = nil + return deleteConversationEffect(conversationID: conversationID) + + case let .deleteResponse(conversationID, .success): + state.conversations.removeAll { $0.id == conversationID } + if state.conversationID == conversationID { resetConversation(state: &state) } + state.toastMessage = "대화가 삭제되었어요." + return toastDismissEffect() + + case let .deleteResponse(_, .failure(error)): + state.toastMessage = message(for: error) + return toastDismissEffect() + + case .toastDismissed: + state.toastMessage = nil + return .cancel(id: CancelID.toast) + + case .delegate: + return .none + } + } + } + +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakSSEDebugLogger.swift b/Projects/Feature/TodakFeature/Sources/TodakSSEDebugLogger.swift new file mode 100644 index 0000000..7221528 --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakSSEDebugLogger.swift @@ -0,0 +1,87 @@ +import Foundation + +#if DEBUG +import OSLog +#endif + +enum TodakSSEDebugLogger { + static func sendRequested(conversationID: UUID?, contentLength: Int) { + log( + "send requested conversation=\(conversationID?.uuidString.lowercased() ?? "new") " + + "contentLength=\(contentLength)" + ) + } + + static func rawEventReceived(name: String?, dataLength: Int?) { + log("raw event name=\(name ?? "none") dataLength=\(dataLength.map(String.init) ?? "none")") + } + + static func eventWithoutDataIgnored(name: String?) { + log("raw event ignored because data is missing name=\(name ?? "none")") + } + + static func decoded(_ event: TodakStreamEvent) { + switch event { + case let .start(conversationID, userMessageID, assistantMessageID, quota): + log( + "decoded start conversation=\(conversationID.uuidString.lowercased()) " + + "user=\(userMessageID.uuidString.lowercased()) " + + "assistant=\(assistantMessageID.uuidString.lowercased()) " + + "quota=\(quota.used)/\(quota.limit)" + ) + case let .delta(text): + log("decoded delta textLength=\(text.count)") + case let .action(action): + log("decoded action type=\(action.type) category=\(action.category.rawValue)") + case let .done(assistantMessageID): + log("decoded done assistant=\(assistantMessageID.uuidString.lowercased())") + case let .error(code, message): + log("decoded error code=\(code) messageLength=\(message.count)") + } + } + + static func streamEnded(receivedDone: Bool) { + log("stream ended receivedDone=\(receivedDone)") + } + + static func streamCancelled() { + log("stream cancelled") + } + + static func streamFailed(_ error: Error) { + log("stream failed error=\(String(describing: error))") + } + + static func reducerReceived(_ event: TodakStreamEvent) { + switch event { + case let .start(conversationID, _, assistantMessageID, quota): + log( + "reducer start conversation=\(conversationID.uuidString.lowercased()) " + + "assistant=\(assistantMessageID.uuidString.lowercased()) " + + "quota=\(quota.used)/\(quota.limit)" + ) + case let .delta(text): + log("reducer delta textLength=\(text.count)") + case .action: + log("reducer action") + case let .done(assistantMessageID): + log("reducer done assistant=\(assistantMessageID.uuidString.lowercased())") + case let .error(code, message): + log("reducer error code=\(code) messageLength=\(message.count)") + } + } + + static func fallbackShown() { + log("reducer fallback shown because stream ended without done") + } + + private static func log(_ message: String) { + #if DEBUG + logger.debug("\(message, privacy: .public)") + #endif + } + + #if DEBUG + private static let logger = Logger(subsystem: "com.yapp.todakun", category: "TodakSSE") + #endif +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakView+Sections.swift b/Projects/Feature/TodakFeature/Sources/TodakView+Sections.swift new file mode 100644 index 0000000..cfc87f3 --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakView+Sections.swift @@ -0,0 +1,289 @@ +import DesignSystem +import Foundation +import SwiftUI + +extension TodakView { + var historyScreen: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + DSHeaderSub( + title: "대화 히스토리", + leftItem: DSHeaderActionItem(identifier: "back", icon: .chevronLeftPlain) { + store.send(.historyBackButtonTapped) + } + ) + + if store.isLoadingHistory && store.conversations.isEmpty { + ProgressView() + .tint(DesignSystemAsset.Colors.primary600.swiftUIColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if store.conversations.isEmpty { + VStack(spacing: 8) { + Text("아직 대화가 없어요.") + .dsBody1Medium + .foregroundStyle(DesignSystemAsset.Colors.gray925.swiftUIColor) + Text("토닥이에게 궁금한 운세를 물어봐 주세요.") + .dsBody3Regular + .foregroundStyle(DesignSystemAsset.Colors.gray600.swiftUIColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + historyList + } + } + + TodakHistoryNewChatButton { + store.send(.newChatButtonTapped) + } + .padding(.trailing, 20) + .padding(.bottom, 20) + .zIndex(1) + } + } + + private var historyList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.conversations) { conversation in + DSConversationHistoryList( + title: conversation.title, + time: relativeTime(conversation.lastMessageAt), + showsUnreadIndicator: conversation.unread, + onDelete: { store.send(.deleteButtonTapped(conversation.id)) } + ) + .contentShape(Rectangle()) + .onTapGesture { store.send(.conversationTapped(conversation.id)) } + .accessibilityAddTraits(.isButton) + + Divider() + .padding(.horizontal, 20) + } + } + .padding(.bottom, 88) + } + } + + var splashView: some View { + GeometryReader { proxy in + ZStack { + DesignSystemAsset.Colors.primary50.swiftUIColor + .ignoresSafeArea() + + TodakFeatureAsset.todakSplash.swiftUIImage + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + .ignoresSafeArea() + + splashTitle + .position(x: proxy.size.width / 2, y: 80) + + Text("토닥이에게\n궁금한 운세를 다 물어봐!") + .dsHeading4Bold + .multilineTextAlignment(.center) + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + .position(x: proxy.size.width / 2, y: proxy.size.height * 0.78) + } + } + } + + private var splashTitle: some View { + HStack(spacing: 12) { + Text("토닥운") + .dsHeading4Bold + Rectangle() + .fill(DesignSystemAsset.Colors.gray400.swiftUIColor) + .frame(width: 1, height: 20) + Text("AI 토닥이") + .dsHeading4Bold + } + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + } + + func guideOverlay(category: TodakCategory) -> some View { + let content = TodakGuideContent(category: category) + + return ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { store.send(.guideDismissed) } + + VStack(spacing: 0) { + TodakCharacterImage(category: category) + .frame(width: 230, height: 230) + + VStack(spacing: 16) { + HStack(alignment: .top) { + Text(content.title) + .dsHeading4Bold + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + .frame(maxWidth: .infinity) + + Button { store.send(.guideDismissed) } label: { + DSIcon(.closeLine, width: 20, height: 20) + .foregroundStyle(DesignSystemAsset.Colors.gray800.swiftUIColor) + } + .buttonStyle(.plain) + .accessibilityLabel("닫기") + } + + Text(content.description) + .dsBody2Regular + .multilineTextAlignment(.center) + .foregroundStyle(DesignSystemAsset.Colors.gray800.swiftUIColor) + } + .padding(24) + .background(DesignSystemAsset.Colors.white.swiftUIColor) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .padding(.horizontal, 20) + } + } + .accessibilityAddTraits(.isModal) + } + + var deleteConfirmationOverlay: some View { + Color.black.opacity(0.4) + .ignoresSafeArea() + .overlay { + DSDialog( + title: "이 대화를 삭제할까요?", + message: "삭제한 대화는 다시 확인할 수 없어요.", + primaryAction: DSDialog.Action("삭제") { + store.send(.deleteConfirmed) + }, + secondaryAction: DSDialog.Action("취소") { + store.send(.deleteCancelled) + } + ) + } + .accessibilityAddTraits(.isModal) + } + + private func relativeTime(_ date: Date?) -> String { + guard let date else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +private struct TodakHistoryNewChatButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + DSIcon(.chatAdd, width: 24, height: 24) + Text("새 채팅") + .dsBody2Medium + } + .foregroundStyle(DesignSystemAsset.Colors.white.swiftUIColor) + .padding(.horizontal, 20) + .frame(height: 48) + .background(DesignSystemAsset.Colors.primary700.swiftUIColor) + .clipShape(Capsule()) + .contentShape(Capsule()) + } + .dsSurfaceButtonStyle(shape: .capsule) + .accessibilityLabel("새 채팅") + } +} + +private struct TodakGuideContent { + let title: String + let description: String + + init(category: TodakCategory) { + switch category { + case .relationship: + title = "관계운을 알려줄게!" + description = "친구, 가족, 동료 등 고민되는 게 있으면 전부 나한테 물어봐." + case .love: + title = "연애운을 알려줄게!" + description = "짝사랑, 썸, 연애 등 남한테 말하지 못하는 비밀을 나한테 털어놔." + case .achievement: + title = "성취운을 알려줄게!" + description = "커리어, 학업, 목표 등 궁금한 점이나 고민은 전부 나한테 물어봐." + case .money: + title = "금전운을 알려줄게!" + description = "수입, 지출, 재테크 등 돈의 흐름에 대해서 나한테 물어봐." + case .health: + title = "건강운을 알려줄게!" + description = "컨디션, 습관, 마음가짐 등 요즘 신경 쓰이는 걸 나한테 말해봐." + case .other: + title = "" + description = "" + } + } +} + +struct TodakCharacterImage: View { + let category: TodakCategory? + let preservesAspectRatio: Bool + + init(category: TodakCategory?, preservesAspectRatio: Bool = true) { + self.category = category + self.preservesAspectRatio = preservesAspectRatio + } + + @ViewBuilder + var body: some View { + if preservesAspectRatio { + image + .resizable() + .scaledToFit() + .accessibilityHidden(true) + } else { + image + .resizable() + .accessibilityHidden(true) + } + } + + private var image: Image { + switch category { + case .relationship: + TodakFeatureAsset.todakRelationship.swiftUIImage + case .love: + TodakFeatureAsset.todakLove.swiftUIImage + case .achievement: + TodakFeatureAsset.todakAchievement.swiftUIImage + case .money: + TodakFeatureAsset.todakMoney.swiftUIImage + case .health: + TodakFeatureAsset.todakHealth.swiftUIImage + case .other: + TodakFeatureAsset.todakOther.swiftUIImage + case nil: + TodakFeatureAsset.todakDefault.swiftUIImage + } + } +} + +struct TodakChatAvatar: View { + let category: TodakCategory? + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 243 / 255, green: 246 / 255, blue: 1), + DesignSystemAsset.Colors.gray50.swiftUIColor + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + + TodakCharacterImage(category: category, preservesAspectRatio: false) +// .frame(width: 81, height: 74) + } + .frame(width: 60, height: 60) + .clipShape(Circle()) + .accessibilityHidden(true) + } +} diff --git a/Projects/Feature/TodakFeature/Sources/TodakView.swift b/Projects/Feature/TodakFeature/Sources/TodakView.swift new file mode 100644 index 0000000..7069e05 --- /dev/null +++ b/Projects/Feature/TodakFeature/Sources/TodakView.swift @@ -0,0 +1,249 @@ +import ComposableArchitecture +import DesignSystem +import SwiftUI + +public struct TodakView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + switch store.screen { + case .chat: + chatScreen + case .history: + historyScreen + } + + if store.showsSplash { + splashView + .transition(.opacity) + .zIndex(2) + } + + if let category = store.guideCategory { + guideOverlay(category: category) + .zIndex(3) + } + + if store.pendingDeletionID != nil { + deleteConfirmationOverlay + .zIndex(4) + } + } + .overlay(alignment: .bottom) { + if let message = store.toastMessage { + DSToast(message) { + store.send(.toastDismissed) + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(5) + } + } + .animation(.easeOut(duration: 0.2), value: store.showsSplash) + .animation(.easeOut(duration: 0.2), value: store.guideCategory) + .animation(.easeOut(duration: 0.2), value: store.toastMessage) + .background(DesignSystemAsset.Colors.white.swiftUIColor) + .task { store.send(.task) } + } + + private var chatScreen: some View { + VStack(spacing: 0) { + DSTodakHeader( + remainingFreeChatCount: store.quota.remaining, + freeChatLimit: store.quota.limit, + rightItems: [ + DSHeaderActionItem(identifier: "new-chat", icon: .chatAdd) { + store.send(.newChatButtonTapped) + }, + DSHeaderActionItem(identifier: "history", icon: .notes) { + store.send(.historyButtonTapped) + } + ], + onClose: { store.send(.closeButtonTapped) } + ) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if store.messages.isEmpty { + entryContent + } else { + TodakChatAvatar(category: store.activeCategory) + + Text(store.entry.greeting) + .dsBody1Medium + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + + ForEach(store.messages) { message in + messageView(message) + .id(message.id) + } + + if store.isStreaming && store.assistantMessageID == nil { + typingIndicator + .id("typing-indicator") + } + } + } + .padding(.horizontal, 20) + .padding(.top, store.messages.isEmpty ? 16 : 20) + .padding(.bottom, 20) + } + .onChange(of: store.messages) { _, messages in + guard let messageID = messages.last?.id else { return } + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(messageID, anchor: .bottom) + } + } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + DSChatTypeBox( + text: $store.draft, + placeholder: inputPlaceholder, + onSend: { store.send(.sendButtonTapped) } + ) + .disabled(store.isStreaming || store.quota.remaining == 0) + .opacity(store.isStreaming || store.quota.remaining == 0 ? 0.6 : 1) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 20) + .background(DesignSystemAsset.Colors.white.swiftUIColor) + } + } + + private var entryContent: some View { + VStack(alignment: .leading, spacing: 0) { + TodakChatAvatar(category: nil) + + Text(store.entry.greeting) + .dsBody1Medium + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + .padding(.top, 20) + + VStack(alignment: .leading, spacing: 16) { + ForEach(store.entry.suggestions) { suggestion in + Button { + store.send(.suggestionTapped(suggestion)) + } label: { + DSTodakExampleQuestion(suggestion.displayText) + } + .buttonStyle(.plain) + .disabled(store.isStreaming || store.quota.remaining == 0) + .accessibilityLabel(suggestion.displayText) + } + } + .padding(.top, 20) + } + } + + @ViewBuilder + private func messageView(_ message: TodakMessage) -> some View { + switch message.role { + case .user: + DSUserChat(message.content) + .frame(maxWidth: .infinity, alignment: .trailing) + + case .assistant, .unknown: + VStack(alignment: .leading, spacing: 12) { + if message.content.isEmpty && message.status == .streaming { + typingIndicator + } else { + Text(message.content) + .dsBody2Regular + .foregroundStyle(DesignSystemAsset.Colors.black.swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let action = message.action { + DSTodakExampleQuestion("📅 \(action.label)") + .accessibilityLabel(action.label) + } + } + } + } + + private var typingIndicator: some View { + TodakTypingIndicator() + } + + private var inputPlaceholder: String { + store.quota.remaining == 0 ? "오늘 무료 채팅을 모두 사용했어요" : "토닥이에게 운세 물어보기" + } + +} + +private struct TodakTypingIndicator: View { + private let dotSize: CGFloat = 4 + private let dotSpacing: CGFloat = 3 + + var body: some View { + HStack(spacing: 9) { + HStack(spacing: dotSpacing) { + ForEach(0..<3, id: \.self) { index in + TodakTypingDot( + color: index == 2 + ? DesignSystemAsset.Colors.primary300.swiftUIColor + : DesignSystemAsset.Colors.primary700.swiftUIColor, + delay: Double(index) * 0.12, + size: dotSize + ) + } + } + .frame(width: 18, height: 7, alignment: .bottom) + + Text("생각 중") + .dsBody2Regular + .foregroundStyle(DesignSystemAsset.Colors.primary700.swiftUIColor) + } + } +} + +private struct TodakTypingDot: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isRaised = false + + let color: Color + let delay: TimeInterval + let size: CGFloat + + private let travel: CGFloat = 3 + + var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + .offset(y: isRaised ? -travel : 0) + .accessibilityHidden(true) + .onAppear(perform: updateAnimation) + .onChange(of: reduceMotion) { _, _ in + updateAnimation() + } + } + + private func updateAnimation() { + guard !reduceMotion else { + withAnimation(.none) { + isRaised = false + } + return + } + + withAnimation(.none) { + isRaised = false + } + withAnimation( + .easeInOut(duration: 0.3) + .repeatForever(autoreverses: true) + .delay(delay) + ) { + isRaised = true + } + } +} diff --git a/Projects/Feature/TodakFeature/Tests/TodakClientTests.swift b/Projects/Feature/TodakFeature/Tests/TodakClientTests.swift new file mode 100644 index 0000000..2893951 --- /dev/null +++ b/Projects/Feature/TodakFeature/Tests/TodakClientTests.swift @@ -0,0 +1,139 @@ +import Foundation +import NetworkCore +import Testing +@testable import TodakFeature + +@Suite +struct TodakClientTests { + @Test("Entry API의 인사말, 추천 질문, 무료 채팅 quota를 매핑한다") + func fetchEntryMapping() async throws { + let json = """ + { + "success": true, + "code": "COMMON-200", + "data": { + "greeting": "오늘은 어떤 게 궁금해?", + "suggestions": [ + { + "emoji": "🤝", + "label": "관계운에 관하여 궁금해", + "seedPrompt": "요즘 관계운이 궁금해.", + "category": "RELATIONSHIP" + }, + { + "emoji": "💬", + "label": "그 외에 다른 운이 궁금해", + "seedPrompt": "요즘 궁금한 게 있어.", + "category": null + } + ], + "quota": { "used": 3, "limit": 3 } + } + } + """ + let client = makeLiveClient { request in + #expect(request.httpMethod == "GET") + #expect(request.url?.path == "/api/v1/chat/entry") + return (Data(json.utf8), try response(for: request, statusCode: 200)) + } + + let entry = try await client.fetchEntry() + #expect(entry.greeting == "오늘은 어떤 게 궁금해?") + #expect(entry.suggestions.map(\.category) == [.relationship, nil]) + #expect(entry.quota == .init(used: 3, limit: 3)) + } + + @Test("SSE 오류 이벤트를 서버 안내 문구와 함께 매핑한다") + func streamErrorMapping() throws { + let event = try decodeStreamEvent( + name: "error", + data: #"{"code":"CHAT-500","message":"토닥이 답변 생성에 실패했습니다."}"# + ) + + #expect(event == .error(code: "CHAT-500", message: "토닥이 답변 생성에 실패했습니다.")) + } + + @Test("event 이름 없는 quota 오류 data를 서버 안내 문구와 함께 매핑한다") + func dataOnlyQuotaErrorMapping() throws { + let event = try decodeDataOnlyStreamError( + data: #"{"code":"CHAT-429","message":"오늘 무료 채팅 횟수를 모두 사용했습니다."}"# + ) + + #expect(event == .error(code: "CHAT-429", message: "오늘 무료 채팅 횟수를 모두 사용했습니다.")) + } + + @Test("대화 상세의 메시지와 액션을 매핑한다") + func fetchConversationMapping() async throws { + let conversationID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let json = """ + { + "success": true, + "code": "200", + "data": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "오늘의 행운", + "messages": [{ + "id": "22222222-2222-2222-2222-222222222222", + "role": "ASSISTANT", + "content": "좋은 날이에요.", + "status": "COMPLETED", + "createdAt": "2026-08-12T10:30:00+09:00", + "action": { + "type": "LUCKY_ACTION", + "label": "행운 액션 보기", + "category": "HEALTH", + "date": "2026-08-12" + } + }] + } + } + """ + let client = makeLiveClient { request in + #expect(request.url?.path == "/api/v1/chat/conversations/\(conversationID.uuidString.lowercased())") + return (Data(json.utf8), try response(for: request, statusCode: 200)) + } + + let conversation = try await client.fetchConversation(conversationID) + #expect(conversation.id == conversationID) + #expect(conversation.messages.first?.role == .assistant) + #expect(conversation.messages.first?.status == .completed) + #expect(conversation.messages.first?.action?.category == .health) + } + + @Test("삭제 API의 success false를 서버 오류로 전달한다") + func deleteFailureMapping() async throws { + let conversationID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let json = """ + { "success": false, "code": "CHAT_404", "message": "대화를 찾을 수 없습니다.", "data": null } + """ + let client = makeLiveClient { request in + #expect(request.httpMethod == "DELETE") + return (Data(json.utf8), try response(for: request, statusCode: 200)) + } + + await #expect( + throws: TodakClientError.server(code: "CHAT_404", message: "대화를 찾을 수 없습니다.") + ) { + try await client.deleteConversation(conversationID) + } + } +} + +private func makeLiveClient(transport: @escaping HTTPClient.Transport) -> TodakClient { + let baseURL = URL(string: "https://api.todakun.com")! + return TodakClient.live( + httpClient: HTTPClient(baseURL: baseURL, transport: transport), + sseClient: SSEClient(baseURL: baseURL) + ) +} + +private func response(for request: URLRequest, statusCode: Int) throws -> HTTPURLResponse { + try #require( + HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + ) +} diff --git a/Projects/Feature/TodakFeature/Tests/TodakFeatureTests.swift b/Projects/Feature/TodakFeature/Tests/TodakFeatureTests.swift index 88d33d2..4faff63 100644 --- a/Projects/Feature/TodakFeature/Tests/TodakFeatureTests.swift +++ b/Projects/Feature/TodakFeature/Tests/TodakFeatureTests.swift @@ -1,9 +1,380 @@ -import XCTest import ComposableArchitecture +import Foundation +import Testing @testable import TodakFeature -final class TodakFeatureTests: XCTestCase { - func testExample() { - XCTAssertTrue(true) +@Suite +@MainActor +struct TodakFeatureTests { + @Test("직접 입력한 질문의 SSE 이벤트를 순서대로 반영한다") + func directMessageStreamsAssistantMessage() async { + let localMessageID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + let conversationID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let userMessageID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let assistantMessageID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = TestClock() + let client = makeClient( + sendMessage: { _, _ in + AsyncThrowingStream { continuation in + continuation.yield( + .start( + conversationID: conversationID, + userMessageID: userMessageID, + assistantMessageID: assistantMessageID, + quota: .init(used: 1, limit: 3) + ) + ) + continuation.yield(.delta("좋은 흐름이에요.")) + continuation.yield(.done(assistantMessageID: assistantMessageID)) + continuation.finish() + } + } + ) + let store = TestStore( + initialState: TodakFeature.State(draft: "이직할까 말까?") + ) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + $0.date.now = now + $0.todakClient = client + $0.uuid = .incrementing + } + + await store.send(.sendButtonTapped) { + $0.draft = "" + $0.isStreaming = true + $0.messages = [ + TodakMessage( + id: localMessageID, + role: .user, + content: "이직할까 말까?", + status: .completed, + createdAt: now + ) + ] + } + await store.receive( + .streamEvent( + .start( + conversationID: conversationID, + userMessageID: userMessageID, + assistantMessageID: assistantMessageID, + quota: .init(used: 1, limit: 3) + ) + ) + ) { + $0.conversationID = conversationID + $0.quota = .init(used: 1, limit: 3) + $0.assistantMessageID = assistantMessageID + $0.messages.append( + TodakMessage( + id: assistantMessageID, + role: .assistant, + content: "", + status: .streaming, + createdAt: now + ) + ) + } + await store.receive(.streamEvent(.delta("좋은 흐름이에요."))) { + $0.messages[1].content = "좋은 흐름이에요." + } + await store.receive(.streamEvent(.done(assistantMessageID: assistantMessageID))) { + $0.messages[1].status = .completed + $0.isStreaming = false + $0.assistantMessageID = nil + } + await store.receive(.streamFinished) + } + + @Test("최초 진입은 스플래시 동안 Entry를 조회하고 서버 quota를 반영한다") + func initialEntryFetchesServerEntryDuringSplash() async { + let clock = TestClock() + let entry = TodakEntry( + greeting: "오늘은 무엇을 알려줄까?", + suggestions: [ + TodakSuggestion( + emoji: "💼", + label: "성취운이 궁금해", + seedPrompt: "요즘 성취운이 궁금해.", + category: .achievement + ) + ], + quota: .init(used: 2, limit: 3) + ) + let client = makeClient(fetchEntry: { entry }) + let store = TestStore(initialState: TodakFeature.State()) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + $0.todakClient = client + } + + #expect(store.state.entry == .initial) + #expect(store.state.showsSplash) + + await store.send(.task) { + $0.didPresentSplash = true + $0.isLoadingEntry = true + } + await store.receive(.entryResponse(.success(entry))) { + $0.entry = entry + $0.quota = .init(used: 2, limit: 3) + $0.isLoadingEntry = false + } + await clock.advance(by: .milliseconds(1_500)) + await store.receive(.splashElapsed) { + $0.didCompleteSplashDelay = true + $0.showsSplash = false + } + } + + @Test("추천 질문은 네트워크 없이 고정 답변과 해당 카테고리 팝업을 표시한다") + func categorySuggestionPresentsLocalReplyAndMatchingGuide() async { + let userMessageID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + let assistantMessageID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = TestClock() + let suggestion = TodakEntry.initial.suggestions[0] + let store = TestStore(initialState: TodakFeature.State()) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + $0.date.now = now + $0.todakClient = .unavailable + $0.uuid = .incrementing + } + + await store.send(.suggestionTapped(suggestion)) { + $0.activeCategory = .relationship + $0.guideCategory = .relationship + $0.messages = [ + TodakMessage( + id: userMessageID, + role: .user, + content: "요즘 관계운이 궁금해.", + status: .completed, + createdAt: now + ), + TodakMessage( + id: assistantMessageID, + role: .assistant, + content: TodakInitialReply.content(for: .relationship), + status: .completed, + createdAt: now + ) + ] + } + await store.send(.guideDismissed) { + $0.guideCategory = nil + } + } +} + +@Suite +@MainActor +struct TodakFeatureRegressionTests { + @Test("기획 확정 전까지 모든 추천 질문은 성취운 고정 답변을 사용한다") + func initialRepliesUseAchievementContentForEveryCategory() { + let achievementContent = TodakInitialReply.content(for: .achievement) + + for category in [ + TodakCategory.relationship, + .love, + .achievement, + .money, + .health, + .other + ] { + #expect(TodakInitialReply.content(for: category) == achievementContent) + } + } + + @Test("SSE 오류는 토닥이 답변을 실패 상태와 서버 안내 문구로 전환한다") + func streamErrorFailsAssistantMessage() async { + let assistantMessageID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let clock = TestClock() + let store = TestStore( + initialState: TodakFeature.State( + messages: [ + TodakMessage( + id: assistantMessageID, + role: .assistant, + content: "답변을 만들고 있어요.", + status: .streaming + ) + ], + isStreaming: true, + assistantMessageID: assistantMessageID + ) + ) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + } + + await store.send( + TodakFeature.Action.streamEvent( + TodakStreamEvent.error(code: "CHAT-500", message: "토닥이 답변 생성에 실패했습니다.") + ) + ) { + $0.isStreaming = false + $0.assistantMessageID = nil + $0.messages[0].status = .failed + $0.toastMessage = "토닥이 답변 생성에 실패했습니다." + } + await store.send(TodakFeature.Action.toastDismissed) { + $0.toastMessage = nil + } + } + + @Test("quota 오류는 남은 무료 채팅 횟수를 즉시 0으로 갱신한다") + func quotaErrorExhaustsRemainingFreeChatCount() async { + let clock = TestClock() + let store = TestStore( + initialState: TodakFeature.State( + quota: .init(used: 2, limit: 3), + isStreaming: true + ) + ) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + } + + await store.send( + .streamEvent( + .error(code: "CHAT-429", message: "오늘 무료 채팅 횟수를 모두 사용했습니다.") + ) + ) { + $0.isStreaming = false + $0.quota = .init(used: 3, limit: 3) + $0.toastMessage = "오늘 무료 채팅 횟수를 모두 사용했습니다." + } + await store.send(.toastDismissed) { + $0.toastMessage = nil + } + } + + @Test("대화 삭제 성공 시 목록과 현재 대화를 즉시 제거하고 안내 문구를 표시한다") + func deletingConversationRemovesItImmediately() async { + let conversationID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + let summary = TodakConversationSummary( + id: conversationID, + title: "삭제할 대화", + lastMessageAt: nil, + unread: true + ) + let clock = TestClock() + let client = makeClient(deleteConversation: { deletedConversationID in + #expect(deletedConversationID == conversationID) + }) + let store = TestStore( + initialState: TodakFeature.State( + conversationID: conversationID, + messages: [ + TodakMessage(id: UUID(), role: .user, content: "질문", status: .completed) + ], + conversations: [summary] + ) + ) { + TodakFeature() + } withDependencies: { + $0.continuousClock = clock + $0.todakClient = client + } + + await store.send(.deleteButtonTapped(conversationID)) { + $0.pendingDeletionID = conversationID + } + await store.send(.deleteConfirmed) { + $0.pendingDeletionID = nil + } + await store.receive(.deleteResponse(conversationID, .success(true))) { + $0.conversations = [] + $0.conversationID = nil + $0.messages = [] + $0.toastMessage = "대화가 삭제되었어요." + } + await store.send(.toastDismissed) { + $0.toastMessage = nil + } + } + + @Test("대화 삭제를 취소하면 목록을 유지한다") + func cancellingConversationDeletionKeepsConversation() async { + let conversationID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + let summary = TodakConversationSummary( + id: conversationID, + title: "유지할 대화", + lastMessageAt: nil, + unread: false + ) + let store = TestStore( + initialState: TodakFeature.State( + conversations: [summary], + pendingDeletionID: conversationID + ) + ) { + TodakFeature() + } + + await store.send(.deleteCancelled) { + $0.pendingDeletionID = nil + } + #expect(store.state.conversations == [summary]) + } + + @Test("대화 히스토리는 최근 메시지 순으로 정렬한다") + func historyIsSortedByMostRecentMessage() async { + let olderID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + let newerID = UUID(uuidString: "00000000-0000-0000-0000-000000000011")! + let undatedID = UUID(uuidString: "00000000-0000-0000-0000-000000000012")! + let older = TodakConversationSummary( + id: olderID, + title: "어제 질문", + lastMessageAt: Date(timeIntervalSince1970: 1_700_000_000), + unread: false + ) + let newer = TodakConversationSummary( + id: newerID, + title: "오늘 질문", + lastMessageAt: Date(timeIntervalSince1970: 1_800_000_000), + unread: true + ) + let undated = TodakConversationSummary( + id: undatedID, + title: "시간 없는 질문", + lastMessageAt: nil, + unread: false + ) + let store = TestStore(initialState: TodakFeature.State(isLoadingHistory: true)) { + TodakFeature() + } + + await store.send(.historyResponse(.success([older, undated, newer]))) { + $0.isLoadingHistory = false + $0.conversations = [newer, older, undated] + } + } +} + +private func makeClient( + fetchEntry: @escaping @Sendable () async throws -> TodakEntry = { .initial }, + deleteConversation: @escaping @Sendable (UUID) async throws -> Void = { _ in }, + sendMessage: @escaping @Sendable (UUID?, String) -> AsyncThrowingStream = { _, _ in + AsyncThrowingStream { $0.finish() } } +) -> TodakClient { + TodakClient( + fetchEntry: fetchEntry, + fetchConversations: { [] }, + fetchConversation: { conversationID in + TodakConversation(id: conversationID, title: "", messages: []) + }, + deleteConversation: deleteConversation, + sendMessage: sendMessage + ) } diff --git a/README.md b/README.md index caad115..2f26d7a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **`Implementation`**: 피처의 핵심 UI 뷰와 비즈니스 로직(TCA Reducer 등)을 구현합니다. - **`Testing`**: 피처의 Mock/Stub 등 테스트 더블을 구현하며, 타 피처의 단위 테스트나 예제 앱 등에서 의존성 주입 시 Mock 객체로 활용됩니다. - 피처의 본체(Implementation)끼리는 절대 서로 참조하지 않으며, 타 피처로의 화면 전환(네비게이션)이 필요할 때는 상대 피처의 **Interface** 타겟만을 의존합니다. -- `MainTab`이나 `Root`와 같이 전체 피처들을 조립하는 껍데기 화면 코드는 피처 레이어가 아닌 최상위 **`App` 레이어** 내부에 작성되어 최종 런타임 조립(Composition Root)을 담당합니다. 현재 `RootFeature`는 저장 세션을 복원해 온보딩 또는 인증 완료 상태로 연결합니다. 인증 완료 목적지는 임시 홈 화면이며, 실제 `TodakFeature` 연결은 후속 작업입니다. +- `MainTab`이나 `Root`와 같이 전체 피처들을 조립하는 껍데기 화면 코드는 피처 레이어가 아닌 최상위 **`App` 레이어** 내부에 작성되어 최종 런타임 조립(Composition Root)을 담당합니다. 현재 `RootFeature`는 저장 세션을 복원해 온보딩 또는 인증 완료 상태로 연결하며, 인증 완료 사용자는 `MainTab`에서 운세·토닥이·행운액션·마이페이지 피처를 사용합니다. ```mermaid graph TD