test: add load() tests with mock service DI (IOS-INFO-035)
- Add service protocols (KnowledgeBaseServicing, KnowledgeItemServicing, QuizServicing, ReadingProgressServicing) for dependency injection - ViewModel init accepts optional mock services, defaults to .shared - Mock service classes for test isolation - load() tests: itemCount, quizCount, aiStatus ready/processing, isLoading, readingStatus/seconds, isMarkedRead, progress error, nil materialId skips progress Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
61de3e1d57
commit
833bc70121
@ -1,6 +1,28 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
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
|
// MARK: - ViewModel
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -22,21 +44,36 @@ final class MaterialDetailViewModel: ObservableObject {
|
|||||||
|
|
||||||
let knowledgeBaseId: String
|
let knowledgeBaseId: String
|
||||||
let materialId: 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.knowledgeBaseId = knowledgeBaseId
|
||||||
self.materialId = materialId
|
self.materialId = materialId
|
||||||
|
self.kbService = kbService
|
||||||
|
self.itemService = itemService
|
||||||
|
self.quizService = quizService
|
||||||
|
self.readingService = readingService
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
async let kbResult = try? KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
|
async let kbResult = try? kbService.detail(id: knowledgeBaseId)
|
||||||
async let itemsResult = try? KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
|
async let itemsResult = try? itemService.list(knowledgeBaseId: knowledgeBaseId)
|
||||||
async let quizzesResult = try? QuizService.shared.list(knowledgeBaseId: knowledgeBaseId)
|
async let quizzesResult = try? quizService.list(knowledgeBaseId: knowledgeBaseId)
|
||||||
async let progressResult: MaterialReadingProgressDTO? = {
|
async let progressResult: MaterialReadingProgressDTO? = {
|
||||||
if let mid = materialId {
|
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
|
return nil
|
||||||
}()
|
}()
|
||||||
|
|||||||
@ -1,14 +1,56 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import AIStudyApp
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
// MARK: - Mock services
|
||||||
|
|
||||||
|
private final class MockKnowledgeBaseService: KnowledgeBaseServicing {
|
||||||
|
var detailResult: Result<KnowledgeBase, Error> = .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
|
@MainActor
|
||||||
final class MaterialDetailViewModelTests: XCTestCase {
|
final class MaterialDetailViewModelTests: XCTestCase {
|
||||||
|
|
||||||
var vm: MaterialDetailViewModel!
|
var vm: MaterialDetailViewModel!
|
||||||
|
var mockKB: MockKnowledgeBaseService!
|
||||||
|
var mockItem: MockKnowledgeItemService!
|
||||||
|
var mockQuiz: MockQuizService!
|
||||||
|
var mockReading: MockReadingProgressService!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.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() {
|
override func tearDown() {
|
||||||
@ -18,128 +60,107 @@ final class MaterialDetailViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Initial state
|
// MARK: - Initial state
|
||||||
|
|
||||||
func test_initial_isLoading_is_true() {
|
func test_initial_isLoading_is_true() { XCTAssertTrue(vm.isLoading) }
|
||||||
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_initial_aiStatus_is_processing() {
|
func test_kbId_and_matId_set() {
|
||||||
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() {
|
|
||||||
XCTAssertEqual(vm.knowledgeBaseId, "kb-test-001")
|
XCTAssertEqual(vm.knowledgeBaseId, "kb-test-001")
|
||||||
}
|
|
||||||
|
|
||||||
func test_materialId_is_set() {
|
|
||||||
XCTAssertEqual(vm.materialId, "mat-001")
|
XCTAssertEqual(vm.materialId, "mat-001")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_without_materialId_defaults_to_nil() {
|
// MARK: - load() items / quizzes
|
||||||
let vm2 = MaterialDetailViewModel(knowledgeBaseId: "kb-002")
|
|
||||||
XCTAssertNil(vm2.materialId)
|
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
|
// MARK: - statusLabel
|
||||||
|
|
||||||
func test_statusLabel_completed() {
|
func test_statusLabel() {
|
||||||
let view = MaterialDetailView(
|
let view = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 0, filePath: "")
|
||||||
knowledgeBaseId: "kb-001", fileName: "test.md",
|
|
||||||
fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md"
|
|
||||||
)
|
|
||||||
XCTAssertEqual(view.statusLabel("completed"), "阅读完成")
|
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"), "阅读中")
|
XCTAssertEqual(view.statusLabel("in_progress"), "阅读中")
|
||||||
}
|
XCTAssertEqual(view.statusLabel("other"), "已开始")
|
||||||
|
|
||||||
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(nil), "已开始")
|
XCTAssertEqual(view.statusLabel(nil), "已开始")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - formatSeconds
|
// MARK: - formatSeconds
|
||||||
|
|
||||||
func test_formatSeconds_less_than_60() {
|
func test_formatSeconds() {
|
||||||
let view = MaterialDetailView(
|
let view = MaterialDetailView(knowledgeBaseId: "k", fileName: "f", fileType: .markdown, fileSize: 0, filePath: "")
|
||||||
knowledgeBaseId: "kb-001", fileName: "test.md",
|
|
||||||
fileType: .markdown, fileSize: 1024, filePath: "/tmp/test.md"
|
|
||||||
)
|
|
||||||
XCTAssertEqual(view.formatSeconds(30), "30 秒")
|
XCTAssertEqual(view.formatSeconds(30), "30 秒")
|
||||||
XCTAssertEqual(view.formatSeconds(0), "0 秒")
|
XCTAssertEqual(view.formatSeconds(120), "2 分钟")
|
||||||
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(3660), "1 小时 1 分钟")
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user