diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift index af61e50..b45acb0 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift @@ -1,6 +1,41 @@ import Combine import Foundation +// MARK: - Service protocols (for testability) + +protocol SessionServicing { + func list() async throws -> [LearningSession] +} +extension LearningSessionService: SessionServicing {} + +protocol ReviewServicing { + func dueCards() async throws -> [ReviewCard] +} +extension ReviewService: ReviewServicing {} + +protocol KBListServicing { + func list() async throws -> [KnowledgeBase] +} +extension KnowledgeBaseService: KBListServicing {} + +protocol QuizListServicing { + func listAll() async throws -> [Quiz] +} +extension QuizService: QuizListServicing { + func listAll() async throws -> [Quiz] { try await list() } +} + +protocol ActivityHomeServicing { + func summary() async throws -> ActivitySummary + func streak() async throws -> ActivityStreak +} +extension ActivityService: ActivityHomeServicing {} + +protocol ContinueLearningServicing { + func getContinueLearning() async throws -> ContinueLearningResponse +} +extension ReadingAPIService: ContinueLearningServicing {} + // MARK: - Action states enum MainAction: Equatable { @@ -36,16 +71,39 @@ final class StudyHomeViewModel: ObservableObject { private var firstQuizId: String? private var firstKbId: String? + private let sessionService: SessionServicing + private let reviewService: ReviewServicing + private let quizService: QuizListServicing + private let kbService: KBListServicing + private let activityService: ActivityHomeServicing + private let continueService: ContinueLearningServicing + + init( + sessionService: SessionServicing = LearningSessionService.shared, + reviewService: ReviewServicing = ReviewService.shared, + quizService: QuizListServicing = QuizService.shared, + kbService: KBListServicing = KnowledgeBaseService.shared, + activityService: ActivityHomeServicing = ActivityService.shared, + continueService: ContinueLearningServicing = ReadingAPIService.shared + ) { + self.sessionService = sessionService + self.reviewService = reviewService + self.quizService = quizService + self.kbService = kbService + self.activityService = activityService + self.continueService = continueService + } + func loadAll() async { loadingState = .loading banner = nil - async let sessions = try? LearningSessionService.shared.list() - async let dueCards = try? ReviewService.shared.dueCards() - async let quizzes = try? QuizService.shared.list() - async let knowledgeBases = try? KnowledgeBaseService.shared.list() - async let summary = try? ActivityService.shared.summary() - async let streak = try? ActivityService.shared.streak() + async let sessions = try? sessionService.list() + async let dueCards = try? reviewService.dueCards() + async let quizzes = try? quizService.listAll() + async let knowledgeBases = try? kbService.list() + async let summary = try? activityService.summary() + async let streak = try? activityService.streak() let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak) @@ -54,11 +112,9 @@ final class StudyHomeViewModel: ObservableObject { let quizzesResult = qRes ?? [] let kbResult = kRes ?? [] - // Store first IDs for navigation firstQuizId = quizzesResult.first?.id firstKbId = kbResult.first?.id - // Evaluate main action mainAction = await evaluatePriority( sessions: sessionsResult, dueCards: dueCardsResult, @@ -66,7 +122,6 @@ final class StudyHomeViewModel: ObservableObject { knowledgeBases: kbResult ) - // Stats todayReviewCount = dueCardsResult.count todayReviewEstimatedMinutes = max(1, dueCardsResult.count) availableQuizCount = quizzesResult.count @@ -75,23 +130,17 @@ final class StudyHomeViewModel: ObservableObject { weeklyActiveDays = sumRes?.activeDays ?? 0 streakDays = stRes?.currentStreak ?? 0 - // Banner - banner = computeBanner( - sessions: sessionsResult, - quizzes: quizzesResult, - dueCards: dueCardsResult - ) - + banner = computeBanner(sessions: sessionsResult, quizzes: quizzesResult, dueCards: dueCardsResult) loadingState = .loaded } func refresh() async { - async let sessions = try? LearningSessionService.shared.list() - async let dueCards = try? ReviewService.shared.dueCards() - async let quizzes = try? QuizService.shared.list() - async let knowledgeBases = try? KnowledgeBaseService.shared.list() - async let summary = try? ActivityService.shared.summary() - async let streak = try? ActivityService.shared.streak() + async let sessions = try? sessionService.list() + async let dueCards = try? reviewService.dueCards() + async let quizzes = try? quizService.listAll() + async let knowledgeBases = try? kbService.list() + async let summary = try? activityService.summary() + async let streak = try? activityService.streak() let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak) @@ -103,12 +152,7 @@ final class StudyHomeViewModel: ObservableObject { firstQuizId = quizzesResult.first?.id firstKbId = kbResult.first?.id - mainAction = await evaluatePriority( - sessions: sessionsResult, - dueCards: dueCardsResult, - quizzes: quizzesResult, - knowledgeBases: kbResult - ) + mainAction = await evaluatePriority(sessions: sessionsResult, dueCards: dueCardsResult, quizzes: quizzesResult, knowledgeBases: kbResult) todayReviewCount = dueCardsResult.count todayReviewEstimatedMinutes = max(1, dueCardsResult.count) @@ -127,8 +171,7 @@ final class StudyHomeViewModel: ObservableObject { quizzes: [Quiz], knowledgeBases: [KnowledgeBase] ) async -> MainAction { - // Priority 1: API continue-learning (most recent material with progress) - if let cl = try? await ReadingAPIService.shared.getContinueLearning(), + if let cl = try? await continueService.getContinueLearning(), let materialId = cl.materialId, !materialId.isEmpty, let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) { let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习" @@ -137,7 +180,6 @@ final class StudyHomeViewModel: ObservableObject { return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: pos) } - // Priority 2: Unfinished session (fallback) if let unfinished = sessions .filter({ $0.status != nil && $0.status != "completed" }) .sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") }) @@ -148,36 +190,25 @@ final class StudyHomeViewModel: ObservableObject { return .continueSession(materialId: unfinished.materialId ?? "", filePath: "", materialType: mt, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil) } - // Priority 2: Today's review if !dueCards.isEmpty { return .todaysReview(count: dueCards.count, estimatedMinutes: max(1, dueCards.count)) } - // Priority 3: Self-test if let quizId = firstQuizId, !quizzes.isEmpty { return .selfTest(quizId: quizId, count: quizzes.count) } - // Priority 4: Start learning if let kbId = firstKbId, !knowledgeBases.isEmpty { return .startLearning(knowledgeBaseId: kbId, kbCount: knowledgeBases.count) } - // Priority 5: Empty return .empty } // MARK: - Banner - private func computeBanner( - sessions: [LearningSession], - quizzes: [Quiz], - dueCards: [ReviewCard] - ) -> String? { - // No banner needed if nothing is happening - if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) { - return nil - } + private func computeBanner(sessions: [LearningSession], quizzes: [Quiz], dueCards: [ReviewCard]) -> String? { + if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) { return nil } return nil } @@ -188,7 +219,6 @@ final class StudyHomeViewModel: ObservableObject { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = formatter.date(from: iso) else { - // Try without fractional seconds formatter.formatOptions = [.withInternetDateTime] guard let date2 = formatter.date(from: iso) else { return "最近" } return relativeTime(from: date2) diff --git a/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift index 3c3dd01..47a9058 100644 --- a/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift +++ b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift @@ -1,13 +1,74 @@ 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() - vm = StudyHomeViewModel() + 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() { @@ -15,46 +76,115 @@ final class StudyHomeViewModelTests: XCTestCase { super.tearDown() } - func testInitialState_hasFiveTasks() { - XCTAssertEqual(vm.tasks.count, 5) + // 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 testInitialState_twoTasksDone() { - XCTAssertEqual(vm.doneCount, 2) + 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 testProgress_calculatesCorrectly() { - XCTAssertEqual(vm.progress, 0.4, accuracy: 0.01) + func test_loadAll_sets_loadingState_loaded() async { + await vm.loadAll() + XCTAssertEqual(vm.loadingState, .loaded) } - func testToggleTask_changesDoneCount() { - let task = vm.tasks.first(where: { !$0.d })! - vm.toggleTask(task) - XCTAssertEqual(vm.doneCount, 3) + // 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") } - func testToggleTask_togglingBackRestoresCount() { - let task = vm.tasks.first(where: { $0.d })! - vm.toggleTask(task) - XCTAssertEqual(vm.doneCount, 1) + // 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) } - func testDoneMinutes_sumsCompletedTasks() { - XCTAssertEqual(vm.doneMinutes, 25) // 10 + 15 + // 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) } - func testRemainingMinutes_sumsPendingTasks() { - XCTAssertEqual(vm.remainingMinutes, 30) // 8 + 12 + 10 + // 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) } - func testToggleTask_updatesProgress() { - let task = vm.tasks.first(where: { !$0.d })! - vm.toggleTask(task) - XCTAssertEqual(vm.progress, 0.6, accuracy: 0.01) + // MARK: - evaluatePriority: P5 Empty + + func test_priority_empty_when_nothing_available() async { + await vm.loadAll() + XCTAssertEqual(vm.mainAction, .empty) } - func testWeekActivity_hasSevenDays() { - XCTAssertEqual(vm.weekActivity.count, 7) - XCTAssertEqual(vm.dayLabels.count, 7) + // 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) } }