| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 Cross-platform event contract tests for the group-apps registry — |
| 5 | // the native fold (GroupApps.fold, fed by ChatFeedModel) must match the web's |
| 6 | // groupapps reducers exactly. Pinned by test/event-vectors/groupapps.json; |
| 7 | // the web side runs the same vectors in test/event-vectors.test.mjs. |
| 8 | final class GroupAppsVectorTests: XCTestCase { |
| 9 | private func fold(seed: [GroupApps.Item] = [], upTo: Int64 = 0, |
| 10 | _ events: [Int64: GroupApps.Event]) -> [GroupApps.Item] { |
| 11 | GroupApps.fold(seed: seed, upTo: upTo, events: events) |
| 12 | } |
| 13 | |
| 14 | // groupapps_add: a member adds a trusted app to the group. |
| 15 | func testAdd() { |
| 16 | let out = fold([1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000)]) |
| 17 | XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)]) |
| 18 | } |
| 19 | |
| 20 | // groupapps_add_duplicate: idempotent by app name — the original add's |
| 21 | // attribution is kept, a duplicate never rewrites by/ts. |
| 22 | func testDuplicateAddKeepsOriginalAttribution() { |
| 23 | let out = fold([ |
| 24 | 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000), |
| 25 | 2: .init(type: "groupapps.add", app: "todo", by: "Bob", ts: 1_700_000_005_000), |
| 26 | ]) |
| 27 | XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)]) |
| 28 | } |
| 29 | |
| 30 | // groupapps_remove: removing folds the app out again. |
| 31 | func testRemove() { |
| 32 | let out = fold([ |
| 33 | 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000), |
| 34 | 2: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 1_700_000_010_000), |
| 35 | ]) |
| 36 | XCTAssertEqual(out, []) |
| 37 | } |
| 38 | |
| 39 | // groupapps_add_malformed: an add with no app name is a no-op, never a crash. |
| 40 | func testMalformedAddIsNoop() { |
| 41 | let out = fold([1: .init(type: "groupapps.add", app: "", by: "Mallory", ts: 0)]) |
| 42 | XCTAssertEqual(out, []) |
| 43 | } |
| 44 | |
| 45 | // groupapps_remove_missing: removing an app that isn't added is a no-op. |
| 46 | func testRemoveMissingIsNoop() { |
| 47 | let out = fold([1: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 0)]) |
| 48 | XCTAssertEqual(out, []) |
| 49 | } |
| 50 | |
| 51 | // Fold order is server-message-id order, whatever order pages arrived in. |
| 52 | func testFoldsInMessageIDOrder() { |
| 53 | let out = fold([ |
| 54 | 3: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 3), |
| 55 | 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1), |
| 56 | 2: .init(type: "groupapps.add", app: "bookmarks", by: "Bob", ts: 2), |
| 57 | ]) |
| 58 | XCTAssertEqual(out, [GroupApps.Item(id: "bookmarks", by: "Bob", ts: 2)]) |
| 59 | } |
| 60 | |
| 61 | // Events at or below the snapshot horizon are already inside the seed — |
| 62 | // refolding them must not double-apply (apps.js foldInto's `mid <= upTo`). |
| 63 | func testSnapshotHorizonSkipsSeededEvents() { |
| 64 | let seed = [GroupApps.Item(id: "todo", by: "Ana", ts: 1)] |
| 65 | let out = fold(seed: seed, upTo: 10, [ |
| 66 | // A stale remove BELOW the horizon must not fold (the snapshot already |
| 67 | // reflects whatever it did). |
| 68 | 9: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 9), |
| 69 | // A fresh add above it does. |
| 70 | 11: .init(type: "groupapps.add", app: "vault", by: "Bob", ts: 11), |
| 71 | ]) |
| 72 | XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1), |
| 73 | GroupApps.Item(id: "vault", by: "Bob", ts: 11)]) |
| 74 | } |
| 75 | |
| 76 | // Unknown app names fold into state (log convergence) but are filtered at |
| 77 | // read time — the web's groupAppsFor rule, now the registry's known(). |
| 78 | @MainActor |
| 79 | func testUnknownAppsStayInert() { |
| 80 | let out = fold([1: .init(type: "groupapps.add", app: "evil-app", by: "Mallory", ts: 0)]) |
| 81 | XCTAssertEqual(out.count, 1) |
| 82 | XCTAssertEqual(AppRegistry.shared(origin: "https://vector-tests.example").known(out), []) |
| 83 | } |
| 84 | |
| 85 | // The chat projection — apps.js groupAppsApp.groupFeedMessage, word for word |
| 86 | // (the group fold prefixes the actor's name onto action lines). |
| 87 | func testChatProjection() { |
| 88 | let add = AppChatProjection.project(.init(type: "groupapps.add", app: "todo")) |
| 89 | XCTAssertEqual(add?.text, "added To-do to this chat") |
| 90 | XCTAssertEqual(add?.action, true) |
| 91 | let remove = AppChatProjection.project(.init(type: "groupapps.remove", app: "todo")) |
| 92 | XCTAssertEqual(remove?.text, "removed To-do from this chat") |
| 93 | XCTAssertEqual(remove?.action, true) |
| 94 | let anonymous = AppChatProjection.project(.init(type: "groupapps.add")) |
| 95 | XCTAssertEqual(anonymous?.text, "added an app to this chat") |
| 96 | } |
| 97 | |
| 98 | // Group lines say where the item went — todo.js/remind.js chatMessage's |
| 99 | // opts.group branch, mirrored word for word (the group vector in |
| 100 | // test/event-vectors/todo.json pins the web side). |
| 101 | func testGroupWordingSaysWhere() { |
| 102 | let add = AppChatProjection.AppEvent(type: "todo.add", text: "buy milk") |
| 103 | XCTAssertEqual(AppChatProjection.project(add)?.text, "added “buy milk”") |
| 104 | XCTAssertEqual(AppChatProjection.project(add, group: true)?.text, |
| 105 | "added “buy milk” to the to-dos") |
| 106 | var remind = AppChatProjection.AppEvent(type: "remind.add", text: "standup") |
| 107 | remind.when = 1_700_000_000_000 |
| 108 | let personal = AppChatProjection.project(remind)?.text ?? "" |
| 109 | let grouped = AppChatProjection.project(remind, group: true)?.text ?? "" |
| 110 | XCTAssertTrue(personal.hasPrefix("set a reminder: “standup” — "), personal) |
| 111 | XCTAssertTrue(grouped.hasPrefix("set a reminder: “standup” for "), grouped) |
| 112 | } |
| 113 | |
| 114 | // A shared Reminders' fire speaks to everyone — remind.js chatMessage's |
| 115 | // opts.group branch ("you asked me" would be wrong when another member set |
| 116 | // it), threaded natively as project(_:group:). |
| 117 | func testRemindFireWordingInGroups() { |
| 118 | let e = AppChatProjection.AppEvent(type: "remind.fire", notice: .init(body: "standup")) |
| 119 | XCTAssertEqual(AppChatProjection.project(e)?.text, |
| 120 | "You asked me to remind you: “standup”") |
| 121 | XCTAssertEqual(AppChatProjection.project(e, group: true)?.text, |
| 122 | "Reminder: “standup”") |
| 123 | } |
| 124 | |
| 125 | // The slot-"apps" snapshot decode: {states: {groupapps: {apps}}, upTo} — |
| 126 | // tolerant of other apps' states sharing the blob. |
| 127 | func testSnapshotParse() { |
| 128 | let json = """ |
| 129 | {"states":{"todo":{"items":[]},"groupapps":{"apps":[{"id":"todo","by":"Ana","ts":1700000000000}]}},"upTo":42} |
| 130 | """.data(using: .utf8)! |
| 131 | let parsed = GroupApps.parseSnapshot(json) |
| 132 | XCTAssertEqual(parsed?.upTo, 42) |
| 133 | XCTAssertEqual(parsed?.seed, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)]) |
| 134 | } |
| 135 | |
| 136 | func testSnapshotParseWithoutGroupApps() { |
| 137 | let json = #"{"states":{"todo":{"items":[]}},"upTo":7}"#.data(using: .utf8)! |
| 138 | let parsed = GroupApps.parseSnapshot(json) |
| 139 | XCTAssertEqual(parsed?.upTo, 7) |
| 140 | XCTAssertEqual(parsed?.seed, []) |
| 141 | } |
| 142 | |
| 143 | // Snapshots written since the registry unification (apps.js installsApp) |
| 144 | // hold the state under "installs" — and it wins over a stale legacy key |
| 145 | // left in the same blob. |
| 146 | func testSnapshotParseUnifiedInstallsKey() { |
| 147 | let json = """ |
| 148 | {"states":{"installs":{"apps":[{"id":"vault","by":"Bob","ts":9}]},"groupapps":{"apps":[{"id":"todo","by":"Ana","ts":1}]}},"upTo":9} |
| 149 | """.data(using: .utf8)! |
| 150 | let parsed = GroupApps.parseSnapshot(json) |
| 151 | XCTAssertEqual(parsed?.upTo, 9) |
| 152 | XCTAssertEqual(parsed?.seed, [GroupApps.Item(id: "vault", by: "Bob", ts: 9)]) |
| 153 | } |
| 154 | } |
| 155 | |