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:
parent
203e45df34
commit
3a8266ece2
@ -1,6 +1,36 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
class ActivityViewModel: ObservableObject {
|
class ActivityViewModel: ObservableObject {
|
||||||
@Published var summary: ActivitySummary?
|
@Published var summary: ActivitySummary?
|
||||||
@ -16,20 +46,34 @@ class ActivityViewModel: ObservableObject {
|
|||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@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 {
|
func loadAll() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
async let s = try? ActivityService.shared.summary()
|
async let s = try? activityService.summary()
|
||||||
async let f = try? FocusItemService.shared.list()
|
async let f = try? focusService.list()
|
||||||
async let h = try? ActivityService.shared.heatmap()
|
async let h = try? activityService.heatmap()
|
||||||
async let st = try? ActivityService.shared.streak()
|
async let st = try? activityService.streak()
|
||||||
async let t = try? ActivityService.shared.trend()
|
async let t = try? activityService.trend()
|
||||||
async let r = try? ActivityService.shared.recommendations()
|
async let r = try? activityService.recommendations()
|
||||||
async let cr = try? ReadingAPIService.shared.getContinueLearning()
|
async let cr = try? readingService.getContinueLearning()
|
||||||
async let ls = try? ReadingAPIService.shared.getLearningSummary()
|
async let ls = try? readingService.getLearningSummary()
|
||||||
async let ltr = try? ReadingAPIService.shared.getLearningTrend()
|
async let ltr = try? readingService.getLearningTrend()
|
||||||
async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading")
|
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)
|
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 {
|
func refresh() async {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
async let s = try? ActivityService.shared.summary()
|
async let s = try? activityService.summary()
|
||||||
async let f = try? FocusItemService.shared.list()
|
async let f = try? focusService.list()
|
||||||
async let h = try? ActivityService.shared.heatmap()
|
async let h = try? activityService.heatmap()
|
||||||
async let st = try? ActivityService.shared.streak()
|
async let st = try? activityService.streak()
|
||||||
async let t = try? ActivityService.shared.trend()
|
async let t = try? activityService.trend()
|
||||||
async let r = try? ActivityService.shared.recommendations()
|
async let r = try? activityService.recommendations()
|
||||||
async let cr = try? ReadingAPIService.shared.getContinueLearning()
|
async let cr = try? readingService.getContinueLearning()
|
||||||
async let ls = try? ReadingAPIService.shared.getLearningSummary()
|
async let ls = try? readingService.getLearningSummary()
|
||||||
async let ltr = try? ReadingAPIService.shared.getLearningTrend()
|
async let ltr = try? readingService.getLearningTrend()
|
||||||
async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading")
|
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)
|
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
|
||||||
|
|
||||||
|
|||||||
181
AIStudyApp/AIStudyAppTests/ActivityViewModelTests.swift
Normal file
181
AIStudyApp/AIStudyAppTests/ActivityViewModelTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user