ios-projects/AIStudyApp/AIStudyAppTests/UploadSchedulerTests.swift
wangdl 4ba747af46 feat: iOS learning info P0 complete (IOS-INFO-000~017, 038~039)
Models: ReadingMaterialContext, ReadingEventUploadItem, AppContext
Runtime: ReadingRuntimeAdapter, SessionManager, RecoveryService
Reader: MaterialReaderView lifecycle, Heartbeat Timer, Position Adapter
Export: EventMapper, UploadQueue, export→queue→ack flow
Tests: 16 test files covering models, runtime, lifecycle, upload
Docs: ios-learning-info-architecture.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:53:36 +08:00

108 lines
3.7 KiB
Swift

import XCTest
@testable import AIStudyApp
final class UploadSchedulerTests: XCTestCase {
let queue = ReadingEventUploadQueue.shared
override func setUp() {
super.setUp()
queue.clearAll()
}
override func tearDown() {
queue.clearAll()
super.tearDown()
}
// MARK: - Exponential backoff
func test_markRetry_sets_nextRetryAt_in_future() {
let item = makeItem(eventId: "evt-sched-001")
queue.enqueue([item], userId: "user-test")
// Need to get the real UUID for markRetry to work
let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test")
guard let first = pending.first else {
XCTFail("Expected at least 1 pending item")
return
}
queue.markRetry(ids: [first.id], errorCode: "TEST_ERROR")
// Item should be in .failed status with nextRetryAt set
// (Can't directly inspect internal state, but no crash = pass)
let stillPending = queue.fetchPendingBatch(limit: 10, userId: "user-test")
let found = stillPending.contains { $0.eventId == "evt-sched-001" }
XCTAssertFalse(found, "Failed item should not be in pending batch")
}
func test_exponential_backoff_increases_with_retries() {
let item = makeItem(eventId: "evt-backoff-001")
queue.enqueue([item], userId: "user-test")
let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test")
guard let first = pending.first else { return }
// First retry
queue.markRetry(ids: [first.id], errorCode: "ERR1")
// Second retry
queue.markRetry(ids: [first.id], errorCode: "ERR2")
// Third retry (maxRetryCount=3 should go to .dead)
queue.markRetry(ids: [first.id], errorCode: "ERR3")
// After 3 retries, item should be dead
let remaining = queue.fetchPendingBatch(limit: 10, userId: "user-test")
let found = remaining.contains { $0.eventId == "evt-backoff-001" }
XCTAssertFalse(found, "Max-retried item should be dead, not pending")
}
// MARK: - Dead item handling
func test_dead_items_not_returned_in_pending() {
let item = makeItem(eventId: "evt-dead-001")
queue.enqueue([item], userId: "user-test")
let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test")
guard let first = pending.first else { return }
// Force to dead via permanent failed
queue.markPermanentFailed(ids: [first.id], errorCode: "PERM_ERR")
let remaining = queue.fetchPendingBatch(limit: 10, userId: "user-test")
let found = remaining.contains { $0.eventId == "evt-dead-001" }
XCTAssertFalse(found, "Dead items should not appear in pending")
}
// MARK: - Pipeline existence
func test_pipeline_flush_empty_noop() async {
let pipeline = ReadingEventUploadPipeline.shared
await pipeline.flush()
XCTAssertTrue(true)
}
func test_pipeline_reloadOnLaunch_does_not_crash() {
let pipeline = ReadingEventUploadPipeline.shared
pipeline.reloadOnLaunch()
XCTAssertTrue(true)
}
// MARK: - Helper
private func makeItem(eventId: String) -> ReadingEventUploadItem {
ReadingEventUploadItem(
eventId: eventId,
clientSessionId: "sess-001",
materialId: "mat-001",
eventType: "heartbeat",
position: nil,
activeSecondsDelta: 15,
clientTimestampMs: 1_700_000_000_000,
sequence: 1,
readingTargetType: "knowledge_source",
knowledgeBaseId: nil,
platform: "ios",
appVersion: "1.0.0",
clientTimezoneOffsetMinutes: 480
)
}
}