ios-projects/AIStudyApp/AIStudyAppTests/SessionManagerTests.swift

285 lines
10 KiB
Swift

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