190 lines
7.1 KiB
Swift
190 lines
7.1 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!
|
|
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))
|
|
}
|
|
}
|