eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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.
8final 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