diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 62bed6d..4443baa 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -32,26 +32,24 @@ BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Source/Domain/Entities/AI/AIInsightItem.swift, - Source/Domain/Entities/AI/AIModelCreationError.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/Entities/Prediction/CategoryPrediction.swift, + Source/Domain/Entities/Prediction/CumulativePrediction.swift, + Source/Domain/Entities/Prediction/PredictionModelMetadata.swift, Source/Domain/Repositories/CategoryRepositoryProtocol.swift, Source/Domain/Repositories/ExpenseRepositoryProtocol.swift, - Source/Domain/UseCases/AIUseCase.swift, - Source/Domain/UseCases/AIUseCaseProtocol.swift, + Source/Domain/Repositories/PredictionRepositoryProtocol.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/Domain/UseCases/PredictionUseCase.swift, + Source/Domain/UseCases/PredictionUseCaseProtocol.swift, Source/Presentation/View/Home/HomeViewModel.swift, Source/Presentation/View/NewExpense/NewExpenseViewModel.swift, + Source/Presentation/View/Prediction/PredictionViewModel.swift, Source/Presentation/View/Settings/SettingsViewModel.swift, ); target = BCB415072FF52D0700C9CABD /* SpendLearningTests */; @@ -251,6 +249,7 @@ DEVELOPMENT_TEAM = RS49L5FVHY; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SpendLearning/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "소비학습"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -261,7 +260,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -288,6 +287,7 @@ DEVELOPMENT_TEAM = RS49L5FVHY; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SpendLearning/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "소비학습"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -298,7 +298,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..0191a49 100644 --- a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "appicon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 0000000..53b5922 Binary files /dev/null and b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index f787627..4b90f73 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -24,10 +24,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func makeTabBarController() -> UITabBarController { let container: ModelContainer do { - container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, AIModelModel.self) + container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, PredictionModel.self) } catch { + // SwiftData 마이그레이션 실패 시 메모리 전용 스토어로 폴백한다. + // 이 경로를 타면 기존에 저장된 사용자 데이터(지출/카테고리/예측 모델)가 이번 실행에서 보이지 않는다. + // 스키마 변경 후 이 로그가 찍히면 VersionedSchema/SchemaMigrationPlan 도입을 검토할 것. + print("⚠️ ModelContainer 생성 실패, 메모리 전용으로 폴백: \(error)") container = try! ModelContainer( - for: ExpenseModel.self, CategoryModel.self, AIModelModel.self, + for: ExpenseModel.self, CategoryModel.self, PredictionModel.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) } @@ -36,8 +40,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext) let categoryUseCase = CategoryUseCase(repository: categoryRepository) - let expenseRepository = MockExpenseRepository() -// 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) @@ -48,16 +52,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) - let aiUseCase = AIUseCase(repository: SwiftDataAIRepository(modelContext: modelContext, expenseRepository: expenseRepository), expenseRepository: expenseRepository) - let aiViewModel = AIViewModel(expenseUseCase: expenseUseCase, aiUseCase: aiUseCase) - let aiViewController = UIHostingController(rootView: AIView(viewModel: aiViewModel)) - aiViewController.tabBarItem = UITabBarItem( + let predictionUseCase = PredictionUseCase(repository: SwiftDataPredictionRepository(modelContext: modelContext, expenseRepository: expenseRepository), expenseRepository: expenseRepository) + let predictionViewModel = PredictionViewModel(expenseUseCase: expenseUseCase, predictionUseCase: predictionUseCase) + let predictionViewController = UIHostingController(rootView: PredictionView(viewModel: predictionViewModel)) + predictionViewController.tabBarItem = UITabBarItem( title: "예측", image: UIImage(systemName: "brain"), selectedImage: UIImage(systemName: "brain.fill") ) - let settingsViewController = SettingsViewController(categoryUseCase: categoryUseCase) + let settingsViewController = SettingsViewController( + categoryUseCase: categoryUseCase, + expenseUseCase: expenseUseCase, + predictionUseCase: predictionUseCase + ) settingsViewController.tabBarItem = UITabBarItem( title: "설정", image: UIImage(systemName: "slider.horizontal.3"), @@ -65,7 +73,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) let tabBar = UITabBarController() - tabBar.viewControllers = [homeViewController, aiViewController, settingsViewController] + tabBar.viewControllers = [homeViewController, predictionViewController, settingsViewController] tabBar.tabBar.tintColor = .DesignSystem.accent return tabBar diff --git a/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift deleted file mode 100644 index 19274ad..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AIModelModel.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation -import SwiftData - -@Model -final class AIModelModel { - - var id: String - var dataCount: Int - var accuracy: Float? - var createdAt: Date - var isSelected: Bool - - init( - id: String, - dataCount: Int, - accuracy: Float?, - createdAt: Date, - isSelected: Bool - ) { - self.id = id - self.dataCount = dataCount - self.accuracy = accuracy - self.createdAt = createdAt - self.isSelected = isSelected - } -} diff --git a/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift new file mode 100644 index 0000000..e216bc4 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift @@ -0,0 +1,48 @@ +// +// PredictionModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +@Model +final class PredictionModel { + + var id: String + var dataCount: Int + var createdAt: Date + private var dailyPredictionsData: Data + private var categoryPredictionsData: Data + + var dailyPredictions: [Int: Int] { + (try? JSONDecoder().decode([Int: Int].self, from: dailyPredictionsData)) ?? [:] + } + + var categoryPredictions: [String: Int] { + (try? JSONDecoder().decode([String: Int].self, from: categoryPredictionsData)) ?? [:] + } + + init( + id: String, + dataCount: Int, + createdAt: Date, + dailyPredictions: [Int: Int], + categoryPredictions: [String: Int] + ) { + self.id = id + self.dataCount = dataCount + self.createdAt = createdAt + self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data() + self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data() + } + + func update(dataCount: Int, createdAt: Date, dailyPredictions: [Int: Int], categoryPredictions: [String: Int]) { + self.dataCount = dataCount + self.createdAt = createdAt + self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data() + self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data() + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift b/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift new file mode 100644 index 0000000..36ff5c1 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift @@ -0,0 +1,159 @@ +// +// StatisticsPredictor.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class StatisticsPredictor { + + /// 패턴으로 신뢰하기 위한 최소 표본 수 + private let minimumSampleCount = 2 + /// 패턴으로 신뢰하기 위한 변동계수(표준편차/평균) 임계값. 낮을수록 값이 일관됨을 의미 + private let coefficientOfVariationThreshold = 0.5 + + // MARK: - predictDaily + + /// 이번 달 일별 예측 지출 금액을 반환한다. + /// 폴백 순서 (각 순위는 표본이 2건 이상이고 변동계수가 임계값 이하일 때만 채택한다): + /// 1. 과거 같은 날짜(dayOfMonth) 지출 평균 — 정기결제 등 날짜 기반 패턴 + /// 2. 데이터가 패턴으로 인정되지 않으면 → 과거 같은 요일(weekday) 지출 평균 — 습관성 소비 패턴 + /// 3. 그것도 패턴으로 인정되지 않으면 → 과거 월 평균 총액 / 이번 달 일수 + func predictDaily(expenses: [Expense], year: Int, month: Int) async -> [Int: Int] { + let calendar = Calendar.current + guard let targetDate = calendar.date(from: DateComponents(year: year, month: month, day: 1)), + let dayRange = calendar.range(of: .day, in: .month, for: targetDate) else { + return [:] + } + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + let monthlyTotal = monthlyAverage(expenses: pastExpenses) + + var result: [Int: Int] = [:] + for day in dayRange { + guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) else { continue } + let dayOfMonth = calendar.component(.day, from: date) + let weekday = calendar.component(.weekday, from: date) + + if let avg = reliableDayOfMonthAverage(expenses: pastExpenses, dayOfMonth: dayOfMonth) { + // 1순위: 같은 날짜의 일관된 평균 (정기결제 등) + result[day] = avg + } else if let avg = reliableWeekdayAverage(expenses: pastExpenses, weekday: weekday) { + // 2순위: 같은 요일의 일관된 평균 (습관성 소비) + result[day] = avg + } else { + // 3순위: 월 평균 총액 / 일수 + result[day] = monthlyTotal / dayRange.count + } + } + return result + } + + // MARK: - predictCategory + + /// 이번 달 카테고리별 예측 지출 금액을 반환한다. + /// 과거 카테고리별 월 평균을 계산해 반환한다. + func predictCategory(expenses: [Expense], year: Int, month: Int) async -> [String: Int] { + let calendar = Calendar.current + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + // 보유한 달 수 계산 + let months = Set(pastExpenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let monthCount = max(months.count, 1) + + // 카테고리별 총합 / 달 수 = 월 평균 + var categoryTotals: [String: Int] = [:] + for expense in pastExpenses { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + return categoryTotals.mapValues { $0 / monthCount } + } + + // MARK: - Private helpers (predictDaily) + + /// 같은 날짜(dayOfMonth)의 지출을 월별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다. + private func reliableDayOfMonthAverage(expenses: [Expense], dayOfMonth: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.day, from: $0.date) == dayOfMonth + } + let monthlyTotals = monthlyTotals(of: matched, calendar: calendar) + return reliableAverage(of: monthlyTotals) + } + + /// 같은 요일(weekday)의 지출을 주별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다. + private func reliableWeekdayAverage(expenses: [Expense], weekday: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.weekday, from: $0.date) == weekday + } + let weeklyTotals = weeklyTotals(of: matched, calendar: calendar) + return reliableAverage(of: weeklyTotals) + } + + /// 지출을 연-월 단위로 묶어 각 달의 합계 목록을 반환한다. + private func monthlyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] { + Dictionary(grouping: expenses) { + "\(calendar.component(.year, from: $0.date))-\(calendar.component(.month, from: $0.date))" + } + .values + .map { group in group.reduce(0) { $0 + $1.amount } } + } + + /// 지출을 연-주 단위로 묶어 각 주의 합계 목록을 반환한다. + private func weeklyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] { + Dictionary(grouping: expenses) { + "\(calendar.component(.year, from: $0.date))-\(calendar.component(.weekOfYear, from: $0.date))" + } + .values + .map { group in group.reduce(0) { $0 + $1.amount } } + } + + /// 표본이 최소 개수 이상이고, 변동계수가 임계값 이하로 일관될 때만 평균을 반환한다. + /// 표본이 부족하거나(우연 배제) 값이 들쭉날쭉하면(패턴 아님) nil을 반환해 다음 순위로 폴백시킨다. + private func reliableAverage(of samples: [Int]) -> Int? { + guard samples.count >= minimumSampleCount else { return nil } + + let mean = Double(samples.reduce(0, +)) / Double(samples.count) + guard mean > 0 else { return nil } + + let variance = samples.reduce(0.0) { $0 + pow(Double($1) - mean, 2) } / Double(samples.count) + let standardDeviation = variance.squareRoot() + let coefficientOfVariation = standardDeviation / mean + + guard coefficientOfVariation <= coefficientOfVariationThreshold else { return nil } + + return Int(mean) + } + + /// 과거 데이터의 월 평균 총 지출 + private func monthlyAverage(expenses: [Expense]) -> Int { + let calendar = Calendar.current + let months = Set(expenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let total = expenses.reduce(0) { $0 + $1.amount } + return total / max(months.count, 1) + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift deleted file mode 100644 index 4fce474..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// MockAIRepository.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation - -final class MockAIRepository: AIRepositoryProtocol { - - private let models: [AIModelMetadata] = [ - AIModelMetadata(id: "SPa1b2c3", dataCount: 1204, accuracy: nil, createdAt: Date()), - AIModelMetadata(id: "SPd4e5f6", dataCount: 980, accuracy: nil, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), - AIModelMetadata(id: "SPg7h8i9", dataCount: 750, accuracy: nil, 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, - ] - } - - func createModel(expenses: [Expense]) async -> Result { - let model = AIModelMetadata(id: "SPa1b2c3", dataCount: expenses.count, accuracy: nil, createdAt: Date()) - return .success(model) - } - - func selectModel(id: String) async {} -} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift index 6f06fba..aa38b37 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -32,14 +32,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { return calendar.date(from: components)! } - // 오늘 기준 N주 전, 특정 요일 날짜 생성 (weekday: 1=일, 2=월 ... 7=토) - func dateByWeekday(weeksAgo: Int, weekday: Int) -> Date { - let weekStart = calendar.date(byAdding: .weekOfYear, value: -weeksAgo, to: now)! - let currentWeekday = calendar.component(.weekday, from: weekStart) - let diff = weekday - currentWeekday - return calendar.date(byAdding: .day, value: diff, to: weekStart)! - } - // MARK: - 이번 달 일반 지출 let thisMonth: [Expense] = [ Expense(date: date(monthOffset: 0, day: 1), category: food, memo: "김밥천국", amount: 8500), @@ -50,34 +42,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { Expense(date: date(monthOffset: 0, day: 10), category: medical, memo: "약국", amount: 8500), ] - // MARK: - 이상 지출 감지용 - // 최근 4주 카페 주 평균 약 6,000원 → 이번 주 20,000원으로 1.5배 초과 - let abnormalThisWeek: [Expense] = [ - Expense(date: dateByWeekday(weeksAgo: 0, weekday: 2), category: cafe, memo: "스타벅스", amount: 20000), - ] - let abnormalPast: [Expense] = [ - Expense(date: dateByWeekday(weeksAgo: 1, weekday: 2), category: cafe, memo: "스타벅스", amount: 6000), - Expense(date: dateByWeekday(weeksAgo: 2, weekday: 2), category: cafe, memo: "스타벅스", amount: 5500), - Expense(date: dateByWeekday(weeksAgo: 3, weekday: 2), category: cafe, memo: "스타벅스", amount: 6500), - Expense(date: dateByWeekday(weeksAgo: 4, weekday: 2), category: cafe, memo: "스타벅스", amount: 6000), - ] - - // MARK: - 지출 예고용 - // 최근 3주 연속 목요일(5)에 식비 지출 → 이번 주 목요일 예고 - let forecastPast: [Expense] = [ - Expense(date: dateByWeekday(weeksAgo: 1, weekday: 5), category: food, memo: "점심 정기모임", amount: 15000), - Expense(date: dateByWeekday(weeksAgo: 2, weekday: 5), category: food, memo: "점심 정기모임", amount: 14000), - Expense(date: dateByWeekday(weeksAgo: 3, weekday: 5), category: food, memo: "점심 정기모임", amount: 16000), - ] - - // MARK: - 미기록 감지용 - // 최근 3주 연속 일요일(1)에 교통비 지출 → 이번 주 일요일 기록 없음 - let unrecordedPast: [Expense] = [ - Expense(date: dateByWeekday(weeksAgo: 1, weekday: 1), category: transport, memo: "일요일 택시", amount: 12000), - Expense(date: dateByWeekday(weeksAgo: 2, weekday: 1), category: transport, memo: "일요일 택시", amount: 11000), - Expense(date: dateByWeekday(weeksAgo: 3, weekday: 1), category: transport, memo: "일요일 택시", amount: 13000), - ] - // MARK: - 과거 일반 지출 let oneMonthAgo: [Expense] = [ Expense(date: date(monthOffset: -1, day: 2), category: food, memo: "김밥천국", amount: 7500), @@ -156,15 +120,11 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { ] return thisMonth - + abnormalThisWeek - + abnormalPast - + forecastPast - + unrecordedPast - + oneMonthAgo - + twoMonthsAgo - + threeMonthsAgo - + fourMonthsAgo - + fiveMonthsAgo +// + oneMonthAgo +// + twoMonthsAgo +// + threeMonthsAgo +// + fourMonthsAgo +// + fiveMonthsAgo }() func fetchExpenses(year: Int, month: Int) async -> [Expense] { @@ -184,6 +144,10 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { expenses.removeAll { $0.id == expense.id } } + func deleteAll() async { + expenses.removeAll() + } + func addExpense(_ expense: Expense) async { expenses.append(expense) } diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift deleted file mode 100644 index 8a6f137..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// SwiftDataAIRepository.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation -import SwiftData - -final class SwiftDataAIRepository: AIRepositoryProtocol { - - private let modelContext: ModelContext - private let expenseRepository: ExpenseRepositoryProtocol - private let strategy = StatisticsPredictionStrategy() - - init( - modelContext: ModelContext, - expenseRepository: ExpenseRepositoryProtocol - ) { - self.modelContext = modelContext - self.expenseRepository = expenseRepository - } - - func fetchModels() async -> [AIModelMetadata] { - let descriptor = FetchDescriptor() - let models = (try? modelContext.fetch(descriptor)) ?? [] - return models - .sorted { - if $0.isSelected != $1.isSelected { return $0.isSelected } - return $0.createdAt > $1.createdAt - } - .map { toMetadata($0) } - } - - func fetchCurrentModel() async -> AIModelMetadata? { - let descriptor = FetchDescriptor() - let models = (try? modelContext.fetch(descriptor)) ?? [] - return models.first { $0.isSelected }.map { toMetadata($0) } - } - - func fetchInsights() async -> [AIInsightItem] { - guard hasSavedModel() else { return [] } - let expenses = await expenseRepository.fetchAllExpenses() - return strategy.predictInsights(expenses: expenses) - } - - func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { - guard hasSavedModel() else { return [:] } - let expenses = await expenseRepository.fetchAllExpenses() - return strategy.predictDaily(expenses: expenses, year: year, month: month) - } - - func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { - guard hasSavedModel() else { return [:] } - let expenses = await expenseRepository.fetchAllExpenses() - return strategy.predictCategory(expenses: expenses, year: year, month: month) - } - - func createModel(expenses: [Expense]) async -> Result { - let calendar = Calendar.current - let now = Date() - let currentYear = calendar.component(.year, from: now) - let currentMonth = calendar.component(.month, from: now) - - // 저번 달 소비가 1건 이상 있어야 생성 가능 - let lastMonthExpenses = expenses.filter { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return !(y == currentYear && m == currentMonth) - } - guard !lastMonthExpenses.isEmpty else { return .failure(.insufficientData) } - - // 기존 모델 선택 해제 - let descriptor = FetchDescriptor() - let existing = (try? modelContext.fetch(descriptor)) ?? [] - existing.forEach { $0.isSelected = false } - - let id = "SP\(UUID().uuidString.prefix(6).lowercased())" - let model = AIModelModel( - id: id, - dataCount: expenses.count, - accuracy: nil, - createdAt: now, - isSelected: true - ) - modelContext.insert(model) - try? modelContext.save() - - return .success(toMetadata(model)) - } - - func selectModel(id: String) async { - let descriptor = FetchDescriptor() - let models = (try? modelContext.fetch(descriptor)) ?? [] - models.forEach { $0.isSelected = ($0.id == id) } - try? modelContext.save() - } - - // MARK: - Private - - private func toMetadata(_ model: AIModelModel) -> AIModelMetadata { - AIModelMetadata( - id: model.id, - dataCount: model.dataCount, - accuracy: model.accuracy, - createdAt: model.createdAt - ) - } - - private func hasSavedModel() -> Bool { - let descriptor = FetchDescriptor() - return !((try? modelContext.fetch(descriptor)) ?? []).isEmpty - } -} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift index a27b84d..73af6db 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift @@ -58,6 +58,13 @@ final class SwiftDataExpenseRepository: ExpenseRepositoryProtocol { modelContext.delete(model) try? modelContext.save() } + + func deleteAll() async { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + models.forEach { modelContext.delete($0) } + try? modelContext.save() + } } // MARK: - Mapping diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift new file mode 100644 index 0000000..68221f9 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift @@ -0,0 +1,92 @@ +// +// SwiftDataPredictionRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +final class SwiftDataPredictionRepository: PredictionRepositoryProtocol { + + private let modelContext: ModelContext + private let expenseRepository: ExpenseRepositoryProtocol + private let predictor = StatisticsPredictor() + + init( + modelContext: ModelContext, + expenseRepository: ExpenseRepositoryProtocol + ) { + self.modelContext = modelContext + self.expenseRepository = expenseRepository + } + + func fetchCurrentModel() async -> PredictionModelMetadata? { + fetchStoredModel().map { toMetadata($0) } + } + + func deleteModel() async { + guard let existing = fetchStoredModel() else { return } + modelContext.delete(existing) + try? modelContext.save() + } + + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata { + let calendar = Calendar.current + let now = Date() + let currentYear = calendar.component(.year, from: now) + let currentMonth = calendar.component(.month, from: now) + + // 예측 계산에는 이번 달을 제외한 과거 데이터만 사용되므로, 표시용 데이터 개수도 이에 맞춘다. + let pastExpenseCount = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == currentYear && m == currentMonth) + }.count + + let dailyPredictions = await predictor.predictDaily(expenses: expenses, year: currentYear, month: currentMonth) + let categoryPredictions = await predictor.predictCategory(expenses: expenses, year: currentYear, month: currentMonth) + + if let existing = fetchStoredModel() { + existing.update( + dataCount: pastExpenseCount, + createdAt: now, + dailyPredictions: dailyPredictions, + categoryPredictions: categoryPredictions + ) + try? modelContext.save() + return toMetadata(existing) + } + + let id = "SP\(UUID().uuidString.prefix(6).lowercased())" + let model = PredictionModel( + id: id, + dataCount: pastExpenseCount, + createdAt: now, + dailyPredictions: dailyPredictions, + categoryPredictions: categoryPredictions + ) + modelContext.insert(model) + try? modelContext.save() + + return toMetadata(model) + } + + // MARK: - Private + + private func fetchStoredModel() -> PredictionModel? { + let descriptor = FetchDescriptor() + return ((try? modelContext.fetch(descriptor)) ?? []).first + } + + private func toMetadata(_ model: PredictionModel) -> PredictionModelMetadata { + PredictionModelMetadata( + id: model.id, + dataCount: model.dataCount, + createdAt: model.createdAt, + dailyPredictions: model.dailyPredictions, + categoryPredictions: model.categoryPredictions + ) + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift b/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift deleted file mode 100644 index d20b903..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// PredictionStrategy.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -protocol PredictionStrategy { - /// 소비 패턴 기반 인사이트 목록 - func predictInsights(expenses: [Expense]) -> [AIInsightItem] - /// 일별 예측 지출 금액 [일: 예측금액] - func predictDaily(expenses: [Expense], year: Int, month: Int) -> [Int: Int] - /// 카테고리별 예측 지출 금액 [카테고리명: 예측금액] - func predictCategory(expenses: [Expense], year: Int, month: Int) -> [String: Int] -} diff --git a/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift b/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift deleted file mode 100644 index 7f6aaa3..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift +++ /dev/null @@ -1,355 +0,0 @@ -// -// StatisticsPredictionStrategy.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation - -final class StatisticsPredictionStrategy: PredictionStrategy { - - // MARK: - predictInsights - - /// 이번 주 소비 패턴을 분석해 인사이트를 반환한다. - /// 이상 지출 → 지출 예고 → 미기록 감지 순서로 최대 3개까지 반환한다. - func predictInsights(expenses: [Expense]) -> [AIInsightItem] { - var insights: [AIInsightItem] = [] - - let calendar = Calendar.current - let now = Date() - let currentWeekOfYear = calendar.component(.weekOfYear, from: now) - let currentYear = calendar.component(.year, from: now) - - // 이번 주 지출 - let thisWeekExpenses = expenses.filter { - calendar.component(.weekOfYear, from: $0.date) == currentWeekOfYear && - calendar.component(.year, from: $0.date) == currentYear - } - - // 이상 지출 감지 - if let item = abnormalSpendingInsight( - thisWeek: thisWeekExpenses, - pastExpenses: expenses, - calendar: calendar - ) { - insights.append(item) - } - - // 지출 예고 - if let item = forecastInsight( - expenses: expenses, - calendar: calendar, - now: now - ) { - insights.append(item) - } - - // 미기록 감지 - if let item = unrecordedInsight( - thisWeek: thisWeekExpenses, - expenses: expenses, - calendar: calendar, - now: now - ) { - insights.append(item) - } - - return insights - } - - // MARK: - predictDaily - - /// 이번 달 일별 예측 지출 금액을 반환한다. - /// 폴백 순서: - /// 1. 과거 같은 주차 + 같은 요일 지출 평균 - /// 2. 데이터 없으면 → 과거 같은 주차 전체 지출 평균 - /// 3. 데이터 없으면 → 과거 월 평균 총액 / 이번 달 일수 - func predictDaily(expenses: [Expense], year: Int, month: Int) -> [Int: Int] { - let calendar = Calendar.current - guard let targetDate = calendar.date(from: DateComponents(year: year, month: month, day: 1)), - let dayRange = calendar.range(of: .day, in: .month, for: targetDate) else { - return [:] - } - - // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 - let pastExpenses = expenses.filter { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return !(y == year && m == month) - } - - let monthlyTotal = monthlyAverage(expenses: pastExpenses) - - var result: [Int: Int] = [:] - for day in dayRange { - guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) else { continue } - let weekOfMonth = calendar.component(.weekOfMonth, from: date) - let weekday = calendar.component(.weekday, from: date) - - if let avg = weekOfMonthAndWeekdayAverage(expenses: pastExpenses, weekOfMonth: weekOfMonth, weekday: weekday), avg > 0 { - // 1순위: 같은 주차 + 같은 요일 평균 - result[day] = avg - } else if let avg = weekOfMonthAverage(expenses: pastExpenses, weekOfMonth: weekOfMonth), avg > 0 { - // 2순위: 같은 주차 전체 평균 - result[day] = avg - } else { - // 3순위: 월 평균 총액 / 일수 - result[day] = monthlyTotal / dayRange.count - } - } - return result - } - - // MARK: - predictCategory - - /// 이번 달 카테고리별 예측 지출 금액을 반환한다. - /// 과거 카테고리별 월 평균을 계산해 반환한다. - func predictCategory(expenses: [Expense], year: Int, month: Int) -> [String: Int] { - let calendar = Calendar.current - - // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 - let pastExpenses = expenses.filter { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return !(y == year && m == month) - } - - // 보유한 달 수 계산 - let months = Set(pastExpenses.map { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return "\(y)-\(m)" - }) - let monthCount = max(months.count, 1) - - // 카테고리별 총합 / 달 수 = 월 평균 - var categoryTotals: [String: Int] = [:] - for expense in pastExpenses { - categoryTotals[expense.category.name, default: 0] += expense.amount - } - - return categoryTotals.mapValues { $0 / monthCount } - } - - // MARK: - Private helpers (predictInsights) - - /// 이상 지출 감지 - /// 이번 주 카테고리 지출이 최근 4주 같은 카테고리 주 평균의 1.5배 이상이면 감지 - /// 여러 카테고리가 해당되면 배수가 가장 큰 것 하나만 반환 - /// "기타" 카테고리는 제외 - private func abnormalSpendingInsight( - thisWeek: [Expense], - pastExpenses: [Expense], - calendar: Calendar - ) -> AIInsightItem? { - let thisWeekByCategory = Dictionary(grouping: thisWeek.filter { $0.category.name != "기타" }, by: { $0.category.name }) - .mapValues { $0.reduce(0) { $0 + $1.amount } } - - var maxRatio: Double = 0 - var maxCategory: String? = nil - - for (category, thisAmount) in thisWeekByCategory { - // 최근 4주 같은 카테고리 지출 수집 - let recent4Weeks = (1...4).flatMap { offset -> [Expense] in - guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: Date()) else { return [] } - let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) - let year = calendar.component(.year, from: pastWeekDate) - return pastExpenses.filter { - $0.category.name == category && - calendar.component(.weekOfYear, from: $0.date) == weekOfYear && - calendar.component(.year, from: $0.date) == year - } - } - - // 과거 데이터가 없으면 비교 기준이 없으므로 제외 - guard !recent4Weeks.isEmpty else { continue } - - // 주 단위로 그룹핑해 주 평균 계산 - let weekGroups = Dictionary(grouping: recent4Weeks) { - "\(calendar.component(.year, from: $0.date))-\(calendar.component(.weekOfYear, from: $0.date))" - } - let avgPerWeek = recent4Weeks.reduce(0) { $0 + $1.amount } / max(weekGroups.count, 1) - - // 과거에 한 번도 쓴 적 없는 카테고리는 비교 기준이 없으므로 제외 - guard avgPerWeek > 0 else { continue } - - let ratio = Double(thisAmount) / Double(avgPerWeek) - - if ratio >= 1.5 && ratio > maxRatio { - maxRatio = ratio - maxCategory = category - } - } - - guard let category = maxCategory else { return nil } - return AIInsightItem( - type: .abnormal, - description: "이번 주 \(category) 지출이 평소보다 많아요" - ) - } - - /// 지출 예고 - /// 최근 3주 연속 같은 요일에 같은 카테고리 지출이 있고, 이번 주 해당 요일이 아직 안 지났으면 예고 - /// 가장 가까운 요일 우선, 같은 요일이면 금액이 가장 큰 것 반환 - /// "기타" 카테고리는 제외 - private func forecastInsight( - expenses: [Expense], - calendar: Calendar, - now: Date - ) -> AIInsightItem? { - let todayWeekday = calendar.component(.weekday, from: now) - guard todayWeekday < 7 else { return nil } - let filteredExpenses = expenses.filter { $0.category.name != "기타" } - - var candidates: [(weekday: Int, category: String, avgAmount: Int)] = [] - - for weekday in (todayWeekday + 1)...7 { - let recentMatches = (1...3).compactMap { offset -> (category: String, amount: Int)? in - guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: now) else { return nil } - let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) - let year = calendar.component(.year, from: pastWeekDate) - guard let expense = filteredExpenses.first(where: { - calendar.component(.weekday, from: $0.date) == weekday && - calendar.component(.weekOfYear, from: $0.date) == weekOfYear && - calendar.component(.year, from: $0.date) == year - }) else { return nil } - return (expense.category.name, expense.amount) - } - - // 3주 모두 같은 카테고리여야 패턴으로 인정 - guard recentMatches.count == 3, - let category = recentMatches.first?.category, - recentMatches.allSatisfy({ $0.category == category }) else { continue } - - let avgAmount = recentMatches.map { $0.amount }.reduce(0, +) / 3 - candidates.append((weekday: weekday, category: category, avgAmount: avgAmount)) - } - - guard !candidates.isEmpty else { return nil } - - // 가장 가까운 요일 우선, 같은 요일이면 금액 큰 것 - let best = candidates - .sorted { lhs, rhs in - if lhs.weekday != rhs.weekday { return lhs.weekday < rhs.weekday } - return lhs.avgAmount > rhs.avgAmount - } - .first! - - let weekdayName = ["일", "월", "화", "수", "목", "금", "토"][best.weekday - 1] - return AIInsightItem( - type: .forecast, - description: "매주 \(weekdayName)요일 \(best.category) 지출이 있는 패턴이에요" - ) - } - - /// 미기록 감지 - /// 최근 3주 연속 같은 요일에 같은 카테고리 지출이 있었는데, 이번 주 해당 요일이 이미 지났는데도 기록이 없으면 감지 - /// 가장 가까운 요일 우선, 같은 요일이면 금액이 가장 큰 것 반환 - /// "기타" 카테고리는 제외 - private func unrecordedInsight( - thisWeek: [Expense], - expenses: [Expense], - calendar: Calendar, - now: Date - ) -> AIInsightItem? { - let todayWeekday = calendar.component(.weekday, from: now) - let filteredExpenses = expenses.filter { $0.category.name != "기타" } - let thisWeekCategories = Set(thisWeek.filter { $0.category.name != "기타" }.map { $0.category.name }) - - var candidates: [(weekday: Int, category: String, avgAmount: Int)] = [] - - // 이미 지난 요일만 확인 - for weekday in 1...todayWeekday { - let recentMatches = (1...3).compactMap { offset -> (category: String, amount: Int)? in - guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: now) else { return nil } - let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) - let year = calendar.component(.year, from: pastWeekDate) - guard let expense = filteredExpenses.first(where: { - calendar.component(.weekday, from: $0.date) == weekday && - calendar.component(.weekOfYear, from: $0.date) == weekOfYear && - calendar.component(.year, from: $0.date) == year - }) else { return nil } - return (expense.category.name, expense.amount) - } - - // 3주 모두 같은 카테고리여야 패턴으로 인정 - guard recentMatches.count == 3, - let category = recentMatches.first?.category, - recentMatches.allSatisfy({ $0.category == category }) else { continue } - - // 이번 주 해당 요일에 같은 카테고리 기록이 없으면 감지 - guard !thisWeekCategories.contains(category) else { continue } - - let avgAmount = recentMatches.map { $0.amount }.reduce(0, +) / 3 - candidates.append((weekday: weekday, category: category, avgAmount: avgAmount)) - } - - guard !candidates.isEmpty else { return nil } - - // 가장 가까운 요일 우선, 같은 요일이면 금액 큰 것 - let best = candidates - .sorted { lhs, rhs in - if lhs.weekday != rhs.weekday { return lhs.weekday < rhs.weekday } - return lhs.avgAmount > rhs.avgAmount - } - .first! - - let weekdayName = ["일", "월", "화", "수", "목", "금", "토"][best.weekday - 1] - return AIInsightItem( - type: .unrecorded, - description: "매주 \(weekdayName)요일 \(best.category) 지출이 있는 패턴인데 이번 주엔 없네요" - ) - } - - // MARK: - Private helpers (predictDaily) - - /// 같은 주차 + 같은 요일에 해당하는 지출의 월 평균 - private func weekOfMonthAndWeekdayAverage(expenses: [Expense], weekOfMonth: Int, weekday: Int) -> Int? { - let calendar = Calendar.current - let matched = expenses.filter { - calendar.component(.weekOfMonth, from: $0.date) == weekOfMonth && - calendar.component(.weekday, from: $0.date) == weekday - } - guard !matched.isEmpty else { return nil } - - // 같은 주차+요일이 등장한 월 수로 나눠 평균 - let months = Set(matched.map { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return "\(y)-\(m)" - }) - let total = matched.reduce(0) { $0 + $1.amount } - return total / max(months.count, 1) - } - - /// 같은 주차 전체 지출의 주 평균 - private func weekOfMonthAverage(expenses: [Expense], weekOfMonth: Int) -> Int? { - let calendar = Calendar.current - let matched = expenses.filter { - calendar.component(.weekOfMonth, from: $0.date) == weekOfMonth - } - guard !matched.isEmpty else { return nil } - - // 같은 주차가 등장한 횟수(월별 주차)로 나눠 평균 - let weeks = Set(matched.map { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return "\(y)-\(m)-\(calendar.component(.weekOfMonth, from: $0.date))" - }) - let total = matched.reduce(0) { $0 + $1.amount } - return total / max(weeks.count, 1) - } - - /// 과거 데이터의 월 평균 총 지출 - private func monthlyAverage(expenses: [Expense]) -> Int { - let calendar = Calendar.current - let months = Set(expenses.map { - let y = calendar.component(.year, from: $0.date) - let m = calendar.component(.month, from: $0.date) - return "\(y)-\(m)" - }) - let total = expenses.reduce(0) { $0 + $1.amount } - return total / max(months.count, 1) - } -} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift deleted file mode 100644 index 48aff8a..0000000 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 "미기록 감지" - } - } - - var emptyDescription: 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/AIModelCreationError.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift deleted file mode 100644 index eb63985..0000000 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// AIModelCreationError.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -enum AIModelCreationError: Error, Equatable { - /// 저번 달 소비 기록이 없어 모델 생성 불가 - case insufficientData -} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift similarity index 65% rename from SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift index ec03ef3..1064403 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift @@ -1,11 +1,11 @@ // -// CategoryPredictionDataPoint.swift +// CategoryPrediction.swift // SpendLearning // // Created by 김성훈 on 7/14/26. // -struct CategoryPredictionDataPoint { +struct CategoryPrediction { 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/Prediction/CumulativePrediction.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CumulativePrediction.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift similarity index 50% rename from SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift index a7288b8..48eec01 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift @@ -1,5 +1,5 @@ // -// AIModelMetadata.swift +// PredictionModelMetadata.swift // SpendLearning // // Created by 김성훈 on 7/14/26. @@ -7,9 +7,10 @@ import Foundation -struct AIModelMetadata { +struct PredictionModelMetadata { let id: String let dataCount: Int - let accuracy: Float? let createdAt: Date + let dailyPredictions: [Int: Int] + let categoryPredictions: [String: Int] } diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift deleted file mode 100644 index 47583e6..0000000 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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] - /// 예측 모델 생성 및 저장 - func createModel(expenses: [Expense]) async -> Result - /// 예측 모델 선택 - func selectModel(id: String) async -} diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift index 075afb0..80cff10 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift @@ -12,4 +12,5 @@ protocol ExpenseRepositoryProtocol { func fetchAllExpenses() async -> [Expense] func addExpense(_ expense: Expense) async func deleteExpense(_ expense: Expense) async + func deleteAll() async } diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift new file mode 100644 index 0000000..0e0b0ee --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift @@ -0,0 +1,15 @@ +// +// PredictionRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol PredictionRepositoryProtocol { + /// 저장된 예측 모델 메타데이터 (단일) + func fetchCurrentModel() async -> PredictionModelMetadata? + /// 현재 지출 데이터로 예측을 다시 계산해 저장한다 + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata + /// 저장된 예측 모델을 삭제한다 + func deleteModel() async +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift deleted file mode 100644 index 4cb3d8f..0000000 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AIUseCase.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation - -final class AIUseCase: AIUseCaseProtocol { - - private let repository: AIRepositoryProtocol - private let expenseRepository: ExpenseRepositoryProtocol - - init( - repository: AIRepositoryProtocol, - expenseRepository: ExpenseRepositoryProtocol - ) { - self.repository = repository - self.expenseRepository = expenseRepository - } - - 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) - } - - func createModel() async -> Result { - let expenses = await expenseRepository.fetchAllExpenses() - return await repository.createModel(expenses: expenses) - } - - func selectModel(id: String) async { - await repository.selectModel(id: id) - } -} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift deleted file mode 100644 index 9d13c51..0000000 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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] - /// 예측 모델 생성 - func createModel() async -> Result - /// 예측 모델 선택 - func selectModel(id: String) async -} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift index 5fbac6f..19bd321 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift @@ -26,4 +26,8 @@ final class ExpenseUseCase: ExpenseUseCaseProtocol { func delete(_ expense: Expense) async { await repository.deleteExpense(expense) } + + func deleteAll() async { + await repository.deleteAll() + } } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift index ac7d19b..46b9422 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift @@ -9,4 +9,5 @@ protocol ExpenseUseCaseProtocol { func fetch(year: Int, month: Int) async -> [Expense] func add(_ expense: Expense) async func delete(_ expense: Expense) async + func deleteAll() async } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift new file mode 100644 index 0000000..e2e5eb3 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift @@ -0,0 +1,35 @@ +// +// PredictionUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class PredictionUseCase: PredictionUseCaseProtocol { + + private let repository: PredictionRepositoryProtocol + private let expenseRepository: ExpenseRepositoryProtocol + + init( + repository: PredictionRepositoryProtocol, + expenseRepository: ExpenseRepositoryProtocol + ) { + self.repository = repository + self.expenseRepository = expenseRepository + } + + func fetchCurrentModel() async -> PredictionModelMetadata? { + await repository.fetchCurrentModel() + } + + func deleteModel() async { + await repository.deleteModel() + } + + func recalculate() async -> PredictionModelMetadata { + let expenses = await expenseRepository.fetchAllExpenses() + return await repository.recalculate(expenses: expenses) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift new file mode 100644 index 0000000..5243773 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift @@ -0,0 +1,15 @@ +// +// PredictionUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol PredictionUseCaseProtocol { + /// 저장된 예측 모델 메타데이터 (단일) + func fetchCurrentModel() async -> PredictionModelMetadata? + /// 현재 지출 데이터로 예측을 다시 계산해 저장한다 + func recalculate() async -> PredictionModelMetadata + /// 저장된 예측 모델을 삭제한다 + func deleteModel() async +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift deleted file mode 100644 index 1e3cd35..0000000 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// AIInsightCardView.swift -// SpendLearning -// -// Created by 김성훈 on 7/8/26. -// - -import SwiftUI - -struct AIInsightCardView: View { - - let items: [AIInsightItem] - let hasPrediction: Bool - - var body: some View { - VStack(spacing: 0) { - header - if hasPrediction { - bodyView - } else { - emptyView - } - } - .background(Color(UIColor.DesignSystem.surface)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - - private var header: some View { - HStack { - Text("인사이트") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.white) - Spacer() - } - .padding(.horizontal, 20) - .padding(.vertical, 14) - .background(Color(UIColor.DesignSystem.accent)) - } - - private var emptyView: some View { - Text("아직 패턴을 분석 중이에요") - .font(.system(size: 14)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - - private var bodyView: some View { - VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.offset) { index, item in - rowView(item: item) - - if index < items.count - 1 { - Divider() - .background(Color(UIColor.DesignSystem.separator)) - .padding(.horizontal, 12) - } - } - } - } - - private func rowView(item: AIInsightItem) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(item.type.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - Text(item.description) - .font(.system(size: 12)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 12) - } -} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift deleted file mode 100644 index e054b8a..0000000 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// AIModelSelectView.swift -// SpendLearning -// -// Created by 김성훈 on 7/10/26. -// - -import SwiftUI - -struct AIModelSelectView: View { - - @Environment(\.dismiss) private var dismiss - @State private var selectedId: String - - let models: [AIModelMetadata] - var onConfirm: (AIModelMetadata) -> Void - - init( - models: [AIModelMetadata], - currentModelId: String, - onConfirm: @escaping (AIModelMetadata) -> Void - ) { - self.models = models - self.onConfirm = onConfirm - self._selectedId = State(initialValue: currentModelId) - } - - var body: some View { - VStack(spacing: 0) { - if models.isEmpty { - emptyView - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(models, id: \.id) { model in - modelRow(model) - .onTapGesture { - selectedId = model.id - } - } - } - .padding(20) - } - - confirmButton - } - } - .background(Color(UIColor.DesignSystem.background)) - } - - 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) - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - Text((model.accuracy.map { "정확도 \(Int($0))% · " } ?? "정확도 측정불가 · ") + "데이터 \(model.dataCount)개") - .font(.system(size: 14)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - - Text(formattedDate(model.createdAt)) - .font(.system(size: 12)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - } - - Spacer() - - ZStack { - Circle() - .stroke(Color(UIColor.DesignSystem.secondary), lineWidth: 1.5) - .frame(width: 22, height: 22) - - if selectedId == model.id { - Circle() - .fill(Color(UIColor.DesignSystem.accent)) - .frame(width: 13, height: 13) - } - } - } - .padding(16) - .background(Color(UIColor.DesignSystem.surface)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - - private var confirmButton: some View { - Button { - if let selected = models.first(where: { $0.id == selectedId }) { - onConfirm(selected) - } - dismiss() - } label: { - Text("적용하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 52) - .background(Color(UIColor.DesignSystem.accent)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - .padding(20) - } - - private func formattedDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd HH:mm 생성" - return formatter.string(from: date) - } -} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift deleted file mode 100644 index d23d4c1..0000000 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// AIStatusCardView.swift -// SpendLearning -// -// Created by 김성훈 on 7/8/26. -// - -import SwiftUI - -struct AIStatusCardView: View { - - @Binding var isShowingToast: Bool - @State private var isShowingCreate = false - @State private var isShowingSelect = false - @State private var isShowingInsufficientDataAlert = false - - let currentModel: AIModelMetadata? - let models: [AIModelMetadata] - let isCreatingModel: Bool - let onCreateModel: () async -> Void - let createModelError: AIModelCreationError? - let onSelectModel: (AIModelMetadata) async -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - modelInfo - buttons - } - .background(Color(UIColor.DesignSystem.surface)) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .alert("모델 생성", isPresented: $isShowingCreate) { - Button("생성하기") { - Task { await onCreateModel() } - } - Button("취소", role: .cancel) {} - } message: { - Text("데이터를 학습해 예측 모델을 생성할까요?") - } - .alert("데이터 부족", isPresented: $isShowingInsufficientDataAlert) { - Button("확인", role: .cancel) {} - } message: { - Text("저번 달 소비 기록이 없어 생성할 수 없어요.\n저번 달 소비를 기록하고 다시 시도해보세요.") - } - .onChange(of: createModelError) { _, error in - if error == .insufficientData { - isShowingInsufficientDataAlert = true - } - } - .tint(Color(UIColor.DesignSystem.accent)) - .sheet(isPresented: $isShowingSelect) { - AIModelSelectView( - models: models, - currentModelId: currentModel?.id ?? "" - ) { selected in - Task { await onSelectModel(selected) } - } - } - } - - 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) 예측 모델") - .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)) - - if let accuracy = model.accuracy { - ProgressView(value: accuracy / 100) - .tint(Color(UIColor.DesignSystem.accent)) - .scaleEffect(x: 1, y: 2) - .padding(.top, 2) - } else { - Text("정확도 측정불가") - .font(.system(size: 13)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - } - } - } - } 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: { - Group { - if isCreatingModel { - ProgressView() - .tint(.white) - } else { - Text("모델 생성하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.primary)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - .disabled(isCreatingModel) - - 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/AICategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift similarity index 84% rename from SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift rename to SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift index 5d93132..88e2bed 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift @@ -1,5 +1,5 @@ // -// AICategoryPredictionCardView.swift +// CategoryPredictionCardView.swift // SpendLearning // // Created by 김성훈 on 7/8/26. @@ -8,14 +8,14 @@ import SwiftUI import Charts -struct AICategoryPredictionCardView: View { +struct CategoryPredictionCardView: View { @State private var isExpanded = false - let data: [CategoryPredictionDataPoint] + let data: [CategoryPrediction] let hasPrediction: Bool - private var displayData: [CategoryPredictionDataPoint] { + private var displayData: [CategoryPrediction] { isExpanded ? data : Array(data.prefix(5)) } @@ -62,7 +62,7 @@ struct AICategoryPredictionCardView: View { } private var noPredictionBanner: some View { - Text("아직 예측 모델이 없어요") + Text("아직 예측이 없어요") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) .frame(maxWidth: .infinity) @@ -109,29 +109,31 @@ struct AICategoryPredictionCardView: View { return Chart { ForEach(displayData, id: \.categoryName) { item in BarMark( - x: .value("금액", max(item.actual, 1000)), + x: .value("금액", item.actual), y: .value("카테고리", item.categoryName), height: .fixed(11) ) .foregroundStyle(Color(UIColor.DesignSystem.accent)) .position(by: .value("타입", "실제")) .annotation(position: .trailing, alignment: .leading, spacing: 6) { - Text(formatted(item.actual)) - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Color(UIColor.DesignSystem.accent)) + if item.actual > 0 { + Text(formatted(item.actual)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + } } } ForEach(displayData, id: \.categoryName) { item in - if let predicted = item.predicted { - BarMark( - x: .value("금액", max(predicted, 1000)), - y: .value("카테고리", item.categoryName), - height: .fixed(11) - ) - .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) - .position(by: .value("타입", "예측")) - .annotation(position: .trailing, alignment: .leading, spacing: 6) { + BarMark( + x: .value("금액", item.predicted ?? 0), + y: .value("카테고리", item.categoryName), + height: .fixed(11) + ) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + .position(by: .value("타입", "예측")) + .annotation(position: .trailing, alignment: .leading, spacing: 6) { + if let predicted = item.predicted, predicted > 0 { Text(formatted(predicted)) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift similarity index 98% rename from SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift rename to SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift index 4ff392a..0a7cacc 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift @@ -1,5 +1,5 @@ // -// AIPredictionCardView.swift +// DailyPredictionCardView.swift // SpendLearning // // Created by 김성훈 on 7/8/26. @@ -8,7 +8,7 @@ import SwiftUI import Charts -struct AIPredictionCardView: View { +struct DailyPredictionCardView: View { let data: [CumulativePrediction] let today: Int @@ -54,7 +54,7 @@ struct AIPredictionCardView: View { } private var noPredictionBanner: some View { - Text("아직 예측 모델이 없어요") + Text("아직 예측이 없어요") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) .frame(maxWidth: .infinity) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift new file mode 100644 index 0000000..10a7709 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift @@ -0,0 +1,106 @@ +// +// PredictionStatusCardView.swift +// SpendLearning +// +// Created by 김성훈 on 7/8/26. +// + +import SwiftUI + +struct PredictionStatusCardView: View { + + @State private var isShowingConfirm = false + + let currentModel: PredictionModelMetadata? + let accuracy: Float? + let isRecalculating: Bool + let onRecalculate: () async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + modelInfo + buttons + } + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .alert("예측 다시 계산", isPresented: $isShowingConfirm) { + Button("계산하기") { + Task { await onRecalculate() } + } + Button("취소", role: .cancel) {} + } message: { + Text("지난달까지의 소비로 예측을 계산할까요?") + } + .tint(Color(UIColor.DesignSystem.accent)) + } + + 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("예측 정확도" + (accuracy.map { " \(Int($0))%" } ?? "")) + .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)) + + if let accuracy { + ProgressView(value: 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 { + Button { + isShowingConfirm = true + } label: { + Group { + if isRecalculating { + ProgressView() + .tint(.white) + } else { + Text("계산하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.accent)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(isRecalculating) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift similarity index 56% rename from SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift rename to SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift index 6bf13b5..db6dfd8 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift @@ -1,5 +1,5 @@ // -// AIView.swift +// PredictionView.swift // SpendLearning // // Created by 김성훈 on 7/8/26. @@ -7,10 +7,9 @@ import SwiftUI -struct AIView: View { +struct PredictionView: View { - @State var viewModel: AIViewModel - @State private var isShowingToast = false + @State var viewModel: PredictionViewModel @State private var isShowingSuccessToast = false var body: some View { @@ -26,34 +25,25 @@ struct AIView: View { .fill(Color(UIColor.DesignSystem.accent)) .offset(y: -5) - AIStatusCardView( - isShowingToast: $isShowingToast, + PredictionStatusCardView( currentModel: viewModel.currentModel, - models: viewModel.models, - isCreatingModel: viewModel.isCreatingModel, - onCreateModel: { - await viewModel.createModel() - if viewModel.createModelError == nil { - isShowingSuccessToast = true - } - }, - createModelError: viewModel.createModelError, - onSelectModel: { selected in - await viewModel.selectModel(id: selected.id) + accuracy: viewModel.accuracy, + isRecalculating: viewModel.isRecalculating, + onRecalculate: { + await viewModel.recalculate() + isShowingSuccessToast = true } ) } - AIInsightCardView(items: viewModel.sortedInsights, hasPrediction: viewModel.hasPrediction) - - AIPredictionCardView( + DailyPredictionCardView( data: viewModel.predictionData, today: viewModel.today, lastDay: viewModel.lastDay, hasPrediction: viewModel.hasPrediction ) - AICategoryPredictionCardView( + CategoryPredictionCardView( data: viewModel.categoryData, hasPrediction: viewModel.hasPrediction ) @@ -68,7 +58,6 @@ struct AIView: View { await viewModel.onAppear() } } - .toast(isShowing: $isShowingToast, message: "아직 생성된 예측 모델이 없어요") - .toast(isShowing: $isShowingSuccessToast, message: "예측 모델이 생성되었어요") + .toast(isShowing: $isShowingSuccessToast, message: "예측이 계산되었어요") } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift similarity index 54% rename from SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift rename to SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift index 9234956..4e52041 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift @@ -1,5 +1,5 @@ // -// AIViewModel.swift +// PredictionViewModel.swift // SpendLearning // // Created by 김성훈 on 7/14/26. @@ -8,44 +8,47 @@ import Foundation @Observable -final class AIViewModel { +final class PredictionViewModel { // MARK: - Output - private(set) var currentModel: AIModelMetadata? = nil - private(set) var models: [AIModelMetadata] = [] - private(set) var insights: [AIInsightItem] = [] + private(set) var currentModel: PredictionModelMetadata? = nil private(set) var predictionData: [CumulativePrediction] = [] - private(set) var categoryData: [CategoryPredictionDataPoint] = [] + private(set) var categoryData: [CategoryPrediction] = [] 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(set) var isCreatingModel: Bool = false - private(set) var createModelError: AIModelCreationError? = nil + private(set) var isRecalculating: Bool = false private let expenseUseCase: ExpenseUseCaseProtocol - private let aiUseCase: AIUseCaseProtocol + private let predictionUseCase: PredictionUseCaseProtocol var hasPrediction: Bool { currentModel != nil } - var sortedInsights: [AIInsightItem] { - let order: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] - return order.map { type in - insights.first { $0.type == type } ?? AIInsightItem(type: type, description: type.emptyDescription) - } + /// 이번 달 1일~오늘까지의 누적 실제/예측 오차를 바탕으로 산출한 정확도(0~100) + /// 오차율이 클수록 감소 폭이 완만해지는 지수 감쇠 방식을 사용해, 오차가 100%를 넘어도 + /// 곧바로 0%가 되지 않고 오차 크기에 따라 점진적으로 낮아지도록 한다. + var accuracy: Float? { + guard let todayPoint = predictionData.first(where: { $0.day == today }), + let actual = todayPoint.actual, + let predicted = todayPoint.predicted, + actual > 0 else { return nil } + + let errorRatio = abs(Double(predicted) - Double(actual)) / Double(actual) + return Float(100 * exp(-errorRatio)) } // MARK: - Init init( expenseUseCase: ExpenseUseCaseProtocol, - aiUseCase: AIUseCaseProtocol + predictionUseCase: PredictionUseCaseProtocol ) { self.expenseUseCase = expenseUseCase - self.aiUseCase = aiUseCase + self.predictionUseCase = predictionUseCase } // MARK: - Input @@ -53,25 +56,11 @@ final class AIViewModel { await load() } - func createModel() async { - isCreatingModel = true - createModelError = nil - - let result = await aiUseCase.createModel() - - switch result { - case .success: - await load() - case .failure(let error): - createModelError = error - } - - isCreatingModel = false - } - - func selectModel(id: String) async { - await aiUseCase.selectModel(id: id) + func recalculate() async { + isRecalculating = true + _ = await predictionUseCase.recalculate() await load() + isRecalculating = false } // MARK: - Private @@ -81,33 +70,13 @@ final class AIViewModel { 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 - ) + async let currentModel = predictionUseCase.fetchCurrentModel() + + let (fetchedExpenses, fetchedModel) = await (expenses, currentModel) self.currentModel = fetchedModel - self.models = fetchedModels - self.insights = fetchedInsights - self.predictionData = makeCumulativePrediction(expenses: fetchedExpenses, predictions: fetchedDaily) - self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedCategory) + self.predictionData = makeCumulativePrediction(expenses: fetchedExpenses, predictions: fetchedModel?.dailyPredictions ?? [:]) + self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedModel?.categoryPredictions ?? [:], hasModel: fetchedModel != nil) } private func makeCumulativePrediction(expenses: [Expense], predictions: [Int: Int]) -> [CumulativePrediction] { @@ -134,7 +103,7 @@ final class AIViewModel { } } - private func makeCategoryData(expenses: [Expense], predictions: [String: Int]) -> [CategoryPredictionDataPoint] { + private func makeCategoryData(expenses: [Expense], predictions: [String: Int], hasModel: Bool) -> [CategoryPrediction] { 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 @@ -142,12 +111,16 @@ final class AIViewModel { let allCategories = Set(categoryTotals.keys).union(Set(predictions.keys)) return allCategories.map { name in - CategoryPredictionDataPoint( + // 예측 모델이 있을 때만, 실제 지출이 있는 카테고리는 과거 예측 데이터가 없어도 0원으로 표시한다. + let predicted = predictions[name] ?? (hasModel && categoryTotals[name] != nil ? 0 : nil) + return CategoryPrediction( categoryName: name, actual: categoryTotals[name] ?? 0, - predicted: predictions[name] + predicted: predicted ) } + // 실제 지출도 없고 예측도 0(또는 없음)인 카테고리는 보여줄 정보가 없으므로 제외한다. + .filter { $0.actual > 0 || ($0.predicted ?? 0) > 0 } .sorted { $0.actual > $1.actual } } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift index b013cf7..e1fd592 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift @@ -29,7 +29,8 @@ final class SettingsRowView: UIView { } // MARK: - Configure - func configure(title: String, subtitle: String) { + func configure(iconName: String, title: String, subtitle: String) { + iconView.update(symbolName: iconName) titleLabel.text = title subtitleLabel.text = subtitle } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift index 3140202..50be54b 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift @@ -14,14 +14,24 @@ final class SettingsViewController: UIViewController { private let titleLabel = UILabel() private let categorySectionLabel = UILabel() private let categoryRowView = SettingsRowView() + private let dataSectionLabel = UILabel() + private let resetRowView = SettingsRowView() // MARK: - Properties private let viewModel: SettingsViewModel private var cancellables = Set() // MARK: - Init - init(categoryUseCase: CategoryUseCaseProtocol) { - self.viewModel = SettingsViewModel(categoryUseCase: categoryUseCase) + init( + categoryUseCase: CategoryUseCaseProtocol, + expenseUseCase: ExpenseUseCaseProtocol, + predictionUseCase: PredictionUseCaseProtocol + ) { + self.viewModel = SettingsViewModel( + categoryUseCase: categoryUseCase, + expenseUseCase: expenseUseCase, + predictionUseCase: predictionUseCase + ) super.init(nibName: nil, bundle: nil) } @@ -47,6 +57,7 @@ private extension SettingsViewController { .receive(on: DispatchQueue.main) .sink { [weak self] categories in self?.categoryRowView.configure( + iconName: "list.bullet", title: "카테고리 관리", subtitle: "\(categories.count)개 카테고리 사용 중" ) @@ -63,10 +74,11 @@ private extension SettingsViewController { setupLabels() setupConstraints() setupCategoryRow() + setupResetRow() } func setupSubviews() { - [titleLabel, categorySectionLabel, categoryRowView].forEach { + [titleLabel, categorySectionLabel, categoryRowView, dataSectionLabel, resetRowView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false view.addSubview($0) } @@ -80,6 +92,10 @@ private extension SettingsViewController { categorySectionLabel.text = "카테고리" categorySectionLabel.font = .systemFont(ofSize: 13, weight: .semibold) categorySectionLabel.textColor = .DesignSystem.subtitle + + dataSectionLabel.text = "데이터" + dataSectionLabel.font = .systemFont(ofSize: 13, weight: .semibold) + dataSectionLabel.textColor = .DesignSystem.subtitle } func setupConstraints() { @@ -94,6 +110,14 @@ private extension SettingsViewController { categoryRowView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), categoryRowView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), categoryRowView.heightAnchor.constraint(equalToConstant: 72), + + dataSectionLabel.topAnchor.constraint(equalTo: categoryRowView.bottomAnchor, constant: 28), + dataSectionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + resetRowView.topAnchor.constraint(equalTo: dataSectionLabel.bottomAnchor, constant: 8), + resetRowView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + resetRowView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + resetRowView.heightAnchor.constraint(equalToConstant: 72), ]) } @@ -103,6 +127,13 @@ private extension SettingsViewController { } } + func setupResetRow() { + resetRowView.configure(iconName: "trash", title: "전체 초기화", subtitle: "모든 소비 기록과 예측이 삭제돼요") + resetRowView.onTap = { [weak self] in + self?.showResetConfirmAlert() + } + } + func loadCategories() { Task { await viewModel.loadCategories() @@ -114,4 +145,23 @@ private extension SettingsViewController { manageVC.modalPresentationStyle = .fullScreen present(manageVC, animated: true) } + + func showResetConfirmAlert() { + let alert = UIAlertController( + title: "전체 초기화", + message: "모든 소비 기록, 카테고리 설정,\n예측이 초기화돼요.\n이 작업은 되돌릴 수 없어요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "초기화", style: .destructive) { [weak self] _ in + self?.resetAllData() + }) + present(alert, animated: true) + } + + func resetAllData() { + Task { + await viewModel.resetAllData() + } + } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift index 1758b22..391325e 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift @@ -18,10 +18,18 @@ final class SettingsViewModel { // MARK: - Private private let categoryUseCase: CategoryUseCaseProtocol + private let expenseUseCase: ExpenseUseCaseProtocol + private let predictionUseCase: PredictionUseCaseProtocol // MARK: - Init - init(categoryUseCase: CategoryUseCaseProtocol) { + init( + categoryUseCase: CategoryUseCaseProtocol, + expenseUseCase: ExpenseUseCaseProtocol, + predictionUseCase: PredictionUseCaseProtocol + ) { self.categoryUseCase = categoryUseCase + self.expenseUseCase = expenseUseCase + self.predictionUseCase = predictionUseCase } // MARK: - Input @@ -84,4 +92,16 @@ final class SettingsViewModel { await loadCategories() } } + + func resetAllData() async { + await expenseUseCase.deleteAll() + await predictionUseCase.deleteModel() + do { + try await categoryUseCase.resetToDefault() + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } } diff --git a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift deleted file mode 100644 index a651060..0000000 --- a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// 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, expenseRepository: StubExpenseRepository()) - - _ = 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, expenseRepository: StubExpenseRepository()) - - 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, expenseRepository: StubExpenseRepository()) - - 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, expenseRepository: StubExpenseRepository()) - - _ = 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, expenseRepository: StubExpenseRepository()) - - _ = 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, expenseRepository: StubExpenseRepository()) - - let result = await sut.fetchInsights() - - #expect(result.count == 1) - #expect(result.first?.description == insight.description) - } - - @Test("createModel 호출 시 Repository의 createModel이 호출된다") - func createModelCallsRepository() async { - let spy = SpyAIRepository() - let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) - - _ = await sut.createModel() - - #expect(spy.createModelCallCount == 1) - } - - @Test("createModel 성공 시 생성된 모델을 반환한다") - func createModelReturnsModelOnSuccess() async { - let spy = SpyAIRepository() - let model = AIModelMetadata(id: "SP260714", dataCount: 10, accuracy: 50, createdAt: Date()) - spy.stubbedCreateModelResult = .success(model) - let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) - - let result = await sut.createModel() - - if case .success(let created) = result { - #expect(created.id == "SP260714") - } else { - Issue.record("성공을 기대했지만 실패 반환") - } - } - - @Test("createModel 실패 시 insufficientData 에러를 반환한다") - func createModelReturnsErrorOnInsufficientData() async { - let spy = SpyAIRepository() - spy.stubbedCreateModelResult = .failure(.insufficientData) - let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) - - let result = await sut.createModel() - - if case .failure(let error) = result { - #expect(error == .insufficientData) - } else { - Issue.record("실패를 기대했지만 성공 반환") - } - } -} - -// 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? - private(set) var createModelCallCount = 0 - - var stubbedModels: [AIModelMetadata] = [] - var stubbedCurrentModel: AIModelMetadata? = nil - var stubbedInsights: [AIInsightItem] = [] - var stubbedCreateModelResult: Result = .failure(.insufficientData) - - 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 - } - - func createModel(expenses: [Expense]) async -> Result { - createModelCallCount += 1 - return stubbedCreateModelResult - } - - func selectModel(id: String) async {} -} - -// MARK: - Stub - -final class StubExpenseRepository: ExpenseRepositoryProtocol { - func fetchExpenses(year: Int, month: Int) async -> [Expense] { [] } - func fetchAllExpenses() async -> [Expense] { [] } - func addExpense(_ expense: Expense) async {} - func deleteExpense(_ expense: Expense) async {} -} diff --git a/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift index c33dc70..671cb18 100644 --- a/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift +++ b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift @@ -77,4 +77,6 @@ final class SpyExpenseRepository: ExpenseRepositoryProtocol { deleteCallCount += 1 deletedExpense = expense } + + func deleteAll() async {} } diff --git a/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift new file mode 100644 index 0000000..6d401a9 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift @@ -0,0 +1,80 @@ +// +// PredictionUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("PredictionUseCase") +@MainActor +struct PredictionUseCaseTests { + + @Test("fetchCurrentModel 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchCurrentModelReturnsRepositoryResult() async { + let spy = SpyPredictionRepository() + let model = PredictionModelMetadata(id: "SP260714", dataCount: 1204, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + spy.stubbedCurrentModel = model + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.fetchCurrentModel() + + #expect(result?.id == model.id) + } + + @Test("recalculate 호출 시 Repository의 recalculate가 호출된다") + func recalculateCallsRepository() async { + let spy = SpyPredictionRepository() + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + _ = await sut.recalculate() + + #expect(spy.recalculateCallCount == 1) + } + + @Test("recalculate 호출 시 계산된 모델을 반환한다") + func recalculateReturnsModel() async { + let spy = SpyPredictionRepository() + let model = PredictionModelMetadata(id: "SP260714", dataCount: 10, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + spy.stubbedRecalculateResult = model + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.recalculate() + + #expect(result.id == "SP260714") + } +} + +// MARK: - Spy + +final class SpyPredictionRepository: PredictionRepositoryProtocol { + private(set) var fetchCurrentModelCallCount = 0 + private(set) var recalculateCallCount = 0 + + var stubbedCurrentModel: PredictionModelMetadata? = nil + var stubbedRecalculateResult = PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + + func fetchCurrentModel() async -> PredictionModelMetadata? { + fetchCurrentModelCallCount += 1 + return stubbedCurrentModel + } + + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata { + recalculateCallCount += 1 + return stubbedRecalculateResult + } + + func deleteModel() async {} +} + +// MARK: - Stub + +final class StubExpenseRepository: ExpenseRepositoryProtocol { + func fetchExpenses(year: Int, month: Int) async -> [Expense] { [] } + func fetchAllExpenses() async -> [Expense] { [] } + func addExpense(_ expense: Expense) async {} + func deleteExpense(_ expense: Expense) async {} + func deleteAll() async {} +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift deleted file mode 100644 index 2d5d6f0..0000000 --- a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// 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.stubbedCurrentModel = AIModelMetadata(id: "SPa1b2c3", dataCount: 10, accuracy: nil, createdAt: Date()) - 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] = [:] - var stubbedCreateModelResult: Result = .failure(.insufficientData) - - 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 } - func createModel() async -> Result { stubbedCreateModelResult } - func selectModel(id: String) async {} -} diff --git a/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift index db9cb9e..46c3b9b 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift @@ -11,6 +11,7 @@ final class StubExpenseUseCase: ExpenseUseCaseProtocol { func add(_ expense: Expense) async {} func fetch(year: Int, month: Int) async -> [Expense] { [] } func delete(_ expense: Expense) async {} + func deleteAll() async {} } @Suite("HomeViewModel") diff --git a/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift index 07027a6..e9e2b71 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift @@ -17,6 +17,7 @@ final class SpyExpenseUseCase: ExpenseUseCaseProtocol { func fetch(year: Int, month: Int) async -> [Expense] { [] } func add(_ expense: Expense) async { addCallCount += 1; addedExpense = expense } func delete(_ expense: Expense) async { deleteCallCount += 1; deletedExpense = expense } + func deleteAll() async {} } final class StubCategoryUseCaseForNewExpense: CategoryUseCaseProtocol { diff --git a/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift new file mode 100644 index 0000000..2d6126d --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift @@ -0,0 +1,160 @@ +// +// PredictionViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("PredictionViewModel") +@MainActor +struct PredictionViewModelTests { + + // MARK: - onAppear / load + + @Test("onAppear 호출 후 predictionData가 채워진다") + func onAppearFillsPredictionData() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata( + id: "SPa1b2c3", dataCount: 10, createdAt: Date(), + dailyPredictions: [1: 10000, 2: 20000], categoryPredictions: [:] + ) + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.predictionData.isEmpty == false) + } + + @Test("onAppear 호출 후 categoryData가 채워진다") + func onAppearFillsCategoryData() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let food = Category(name: "식비", emoji: "🍚") + expenseStub.stubbedExpenses = [ + Expense(date: Date(), category: food, memo: nil, amount: 5000) + ] + let predictionStub = StubPredictionUseCase() + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.categoryData.isEmpty == false) + #expect(sut.categoryData.first?.categoryName == "식비") + } + + // MARK: - hasPrediction + + @Test("예측 데이터가 없으면 hasPrediction이 false다") + func hasPredictionIsFalseWhenNoPredictions() async { + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + #expect(sut.hasPrediction == false) + } + + @Test("예측 데이터가 있으면 hasPrediction이 true다") + func hasPredictionIsTrueWhenPredictionsExist() async { + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata(id: "SPa1b2c3", dataCount: 10, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.hasPrediction == true) + } + + // MARK: - makeCumulativePrediction + + @Test("오늘 날짜에 소비가 없어도 actual이 nil이 아니다") + func todayActualIsNotNilEvenWithNoExpenses() async { + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + let todayPoint = sut.predictionData.first(where: { $0.day == sut.today }) + #expect(todayPoint?.actual != nil) + } + + @Test("누적 actual이 올바르게 계산된다") + func cumulativeActualIsCorrect() async { + let expenseStub = StubExpenseUseCaseForPrediction() + 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 = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: StubPredictionUseCase()) + + 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 = StubExpenseUseCaseForPrediction() + 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 = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + #expect(sut.categoryData.first?.categoryName == "식비") + #expect(sut.categoryData.last?.categoryName == "교통") + } + + @Test("실제 지출이 있는 카테고리는 과거 예측 데이터가 없어도 0원으로 예측된다") + func categoryWithActualButNoPredictionShowsZeroPredicted() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let event = Category(name: "경조사", emoji: "🎉") + let day1 = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 1))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: event, memo: nil, amount: 3) + ] + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata( + id: "SPa1b2c3", dataCount: 10, createdAt: Date(), + dailyPredictions: [:], categoryPredictions: [:] + ) + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + let eventPoint = sut.categoryData.first(where: { $0.categoryName == "경조사" }) + #expect(eventPoint?.predicted == 0) + } +} + +// MARK: - Stubs + +final class StubExpenseUseCaseForPrediction: ExpenseUseCaseProtocol { + var stubbedExpenses: [Expense] = [] + func fetch(year: Int, month: Int) async -> [Expense] { stubbedExpenses } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} + func deleteAll() async {} +} + +final class StubPredictionUseCase: PredictionUseCaseProtocol { + var stubbedCurrentModel: PredictionModelMetadata? = nil + var stubbedRecalculateResult = PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + + func fetchCurrentModel() async -> PredictionModelMetadata? { stubbedCurrentModel } + func recalculate() async -> PredictionModelMetadata { stubbedRecalculateResult } + func deleteModel() async {} +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift index ee10b47..ddcd243 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift @@ -6,6 +6,7 @@ // import Testing +import Foundation @Suite("SettingsViewModel") @MainActor @@ -16,7 +17,11 @@ struct SettingsViewModelTests { let stub = StubCategoryUseCase() let category = Category(name: "식비", emoji: "🍚") stub.stubbedCategories = [category] - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.loadCategories() @@ -27,7 +32,11 @@ struct SettingsViewModelTests { @Test("addCategory 호출 후 categories가 갱신된다") func addCategoryRefreshesCategories() async { let stub = StubCategoryUseCase() - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) stub.stubbedCategories = [Category(name: "카페", emoji: "☕️")] await sut.addCategory(name: "카페", emoji: "☕️") @@ -41,7 +50,11 @@ struct SettingsViewModelTests { let stub = StubCategoryUseCase() let original = Category(name: "식비", emoji: "🍚") stub.stubbedCategories = [Category(name: "외식", emoji: "🍖")] - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.updateCategory(original, name: "외식", emoji: "🍖") @@ -53,7 +66,11 @@ struct SettingsViewModelTests { let stub = StubCategoryUseCase() let category = Category(name: "쇼핑", emoji: "🛍️") stub.stubbedCategories = [category] - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.loadCategories() stub.stubbedCategories = [] @@ -65,7 +82,11 @@ struct SettingsViewModelTests { @Test("resetCategories 호출 후 categories가 갱신된다") func resetCategoriesRefreshesCategories() async { let stub = StubCategoryUseCase() - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) stub.stubbedCategories = [ Category(name: "식비", emoji: "🍚"), Category(name: "교통", emoji: "🚌"), @@ -82,7 +103,11 @@ struct SettingsViewModelTests { let first = Category(name: "교통", emoji: "🚌") let second = Category(name: "식비", emoji: "🍚") stub.stubbedCategories = [first, second] - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.reorderCategories([first, second]) @@ -94,7 +119,11 @@ struct SettingsViewModelTests { func loadCategoriesSetsErrorOnFailure() async { let stub = StubCategoryUseCase() stub.shouldThrow = true - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.loadCategories() @@ -106,7 +135,11 @@ struct SettingsViewModelTests { let stub = StubCategoryUseCase() let existing = Category(name: "식비", emoji: "🍚") stub.stubbedCategories = [existing] - let sut = SettingsViewModel(categoryUseCase: stub) + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) await sut.loadCategories() stub.shouldThrow = true @@ -153,3 +186,18 @@ final class StubCategoryUseCase: CategoryUseCaseProtocol { enum StubError: Error { case generic } + +final class StubExpenseUseCaseForSettings: ExpenseUseCaseProtocol { + func fetch(year: Int, month: Int) async -> [Expense] { [] } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} + func deleteAll() async {} +} + +final class StubPredictionUseCaseForSettings: PredictionUseCaseProtocol { + func fetchCurrentModel() async -> PredictionModelMetadata? { nil } + func recalculate() async -> PredictionModelMetadata { + PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + } + func deleteModel() async {} +}