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) } }