diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 5bcd060..22f25e7 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -32,15 +32,23 @@ BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Source/Domain/Entities/CalendarDay.swift, - Source/Domain/Entities/Category.swift, - Source/Domain/Entities/Expense.swift, + Source/Domain/Entities/AI/AIInsightItem.swift, + Source/Domain/Entities/AI/AIModelMetadata.swift, + Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift, + Source/Domain/Entities/AI/CumulativePrediction.swift, + Source/Domain/Entities/Expense/CalendarDay.swift, + Source/Domain/Entities/Expense/Category.swift, + Source/Domain/Entities/Expense/Expense.swift, + Source/Domain/Repositories/AIRepositoryProtocol.swift, Source/Domain/Repositories/CategoryRepositoryProtocol.swift, Source/Domain/Repositories/ExpenseRepositoryProtocol.swift, + Source/Domain/UseCases/AIUseCase.swift, + Source/Domain/UseCases/AIUseCaseProtocol.swift, Source/Domain/UseCases/CategoryUseCase.swift, Source/Domain/UseCases/CategoryUseCaseProtocol.swift, Source/Domain/UseCases/ExpenseUseCase.swift, Source/Domain/UseCases/ExpenseUseCaseProtocol.swift, + Source/Presentation/View/AI/AIViewModel.swift, Source/Presentation/View/Home/HomeViewModel.swift, Source/Presentation/View/NewExpense/NewExpenseViewModel.swift, Source/Presentation/View/Settings/SettingsViewModel.swift, diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index c5d096a..1fc71d1 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -36,7 +36,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext) let categoryUseCase = CategoryUseCase(repository: categoryRepository) - let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) + let expenseRepository = MockExpenseRepository() +// let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) let expenseUseCase = ExpenseUseCase(repository: expenseRepository) let homeViewModel = HomeViewModel(expenseUseCase: expenseUseCase) @@ -47,7 +48,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) - let aiViewController = UIHostingController(rootView: AIView()) + let aiUseCase = AIUseCase(repository: MockAIRepository()) + let aiViewModel = AIViewModel(expenseUseCase: expenseUseCase, aiUseCase: aiUseCase) + let aiViewController = UIHostingController(rootView: AIView(viewModel: aiViewModel)) aiViewController.tabBarItem = UITabBarItem( title: "예측", image: UIImage(systemName: "brain"), diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift new file mode 100644 index 0000000..6c6d5dd --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -0,0 +1,55 @@ +// +// MockAIRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class MockAIRepository: AIRepositoryProtocol { + + private let models: [AIModelMetadata] = [ + AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()), + AIModelMetadata(id: "SP260614", dataCount: 980, accuracy: 76, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), + AIModelMetadata(id: "SP260514", dataCount: 750, accuracy: 71, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), + ] + + func fetchModels() async -> [AIModelMetadata] { + models + } + + func fetchCurrentModel() async -> AIModelMetadata? { + models.first + } + + func fetchInsights() async -> [AIInsightItem] { + [ + AIInsightItem(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), + AIInsightItem(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), + AIInsightItem(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), + ] + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + [ + 1: 15000, 2: 18000, 3: 12000, 4: 22000, 5: 19000, + 6: 8000, 7: 25000, 8: 17000, 9: 21000, 10: 14000, + 11: 19000, 12: 23000, 13: 16000, 14: 20000, 15: 18000, + 16: 22000, 17: 15000, 18: 19000, 19: 24000, 20: 17000, + 21: 21000, 22: 18000, 23: 16000, 24: 22000, 25: 20000, + 26: 15000, 27: 19000, 28: 23000, 29: 17000, 30: 21000, + 31: 18000, + ] + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + [ + "식비": 280000, + "카페/간식": 45000, + "교통": 60000, + "쇼핑": 200000, + "의료/건강": 30000, + ] + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift new file mode 100644 index 0000000..e3fdb2a --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift @@ -0,0 +1,23 @@ +// +// AIInsightItem.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +enum AIInsightItemType { + case abnormal, forecast, unrecorded + + var title: String { + switch self { + case .abnormal: return "이상 지출 감지" + case .forecast: return "지출 예고" + case .unrecorded: return "미기록 감지" + } + } +} + +struct AIInsightItem { + let type: AIInsightItemType + let description: String +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift new file mode 100644 index 0000000..b7f9ded --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift @@ -0,0 +1,15 @@ +// +// AIModelMetadata.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +struct AIModelMetadata { + let id: String + let dataCount: Int + let accuracy: Float + let createdAt: Date +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift new file mode 100644 index 0000000..ec03ef3 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift @@ -0,0 +1,12 @@ +// +// CategoryPredictionDataPoint.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct CategoryPredictionDataPoint { + let categoryName: String + let actual: Int + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift new file mode 100644 index 0000000..d5382a4 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift @@ -0,0 +1,12 @@ +// +// CumulativePrediction.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct CumulativePrediction { + let day: Int + let actual: Int? + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/CalendarDay.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/CalendarDay.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Category.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/Category.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Expense.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/Expense.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift new file mode 100644 index 0000000..3837218 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -0,0 +1,19 @@ +// +// AIRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol AIRepositoryProtocol { + /// 저장된 모든 AI 모델 메타데이터 목록 + func fetchModels() async -> [AIModelMetadata] + /// 현재 사용 중인 AI 모델 메타데이터 + func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 인사이트 목록 + func fetchInsights() async -> [AIInsightItem] + /// 이번 달 일별 예측 금액 [일: 예측금액] + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] + /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift new file mode 100644 index 0000000..768831f --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -0,0 +1,37 @@ +// +// AIUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class AIUseCase: AIUseCaseProtocol { + + private let repository: AIRepositoryProtocol + + init(repository: AIRepositoryProtocol) { + self.repository = repository + } + + func fetchModels() async -> [AIModelMetadata] { + await repository.fetchModels() + } + + func fetchCurrentModel() async -> AIModelMetadata? { + await repository.fetchCurrentModel() + } + + func fetchInsights() async -> [AIInsightItem] { + await repository.fetchInsights() + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + await repository.fetchDailyPredictions(year: year, month: month) + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + await repository.fetchCategoryPredictions(year: year, month: month) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift new file mode 100644 index 0000000..524bcc4 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -0,0 +1,19 @@ +// +// AIUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol AIUseCaseProtocol { + /// 저장된 모든 AI 모델 메타데이터 목록 + func fetchModels() async -> [AIModelMetadata] + /// 현재 사용 중인 AI 모델 메타데이터 + func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 인사이트 목록 + func fetchInsights() async -> [AIInsightItem] + /// 이번 달 일별 예측 금액 [일: 예측금액] + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] + /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift new file mode 100644 index 0000000..7c3f543 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift @@ -0,0 +1,64 @@ +// +// ToastView.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import SwiftUI + +struct ToastModifier: ViewModifier { + + @Binding var isShowing: Bool + let message: String + + @State private var isVisible = false + @State private var task: Task? = nil + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if isVisible { + Text(message) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(UIColor.DesignSystem.primary)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 16) + .ignoresSafeArea(edges: .bottom) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + .animation(.easeInOut(duration: 0.3), value: isVisible) + .onChange(of: isShowing) { _, newValue in + guard newValue else { return } + isShowing = false + task?.cancel() + Task { @MainActor in + withTransaction(Transaction(animation: nil)) { + isVisible = false + } + try? await Task.sleep(for: .milliseconds(50)) + withAnimation { + isVisible = true + } + task = Task { + try? await Task.sleep(for: .seconds(2)) + if !Task.isCancelled { + withAnimation { + isVisible = false + } + } + } + } + } + } +} + +extension View { + func toast(isShowing: Binding, message: String) -> some View { + modifier(ToastModifier(isShowing: isShowing, message: message)) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift index a8cad2e..5d93132 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift @@ -8,21 +8,12 @@ import SwiftUI import Charts -struct CategoryPredictionDataPoint { - let categoryName: String - let actual: Int - let predicted: Int? -} - struct AICategoryPredictionCardView: View { - let data: [CategoryPredictionDataPoint] - @State private var isExpanded = false - private var hasPrediction: Bool { - data.compactMap { $0.predicted }.isEmpty == false - } + let data: [CategoryPredictionDataPoint] + let hasPrediction: Bool private var displayData: [CategoryPredictionDataPoint] { isExpanded ? data : Array(data.prefix(5)) @@ -38,7 +29,9 @@ struct AICategoryPredictionCardView: View { emptyView } else { chart - expandButton + if data.count > 5 { + expandButton + } } } .background(Color(UIColor.DesignSystem.surface)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift index 8c50f21..12567bc 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift @@ -7,40 +7,15 @@ import SwiftUI -enum AIInsightItemType { - case abnormal, forecast, unrecorded - - var title: String { - switch self { - case .abnormal: return "이상 지출 감지" - case .forecast: return "지출 예고" - case .unrecorded: return "미기록 감지" - } - } -} - -struct AIInsightItemData { - let type: AIInsightItemType - let description: String -} - struct AIInsightCardView: View { - let items: [AIInsightItemData] - - private let orderedTypes: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] - - private var visibleItems: [AIInsightItemData] { - orderedTypes.compactMap { type in - items.first { $0.type == type } - } - } + let items: [AIInsightItem] var body: some View { VStack(spacing: 0) { header - if visibleItems.isEmpty { + if items.isEmpty { emptyView } else { bodyView @@ -72,10 +47,10 @@ struct AIInsightCardView: View { private var bodyView: some View { VStack(spacing: 0) { - ForEach(Array(visibleItems.enumerated()), id: \.offset) { index, item in + ForEach(Array(items.enumerated()), id: \.offset) { index, item in rowView(item: item) - if index < visibleItems.count - 1 { + if index < items.count - 1 { Divider() .background(Color(UIColor.DesignSystem.separator)) .padding(.horizontal, 12) @@ -84,7 +59,7 @@ struct AIInsightCardView: View { } } - private func rowView(item: AIInsightItemData) -> some View { + private func rowView(item: AIInsightItem) -> some View { VStack(alignment: .leading, spacing: 4) { Text(item.type.title) .font(.system(size: 14, weight: .semibold)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift index 8549f56..d773aa8 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift @@ -7,26 +7,18 @@ import SwiftUI -struct AIModelItem { - let id: String - let dataCount: Int - let accuracy: Float - let createdAt: Date -} - struct AIModelSelectView: View { @Environment(\.dismiss) private var dismiss - - let models: [AIModelItem] - var onConfirm: (AIModelItem) -> Void - @State private var selectedId: String + let models: [AIModelMetadata] + var onConfirm: (AIModelMetadata) -> Void + init( - models: [AIModelItem], + models: [AIModelMetadata], currentModelId: String, - onConfirm: @escaping (AIModelItem) -> Void + onConfirm: @escaping (AIModelMetadata) -> Void ) { self.models = models self.onConfirm = onConfirm @@ -35,24 +27,41 @@ struct AIModelSelectView: View { var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(spacing: 12) { - ForEach(models, id: \.id) { model in - modelRow(model) - .onTapGesture { - selectedId = model.id - } + if models.isEmpty { + emptyView + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(models, id: \.id) { model in + modelRow(model) + .onTapGesture { + selectedId = model.id + } + } } + .padding(20) } - .padding(20) - } - confirmButton + confirmButton + } } .background(Color(UIColor.DesignSystem.background)) } - private func modelRow(_ model: AIModelItem) -> some View { + private var emptyView: some View { + VStack(spacing: 8) { + Text("생성된 모델이 없어요") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("소비를 꾸준히 기록하고 모델을 생성해보세요.") + .font(.system(size: 13)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func modelRow(_ model: AIModelMetadata) -> some View { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 4) { Text(model.id) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift index 4a64567..4ff392a 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift @@ -8,27 +8,12 @@ import SwiftUI import Charts -struct PredictionDataPoint { - let day: Int - let actual: Int? - let predicted: Int? -} - -private struct CumulativeDataPoint { - let day: Int - let actual: Int? - let predicted: Int? -} - struct AIPredictionCardView: View { - let data: [PredictionDataPoint] + let data: [CumulativePrediction] let today: Int let lastDay: Int - - private var hasPrediction: Bool { - data.compactMap { $0.predicted }.isEmpty == false - } + let hasPrediction: Bool var body: some View { VStack(spacing: 0) { @@ -97,31 +82,14 @@ struct AIPredictionCardView: View { } private var chart: some View { - var cumulativeActual = 0 - var cumulativePredicted = 0 - let cumulativeData: [CumulativeDataPoint] = data.map { point in - if let actual = point.actual { - cumulativeActual += actual - } - if let predicted = point.predicted { - cumulativePredicted += predicted - } - return CumulativeDataPoint( - day: point.day, - actual: point.actual != nil ? cumulativeActual : nil, - predicted: point.predicted != nil ? cumulativePredicted : nil - ) - } - - let actualValues = cumulativeData.compactMap { $0.actual } - let predictedValues = cumulativeData.compactMap { $0.predicted } + let actualValues = data.compactMap { $0.actual } + let predictedValues = data.compactMap { $0.predicted } let maxValue = (actualValues + predictedValues).max() ?? 0 - let predictedTotal = cumulativeData.compactMap { $0.predicted }.last ?? 0 + let predictedTotal = data.compactMap { $0.predicted }.last ?? 0 return Chart { - if hasPrediction { - ForEach(cumulativeData, id: \.day) { point in + ForEach(data, id: \.day) { point in if let predicted = point.predicted { LineMark( x: .value("날짜", point.day), @@ -133,7 +101,7 @@ struct AIPredictionCardView: View { } } - if let lastPoint = cumulativeData.last, let predicted = lastPoint.predicted { + if let lastPoint = data.last, let predicted = lastPoint.predicted { PointMark( x: .value("날짜", lastPoint.day), y: .value("금액", predicted) @@ -147,7 +115,7 @@ struct AIPredictionCardView: View { } } - ForEach(cumulativeData, id: \.day) { point in + ForEach(data, id: \.day) { point in if let actual = point.actual { LineMark( x: .value("날짜", point.day), @@ -162,8 +130,8 @@ struct AIPredictionCardView: View { .foregroundStyle(Color(UIColor.DesignSystem.subtitle).opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .annotation(position: .top) { - let actualTotal = cumulativeData.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 - let predictedToday = cumulativeData.filter { $0.day <= today }.compactMap { $0.predicted }.last ?? 0 + let actualTotal = data.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 + let predictedToday = data.filter { $0.day <= today }.compactMap { $0.predicted }.last ?? 0 VStack(alignment: .leading, spacing: 2) { Text("실제: \(formatted(actualTotal))") @@ -180,7 +148,7 @@ struct AIPredictionCardView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } - if let todayPoint = cumulativeData.first(where: { $0.day == today }), + if let todayPoint = data.first(where: { $0.day == today }), let actual = todayPoint.actual { PointMark( x: .value("날짜", todayPoint.day), diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 5bd5004..7e18ce4 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -9,74 +9,17 @@ import SwiftUI struct AIStatusCardView: View { - let modelId: String - let dataCount: Int - let accuracy: Float - + @Binding var isShowingToast: Bool @State private var isShowingCreate = false @State private var isShowingSelect = false - private let mockModels: [AIModelItem] = [ - AIModelItem(id: "CAT13A", dataCount: 1204, accuracy: 82, createdAt: Date()), - AIModelItem(id: "CAT12B", dataCount: 980, accuracy: 76, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), - AIModelItem(id: "CAT11C", dataCount: 750, accuracy: 71, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), - ] + let currentModel: AIModelMetadata? + let models: [AIModelMetadata] var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "cpu") - .resizable() - .scaledToFit() - .frame(width: 64, height: 64) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - VStack(alignment: .leading, spacing: 4) { - Text("\(modelId) 예측 모델 (\(Int(accuracy))%)") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - Text("학습에 사용된 데이터: \(dataCount)개") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - - ProgressView(value: accuracy / 100) - .tint(Color(UIColor.DesignSystem.accent)) - .scaleEffect(x: 1, y: 2) - .padding(.top, 2) - } - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 20) - - HStack(spacing: 12) { - Button { - isShowingCreate = true - } label: { - Text("모델 생성하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.primary)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - - Button { - isShowingSelect = true - } label: { - Text("모델 교체하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.accent)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + modelInfo + buttons } .background(Color(UIColor.DesignSystem.surface)) .clipShape(RoundedRectangle(cornerRadius: 20)) @@ -88,13 +31,92 @@ struct AIStatusCardView: View { } message: { Text("데이터를 학습해 예측 모델을 생성할까요?") } + .tint(Color(UIColor.DesignSystem.accent)) .sheet(isPresented: $isShowingSelect) { AIModelSelectView( - models: mockModels, - currentModelId: modelId + models: models, + currentModelId: currentModel?.id ?? "" ) { selected in print("선택된 모델: \(selected.id)") } } } + + private var modelInfo: some View { + Group { + if let model = currentModel { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "cpu") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + VStack(alignment: .leading, spacing: 4) { + Text("\(model.id) 예측 모델 (\(Int(model.accuracy))%)") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("학습에 사용된 데이터: \(model.dataCount)개") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + + ProgressView(value: model.accuracy / 100) + .tint(Color(UIColor.DesignSystem.accent)) + .scaleEffect(x: 1, y: 2) + .padding(.top, 2) + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("예측 모델이 없어요") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("소비를 꾸준히 기록할수록 모델이 더 정확해져요.\n기록이 쌓이면 아래 '모델 생성하기' 버튼을 눌러\n나만의 예측 모델을 만들어보세요!") + .font(.system(size: 13)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 20) + } + + private var buttons: some View { + HStack(spacing: 12) { + Button { + isShowingCreate = true + } label: { + Text("모델 생성하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.primary)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + + Button { + if models.isEmpty { + isShowingToast = true + } else { + isShowingSelect = true + } + } label: { + Text("모델 교체하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.accent).opacity(models.isEmpty ? 0.4 : 1.0)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 553b1e6..037fc90 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -9,6 +9,9 @@ import SwiftUI struct AIView: View { + @State var viewModel: AIViewModel + @State private var isShowingToast = false + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -23,144 +26,36 @@ struct AIView: View { .offset(y: -5) AIStatusCardView( - modelId: "CAT13A", - dataCount: 1204, - accuracy: 82, + isShowingToast: $isShowingToast, + currentModel: viewModel.currentModel, + models: viewModel.models, ) } - AIInsightCardView(items: [ - AIInsightItemData(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), - AIInsightItemData(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), - AIInsightItemData(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), - ]) - -// AIPredictionCardView( -// data: [ -// PredictionDataPoint(day: 1, actual: 12000, predicted: nil), -// PredictionDataPoint(day: 2, actual: 28000, predicted: nil), -// PredictionDataPoint(day: 3, actual: 45000, predicted: nil), -// PredictionDataPoint(day: 4, actual: 67000, predicted: nil), -// PredictionDataPoint(day: 5, actual: 82000, predicted: nil), -// PredictionDataPoint(day: 6, actual: 95000, predicted: nil), -// PredictionDataPoint(day: 7, actual: 110000, predicted: nil), -// PredictionDataPoint(day: 8, actual: 125000, predicted: nil), -// PredictionDataPoint(day: 9, actual: 138000, predicted: nil), -// PredictionDataPoint(day: 10, actual: 152000, predicted: nil), -// PredictionDataPoint(day: 11, actual: 170000, predicted: nil), -// PredictionDataPoint(day: 12, actual: 185000, predicted: nil), -// PredictionDataPoint(day: 13, actual: 198000, predicted: nil), -// PredictionDataPoint(day: 14, actual: 215000, predicted: nil), -// PredictionDataPoint(day: 15, actual: nil, predicted: nil), -// PredictionDataPoint(day: 16, actual: nil, predicted: nil), -// PredictionDataPoint(day: 17, actual: nil, predicted: nil), -// PredictionDataPoint(day: 18, actual: nil, predicted: nil), -// PredictionDataPoint(day: 19, actual: nil, predicted: nil), -// PredictionDataPoint(day: 20, actual: nil, predicted: nil), -// PredictionDataPoint(day: 21, actual: nil, predicted: nil), -// PredictionDataPoint(day: 22, actual: nil, predicted: nil), -// PredictionDataPoint(day: 23, actual: nil, predicted: nil), -// PredictionDataPoint(day: 24, actual: nil, predicted: nil), -// PredictionDataPoint(day: 25, actual: nil, predicted: nil), -// PredictionDataPoint(day: 26, actual: nil, predicted: nil), -// PredictionDataPoint(day: 27, actual: nil, predicted: nil), -// PredictionDataPoint(day: 28, actual: nil, predicted: nil), -// PredictionDataPoint(day: 29, actual: nil, predicted: nil), -// PredictionDataPoint(day: 30, actual: nil, predicted: nil), -// PredictionDataPoint(day: 31, actual: nil, predicted: nil), -// ], -// today: 14, -// lastDay: 31 -// ) -// -// AICategoryPredictionCardView(data: [ -// CategoryPredictionDataPoint(categoryName: "식비", actual: 320000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "교통", actual: 54000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "카페", actual: 87000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "쇼핑", actual: 152000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "구독", actual: 29000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "의료", actual: 45000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "운동", actual: 62000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "도서", actual: 18000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "영화", actual: 24000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "여행", actual: 0, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "숙박", actual: 0, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "미용", actual: 35000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "반려동물", actual: 55000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "세금", actual: 120000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "보험", actual: 80000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "통신", actual: 55000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "게임", actual: 12000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "음악", actual: 9000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "편의점", actual: 43000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "기타", actual: 27000, predicted: nil), -// ].sorted { $0.actual > $1.actual }) + AIInsightCardView(items: viewModel.sortedInsights) AIPredictionCardView( - data: [ - PredictionDataPoint(day: 1, actual: 12000, predicted: 15000), - PredictionDataPoint(day: 2, actual: 28000, predicted: 30000), - PredictionDataPoint(day: 3, actual: 45000, predicted: 45000), - PredictionDataPoint(day: 4, actual: 67000, predicted: 60000), - PredictionDataPoint(day: 5, actual: 82000, predicted: 75000), - PredictionDataPoint(day: 6, actual: 95000, predicted: 90000), - PredictionDataPoint(day: 7, actual: 110000, predicted: 105000), - PredictionDataPoint(day: 8, actual: 125000, predicted: 120000), - PredictionDataPoint(day: 9, actual: 138000, predicted: 135000), - PredictionDataPoint(day: 10, actual: 152000, predicted: 150000), - PredictionDataPoint(day: 11, actual: 170000, predicted: 165000), - PredictionDataPoint(day: 12, actual: 185000, predicted: 180000), - PredictionDataPoint(day: 13, actual: 198000, predicted: 195000), - PredictionDataPoint(day: 14, actual: 215000, predicted: 210000), - PredictionDataPoint(day: 15, actual: nil, predicted: 225000), - PredictionDataPoint(day: 16, actual: nil, predicted: 240000), - PredictionDataPoint(day: 17, actual: nil, predicted: 255000), - PredictionDataPoint(day: 18, actual: nil, predicted: 270000), - PredictionDataPoint(day: 19, actual: nil, predicted: 285000), - PredictionDataPoint(day: 20, actual: nil, predicted: 300000), - PredictionDataPoint(day: 21, actual: nil, predicted: 315000), - PredictionDataPoint(day: 22, actual: nil, predicted: 330000), - PredictionDataPoint(day: 23, actual: nil, predicted: 345000), - PredictionDataPoint(day: 24, actual: nil, predicted: 360000), - PredictionDataPoint(day: 25, actual: nil, predicted: 375000), - PredictionDataPoint(day: 26, actual: nil, predicted: 390000), - PredictionDataPoint(day: 27, actual: nil, predicted: 405000), - PredictionDataPoint(day: 28, actual: nil, predicted: 420000), - PredictionDataPoint(day: 29, actual: nil, predicted: 435000), - PredictionDataPoint(day: 30, actual: nil, predicted: 450000), - PredictionDataPoint(day: 31, actual: nil, predicted: 465000), - ], - today: 14, - lastDay: 31 + data: viewModel.predictionData, + today: viewModel.today, + lastDay: viewModel.lastDay, + hasPrediction: viewModel.hasPrediction ) - AICategoryPredictionCardView(data: [ - CategoryPredictionDataPoint(categoryName: "식비", actual: 320000, predicted: 280000), - CategoryPredictionDataPoint(categoryName: "교통", actual: 54000, predicted: 60000), - CategoryPredictionDataPoint(categoryName: "카페", actual: 87000, predicted: 45000), - CategoryPredictionDataPoint(categoryName: "쇼핑", actual: 152000, predicted: 200000), - CategoryPredictionDataPoint(categoryName: "구독", actual: 29000, predicted: 29000), - CategoryPredictionDataPoint(categoryName: "의료", actual: 45000, predicted: 30000), - CategoryPredictionDataPoint(categoryName: "운동", actual: 62000, predicted: 70000), - CategoryPredictionDataPoint(categoryName: "도서", actual: 18000, predicted: 25000), - CategoryPredictionDataPoint(categoryName: "영화", actual: 24000, predicted: 20000), - CategoryPredictionDataPoint(categoryName: "여행", actual: 0, predicted: 150000), - CategoryPredictionDataPoint(categoryName: "숙박", actual: 0, predicted: 80000), - CategoryPredictionDataPoint(categoryName: "미용", actual: 35000, predicted: 40000), - CategoryPredictionDataPoint(categoryName: "반려동물", actual: 55000, predicted: 50000), - CategoryPredictionDataPoint(categoryName: "세금", actual: 120000, predicted: 120000), - CategoryPredictionDataPoint(categoryName: "보험", actual: 80000, predicted: 80000), - CategoryPredictionDataPoint(categoryName: "통신", actual: 55000, predicted: 55000), - CategoryPredictionDataPoint(categoryName: "게임", actual: 12000, predicted: 15000), - CategoryPredictionDataPoint(categoryName: "음악", actual: 9000, predicted: 9000), - CategoryPredictionDataPoint(categoryName: "편의점", actual: 43000, predicted: 35000), - CategoryPredictionDataPoint(categoryName: "기타", actual: 27000, predicted: 0), - ].sorted { $0.actual > $1.actual }) - .padding(.bottom, 20) + AICategoryPredictionCardView( + data: viewModel.categoryData, + hasPrediction: viewModel.hasPrediction + ) } + .padding(.bottom, 20) .padding(.horizontal, 20) } .background(Color(UIColor.DesignSystem.background)) .scrollIndicators(.hidden) + .onAppear { + Task { + await viewModel.onAppear() + } + } + .toast(isShowing: $isShowingToast, message: "아직 생성된 예측 모델이 없어요") } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift new file mode 100644 index 0000000..8966db9 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -0,0 +1,130 @@ +// +// AIViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +@Observable +final class AIViewModel { + + // MARK: - Output + private(set) var currentModel: AIModelMetadata? = nil + private(set) var models: [AIModelMetadata] = [] + private(set) var insights: [AIInsightItem] = [] + private(set) var predictionData: [CumulativePrediction] = [] + private(set) var categoryData: [CategoryPredictionDataPoint] = [] + private(set) var today: Int = Calendar.current.component(.day, from: Date()) + private(set) var lastDay: Int = { + let calendar = Calendar.current + let range = calendar.range(of: .day, in: .month, for: Date()) + return range?.count ?? 30 + }() + + private let expenseUseCase: ExpenseUseCaseProtocol + private let aiUseCase: AIUseCaseProtocol + + var hasPrediction: Bool { + predictionData.compactMap { $0.predicted }.isEmpty == false + } + + var sortedInsights: [AIInsightItem] { + let order: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] + return order.compactMap { type in + insights.first { $0.type == type } + } + } + + // MARK: - Init + init( + expenseUseCase: ExpenseUseCaseProtocol, + aiUseCase: AIUseCaseProtocol + ) { + self.expenseUseCase = expenseUseCase + self.aiUseCase = aiUseCase + } + + // MARK: - Input + func onAppear() async { + await load() + } + + // MARK: - Private + private func load() async { + let calendar = Calendar.current + let year = calendar.component(.year, from: Date()) + let month = calendar.component(.month, from: Date()) + + async let expenses = expenseUseCase.fetch(year: year, month: month) + async let currentModel = aiUseCase.fetchCurrentModel() + async let models = aiUseCase.fetchModels() + async let insights = aiUseCase.fetchInsights() + async let dailyPredictions = aiUseCase.fetchDailyPredictions(year: year, month: month) + async let categoryPredictions = aiUseCase.fetchCategoryPredictions(year: year, month: month) + + let ( + fetchedExpenses, + fetchedModel, + fetchedModels, + fetchedInsights, + fetchedDaily, + fetchedCategory + ) = await ( + expenses, + currentModel, + models, + insights, + dailyPredictions, + categoryPredictions + ) + + self.currentModel = fetchedModel + self.models = fetchedModels + self.insights = fetchedInsights + self.predictionData = makeCumulativePrediction(expenses: fetchedExpenses, predictions: fetchedDaily) + self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedCategory) + } + + private func makeCumulativePrediction(expenses: [Expense], predictions: [Int: Int]) -> [CumulativePrediction] { + var cumulativeActual = 0 + var cumulativePredicted = 0 + + return (1...lastDay).map { day in + let daily = expenses + .filter { Calendar.current.component(.day, from: $0.date) == day } + .reduce(0) { $0 + $1.amount } + + if day <= today { + cumulativeActual += daily + } + if let predicted = predictions[day] { + cumulativePredicted += predicted + } + + return CumulativePrediction( + day: day, + actual: day <= today ? cumulativeActual : nil, + predicted: predictions[day] != nil ? cumulativePredicted : nil + ) + } + } + + private func makeCategoryData(expenses: [Expense], predictions: [String: Int]) -> [CategoryPredictionDataPoint] { + var categoryTotals: [String: Int] = [:] + for expense in expenses where Calendar.current.component(.day, from: expense.date) <= today { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + let allCategories = Set(categoryTotals.keys).union(Set(predictions.keys)) + return allCategories.map { name in + CategoryPredictionDataPoint( + categoryName: name, + actual: categoryTotals[name] ?? 0, + predicted: predictions[name] + ) + } + .sorted { $0.actual > $1.actual } + } +} diff --git a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift new file mode 100644 index 0000000..2b12482 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift @@ -0,0 +1,130 @@ +// +// AIUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("AIUseCase") +@MainActor +struct AIUseCaseTests { + + @Test("fetchModels 호출 시 Repository의 fetchModels가 호출된다") + func fetchModelsCallsRepository() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchModels() + + #expect(spy.fetchModelsCallCount == 1) + } + + @Test("fetchModels 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchModelsReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) + spy.stubbedModels = [model] + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchModels() + + #expect(result.count == 1) + #expect(result.first?.id == model.id) + } + + @Test("fetchCurrentModel 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchCurrentModelReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) + spy.stubbedCurrentModel = model + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchCurrentModel() + + #expect(result?.id == model.id) + } + + @Test("fetchDailyPredictions 호출 시 Repository가 올바른 year/month로 호출된다") + func fetchDailyPredictionsCallsRepositoryWithCorrectYearMonth() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchDailyPredictions(year: 2026, month: 7) + + #expect(spy.fetchDailyCallCount == 1) + #expect(spy.fetchedYear == 2026) + #expect(spy.fetchedMonth == 7) + } + + @Test("fetchCategoryPredictions 호출 시 Repository가 올바른 year/month로 호출된다") + func fetchCategoryPredictionsCallsRepositoryWithCorrectYearMonth() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchCategoryPredictions(year: 2026, month: 7) + + #expect(spy.fetchCategoryCallCount == 1) + #expect(spy.fetchedYear == 2026) + #expect(spy.fetchedMonth == 7) + } + + @Test("fetchInsights 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchInsightsReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let insight = AIInsightItem(type: .abnormal, description: "테스트") + spy.stubbedInsights = [insight] + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchInsights() + + #expect(result.count == 1) + #expect(result.first?.description == insight.description) + } +} + +// MARK: - Spy + +final class SpyAIRepository: AIRepositoryProtocol { + private(set) var fetchModelsCallCount = 0 + private(set) var fetchCurrentModelCallCount = 0 + private(set) var fetchDailyCallCount = 0 + private(set) var fetchCategoryCallCount = 0 + private(set) var fetchInsightsCallCount = 0 + private(set) var fetchedYear: Int? + private(set) var fetchedMonth: Int? + var stubbedModels: [AIModelMetadata] = [] + var stubbedCurrentModel: AIModelMetadata? = nil + var stubbedInsights: [AIInsightItem] = [] + + func fetchModels() async -> [AIModelMetadata] { + fetchModelsCallCount += 1 + return stubbedModels + } + + func fetchCurrentModel() async -> AIModelMetadata? { + fetchCurrentModelCallCount += 1 + return stubbedCurrentModel + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + fetchDailyCallCount += 1 + fetchedYear = year + fetchedMonth = month + return [:] + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + fetchCategoryCallCount += 1 + fetchedYear = year + fetchedMonth = month + return [:] + } + + func fetchInsights() async -> [AIInsightItem] { + fetchInsightsCallCount += 1 + return stubbedInsights + } +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift new file mode 100644 index 0000000..6869a43 --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift @@ -0,0 +1,173 @@ +// +// AIViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("AIViewModel") +@MainActor +struct AIViewModelTests { + + // MARK: - onAppear / load + + @Test("onAppear 호출 후 predictionData가 채워진다") + func onAppearFillsPredictionData() async { + let expenseStub = StubExpenseUseCaseForAI() + let aiStub = StubAIUseCase() + aiStub.stubbedDailyPredictions = [1: 10000, 2: 20000] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.predictionData.isEmpty == false) + } + + @Test("onAppear 호출 후 categoryData가 채워진다") + func onAppearFillsCategoryData() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + expenseStub.stubbedExpenses = [ + Expense(date: Date(), category: food, memo: nil, amount: 5000) + ] + let aiStub = StubAIUseCase() + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.categoryData.isEmpty == false) + #expect(sut.categoryData.first?.categoryName == "식비") + } + + // MARK: - hasPrediction + + @Test("예측 데이터가 없으면 hasPrediction이 false다") + func hasPredictionIsFalseWhenNoPredictions() async { + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + #expect(sut.hasPrediction == false) + } + + @Test("예측 데이터가 있으면 hasPrediction이 true다") + func hasPredictionIsTrueWhenPredictionsExist() async { + let aiStub = StubAIUseCase() + aiStub.stubbedDailyPredictions = [1: 10000] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.hasPrediction == true) + } + + // MARK: - makeCumulativePrediction + + @Test("오늘 날짜에 소비가 없어도 actual이 nil이 아니다") + func todayActualIsNotNilEvenWithNoExpenses() async { + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + let todayPoint = sut.predictionData.first(where: { $0.day == sut.today }) + #expect(todayPoint?.actual != nil) + } + + @Test("누적 actual이 올바르게 계산된다") + func cumulativeActualIsCorrect() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + let calendar = Calendar.current + let day1 = calendar.date(from: DateComponents(year: 2026, month: 7, day: 1))! + let day2 = calendar.date(from: DateComponents(year: 2026, month: 7, day: 2))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: food, memo: nil, amount: 1000), + Expense(date: day2, category: food, memo: nil, amount: 2000), + ] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + let day2Point = sut.predictionData.first(where: { $0.day == 2 }) + #expect(day2Point?.actual == 3000) + } + + // MARK: - makeCategoryData + + @Test("categoryData가 actual 기준 내림차순으로 정렬된다") + func categoryDataIsSortedByActualDescending() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + let transport = Category(name: "교통", emoji: "🚌") + let day1 = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 1))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: transport, memo: nil, amount: 1000), + Expense(date: day1, category: food, memo: nil, amount: 5000), + ] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + #expect(sut.categoryData.first?.categoryName == "식비") + #expect(sut.categoryData.last?.categoryName == "교통") + } + + // MARK: - sortedInsights + + @Test("sortedInsights가 abnormal, forecast, unrecorded 순서로 정렬된다") + func sortedInsightsFollowsDefinedOrder() async { + let aiStub = StubAIUseCase() + aiStub.stubbedInsights = [ + AIInsightItem(type: .unrecorded, description: "미기록"), + AIInsightItem(type: .forecast, description: "예고"), + AIInsightItem(type: .abnormal, description: "이상"), + ] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.sortedInsights[0].type == .abnormal) + #expect(sut.sortedInsights[1].type == .forecast) + #expect(sut.sortedInsights[2].type == .unrecorded) + } + + @Test("같은 타입의 인사이트가 여러 개여도 하나만 나온다") + func sortedInsightsDeduplicatesByType() async { + let aiStub = StubAIUseCase() + aiStub.stubbedInsights = [ + AIInsightItem(type: .abnormal, description: "이상1"), + AIInsightItem(type: .abnormal, description: "이상2"), + ] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.sortedInsights.filter { $0.type == .abnormal }.count == 1) + } +} + +// MARK: - Stubs + +final class StubExpenseUseCaseForAI: ExpenseUseCaseProtocol { + var stubbedExpenses: [Expense] = [] + func fetch(year: Int, month: Int) async -> [Expense] { stubbedExpenses } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} +} + +final class StubAIUseCase: AIUseCaseProtocol { + var stubbedModels: [AIModelMetadata] = [] + var stubbedCurrentModel: AIModelMetadata? = nil + var stubbedInsights: [AIInsightItem] = [] + var stubbedDailyPredictions: [Int: Int] = [:] + var stubbedCategoryPredictions: [String: Int] = [:] + + func fetchModels() async -> [AIModelMetadata] { stubbedModels } + func fetchCurrentModel() async -> AIModelMetadata? { stubbedCurrentModel } + func fetchInsights() async -> [AIInsightItem] { stubbedInsights } + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { stubbedDailyPredictions } + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { stubbedCategoryPredictions } +}