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>
108 lines
3.7 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|