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>
102 lines
3.6 KiB
Swift
102 lines
3.6 KiB
Swift
import XCTest
|
|
@testable import AIStudyApp
|
|
|
|
final class ReadingEventCollectorTests: XCTestCase {
|
|
|
|
// MARK: - V1 Collector Lifecycle
|
|
|
|
func test_open_sets_activeMaterialId() {
|
|
let collector = ReadingEventCollector.shared
|
|
let previous = collector.lastPosition
|
|
|
|
collector.open(materialId: "mat-test-001")
|
|
|
|
// open should not crash, and should push a materialOpened event
|
|
// (verified by no crash; event count tested via export)
|
|
let events = collector.exportPending()
|
|
XCTAssertGreaterThanOrEqual(events.count, 1, "open should produce at least 1 event")
|
|
XCTAssertEqual(events.first?.materialId, "mat-test-001")
|
|
|
|
// Cleanup
|
|
collector.clearExported(events.count)
|
|
|
|
// Reset position to previous
|
|
if previous == nil {
|
|
// Nothing to restore
|
|
}
|
|
}
|
|
|
|
func test_close_returns_active_seconds() {
|
|
let collector = ReadingEventCollector.shared
|
|
|
|
collector.open(materialId: "mat-test-002")
|
|
// Simulate some reading time by opening and immediately closing
|
|
let seconds = collector.close(materialId: "mat-test-002")
|
|
|
|
XCTAssertGreaterThanOrEqual(seconds, 0, "close should return active seconds >= 0")
|
|
}
|
|
|
|
func test_close_on_unopened_material_does_not_crash() {
|
|
let collector = ReadingEventCollector.shared
|
|
|
|
// Closing a material that wasn't opened should not crash
|
|
let seconds = collector.close(materialId: "mat-never-opened")
|
|
XCTAssertEqual(seconds, 0, "close on unopened should return 0")
|
|
}
|
|
|
|
func test_mark_as_read_pushes_event() {
|
|
let collector = ReadingEventCollector.shared
|
|
collector.open(materialId: "mat-test-003")
|
|
|
|
let preCount = collector.exportPending().count
|
|
collector.clearExported(preCount)
|
|
|
|
collector.markAsRead(materialId: "mat-test-003")
|
|
let postEvents = collector.exportPending()
|
|
let hasMarkedAsRead = postEvents.contains { event in
|
|
if case .markedAsRead = event { return true }
|
|
return false
|
|
}
|
|
XCTAssertTrue(hasMarkedAsRead || !postEvents.isEmpty, "markAsRead should push an event")
|
|
|
|
// Cleanup
|
|
collector.clearExported(postEvents.count)
|
|
_ = collector.close(materialId: "mat-test-003")
|
|
let remaining = collector.exportPending()
|
|
collector.clearExported(remaining.count)
|
|
}
|
|
|
|
func test_duplicate_open_is_safe() {
|
|
let collector = ReadingEventCollector.shared
|
|
|
|
collector.open(materialId: "mat-test-004")
|
|
collector.open(materialId: "mat-test-004") // duplicate
|
|
|
|
// Should not crash, events should still be exportable
|
|
let events = collector.exportPending()
|
|
XCTAssertFalse(events.isEmpty)
|
|
|
|
collector.clearExported(events.count)
|
|
_ = collector.close(materialId: "mat-test-004")
|
|
let remaining = collector.exportPending()
|
|
collector.clearExported(remaining.count)
|
|
}
|
|
|
|
func test_lastPosition_updated() {
|
|
let collector = ReadingEventCollector.shared
|
|
collector.open(materialId: "mat-test-005")
|
|
|
|
let pos = ReadingPosition.markdown(blockId: "block-1", scrollProgress: 0.5)
|
|
collector.updatePosition(materialId: "mat-test-005", position: pos)
|
|
|
|
XCTAssertNotNil(collector.lastPosition, "lastPosition should be updated")
|
|
|
|
// Cleanup
|
|
let events = collector.exportPending()
|
|
collector.clearExported(events.count)
|
|
_ = collector.close(materialId: "mat-test-005")
|
|
let remaining = collector.exportPending()
|
|
collector.clearExported(remaining.count)
|
|
}
|
|
}
|