| 1 | import Foundation |
| 2 | import XCTest |
| 3 | @testable import LChatCore |
| 4 | |
| 5 | // π€ The shared chat event vectors (test/event-vectors/chat.json), read from |
| 6 | // the SOURCE TREE β the same file the web suite (test/event-vectors.test.mjs) |
| 7 | // runs, so there is no copied fixture to drift (the AppDefaultCommandTests |
| 8 | // pattern). Strict by design: a vector or key the contract names must exist, |
| 9 | // so a test asking for it FAILS rather than skipping when the JSON lags. |
| 10 | enum ChatVectors { |
| 11 | static var url: URL { |
| 12 | URL(fileURLWithPath: #filePath) |
| 13 | .deletingLastPathComponent() // Tests/LChatCoreTests |
| 14 | .deletingLastPathComponent() // Tests |
| 15 | .deletingLastPathComponent() // LChatCore |
| 16 | .deletingLastPathComponent() // ios |
| 17 | .deletingLastPathComponent() // repo |
| 18 | .appending(path: "test/event-vectors/chat.json") |
| 19 | } |
| 20 | |
| 21 | /// Every entry under "vectors". |
| 22 | static func all() throws -> [[String: Any]] { |
| 23 | let data = try Data(contentsOf: url) |
| 24 | let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any], |
| 25 | "chat.json is not a JSON object") |
| 26 | return try XCTUnwrap(root["vectors"] as? [[String: Any]], "chat.json has no vectors array") |
| 27 | } |
| 28 | |
| 29 | /// One named vector β a hard failure when it's missing. |
| 30 | static func vector(_ name: String) throws -> [String: Any] { |
| 31 | try XCTUnwrap(try all().first { $0["name"] as? String == name }, |
| 32 | "chat.json has no vector named \(name)") |
| 33 | } |
| 34 | |
| 35 | /// A vector's decrypted_payload decoded the way the app decodes a blob |
| 36 | /// (JSONDecoder β MessagePayload). |
| 37 | static func payload(_ v: [String: Any]) throws -> MessagePayload { |
| 38 | let obj = try XCTUnwrap(v["decrypted_payload"], "vector has no decrypted_payload") |
| 39 | let data = try JSONSerialization.data(withJSONObject: obj) |
| 40 | return try JSONDecoder().decode(MessagePayload.self, from: data) |
| 41 | } |
| 42 | |
| 43 | /// The server-side frame metadata β id + created_at (seconds). |
| 44 | static func serverMessage(_ v: [String: Any]) throws -> (id: Int64, at: Int64) { |
| 45 | let meta = try XCTUnwrap(v["server_message"] as? [String: Any], "vector has no server_message") |
| 46 | let id = try XCTUnwrap((meta["id"] as? NSNumber)?.int64Value, "server_message.id") |
| 47 | let at = try XCTUnwrap((meta["created_at"] as? NSNumber)?.int64Value, "server_message.created_at") |
| 48 | return (id, at) |
| 49 | } |
| 50 | |
| 51 | /// A vector's whole message_sequence, folded the way a client folds it: |
| 52 | /// ordinary payloads become feed messages, "threadconvert" payloads become |
| 53 | /// claims in the conversion map β keyed on the CONVERT ROW's own server id, |
| 54 | /// which is the tie-break between two people converting overlapping chains. |
| 55 | /// `skipConverts` folds the same sequence the way a client too old to know |
| 56 | /// the payload type does: it drops the event and the chain stays inline. |
| 57 | static func sequence(_ v: [String: Any], skipConverts: Bool = false) throws |
| 58 | -> (messages: [ChatFeedModel.Message], converts: [Int64: ChatFeedModel.ThreadConvert]) { |
| 59 | let steps = try XCTUnwrap(v["message_sequence"] as? [[String: Any]], |
| 60 | "vector has no message_sequence") |
| 61 | var list: [ChatFeedModel.Message] = [] |
| 62 | var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| 63 | for step in steps { |
| 64 | let p = try payload(step) |
| 65 | if p.isThreadConvertEvent { |
| 66 | guard !skipConverts else { continue } |
| 67 | let meta = try serverMessage(step) |
| 68 | ChatFeedModel.claimConverts(&claims, root: p.root ?? 0, ids: p.ids ?? [], at: meta.id) |
| 69 | continue |
| 70 | } |
| 71 | list.append(try message(step)) |
| 72 | } |
| 73 | // Server order, however the steps were delivered β the model sorts the |
| 74 | // same way, so an out-of-order vector folds to the same list. |
| 75 | list.sort { $0.id < $1.id } |
| 76 | return (list, claims) |
| 77 | } |
| 78 | |
| 79 | /// A vector folded into the feed's Message the way ChatFeedModel.fold does |
| 80 | /// (reply = payload.replyTo; raw = the payload). `mine` is false β the |
| 81 | /// vectors are everyone else's messages. |
| 82 | static func message(_ v: [String: Any]) throws -> ChatFeedModel.Message { |
| 83 | let p = try payload(v) |
| 84 | let meta = try serverMessage(v) |
| 85 | return ChatFeedModel.Message(id: meta.id, name: p.name ?? "", text: p.text ?? "", |
| 86 | mine: false, at: meta.at, action: p.action ?? false, |
| 87 | edited: false, attachments: p.attachments ?? [], |
| 88 | reply: p.replyTo, raw: p) |
| 89 | } |
| 90 | } |
| 91 | |