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) } }