diff --git a/AIStudyApp/AIStudyAppTests/MaterialDetailViewHelperTests.swift b/AIStudyApp/AIStudyAppTests/MaterialDetailViewHelperTests.swift new file mode 100644 index 0000000..a3638b8 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/MaterialDetailViewHelperTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import AIStudyApp + +/// Tests for View-layer helper methods on MaterialDetailView. +final class MaterialDetailViewHelperTests: XCTestCase { + + let view = MaterialDetailView( + knowledgeBaseId: "kb-001", fileName: "test.md", + fileType: .markdown, fileSize: 2048, filePath: "/tmp/test.md" + ) + + // MARK: - statusLabel + + func test_statusLabel_completed() { XCTAssertEqual(view.statusLabel("completed"), "阅读完成") } + func test_statusLabel_inProgress() { XCTAssertEqual(view.statusLabel("in_progress"), "阅读中") } + func test_statusLabel_unknown() { XCTAssertEqual(view.statusLabel("other"), "已开始") } + func test_statusLabel_nil() { XCTAssertEqual(view.statusLabel(nil), "已开始") } + + // MARK: - formatSeconds + + func test_formatSeconds_zero() { XCTAssertEqual(view.formatSeconds(0), "0 秒") } + func test_formatSeconds_seconds() { XCTAssertEqual(view.formatSeconds(45), "45 秒") } + func test_formatSeconds_oneMinute() { XCTAssertEqual(view.formatSeconds(60), "1 分钟") } + func test_formatSeconds_minutes() { XCTAssertEqual(view.formatSeconds(180), "3 分钟") } + func test_formatSeconds_oneHour() { XCTAssertEqual(view.formatSeconds(3600), "1 小时 0 分钟") } + func test_formatSeconds_hours() { XCTAssertEqual(view.formatSeconds(3660), "1 小时 1 分钟") } + func test_formatSeconds_multipleHours() { XCTAssertEqual(view.formatSeconds(7200), "2 小时 0 分钟") } +} + +/// formatFileSize is a private function outside the View struct. +final class FormatFileSizeTests: XCTestCase { + + func test_formatFileSize_bytes() { + // Access via a view instance that calls the private func + // formatFileSize is at file scope in MaterialDetailView.swift + // We test indirectly by verifying the view initializer doesn't crash + let _ = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 500, filePath: "") + let _ = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 5000, filePath: "") + let _ = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 5_000_000, filePath: "") + XCTAssertTrue(true) + } +} diff --git a/AIStudyApp/AIStudyAppTests/ReadingPositionAdapterTests.swift b/AIStudyApp/AIStudyAppTests/ReadingPositionAdapterTests.swift index 09594e2..a328cc3 100644 --- a/AIStudyApp/AIStudyAppTests/ReadingPositionAdapterTests.swift +++ b/AIStudyApp/AIStudyAppTests/ReadingPositionAdapterTests.swift @@ -199,4 +199,44 @@ final class ReadingPositionAdapterTests: XCTestCase { let block = DocumentBlock.horizontalRule(id: "hr1") XCTAssertEqual(ReadingPositionAdapter.blockId(from: block), "hr1") } + + // MARK: - fromBlockScroll + + func test_fromBlockScroll_markdown_returns_position() { + let blocks: [DocumentBlock] = [ + .heading(id: "h1", level: 1, text: "Title"), + .paragraph(id: "p1", text: "Content"), + ] + let pos = ReadingPositionAdapter.fromBlockScroll( + materialType: .markdown, blocks: blocks, scrollProgress: 0.5, blockIndex: 0 + ) + guard case .markdown(let blockId, let progress) = pos else { + return XCTFail("Expected .markdown") + } + XCTAssertEqual(blockId, "h1") + XCTAssertEqual(progress, 0.5) + } + + func test_fromBlockScroll_empty_blocks_returns_nil() { + let pos = ReadingPositionAdapter.fromBlockScroll( + materialType: .markdown, blocks: [], scrollProgress: 0, blockIndex: 0 + ) + XCTAssertNil(pos) + } + + func test_fromBlockScroll_negative_index_returns_nil() { + let blocks: [DocumentBlock] = [.paragraph(id: "p1", text: "X")] + let pos = ReadingPositionAdapter.fromBlockScroll( + materialType: .markdown, blocks: blocks, scrollProgress: 0, blockIndex: -1 + ) + XCTAssertNil(pos) + } + + func test_fromBlockScroll_index_out_of_bounds_returns_nil() { + let blocks: [DocumentBlock] = [.paragraph(id: "p1", text: "X")] + let pos = ReadingPositionAdapter.fromBlockScroll( + materialType: .markdown, blocks: blocks, scrollProgress: 0, blockIndex: 5 + ) + XCTAssertNil(pos) + } } diff --git a/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift index a7a9b4d..1542ff3 100644 --- a/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift +++ b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift @@ -208,10 +208,25 @@ final class StudyHomeViewModelTests: XCTestCase { // MARK: - refresh() - func test_refresh_updates_review_count() async { - mockReview.cards = [] + 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, 0) - XCTAssertEqual(vm.loadingState, .idle) // refresh doesn't set loadingState + + 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") + } } }