diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift index 980de70..1892a93 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift @@ -1,6 +1,36 @@ import Combine import Foundation +// MARK: - Service protocols (for testability) + +protocol ActivityServicing { + func summary() async throws -> ActivitySummary + func heatmap() async throws -> [String: Int] + func streak() async throws -> ActivityStreak + func trend(days: Int) async throws -> [ActivityTrend] + func recommendations() async throws -> [ActivityRecommendation] +} +extension ActivityService: ActivityServicing {} + +protocol FocusItemServicing { + func list() async throws -> [FocusItem] +} +extension FocusItemService: FocusItemServicing {} + +protocol ReadingContinueServicing { + func getContinueLearning() async throws -> ContinueLearningResponse +} +extension ReadingAPIService: ReadingContinueServicing {} + +protocol LearningStatusServicing { + func getLearningSummary() async throws -> LearningSummaryResponse + func getLearningTrend() async throws -> LearningTrendResponse + func getLearningRecords(limit: Int, type: String) async throws -> LearningRecordsResponse +} +extension ReadingAPIService: LearningStatusServicing {} + +// MARK: - ViewModel + @MainActor class ActivityViewModel: ObservableObject { @Published var summary: ActivitySummary? @@ -16,20 +46,34 @@ class ActivityViewModel: ObservableObject { @Published var isLoading = false @Published var errorMessage: String? + private let activityService: ActivityServicing + private let focusService: FocusItemServicing + private let readingService: ReadingContinueServicing & LearningStatusServicing + + init( + activityService: ActivityServicing = ActivityService.shared, + focusService: FocusItemServicing = FocusItemService.shared, + readingService: ReadingContinueServicing & LearningStatusServicing = ReadingAPIService.shared + ) { + self.activityService = activityService + self.focusService = focusService + self.readingService = readingService + } + func loadAll() async { isLoading = true errorMessage = nil - async let s = try? ActivityService.shared.summary() - async let f = try? FocusItemService.shared.list() - async let h = try? ActivityService.shared.heatmap() - async let st = try? ActivityService.shared.streak() - async let t = try? ActivityService.shared.trend() - async let r = try? ActivityService.shared.recommendations() - async let cr = try? ReadingAPIService.shared.getContinueLearning() - async let ls = try? ReadingAPIService.shared.getLearningSummary() - async let ltr = try? ReadingAPIService.shared.getLearningTrend() - async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") + async let s = try? activityService.summary() + async let f = try? focusService.list() + async let h = try? activityService.heatmap() + async let st = try? activityService.streak() + async let t = try? activityService.trend() + async let r = try? activityService.recommendations() + async let cr = try? readingService.getContinueLearning() + async let ls = try? readingService.getLearningSummary() + async let ltr = try? readingService.getLearningTrend() + async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading") let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs) @@ -54,16 +98,16 @@ class ActivityViewModel: ObservableObject { func refresh() async { errorMessage = nil - async let s = try? ActivityService.shared.summary() - async let f = try? FocusItemService.shared.list() - async let h = try? ActivityService.shared.heatmap() - async let st = try? ActivityService.shared.streak() - async let t = try? ActivityService.shared.trend() - async let r = try? ActivityService.shared.recommendations() - async let cr = try? ReadingAPIService.shared.getContinueLearning() - async let ls = try? ReadingAPIService.shared.getLearningSummary() - async let ltr = try? ReadingAPIService.shared.getLearningTrend() - async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") + async let s = try? activityService.summary() + async let f = try? focusService.list() + async let h = try? activityService.heatmap() + async let st = try? activityService.streak() + async let t = try? activityService.trend() + async let r = try? activityService.recommendations() + async let cr = try? readingService.getContinueLearning() + async let ls = try? readingService.getLearningSummary() + async let ltr = try? readingService.getLearningTrend() + async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading") let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs) diff --git a/AIStudyApp/AIStudyAppTests/ActivityViewModelTests.swift b/AIStudyApp/AIStudyAppTests/ActivityViewModelTests.swift new file mode 100644 index 0000000..997c600 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/ActivityViewModelTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import AIStudyApp + +// MARK: - Mock services + +private final class MockActivityService: ActivityServicing { + var summary: ActivitySummary? + var heatmap: [String: Int] = [:] + var streak: ActivityStreak? + var trends: [ActivityTrend] = [] + var recommendations: [ActivityRecommendation] = [] + + func summary() async throws -> ActivitySummary { try resultOrThrow(summary) } + func heatmap() async throws -> [String: Int] { heatmap } + func streak() async throws -> ActivityStreak { try resultOrThrow(streak) } + func trend(days: Int) async throws -> [ActivityTrend] { trends } + func recommendations() async throws -> [ActivityRecommendation] { recommendations } +} + +private final class MockFocusItemService: FocusItemServicing { + var items: [FocusItem] = [] + func list() async throws -> [FocusItem] { items } +} + +private final class MockReadingAPIForActivity: ReadingContinueServicing & LearningStatusServicing { + var continueLearning: ContinueLearningResponse? + var learningSummary: LearningSummaryResponse? + var learningTrend: LearningTrendResponse? + var records: LearningRecordsResponse? + + func getContinueLearning() async throws -> ContinueLearningResponse { try resultOrThrow(continueLearning) } + func getLearningSummary() async throws -> LearningSummaryResponse { try resultOrThrow(learningSummary) } + func getLearningTrend() async throws -> LearningTrendResponse { try resultOrThrow(learningTrend) } + func getLearningRecords(limit: Int, type: String) async throws -> LearningRecordsResponse { try resultOrThrow(records) } +} + +private func resultOrThrow(_ value: T?) throws -> T { + guard let v = value else { throw NSError(domain: "test", code: -1) } + return v +} + +// MARK: - Tests + +@MainActor +final class ActivityViewModelTests: XCTestCase { + + var vm: ActivityViewModel! + var mockActivity: MockActivityService! + var mockFocus: MockFocusItemService! + var mockReading: MockReadingAPIForActivity! + + override func setUp() { + super.setUp() + mockActivity = MockActivityService() + mockFocus = MockFocusItemService() + mockReading = MockReadingAPIForActivity() + vm = ActivityViewModel( + activityService: mockActivity, + focusService: mockFocus, + readingService: mockReading + ) + } + + override func tearDown() { + vm = nil + super.tearDown() + } + + // MARK: - Initial state + + func test_initial_isLoading_false() { XCTAssertFalse(vm.isLoading) } + func test_initial_errorMessage_nil() { XCTAssertNil(vm.errorMessage) } + func test_initial_summary_nil() { XCTAssertNil(vm.summary) } + func test_initial_learningSummary_nil() { XCTAssertNil(vm.learningSummary) } + func test_initial_readingTrend_nil() { XCTAssertNil(vm.readingTrend) } + + // MARK: - loadAll() basic + + func test_loadAll_sets_isLoading_false() async { + await vm.loadAll() + XCTAssertFalse(vm.isLoading) + } + + func test_loadAll_sets_summary() async { + mockActivity.summary = ActivitySummary(totalMinutes: 60, totalCardsReviewed: 10, activeDays: 5, dailyAverage: 12) + await vm.loadAll() + XCTAssertEqual(vm.summary?.activeDays, 5) + } + + func test_loadAll_errorMessage_when_summary_nil() async { + mockActivity.summary = nil + await vm.loadAll() + XCTAssertEqual(vm.errorMessage, "加载分析数据失败,请下拉刷新重试") + } + + func test_loadAll_sets_focusItems() async { + mockFocus.items = [FocusItem(id: "f1", userId: nil, knowledgeBaseId: nil, knowledgeItemId: nil, title: "Focus", reason: nil, suggestion: nil, priority: nil, status: "active", masteryScore: nil)] + await vm.loadAll() + XCTAssertEqual(vm.focusItems.count, 1) + } + + func test_loadAll_sets_heatmap() async { + mockActivity.heatmap = ["2026-01-01": 3] + await vm.loadAll() + XCTAssertEqual(vm.heatmap["2026-01-01"], 3) + } + + func test_loadAll_sets_streak() async { + mockActivity.streak = ActivityStreak(currentStreak: 5, longestStreak: 10, lastActiveDate: "2026-01-01") + await vm.loadAll() + XCTAssertEqual(vm.streak?.currentStreak, 5) + } + + func test_loadAll_sets_trends() async { + mockActivity.trends = [ActivityTrend(date: "2026-01-01", value: 8, label: "阅读")] + await vm.loadAll() + XCTAssertEqual(vm.trends.first?.label, "阅读") + } + + func test_loadAll_sets_recommendations() async { + mockActivity.recommendations = [ActivityRecommendation(title: "继续学习", description: nil, type: "study", priority: nil)] + await vm.loadAll() + XCTAssertEqual(vm.recommendations.first?.title, "继续学习") + } + + // MARK: - loadAll() learningSummary / trend + + func test_loadAll_sets_learningSummary() async { + mockReading.learningSummary = LearningSummaryResponse( + todaySeconds: 120, weekSeconds: 600, totalSeconds: 3600, + activeDays: 5, sessionsCount: 10, materialsReadCount: 3, + markedReadCount: 1, dailyAverageSeconds: 600 + ) + await vm.loadAll() + XCTAssertEqual(vm.learningSummary?.todaySeconds, 120) + XCTAssertEqual(vm.learningSummary?.activeDays, 5) + } + + func test_loadAll_sets_readingTrend() async { + mockReading.learningTrend = LearningTrendResponse( + days: 7, + series: [LearningTrendResponse.TrendPoint(date: "2026-01-01", value: 100)] + ) + await vm.loadAll() + XCTAssertEqual(vm.readingTrend?.days, 7) + XCTAssertEqual(vm.readingTrend?.series.first?.value, 100) + } + + func test_loadAll_sets_continueReading() async { + mockReading.continueLearning = ContinueLearningResponse( + type: "knowledge_source", materialId: "m1", title: "Book", + lastProgress: 0.5, totalActiveSeconds: 300, lastReadAt: "2026-01-01" + ) + await vm.loadAll() + XCTAssertEqual(vm.continueReading?.materialId, "m1") + } + + func test_loadAll_sets_recentRecords() async { + mockReading.records = LearningRecordsResponse(items: [ + LearningRecordsResponse.RecordItem(id: "r1", recordType: "reading", title: "Rec1", description: nil, durationSeconds: 120, occurredAt: "2026-01-01") + ], nextCursor: nil) + await vm.loadAll() + XCTAssertEqual(vm.recentRecords.count, 1) + XCTAssertEqual(vm.recentRecords.first?.title, "Rec1") + } + + func test_loadAll_learningSummary_nil_on_error() async { + mockReading.learningSummary = nil + await vm.loadAll() + XCTAssertNil(vm.learningSummary) + } + + // MARK: - refresh() + + func test_refresh_clears_errorMessage() async { + vm.errorMessage = "old error" + mockActivity.summary = ActivitySummary(totalMinutes: 0, totalCardsReviewed: 0, activeDays: 0, dailyAverage: 0) + await vm.refresh() + XCTAssertNil(vm.errorMessage) + } +}