test: add StudyHomeViewModel unit tests with DI (IOS-INFO-037)
- Add 6 service protocols (SessionServicing, ReviewServicing, KBListServicing, QuizListServicing, ActivityHomeServicing, ContinueLearningServicing) - ViewModel init supports DI with defaults to .shared - 16 tests: initial state, loadAll stats, all 5 priority levels (continueSession→todaysReview→selfTest→startLearning→empty), banner, weekly stats from summary/streak - Priority P1 continueSession tested with mock API + temp file Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3a8266ece2
commit
9ec6fdc3d5
@ -1,6 +1,41 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
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
|
// MARK: - Action states
|
||||||
|
|
||||||
enum MainAction: Equatable {
|
enum MainAction: Equatable {
|
||||||
@ -36,16 +71,39 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
private var firstQuizId: String?
|
private var firstQuizId: String?
|
||||||
private var firstKbId: 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 {
|
func loadAll() async {
|
||||||
loadingState = .loading
|
loadingState = .loading
|
||||||
banner = nil
|
banner = nil
|
||||||
|
|
||||||
async let sessions = try? LearningSessionService.shared.list()
|
async let sessions = try? sessionService.list()
|
||||||
async let dueCards = try? ReviewService.shared.dueCards()
|
async let dueCards = try? reviewService.dueCards()
|
||||||
async let quizzes = try? QuizService.shared.list()
|
async let quizzes = try? quizService.listAll()
|
||||||
async let knowledgeBases = try? KnowledgeBaseService.shared.list()
|
async let knowledgeBases = try? kbService.list()
|
||||||
async let summary = try? ActivityService.shared.summary()
|
async let summary = try? activityService.summary()
|
||||||
async let streak = try? ActivityService.shared.streak()
|
async let streak = try? activityService.streak()
|
||||||
|
|
||||||
let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, 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 quizzesResult = qRes ?? []
|
||||||
let kbResult = kRes ?? []
|
let kbResult = kRes ?? []
|
||||||
|
|
||||||
// Store first IDs for navigation
|
|
||||||
firstQuizId = quizzesResult.first?.id
|
firstQuizId = quizzesResult.first?.id
|
||||||
firstKbId = kbResult.first?.id
|
firstKbId = kbResult.first?.id
|
||||||
|
|
||||||
// Evaluate main action
|
|
||||||
mainAction = await evaluatePriority(
|
mainAction = await evaluatePriority(
|
||||||
sessions: sessionsResult,
|
sessions: sessionsResult,
|
||||||
dueCards: dueCardsResult,
|
dueCards: dueCardsResult,
|
||||||
@ -66,7 +122,6 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
knowledgeBases: kbResult
|
knowledgeBases: kbResult
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stats
|
|
||||||
todayReviewCount = dueCardsResult.count
|
todayReviewCount = dueCardsResult.count
|
||||||
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
|
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
|
||||||
availableQuizCount = quizzesResult.count
|
availableQuizCount = quizzesResult.count
|
||||||
@ -75,23 +130,17 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
weeklyActiveDays = sumRes?.activeDays ?? 0
|
weeklyActiveDays = sumRes?.activeDays ?? 0
|
||||||
streakDays = stRes?.currentStreak ?? 0
|
streakDays = stRes?.currentStreak ?? 0
|
||||||
|
|
||||||
// Banner
|
banner = computeBanner(sessions: sessionsResult, quizzes: quizzesResult, dueCards: dueCardsResult)
|
||||||
banner = computeBanner(
|
|
||||||
sessions: sessionsResult,
|
|
||||||
quizzes: quizzesResult,
|
|
||||||
dueCards: dueCardsResult
|
|
||||||
)
|
|
||||||
|
|
||||||
loadingState = .loaded
|
loadingState = .loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
async let sessions = try? LearningSessionService.shared.list()
|
async let sessions = try? sessionService.list()
|
||||||
async let dueCards = try? ReviewService.shared.dueCards()
|
async let dueCards = try? reviewService.dueCards()
|
||||||
async let quizzes = try? QuizService.shared.list()
|
async let quizzes = try? quizService.listAll()
|
||||||
async let knowledgeBases = try? KnowledgeBaseService.shared.list()
|
async let knowledgeBases = try? kbService.list()
|
||||||
async let summary = try? ActivityService.shared.summary()
|
async let summary = try? activityService.summary()
|
||||||
async let streak = try? ActivityService.shared.streak()
|
async let streak = try? activityService.streak()
|
||||||
|
|
||||||
let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, 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
|
firstQuizId = quizzesResult.first?.id
|
||||||
firstKbId = kbResult.first?.id
|
firstKbId = kbResult.first?.id
|
||||||
|
|
||||||
mainAction = await evaluatePriority(
|
mainAction = await evaluatePriority(sessions: sessionsResult, dueCards: dueCardsResult, quizzes: quizzesResult, knowledgeBases: kbResult)
|
||||||
sessions: sessionsResult,
|
|
||||||
dueCards: dueCardsResult,
|
|
||||||
quizzes: quizzesResult,
|
|
||||||
knowledgeBases: kbResult
|
|
||||||
)
|
|
||||||
|
|
||||||
todayReviewCount = dueCardsResult.count
|
todayReviewCount = dueCardsResult.count
|
||||||
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
|
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
|
||||||
@ -127,8 +171,7 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
quizzes: [Quiz],
|
quizzes: [Quiz],
|
||||||
knowledgeBases: [KnowledgeBase]
|
knowledgeBases: [KnowledgeBase]
|
||||||
) async -> MainAction {
|
) async -> MainAction {
|
||||||
// Priority 1: API continue-learning (most recent material with progress)
|
if let cl = try? await continueService.getContinueLearning(),
|
||||||
if let cl = try? await ReadingAPIService.shared.getContinueLearning(),
|
|
||||||
let materialId = cl.materialId, !materialId.isEmpty,
|
let materialId = cl.materialId, !materialId.isEmpty,
|
||||||
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
|
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
|
||||||
let kbTitle = cl.title ?? knowledgeBases.first?.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)
|
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
|
if let unfinished = sessions
|
||||||
.filter({ $0.status != nil && $0.status != "completed" })
|
.filter({ $0.status != nil && $0.status != "completed" })
|
||||||
.sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") })
|
.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)
|
return .continueSession(materialId: unfinished.materialId ?? "", filePath: "", materialType: mt, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Today's review
|
|
||||||
if !dueCards.isEmpty {
|
if !dueCards.isEmpty {
|
||||||
return .todaysReview(count: dueCards.count, estimatedMinutes: max(1, dueCards.count))
|
return .todaysReview(count: dueCards.count, estimatedMinutes: max(1, dueCards.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Self-test
|
|
||||||
if let quizId = firstQuizId, !quizzes.isEmpty {
|
if let quizId = firstQuizId, !quizzes.isEmpty {
|
||||||
return .selfTest(quizId: quizId, count: quizzes.count)
|
return .selfTest(quizId: quizId, count: quizzes.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: Start learning
|
|
||||||
if let kbId = firstKbId, !knowledgeBases.isEmpty {
|
if let kbId = firstKbId, !knowledgeBases.isEmpty {
|
||||||
return .startLearning(knowledgeBaseId: kbId, kbCount: knowledgeBases.count)
|
return .startLearning(knowledgeBaseId: kbId, kbCount: knowledgeBases.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 5: Empty
|
|
||||||
return .empty
|
return .empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Banner
|
// MARK: - Banner
|
||||||
|
|
||||||
private func computeBanner(
|
private func computeBanner(sessions: [LearningSession], quizzes: [Quiz], dueCards: [ReviewCard]) -> String? {
|
||||||
sessions: [LearningSession],
|
if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) { return nil }
|
||||||
quizzes: [Quiz],
|
|
||||||
dueCards: [ReviewCard]
|
|
||||||
) -> String? {
|
|
||||||
// No banner needed if nothing is happening
|
|
||||||
if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +219,6 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
guard let date = formatter.date(from: iso) else {
|
guard let date = formatter.date(from: iso) else {
|
||||||
// Try without fractional seconds
|
|
||||||
formatter.formatOptions = [.withInternetDateTime]
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
guard let date2 = formatter.date(from: iso) else { return "最近" }
|
guard let date2 = formatter.date(from: iso) else { return "最近" }
|
||||||
return relativeTime(from: date2)
|
return relativeTime(from: date2)
|
||||||
|
|||||||
@ -1,13 +1,74 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import AIStudyApp
|
@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<T>(_ value: T?) throws -> T {
|
||||||
|
guard let v = value else { throw NSError(domain: "test", code: -1) }
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class StudyHomeViewModelTests: XCTestCase {
|
final class StudyHomeViewModelTests: XCTestCase {
|
||||||
|
|
||||||
var vm: StudyHomeViewModel!
|
var vm: StudyHomeViewModel!
|
||||||
|
var mockSession: MockSessionService!
|
||||||
|
var mockReview: MockReviewService!
|
||||||
|
var mockQuiz: MockQuizListService!
|
||||||
|
var mockKB: MockKBListService!
|
||||||
|
var mockActivity: MockActivityHomeService!
|
||||||
|
var mockContinue: MockContinueService!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.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() {
|
override func tearDown() {
|
||||||
@ -15,46 +76,115 @@ final class StudyHomeViewModelTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInitialState_hasFiveTasks() {
|
// MARK: - Initial state
|
||||||
XCTAssertEqual(vm.tasks.count, 5)
|
|
||||||
|
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() {
|
func test_loadAll_sets_availableQuizCount() async {
|
||||||
XCTAssertEqual(vm.doneCount, 2)
|
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() {
|
func test_loadAll_sets_loadingState_loaded() async {
|
||||||
XCTAssertEqual(vm.progress, 0.4, accuracy: 0.01)
|
await vm.loadAll()
|
||||||
|
XCTAssertEqual(vm.loadingState, .loaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToggleTask_changesDoneCount() {
|
// MARK: - evaluatePriority: P1 Continue Session
|
||||||
let task = vm.tasks.first(where: { !$0.d })!
|
|
||||||
vm.toggleTask(task)
|
func test_priority_continueSession_when_api_returns_progress() async {
|
||||||
XCTAssertEqual(vm.doneCount, 3)
|
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() {
|
// MARK: - evaluatePriority: P2 TodaysReview
|
||||||
let task = vm.tasks.first(where: { $0.d })!
|
|
||||||
vm.toggleTask(task)
|
func test_priority_todaysReview_when_cards_present() async {
|
||||||
XCTAssertEqual(vm.doneCount, 1)
|
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() {
|
// MARK: - evaluatePriority: P3 SelfTest
|
||||||
XCTAssertEqual(vm.doneMinutes, 25) // 10 + 15
|
|
||||||
|
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() {
|
// MARK: - evaluatePriority: P4 StartLearning
|
||||||
XCTAssertEqual(vm.remainingMinutes, 30) // 8 + 12 + 10
|
|
||||||
|
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() {
|
// MARK: - evaluatePriority: P5 Empty
|
||||||
let task = vm.tasks.first(where: { !$0.d })!
|
|
||||||
vm.toggleTask(task)
|
func test_priority_empty_when_nothing_available() async {
|
||||||
XCTAssertEqual(vm.progress, 0.6, accuracy: 0.01)
|
await vm.loadAll()
|
||||||
|
XCTAssertEqual(vm.mainAction, .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWeekActivity_hasSevenDays() {
|
// MARK: - Banner
|
||||||
XCTAssertEqual(vm.weekActivity.count, 7)
|
|
||||||
XCTAssertEqual(vm.dayLabels.count, 7)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user