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>
137 lines
4.9 KiB
Swift
137 lines
4.9 KiB
Swift
import XCTest
|
|
@testable import AIStudyApp
|
|
|
|
final class ReadingEventUploadItemTests: XCTestCase {
|
|
|
|
// MARK: - Codable roundtrip
|
|
|
|
func test_codable_roundtrip_without_position() throws {
|
|
let item = ReadingEventUploadItem(
|
|
eventId: "evt-001",
|
|
clientSessionId: "sess-001",
|
|
materialId: "mat-001",
|
|
eventType: "material_opened",
|
|
position: nil,
|
|
activeSecondsDelta: 0,
|
|
clientTimestampMs: 1_718_150_400_000,
|
|
sequence: 1,
|
|
readingTargetType: "knowledge_source",
|
|
knowledgeBaseId: "kb-001",
|
|
platform: "ios",
|
|
appVersion: "1.0.0",
|
|
clientTimezoneOffsetMinutes: 480
|
|
)
|
|
let data = try JSONEncoder().encode(item)
|
|
let decoded = try JSONDecoder().decode(ReadingEventUploadItem.self, from: data)
|
|
|
|
XCTAssertEqual(decoded.eventId, "evt-001")
|
|
XCTAssertEqual(decoded.materialId, "mat-001")
|
|
XCTAssertEqual(decoded.eventType, "material_opened")
|
|
XCTAssertEqual(decoded.readingTargetType, "knowledge_source")
|
|
XCTAssertEqual(decoded.knowledgeBaseId, "kb-001")
|
|
XCTAssertEqual(decoded.platform, "ios")
|
|
XCTAssertEqual(decoded.appVersion, "1.0.0")
|
|
XCTAssertEqual(decoded.clientTimezoneOffsetMinutes, 480)
|
|
}
|
|
|
|
func test_codable_roundtrip_nil_knowledge_base_id() throws {
|
|
let item = ReadingEventUploadItem(
|
|
eventId: "evt-002",
|
|
clientSessionId: "sess-002",
|
|
materialId: "mat-002",
|
|
eventType: "heartbeat",
|
|
position: nil,
|
|
activeSecondsDelta: 30,
|
|
clientTimestampMs: 1_718_150_500_000,
|
|
sequence: 2,
|
|
readingTargetType: "temporary_file",
|
|
knowledgeBaseId: nil,
|
|
platform: "ios",
|
|
appVersion: "1.0.0",
|
|
clientTimezoneOffsetMinutes: 480
|
|
)
|
|
let data = try JSONEncoder().encode(item)
|
|
let decoded = try JSONDecoder().decode(ReadingEventUploadItem.self, from: data)
|
|
|
|
XCTAssertNil(decoded.knowledgeBaseId)
|
|
}
|
|
|
|
func test_json_uses_camel_case_keys() throws {
|
|
let item = ReadingEventUploadItem(
|
|
eventId: "evt-003",
|
|
clientSessionId: "sess-003",
|
|
materialId: "mat-003",
|
|
eventType: "position_changed",
|
|
position: nil,
|
|
activeSecondsDelta: 15,
|
|
clientTimestampMs: 1_718_150_600_000,
|
|
sequence: 3,
|
|
readingTargetType: "knowledge_source",
|
|
knowledgeBaseId: nil,
|
|
platform: "ios",
|
|
appVersion: "1.0.0",
|
|
clientTimezoneOffsetMinutes: 480
|
|
)
|
|
let data = try JSONEncoder().encode(item)
|
|
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
|
|
|
XCTAssertNotNil(json["eventId"])
|
|
XCTAssertNotNil(json["clientSessionId"])
|
|
XCTAssertNotNil(json["materialId"])
|
|
XCTAssertNotNil(json["eventType"])
|
|
XCTAssertNotNil(json["clientTimestampMs"])
|
|
XCTAssertNotNil(json["readingTargetType"])
|
|
XCTAssertNotNil(json["clientTimezoneOffsetMinutes"])
|
|
XCTAssertNil(json["event_id"]) // snake_case should not appear
|
|
}
|
|
|
|
// MARK: - Batch request/response
|
|
|
|
func test_batch_request_encodes_events_array() throws {
|
|
let events = [
|
|
ReadingEventUploadItem(
|
|
eventId: "evt-001",
|
|
clientSessionId: "sess-001",
|
|
materialId: "mat-001",
|
|
eventType: "material_opened",
|
|
position: nil,
|
|
activeSecondsDelta: 0,
|
|
clientTimestampMs: 1_718_150_400_000,
|
|
sequence: 1,
|
|
readingTargetType: "knowledge_source",
|
|
knowledgeBaseId: nil,
|
|
platform: "ios",
|
|
appVersion: "1.0.0",
|
|
clientTimezoneOffsetMinutes: 480
|
|
)
|
|
]
|
|
let batch = ReadingEventBatchRequest(events: events)
|
|
let data = try JSONEncoder().encode(batch)
|
|
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
|
|
|
let eventsArray = json["events"] as! [[String: Any]]
|
|
XCTAssertEqual(eventsArray.count, 1)
|
|
XCTAssertEqual(eventsArray[0]["eventId"] as? String, "evt-001")
|
|
}
|
|
|
|
func test_batch_response_decodes() throws {
|
|
let json = """
|
|
{
|
|
"processed": 3,
|
|
"duplicate": 1,
|
|
"failed": 0,
|
|
"warnings": [
|
|
{"eventId": "evt-dup", "code": "DUPLICATE_EVENT", "message": "already processed"}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let resp = try JSONDecoder().decode(ReadingEventBatchResponse.self, from: json)
|
|
XCTAssertEqual(resp.processed, 3)
|
|
XCTAssertEqual(resp.duplicate, 1)
|
|
XCTAssertEqual(resp.failed, 0)
|
|
XCTAssertEqual(resp.warnings?.count, 1)
|
|
XCTAssertEqual(resp.warnings?.first?.code, "DUPLICATE_EVENT")
|
|
}
|
|
}
|