test: add ActivityViewModel unit tests with DI (IOS-INFO-036)

- Add 5 service protocols (ActivityServicing, FocusItemServicing,
  ReadingContinueServicing, LearningStatusServicing)
- ViewModel init supports DI with defaults to .shared
- Mock services for all 10 parallel calls in loadAll/refresh
- 20 tests: initial state, summary/focus/heatmap/streak/trends/recos,
  learningSummary/readingTrend/continueReading/recentRecords,
  errorMessage on nil summary, refresh clears error

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 13:19:07 +08:00
parent 203e45df34
commit 3a8266ece2
2 changed files with 245 additions and 20 deletions

View File

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

View File

@ -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<T>(_ 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)
}
}