import XCTest @testable import AIStudyApp @MainActor private final class SpyReadingRuntimeAdapter: ReadingRuntimeAdapter { var fingerprint = "fingerprint-1" private var nextSequence: UInt64 = 1 func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String { "spy-session" } func pauseSession(_ sessionId: String) throws {} func resumeSession(_ sessionId: String) throws {} func closeSession(_ sessionId: String, timestampMs: Int64) throws {} func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialOpened) } func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialClosed) } func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, position: position) } func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, checkpoint: checkpoint) } func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, position: position) } func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, checkpoint: checkpoint) } func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 { makeEvent(sessionId: sessionId, materialId: materialId, eventType: .markedAsRead) } func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] { [] } func ackEvents(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) } func markFailed(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) } func reloadStaleEvents() -> UInt32 { 0 } func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { 0 } func runtimeComputeFileFingerprint(filePath: String) throws -> String { fingerprint } func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String { "\(sourceFingerprint)|\(rendererKind)|\(parserVersion)|\(schemaVersion)" } private func makeEvent( sessionId: String, materialId: String, eventType: ReadingEventTypeV2, position: ReadingPosition? = nil, checkpoint: ReadingCheckpoint? = nil ) -> ReadingEventV2 { defer { nextSequence += 1 } return ReadingEventV2( eventId: "evt-\(nextSequence)", clientSessionId: sessionId, materialId: materialId, eventType: eventType, position: position, checkpoint: checkpoint, activeSecondsDelta: 0, timestampMs: 1_700_000_000_000, sequence: nextSequence ) } } @MainActor final class SessionManagerTests: XCTestCase { var manager: ReadingRuntimeSessionManager! var noopAdapter: NoopReadingRuntimeAdapter! var spyAdapter: SpyReadingRuntimeAdapter! override func setUp() { super.setUp() manager = ReadingRuntimeSessionManager.shared noopAdapter = NoopReadingRuntimeAdapter() spyAdapter = SpyReadingRuntimeAdapter() 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 } } // MARK: - Restore checkpoint compatibility func test_isCompatibleRestoreCheckpoint_matches_active_representation() throws { manager.adapter = spyAdapter let context = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "restore-match", materialType: .markdown, filePath: "", fileName: "doc.md", fileType: "markdown" ) try manager.openMaterial(context) let checkpoint = ReadingCheckpoint( schemaVersion: 2, materialId: "restore-match", representationFingerprint: "restore-match|block|block-parser-v1|2", rendererKind: .block, capturedAtMs: 1, deviceId: "device-1", deviceSequence: 1, position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2) ) XCTAssertTrue(manager.isCompatibleRestoreCheckpoint(checkpoint)) } func test_isCompatibleRestoreCheckpoint_rejects_mismatched_fingerprint() throws { manager.adapter = spyAdapter let context = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "restore-mismatch", materialType: .markdown, filePath: "", fileName: "doc.md", fileType: "markdown" ) try manager.openMaterial(context) let checkpoint = ReadingCheckpoint( schemaVersion: 2, materialId: "restore-mismatch", representationFingerprint: "stale-fingerprint", rendererKind: .block, capturedAtMs: 1, deviceId: "device-1", deviceSequence: 1, position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2) ) XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint)) } func test_isCompatibleRestoreCheckpoint_rejects_mismatched_renderer_kind() throws { manager.adapter = spyAdapter let context = ReadingMaterialContext( readingTargetType: .knowledgeSource, materialId: "restore-renderer", materialType: .markdown, filePath: "", fileName: "doc.md", fileType: "markdown" ) try manager.openMaterial(context) let checkpoint = ReadingCheckpoint( schemaVersion: 2, materialId: "restore-renderer", representationFingerprint: "restore-renderer|block|block-parser-v1|2", rendererKind: .pdf, capturedAtMs: 1, deviceId: "device-1", deviceSequence: 1, position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0) ) XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint)) } }