diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift index 67fbc3c..4196047 100644 --- a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift @@ -1,6 +1,28 @@ import SwiftUI import Combine +// MARK: - Service protocols (for testability) + +protocol KnowledgeBaseServicing { + func detail(id: String) async throws -> KnowledgeBase +} +extension KnowledgeBaseService: KnowledgeBaseServicing {} + +protocol KnowledgeItemServicing { + func list(knowledgeBaseId: String, sortBy: String?, order: String?) async throws -> [KnowledgeItem] +} +extension KnowledgeItemService: KnowledgeItemServicing {} + +protocol QuizServicing { + func list(knowledgeBaseId: String) async throws -> [Quiz] +} +extension QuizService: QuizServicing {} + +protocol ReadingProgressServicing { + func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO +} +extension ReadingAPIService: ReadingProgressServicing {} + // MARK: - ViewModel @MainActor @@ -22,21 +44,36 @@ final class MaterialDetailViewModel: ObservableObject { let knowledgeBaseId: String let materialId: String? + private let kbService: KnowledgeBaseServicing + private let itemService: KnowledgeItemServicing + private let quizService: QuizServicing + private let readingService: ReadingProgressServicing - init(knowledgeBaseId: String, materialId: String? = nil) { + init( + knowledgeBaseId: String, + materialId: String? = nil, + kbService: KnowledgeBaseServicing = KnowledgeBaseService.shared, + itemService: KnowledgeItemServicing = KnowledgeItemService.shared, + quizService: QuizServicing = QuizService.shared, + readingService: ReadingProgressServicing = ReadingAPIService.shared + ) { self.knowledgeBaseId = knowledgeBaseId self.materialId = materialId + self.kbService = kbService + self.itemService = itemService + self.quizService = quizService + self.readingService = readingService } func load() async { isLoading = true - async let kbResult = try? KnowledgeBaseService.shared.detail(id: knowledgeBaseId) - async let itemsResult = try? KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) - async let quizzesResult = try? QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) + async let kbResult = try? kbService.detail(id: knowledgeBaseId) + async let itemsResult = try? itemService.list(knowledgeBaseId: knowledgeBaseId) + async let quizzesResult = try? quizService.list(knowledgeBaseId: knowledgeBaseId) async let progressResult: MaterialReadingProgressDTO? = { if let mid = materialId { - return try? await ReadingAPIService.shared.getReadingProgress(materialId: mid, targetType: "knowledge_source") + return try? await readingService.getReadingProgress(materialId: mid, targetType: "knowledge_source") } return nil }() diff --git a/AIStudyApp/AIStudyAppTests/MaterialDetailViewModelTests.swift b/AIStudyApp/AIStudyAppTests/MaterialDetailViewModelTests.swift index be31aa7..127f936 100644 --- a/AIStudyApp/AIStudyAppTests/MaterialDetailViewModelTests.swift +++ b/AIStudyApp/AIStudyAppTests/MaterialDetailViewModelTests.swift @@ -1,14 +1,56 @@ import XCTest @testable import AIStudyApp +// MARK: - Mock services + +private final class MockKnowledgeBaseService: KnowledgeBaseServicing { + var detailResult: Result = .failure(NSError(domain: "test", code: -1)) + func detail(id: String) async throws -> KnowledgeBase { try detailResult.get() } +} + +private final class MockKnowledgeItemService: KnowledgeItemServicing { + var items: [KnowledgeItem] = [] + func list(knowledgeBaseId: String, sortBy: String?, order: String?) async throws -> [KnowledgeItem] { items } +} + +private final class MockQuizService: QuizServicing { + var quizzes: [Quiz] = [] + func list(knowledgeBaseId: String) async throws -> [Quiz] { quizzes } +} + +private final class MockReadingProgressService: ReadingProgressServicing { + var progress: MaterialReadingProgressDTO? + func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO { + guard let p = progress else { throw NSError(domain: "test", code: -1) } + return p + } +} + +// MARK: - Tests + @MainActor final class MaterialDetailViewModelTests: XCTestCase { var vm: MaterialDetailViewModel! + var mockKB: MockKnowledgeBaseService! + var mockItem: MockKnowledgeItemService! + var mockQuiz: MockQuizService! + var mockReading: MockReadingProgressService! override func setUp() { super.setUp() - vm = MaterialDetailViewModel(knowledgeBaseId: "kb-test-001", materialId: "mat-001") + mockKB = MockKnowledgeBaseService() + mockItem = MockKnowledgeItemService() + mockQuiz = MockQuizService() + mockReading = MockReadingProgressService() + vm = MaterialDetailViewModel( + knowledgeBaseId: "kb-test-001", + materialId: "mat-001", + kbService: mockKB, + itemService: mockItem, + quizService: mockQuiz, + readingService: mockReading + ) } override func tearDown() { @@ -18,128 +60,107 @@ final class MaterialDetailViewModelTests: XCTestCase { // MARK: - Initial state - func test_initial_isLoading_is_true() { - XCTAssertTrue(vm.isLoading) - } - - func test_initial_aiStatus_is_processing() { - XCTAssertEqual(vm.aiStatus, .processing) - } - - func test_initial_itemCount_is_zero() { - XCTAssertEqual(vm.itemCount, 0) - } - - func test_initial_quizCount_is_zero() { - XCTAssertEqual(vm.quizCount, 0) - } - - func test_initial_isMarkedRead_is_false() { - XCTAssertFalse(vm.isMarkedRead) - } - - func test_initial_readingSeconds_is_zero() { - XCTAssertEqual(vm.readingSeconds, 0) - } - - func test_initial_readingStatus_is_nil() { - XCTAssertNil(vm.readingStatus) - } - - func test_knowledgeBaseId_is_set() { + func test_initial_isLoading_is_true() { XCTAssertTrue(vm.isLoading) } + func test_initial_aiStatus_is_processing() { XCTAssertEqual(vm.aiStatus, .processing) } + func test_initial_itemCount_is_zero() { XCTAssertEqual(vm.itemCount, 0) } + func test_initial_isMarkedRead_is_false() { XCTAssertFalse(vm.isMarkedRead) } + func test_kbId_and_matId_set() { XCTAssertEqual(vm.knowledgeBaseId, "kb-test-001") - } - - func test_materialId_is_set() { XCTAssertEqual(vm.materialId, "mat-001") } - func test_init_without_materialId_defaults_to_nil() { - let vm2 = MaterialDetailViewModel(knowledgeBaseId: "kb-002") - XCTAssertNil(vm2.materialId) + // MARK: - load() items / quizzes + + func test_load_sets_itemCount() async { + mockItem.items = [KnowledgeItem(id: "i1", knowledgeBaseId: "kb1", title: "Item1")] + await vm.load() + XCTAssertEqual(vm.itemCount, 1) + XCTAssertFalse(vm.isLoading) + } + + func test_load_sets_quizCount() async { + mockQuiz.quizzes = [Quiz(id: "q1", knowledgeBaseId: "kb1", title: "Q", questionCount: 3, sourceType: "ai", status: "ready")] + await vm.load() + XCTAssertEqual(vm.quizCount, 1) + } + + func test_load_aiStatus_ready_when_items_present() async { + mockItem.items = [KnowledgeItem(id: "i1", knowledgeBaseId: "kb1", title: "X")] + await vm.load() + XCTAssertEqual(vm.aiStatus, .ready) + } + + func test_load_aiStatus_processing_when_empty() async { + await vm.load() + XCTAssertEqual(vm.aiStatus, .processing) + } + + func test_load_isLoading_false_after() async { + await vm.load() + XCTAssertFalse(vm.isLoading) + } + + // MARK: - load() progress + + func test_load_sets_readingStatus_and_seconds() async { + mockReading.progress = MaterialReadingProgressDTO( + materialId: "mat-001", targetType: "knowledge_source", + status: "in_progress", totalActiveSeconds: 120, + isMarkedRead: false, lastReadAt: nil, lastPosition: nil + ) + await vm.load() + XCTAssertEqual(vm.readingStatus, "in_progress") + XCTAssertEqual(vm.readingSeconds, 120) + } + + func test_load_sets_isMarkedRead_true() async { + mockReading.progress = MaterialReadingProgressDTO( + materialId: "mat-001", targetType: "knowledge_source", + status: "completed", totalActiveSeconds: 300, + isMarkedRead: true, lastReadAt: nil, lastPosition: nil + ) + await vm.load() + XCTAssertTrue(vm.isMarkedRead) + } + + func test_load_handles_progress_error_gracefully() async { + mockReading.progress = nil // throws + await vm.load() + XCTAssertNil(vm.readingStatus) + XCTAssertEqual(vm.readingSeconds, 0) + XCTAssertFalse(vm.isLoading) + } + + func test_load_skips_progress_when_materialId_nil() async { + let vm2 = MaterialDetailViewModel( + knowledgeBaseId: "kb-002", materialId: nil, + kbService: mockKB, itemService: mockItem, + quizService: mockQuiz, readingService: mockReading + ) + mockReading.progress = MaterialReadingProgressDTO( + materialId: "mat-001", targetType: "knowledge_source", + status: "done", totalActiveSeconds: 99, isMarkedRead: true, lastReadAt: nil, lastPosition: nil + ) + await vm2.load() + XCTAssertNil(vm2.readingStatus) } // MARK: - statusLabel - func test_statusLabel_completed() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) + func test_statusLabel() { + let view = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 0, filePath: "") XCTAssertEqual(view.statusLabel("completed"), "阅读完成") - } - - func test_statusLabel_inProgress() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) XCTAssertEqual(view.statusLabel("in_progress"), "阅读中") - } - - func test_statusLabel_unknown() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) - XCTAssertEqual(view.statusLabel("some_other"), "已开始") - } - - func test_statusLabel_nil() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) + XCTAssertEqual(view.statusLabel("other"), "已开始") XCTAssertEqual(view.statusLabel(nil), "已开始") } // MARK: - formatSeconds - func test_formatSeconds_less_than_60() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) + func test_formatSeconds() { + let view = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 0, filePath: "") XCTAssertEqual(view.formatSeconds(30), "30 秒") - XCTAssertEqual(view.formatSeconds(0), "0 秒") - XCTAssertEqual(view.formatSeconds(59), "59 秒") - } - - func test_formatSeconds_minutes() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) - XCTAssertEqual(view.formatSeconds(60), "1 分钟") - XCTAssertEqual(view.formatSeconds(3599), "59 分钟") - } - - func test_formatSeconds_hours() { - let view = MaterialDetailView( - knowledgeBaseId: "kb-001", fileName: "test.md", - fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md" - ) - XCTAssertEqual(view.formatSeconds(3600), "1 小时 0 分钟") + XCTAssertEqual(view.formatSeconds(120), "2 分钟") XCTAssertEqual(view.formatSeconds(3660), "1 小时 1 分钟") - XCTAssertEqual(view.formatSeconds(7200), "2 小时 0 分钟") - } - - // MARK: - aiStatus logic (synchronous) - - func test_aiStatus_is_ready_when_itemCount_positive() { - vm.itemCount = 5 - // Simulate: loading done, items present → ready - // load() sets aiStatus = itemCount > 0 ? .ready : .processing - // We can't test load() without mock services, but we test the property boundary - XCTAssertEqual(vm.aiStatus, .processing) // load() not yet called - } - - // MARK: - Edge: nil materialId skips progress fetch - - func test_without_materialId_readingStatus_stays_nil_after_init() { - let vm2 = MaterialDetailViewModel(knowledgeBaseId: "kb-003") - XCTAssertNil(vm2.readingStatus) - XCTAssertEqual(vm2.readingSeconds, 0) - XCTAssertFalse(vm2.isMarkedRead) } }