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>
136 lines
4.0 KiB
Swift
136 lines
4.0 KiB
Swift
import XCTest
|
|
@testable import AIStudyApp
|
|
|
|
final class ReadingAPIClientTests: XCTestCase {
|
|
|
|
// MARK: - Batch response decoding
|
|
|
|
func test_batch_response_decodes_all_fields() throws {
|
|
let json = """
|
|
{
|
|
"processed": 5,
|
|
"duplicate": 2,
|
|
"failed": 1,
|
|
"warnings": [
|
|
{"eventId": "evt-001", "code": "DUPLICATE_EVENT", "message": "already processed"}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let resp = try JSONDecoder().decode(ReadingEventBatchResponse.self, from: json)
|
|
XCTAssertEqual(resp.processed, 5)
|
|
XCTAssertEqual(resp.duplicate, 2)
|
|
XCTAssertEqual(resp.failed, 1)
|
|
XCTAssertEqual(resp.warnings?.count, 1)
|
|
}
|
|
|
|
func test_batch_response_no_warnings() throws {
|
|
let json = """
|
|
{
|
|
"processed": 3,
|
|
"duplicate": 0,
|
|
"failed": 0
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let resp = try JSONDecoder().decode(ReadingEventBatchResponse.self, from: json)
|
|
XCTAssertEqual(resp.processed, 3)
|
|
XCTAssertNil(resp.warnings)
|
|
}
|
|
|
|
func test_batch_response_duplicate_not_counted_as_failed() throws {
|
|
let json = """
|
|
{
|
|
"processed": 1,
|
|
"duplicate": 5,
|
|
"failed": 0
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let resp = try JSONDecoder().decode(ReadingEventBatchResponse.self, from: json)
|
|
// Duplicate should not count as failed
|
|
XCTAssertEqual(resp.failed, 0)
|
|
XCTAssertEqual(resp.duplicate, 5)
|
|
}
|
|
|
|
// MARK: - API service existence
|
|
|
|
func test_api_service_shared_exists() {
|
|
let api = ReadingAPIService.shared
|
|
XCTAssertNotNil(api)
|
|
}
|
|
|
|
// MARK: - Other response DTOs
|
|
|
|
func test_material_reading_progress_decodes() throws {
|
|
let json = """
|
|
{
|
|
"status": "reading",
|
|
"lastProgress": 0.65,
|
|
"totalActiveSeconds": 1200,
|
|
"isMarkedRead": false,
|
|
"firstOpenedAt": "2026-01-01T00:00:00Z",
|
|
"lastReadAt": "2026-06-12T00:00:00Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let dto = try JSONDecoder().decode(MaterialReadingProgressDTO.self, from: json)
|
|
XCTAssertEqual(dto.status, "reading")
|
|
XCTAssertEqual(dto.totalActiveSeconds, 1200)
|
|
XCTAssertFalse(dto.isMarkedRead)
|
|
}
|
|
|
|
func test_continue_learning_response_decodes() throws {
|
|
let json = """
|
|
{
|
|
"type": "knowledge_source",
|
|
"materialId": "mat-001",
|
|
"title": "Rust Book",
|
|
"lastProgress": 0.4,
|
|
"totalActiveSeconds": 600,
|
|
"lastReadAt": "2026-06-12T00:00:00Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let dto = try JSONDecoder().decode(ContinueLearningResponse.self, from: json)
|
|
XCTAssertEqual(dto.type, "knowledge_source")
|
|
XCTAssertEqual(dto.materialId, "mat-001")
|
|
}
|
|
|
|
func test_continue_learning_response_none_type() throws {
|
|
let json = """
|
|
{
|
|
"type": "none",
|
|
"materialId": null,
|
|
"title": null,
|
|
"lastProgress": null,
|
|
"totalActiveSeconds": null,
|
|
"lastReadAt": null
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let dto = try JSONDecoder().decode(ContinueLearningResponse.self, from: json)
|
|
XCTAssertEqual(dto.type, "none")
|
|
XCTAssertNil(dto.materialId)
|
|
}
|
|
|
|
func test_learning_summary_decodes() throws {
|
|
let json = """
|
|
{
|
|
"todaySeconds": 300,
|
|
"weekSeconds": 2100,
|
|
"totalSeconds": 50000,
|
|
"activeDays": 45,
|
|
"sessionsCount": 120,
|
|
"materialsReadCount": 15,
|
|
"markedReadCount": 8,
|
|
"dailyAverageSeconds": 1100
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let dto = try JSONDecoder().decode(LearningSummaryResponse.self, from: json)
|
|
XCTAssertEqual(dto.todaySeconds, 300)
|
|
XCTAssertEqual(dto.materialsReadCount, 15)
|
|
}
|
|
}
|