285 lines
10 KiB
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))
|
|
}
|
|
}
|