import XCTest @testable import AIStudyApp @MainActor final class SessionManagerTests: XCTestCase { var manager: ReadingRuntimeSessionManager! var noopAdapter: NoopReadingRuntimeAdapter! override func setUp() { super.setUp() manager = ReadingRuntimeSessionManager.shared noopAdapter = NoopReadingRuntimeAdapter() manager.adapter = noopAdapter // Ensure clean state manager.closeMaterial() } override func tearDown() { manager.closeMaterial() super.tearDown() } // MARK: - State machine func test_initial_state_is_idle() { // After setup + closeMaterial, should be idle or closed XCTAssertTrue( manager.state == .idle || manager.state == .closed, "Expected idle or closed, got \(manager.state)" ) } func test_duplicate_open_throws_alreadyActive() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "test-dup" ) // Noop adapter throws on push* but startSession succeeds // So openMaterial will fail at pushOpened, state goes to .failed // Let's test with a context that won't throw on startSession do { try manager.openMaterial(ctx) } catch { // Expected with noop (pushOpened throws) } // Second open should throw XCTAssertThrowsError(try manager.openMaterial(ctx)) { error in XCTAssertTrue(error is SessionError) } } func test_close_is_idempotent() { // Close when already closed/idle should not crash manager.closeMaterial() manager.closeMaterial() manager.closeMaterial() XCTAssertTrue( manager.state == .idle || manager.state == .closed, "Multiple closes should not crash" ) } func test_pause_guards_against_non_active() { // Pause when idle should no-op manager.pause() XCTAssertTrue( manager.state == .idle || manager.state == .closed, "Pause from idle should no-op" ) } func test_resume_guards_against_non_paused() { // Resume when idle should no-op manager.resume() XCTAssertTrue( manager.state == .idle || manager.state == .closed, "Resume from idle should no-op" ) } // MARK: - Session ID func test_session_id_is_nil_when_closed() { manager.closeMaterial() XCTAssertNil(manager.activeSessionId, "Session ID should be nil when closed") } func test_lastPosition_is_nil_initially() { manager.closeMaterial() XCTAssertNil(manager.lastPosition, "lastPosition should be nil initially") } // MARK: - Mark as read guard func test_markAsRead_guards_against_inactive() { manager.closeMaterial() // Should not crash when called without active session manager.markAsRead() XCTAssertTrue(true, "markAsRead from idle should not crash") } // MARK: - State transitions func test_openMaterial_with_noop_adapter_sets_failed_state() { let ctx = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "test-fail" ) // Noop adapter throws on pushOpened, so state should go to .failed do { try manager.openMaterial(ctx) } catch { // Expected } // After failure, state should be .failed or reset if case .failed = manager.state { // Expected path } else { // Also acceptable: state was reset } } }