Models: ReadingMaterialContext, ReadingEventUploadItem, AppContext Runtime: ReadingRuntimeAdapter, SessionManager, RecoveryService Reader: MaterialReaderView lifecycle, Heartbeat Timer, Position Adapter Export: EventMapper, UploadQueue, export→queue→ack flow Tests: 16 test files covering models, runtime, lifecycle, upload Docs: ios-learning-info-architecture.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
126 lines
3.6 KiB
Swift
126 lines
3.6 KiB
Swift
import XCTest
|
|
@testable import AIStudyApp
|
|
|
|
@MainActor
|
|
final class SessionManagerTests: XCTestCase {
|
|
|
|
var manager: ReadingRuntimeSessionManager!
|
|
var noopAdapter: NoopReadingRuntimeAdapter!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
manager = ReadingRuntimeSessionManager.shared
|
|
noopAdapter = NoopReadingRuntimeAdapter()
|
|
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
|
|
}
|
|
}
|
|
}
|