import XCTest @testable import AIStudyApp @MainActor final class HeartbeatTimerTests: XCTestCase { var manager: ReadingRuntimeSessionManager! override func setUp() { super.setUp() manager = ReadingRuntimeSessionManager.shared manager.adapter = NoopReadingRuntimeAdapter() manager.closeMaterial() } override func tearDown() { manager.closeMaterial() super.tearDown() } // MARK: - Timer lifecycle func test_openMaterial_starts_heartbeat() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-test-001" ) // startSession succeeds with noop, pushOpened fails // After open attempt, if state was briefly active, heartbeat was started // Then pushOpened fails and state goes to .failed, stopping heartbeat try? manager.openMaterial(ctx) // After failure, state should not be .active // Heartbeat should be stopped (cleanup in catch block) XCTAssertNotEqual(manager.state, .active, "Noop adapter push throws → state should not remain active") } func test_closeMaterial_stops_heartbeat() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-test-002" ) try? manager.openMaterial(ctx) manager.closeMaterial() // Close always stops heartbeat — verify we reach here XCTAssertTrue(true) } func test_pause_stops_heartbeat() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-test-003" ) try? manager.openMaterial(ctx) manager.pause() // Should not crash XCTAssertTrue(true) } func test_resume_restarts_heartbeat() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-test-004" ) try? manager.openMaterial(ctx) if manager.state == .active { manager.pause() XCTAssertEqual(manager.state, .paused) manager.resume() XCTAssertEqual(manager.state, .active) } // No crash = pass XCTAssertTrue(true) } func test_multiple_open_close_cycles_do_not_leak_timers() { for i in 0..<5 { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-leak-\(i)" ) try? manager.openMaterial(ctx) manager.closeMaterial() } // After 5 cycles, should not have leaked timers XCTAssertNotEqual(manager.state, .active) } func test_close_from_idle_does_not_crash() { manager.closeMaterial() // already idle/closed manager.closeMaterial() // double XCTAssertTrue(true) } // MARK: - Heartbeat error handling func test_heartbeat_error_does_not_crash_session() { // Noop adapter throws on pushHeartbeat // The SessionManager uses try? so errors are silently ignored // This test verifies the session doesn't crash let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "hb-err-001" ) try? manager.openMaterial(ctx) // If state is .failed, that's expected with noop // Heartbeat error should not cause additional issues manager.closeMaterial() XCTAssertTrue(true) } }