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! fileprivate var spyAdapter: SpyReadingRuntimeAdapter! override func setUp() { super.setUp() manager = ReadingRuntimeSessionManager.shared noopAdapter = NoopReadingRuntimeAdapter() spyAdapter = SpyReadingRuntimeAdapter() manager.adapter = noopAdapter manager.closeMaterial() } override func tearDown() { manager.closeMaterial() super.tearDown() } func test_initial_state_is_idle() { XCTAssertTrue(manager.state == .idle || manager.state == .closed) } func test_markAsRead_guards_against_inactive() { manager.closeMaterial() manager.markAsRead() XCTAssertTrue(true) } 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", 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", rendererKind: .pdf, capturedAtMs: 1, deviceId: "device-1", deviceSequence: 1, position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0) ) XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint)) } }