import XCTest @testable import AIStudyApp // MARK: - Mock services private final class MockSessionService: SessionServicing { var sessions: [LearningSession] = [] func list() 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 list() 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(_ 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") } } }