- Protocol conformance: list→listAll with extension defaults - ContinueLearningResponse has no lastPosition→use nil - LearningSession has no readingTargetType/materialId→use defaults - Color.zxBlue→Color.blue, Color.zxIndigo→Color.indigo - Shadowed max parameter→Swift.max - ForEach Identifiable→explicit id: \.id Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
233 lines
8.5 KiB
Swift
233 lines
8.5 KiB
Swift
import XCTest
|
|
@testable import AIStudyApp
|
|
|
|
// MARK: - Mock services
|
|
|
|
private final class MockSessionService: SessionServicing {
|
|
var sessions: [LearningSession] = []
|
|
func listAll() async throws -> [LearningSession] { sessions }
|
|
}
|
|
|
|
private final class MockReviewService: ReviewServicing {
|
|
var cards: [ReviewCard] = []
|
|
func dueCards() async throws -> [ReviewCard] { cards }
|
|
}
|
|
|
|
private final class MockQuizListService: QuizListServicing {
|
|
var quizzes: [Quiz] = []
|
|
func listAll() async throws -> [Quiz] { quizzes }
|
|
}
|
|
|
|
private final class MockKBListService: KBListServicing {
|
|
var kbs: [KnowledgeBase] = []
|
|
func listAll() async throws -> [KnowledgeBase] { kbs }
|
|
}
|
|
|
|
private final class MockActivityHomeService: ActivityHomeServicing {
|
|
var summary: ActivitySummary?
|
|
var streak: ActivityStreak?
|
|
func summary() async throws -> ActivitySummary { try resultOrThrow(summary) }
|
|
func streak() async throws -> ActivityStreak { try resultOrThrow(streak) }
|
|
}
|
|
|
|
private final class MockContinueService: ContinueLearningServicing {
|
|
var response: ContinueLearningResponse?
|
|
func getContinueLearning() async throws -> ContinueLearningResponse { try resultOrThrow(response) }
|
|
}
|
|
|
|
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 StudyHomeViewModelTests: XCTestCase {
|
|
|
|
var vm: StudyHomeViewModel!
|
|
var mockSession: MockSessionService!
|
|
var mockReview: MockReviewService!
|
|
var mockQuiz: MockQuizListService!
|
|
var mockKB: MockKBListService!
|
|
var mockActivity: MockActivityHomeService!
|
|
var mockContinue: MockContinueService!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
mockSession = MockSessionService()
|
|
mockReview = MockReviewService()
|
|
mockQuiz = MockQuizListService()
|
|
mockKB = MockKBListService()
|
|
mockActivity = MockActivityHomeService()
|
|
mockContinue = MockContinueService()
|
|
vm = StudyHomeViewModel(
|
|
sessionService: mockSession,
|
|
reviewService: mockReview,
|
|
quizService: mockQuiz,
|
|
kbService: mockKB,
|
|
activityService: mockActivity,
|
|
continueService: mockContinue
|
|
)
|
|
}
|
|
|
|
override func tearDown() {
|
|
vm = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
// MARK: - Initial state
|
|
|
|
func test_initial_loadingState_is_idle() { XCTAssertEqual(vm.loadingState, .idle) }
|
|
func test_initial_mainAction_nil() { XCTAssertNil(vm.mainAction) }
|
|
func test_initial_todayReviewCount_zero() { XCTAssertEqual(vm.todayReviewCount, 0) }
|
|
|
|
// MARK: - loadAll() stats
|
|
|
|
func test_loadAll_sets_todayReviewCount() async {
|
|
mockReview.cards = [ReviewCard(id: "r1", front: "Q", back: "A")]
|
|
await vm.loadAll()
|
|
XCTAssertEqual(vm.todayReviewCount, 1)
|
|
}
|
|
|
|
func test_loadAll_sets_availableQuizCount() async {
|
|
mockQuiz.quizzes = [Quiz(id: "q1", knowledgeBaseId: "kb1", title: "Q", questionCount: 3, sourceType: "ai", status: "ready")]
|
|
await vm.loadAll()
|
|
XCTAssertEqual(vm.availableQuizCount, 1)
|
|
}
|
|
|
|
func test_loadAll_sets_loadingState_loaded() async {
|
|
await vm.loadAll()
|
|
XCTAssertEqual(vm.loadingState, .loaded)
|
|
}
|
|
|
|
// MARK: - evaluatePriority: P1 Continue Session
|
|
|
|
func test_priority_continueSession_when_api_returns_progress() async {
|
|
mockContinue.response = ContinueLearningResponse(
|
|
type: "knowledge_source", materialId: "mat-001", title: "My Book",
|
|
lastProgress: 0.5, totalActiveSeconds: 300, lastReadAt: "2026-01-01T00:00:00.000Z", lastPosition: nil
|
|
)
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let fileURL = docs.appendingPathComponent("mat-001")
|
|
FileManager.default.createFile(atPath: fileURL.path, contents: "test".data(using: .utf8))
|
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
|
|
|
mockKB.kbs = [KnowledgeBase(id: "kb1", title: "My KB", description: nil, knowledgeCount: 5, sourceCount: 1, userId: "u1", createdAt: "2026-01-01")]
|
|
|
|
await vm.loadAll()
|
|
|
|
guard case .continueSession(let mid, _, _, _, _, _) = vm.mainAction else {
|
|
return XCTFail("Expected continueSession, got \(String(describing: vm.mainAction))")
|
|
}
|
|
XCTAssertEqual(mid, "mat-001")
|
|
}
|
|
|
|
// MARK: - evaluatePriority: P2 TodaysReview
|
|
|
|
func test_priority_todaysReview_when_cards_present() async {
|
|
mockReview.cards = [ReviewCard(id: "r1", front: "Q", back: "A"), ReviewCard(id: "r2", front: "Q2", back: "A2")]
|
|
await vm.loadAll()
|
|
|
|
guard case .todaysReview(let count, let minutes) = vm.mainAction else {
|
|
return XCTFail("Expected todaysReview, got \(String(describing: vm.mainAction))")
|
|
}
|
|
XCTAssertEqual(count, 2)
|
|
XCTAssertEqual(minutes, 2)
|
|
}
|
|
|
|
// MARK: - evaluatePriority: P3 SelfTest
|
|
|
|
func test_priority_selfTest_when_quizzes_available() async {
|
|
mockQuiz.quizzes = [Quiz(id: "q1", knowledgeBaseId: "kb1", title: "Q", questionCount: 5, sourceType: "ai", status: "ready")]
|
|
await vm.loadAll()
|
|
|
|
guard case .selfTest(let qid, let count) = vm.mainAction else {
|
|
return XCTFail("Expected selfTest")
|
|
}
|
|
XCTAssertEqual(qid, "q1")
|
|
XCTAssertEqual(count, 1)
|
|
}
|
|
|
|
// MARK: - evaluatePriority: P4 StartLearning
|
|
|
|
func test_priority_startLearning_when_kbs_available() async {
|
|
mockKB.kbs = [KnowledgeBase(id: "kb1", title: "KB", description: nil, knowledgeCount: 5, sourceCount: 1, userId: "u1", createdAt: "2026-01-01")]
|
|
await vm.loadAll()
|
|
|
|
guard case .startLearning(let kbId, let count) = vm.mainAction else {
|
|
return XCTFail("Expected startLearning")
|
|
}
|
|
XCTAssertEqual(kbId, "kb1")
|
|
XCTAssertEqual(count, 1)
|
|
}
|
|
|
|
// MARK: - evaluatePriority: P5 Empty
|
|
|
|
func test_priority_empty_when_nothing_available() async {
|
|
await vm.loadAll()
|
|
XCTAssertEqual(vm.mainAction, .empty)
|
|
}
|
|
|
|
// MARK: - Banner
|
|
|
|
func test_banner_nil_when_all_empty() async {
|
|
await vm.loadAll()
|
|
XCTAssertNil(vm.banner)
|
|
}
|
|
|
|
// MARK: - Weekly stats
|
|
|
|
func test_loadAll_sets_weekly_stats_from_summary() async {
|
|
mockActivity.summary = ActivitySummary(totalMinutes: 120, totalCardsReviewed: 30, activeDays: 5, dailyAverage: 20)
|
|
mockActivity.streak = ActivityStreak(currentStreak: 3, longestStreak: 7, lastActiveDate: "2026-01-01")
|
|
await vm.loadAll()
|
|
XCTAssertEqual(vm.weeklyMinutes, 120)
|
|
XCTAssertEqual(vm.weeklyCardsReviewed, 30)
|
|
XCTAssertEqual(vm.weeklyActiveDays, 5)
|
|
XCTAssertEqual(vm.streakDays, 3)
|
|
}
|
|
|
|
// MARK: - P1 fallback: continueService throws → unfinished session
|
|
|
|
func test_priority_fallback_to_unfinished_session_when_continueService_throws() async {
|
|
mockContinue.response = nil // throws
|
|
mockSession.sessions = [LearningSession(
|
|
id: "s1", userId: nil, knowledgeBaseId: "kb1", knowledgeItemId: nil,
|
|
mode: nil, status: "in_progress", startedAt: "2026-01-02T00:00:00.000Z",
|
|
endedAt: nil, durationSeconds: nil, focusMinutes: nil, createdAt: nil
|
|
)]
|
|
mockKB.kbs = [KnowledgeBase(id: "kb1", title: "My KB", description: nil, knowledgeCount: 5, sourceCount: 1, userId: "u1", createdAt: "2026-01-01")]
|
|
|
|
await vm.loadAll()
|
|
|
|
guard case .continueSession = vm.mainAction else {
|
|
return XCTFail("Expected continueSession fallback, got \(String(describing: vm.mainAction))")
|
|
}
|
|
}
|
|
|
|
// MARK: - refresh()
|
|
|
|
func test_refresh_updates_stats() async {
|
|
mockReview.cards = [ReviewCard(id: "r1", front: "Q", back: "A")]
|
|
mockQuiz.quizzes = [Quiz(id: "q1", knowledgeBaseId: "kb1", title: "Q", questionCount: 3, sourceType: "ai", status: "ready")]
|
|
mockActivity.summary = ActivitySummary(totalMinutes: 60, totalCardsReviewed: 5, activeDays: 3, dailyAverage: 20)
|
|
mockActivity.streak = ActivityStreak(currentStreak: 2, longestStreak: 10, lastActiveDate: nil)
|
|
|
|
await vm.refresh()
|
|
|
|
XCTAssertEqual(vm.todayReviewCount, 1)
|
|
XCTAssertEqual(vm.availableQuizCount, 1)
|
|
XCTAssertEqual(vm.weeklyMinutes, 60)
|
|
XCTAssertEqual(vm.streakDays, 2)
|
|
}
|
|
|
|
func test_refresh_updates_mainAction() async {
|
|
mockReview.cards = [ReviewCard(id: "r1", front: "Q", back: "A")]
|
|
await vm.refresh()
|
|
guard case .todaysReview = vm.mainAction else {
|
|
return XCTFail("Expected todaysReview after refresh")
|
|
}
|
|
}
|
|
}
|