eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2@testable import LChatCore
3
4// πŸ€– The Swift half of the app-schema drift gate: the same event vectors and
5// the same semantic checks the JS engine runs in test/schema-engine.test.mjs.
6// The vector fixtures (Fixtures/schema-vectors-*.json) are byte-identical
7// copies of test/event-vectors/*.json β€” the node test enforces that, so both
8// engines provably interpret the same schemas the same way.
9@MainActor
10final class SchemaEngineTests: XCTestCase {
11
12 let NOW: Double = 1_700_000_100_000
13
14 func schema(_ name: String) throws -> AppJSON {
15 try XCTUnwrap(AppSchemaRegistry.schema(for: name), "bundled schema \(name).json")
16 }
17
18 func fixture(_ name: String) throws -> AppJSON {
19 let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json",
20 subdirectory: "Fixtures"))
21 return try XCTUnwrap(AppJSON.parse(try Data(contentsOf: url)))
22 }
23
24 // MARK: the shared event vectors
25
26 func runVectors(schema: AppJSON, vectors: AppJSON) {
27 for v in vectors["vectors"].arrayValue {
28 let events: [AppJSON]
29 if case .array(let seq) = v["event_sequence"] { events = seq }
30 else if case .object = v["event"] { events = [v["event"]] }
31 else { continue } // doc-only vectors (grace constants)
32 var state = v["initial_state"].isNull ? SchemaEngine.initialState(schema) : v["initial_state"]
33 for e in events { state = SchemaEngine.fold(schema, state: state, event: e) }
34 let expected = v["expected_final_state"].isNull ? v["expected_state_update"] : v["expected_final_state"]
35 XCTAssertEqual(state, expected, "vector \(v["name"].stringValue ?? "?")")
36 }
37 }
38
39 func testTodoVectors() throws {
40 runVectors(schema: try schema("todo"), vectors: try fixture("schema-vectors-todo"))
41 }
42
43 func testRemindVectors() throws {
44 runVectors(schema: try schema("remind"), vectors: try fixture("schema-vectors-remind"))
45 }
46
47 // MARK: fold semantics
48
49 func testFoldGuardsAndIdempotence() throws {
50 let todo = try schema("todo")
51 var state = SchemaEngine.initialState(todo)
52 // A textless add fails `require` and no-ops.
53 state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("")]))
54 XCTAssertEqual(state["items"].arrayValue.count, 0)
55 // Unknown types no-op.
56 state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("mystery.event")]))
57 XCTAssertEqual(state, SchemaEngine.initialState(todo))
58 // Adds dedupe by id (history refolds must not duplicate).
59 let add = AppJSON.object(["type": .string("todo.add"), "id": .string("a"), "text": .string("x"), "name": .string("Ana")])
60 state = SchemaEngine.fold(todo, state: state, event: add)
61 state = SchemaEngine.fold(todo, state: state, event: add)
62 XCTAssertEqual(state["items"].arrayValue.count, 1)
63 // Missing transport fields coerce to declared zero values.
64 let item = state["items"].arrayValue[0]
65 XCTAssertEqual(item["done"], .bool(false))
66 XCTAssertEqual(item["doneAt"], .number(0))
67 // Update on a missing id no-ops (a fire racing a dismiss).
68 let before = state
69 state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("todo.done"), "id": .string("ghost"), "ts": .number(NOW)]))
70 XCTAssertEqual(state, before)
71 }
72
73 func testRemindSortIsStableSoonestFirst() throws {
74 let remind = try schema("remind")
75 var state = SchemaEngine.initialState(remind)
76 for (id, when) in [("late", NOW + 9000), ("soon", NOW + 1000), ("mid", NOW + 5000)] {
77 state = SchemaEngine.fold(remind, state: state, event: .object([
78 "type": .string("remind.add"), "id": .string(id), "text": .string(id),
79 "when": .number(when), "ts": .number(1), "sid": .string("s"),
80 ]))
81 }
82 XCTAssertEqual(state["items"].arrayValue.map { $0["id"].stringValue }, ["soon", "mid", "late"])
83 }
84
85 // MARK: partition + list model
86
87 func todoState(_ todo: AppJSON) -> AppJSON {
88 var s = SchemaEngine.initialState(todo)
89 for (id, text) in [("a", "one"), ("b", "two"), ("c", "three")] {
90 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string(id), "text": .string(text), "name": .string("Ana")]))
91 }
92 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("b"), "ts": .number(NOW - 60000)]))
93 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("c"), "ts": .number(NOW - 200)]))
94 return s
95 }
96
97 func testTodoListModel() throws {
98 let todo = try schema("todo")
99 let model = SchemaEngine.listModel(todo, state: todoState(todo), now: NOW)
100 XCTAssertEqual(model.summary, "1 left")
101 let open = try XCTUnwrap(model.sections.first { $0.id == "open" })
102 // Newest-first, in place: c (grace, still visible) then a.
103 XCTAssertEqual(open.rows.map(\.id), ["c", "a"])
104 XCTAssertEqual(open.rows.map(\.grace), [true, false])
105 let done = try XCTUnwrap(model.sections.first { $0.id == "completed" })
106 XCTAssertEqual(done.title, "Completed (1)")
107 XCTAssertTrue(done.collapsed)
108 XCTAssertEqual(done.rows.map(\.id), ["b"])
109 }
110
111 func testTodoReorderMoveOpAndSurface() throws {
112 let todo = try schema("todo")
113 var s = SchemaEngine.initialState(todo)
114 for (id, text) in [("a", "one"), ("b", "two"), ("c", "three")] {
115 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string(id), "text": .string(text), "name": .string("Ana")]))
116 }
117 // Visual (newest-first): c, b, a. Drag c below b = c now follows a in
118 // the array β†’ array a, c, b β†’ visual b, c, a.
119 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("c"), "after": .string("a")]))
120 XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["a", "c", "b"])
121 let m = SchemaEngine.listModel(todo, state: s, now: NOW)
122 let open = try XCTUnwrap(m.sections.first { $0.id == "open" })
123 XCTAssertEqual(open.rows.map(\.id), ["b", "c", "a"])
124 // The open section is reorderable (newest-first); Completed is not.
125 XCTAssertEqual(open.reorder, "move")
126 XCTAssertTrue(open.descending)
127 XCTAssertEqual(m.sections.first { $0.id == "completed" }?.reorder, "")
128 // Searching suppresses reorder β€” a filtered list has no meaningful drop.
129 let filtered = SchemaEngine.listModel(todo, state: s, now: NOW, query: "t")
130 XCTAssertEqual(filtered.sections.first { $0.id == "open" }?.reorder, "")
131 // Anchor to the start, and an unknown anchor no-ops.
132 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("b"), "after": .string("")]))
133 XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["b", "a", "c"])
134 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("a"), "after": .string("gone")]))
135 XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["b", "a", "c"])
136 }
137
138 func testTodoSummaryCases() throws {
139 let todo = try schema("todo")
140 XCTAssertEqual(SchemaEngine.listModel(todo, state: SchemaEngine.initialState(todo), now: NOW).summary,
141 "No to-dos yet β€” add one above")
142 var s = SchemaEngine.fold(todo, state: SchemaEngine.initialState(todo),
143 event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("x")]))
144 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("a"), "ts": .number(NOW - 60000)]))
145 XCTAssertEqual(SchemaEngine.listModel(todo, state: s, now: NOW).summary, "all done πŸŽ‰")
146 }
147
148 func testSearchFiltersRows() throws {
149 let todo = try schema("todo")
150 var s = SchemaEngine.initialState(todo)
151 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("buy milk")]))
152 s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string("b"), "text": .string("call mum")]))
153 let model = SchemaEngine.listModel(todo, state: s, now: NOW, query: "MILK")
154 XCTAssertEqual(model.sections[0].rows.map(\.id), ["a"])
155 }
156
157 func testRemindSectionsAndEmpty() throws {
158 let remind = try schema("remind")
159 var s = SchemaEngine.initialState(remind)
160 for (id, when) in [("late", NOW + 7_200_000), ("soon", NOW + 60_000), ("old", NOW - 90_000)] {
161 s = SchemaEngine.fold(remind, state: s, event: .object([
162 "type": .string("remind.add"), "id": .string(id), "text": .string(id),
163 "when": .number(when), "ts": .number(1), "sid": .string("s"),
164 ]))
165 }
166 s = SchemaEngine.fold(remind, state: s, event: .object(["type": .string("remind.fire"), "id": .string("old"), "ts": .number(NOW - 60_000)]))
167 let model = SchemaEngine.listModel(remind, state: s, now: NOW)
168 XCTAssertEqual(model.sections.first { $0.id == "upcoming" }?.rows.map(\.id), ["soon", "late"])
169 XCTAssertEqual(model.sections.first { $0.id == "previous" }?.title, "Previous reminders (1)")
170 XCTAssertEqual(model.empty, "")
171 var s2 = SchemaEngine.fold(remind, state: s, event: .object(["type": .string("remind.done"), "id": .string("late")]))
172 s2 = SchemaEngine.fold(remind, state: s2, event: .object(["type": .string("remind.done"), "id": .string("soon")]))
173 XCTAssertEqual(SchemaEngine.listModel(remind, state: s2, now: NOW).empty, "No upcoming reminders.")
174 XCTAssertEqual(SchemaEngine.listModel(remind, state: SchemaEngine.initialState(remind), now: NOW).empty,
175 "No reminders yet. Add one above, or type /remind in any chat.")
176 }
177
178 func testRemindRowModel() throws {
179 let remind = try schema("remind")
180 let upcoming = AppJSON.object([
181 "id": .string("a"), "text": .string("call"), "when": .number(NOW + 2 * 86_400_000),
182 "ts": .number(1), "sid": .string("s"), "fired": .bool(false), "firedAt": .number(0),
183 ])
184 var row = SchemaEngine.rowModel(remind, item: upcoming, now: NOW)
185 XCTAssertEqual(row.title, "call")
186 XCTAssertEqual(row.subtitle, "in 2d")
187 XCTAssertFalse(row.accent)
188 XCTAssertFalse(row.note.isEmpty) // absolute time shown when >1d out
189 var due = upcoming.objectValue!
190 due["when"] = .number(NOW - 120_000)
191 row = SchemaEngine.rowModel(remind, item: .object(due), now: NOW)
192 XCTAssertEqual(row.subtitle, "2m ago")
193 XCTAssertTrue(row.accent)
194 XCTAssertEqual(row.note, "")
195 var fired = upcoming.objectValue!
196 fired["fired"] = .bool(true)
197 fired["firedAt"] = .number(NOW - 300_000)
198 row = SchemaEngine.rowModel(remind, item: .object(fired), now: NOW)
199 XCTAssertEqual(row.subtitle, "Reminded Β· 5m ago")
200 XCTAssertTrue(row.strike)
201 }
202
203 func testTodoDetailModel() throws {
204 let todo = try schema("todo")
205 let item = AppJSON.object(["id": .string("a"), "text": .string("buy milk"),
206 "done": .bool(false), "doneAt": .number(0), "by": .string("Ana")])
207 let model = try XCTUnwrap(SchemaEngine.detailModel(todo, item: item, now: NOW))
208 XCTAssertEqual(model.title, "buy milk")
209 guard case .definitions(let defs)? = model.sections.first else { return XCTFail("definitions first") }
210 XCTAssertEqual(defs.map(\.term), ["Status", "Added by"])
211 XCTAssertEqual(defs.map(\.value), ["To do", "Ana"])
212 guard case .buttons(let buttons)? = model.sections.last else { return XCTFail("buttons last") }
213 XCTAssertEqual(buttons.map(\.label), ["Mark done", "Remove"])
214 }
215
216 func testRelTime() {
217 XCTAssertEqual(SchemaEngine.relTime(when: NOW + 20_000, now: NOW), "now")
218 XCTAssertEqual(SchemaEngine.relTime(when: NOW + 45 * 60_000, now: NOW), "in 45m")
219 XCTAssertEqual(SchemaEngine.relTime(when: NOW - 3 * 3_600_000, now: NOW), "3h ago")
220 XCTAssertEqual(SchemaEngine.relTime(when: NOW + 3 * 86_400_000, now: NOW), "in 3d")
221 }
222
223 // MARK: actions
224
225 final class FakeIO: SchemaActionIO {
226 var emitted: [AppJSON] = []
227 var scheduled: [(event: AppJSON, atMs: Double)] = []
228 var unscheduled: [String] = []
229 var hints: [String] = []
230 var scheduleResult: String? = "sid-9"
231 var parseResult: AppJSON? = .object(["text": .string("call mum"), "when": .number(1_700_007_300_000)])
232
233 func emit(_ event: AppJSON) async -> Bool {
234 emitted.append(event)
235 return true
236 }
237 func schedule(event: AppJSON, atMs: Double) async -> String? {
238 scheduled.append((event, atMs))
239 return scheduleResult
240 }
241 func unschedule(_ sid: String) async { unscheduled.append(sid) }
242 func newId() -> String { "id-1" }
243 func parse(kind: String, text: String) -> AppJSON? { kind == "reminder" ? parseResult : nil }
244 func hint(_ text: String) { hints.append(text) }
245 }
246
247 func testTodoActions() async throws {
248 let todo = try schema("todo")
249 let io = FakeIO()
250 var ran = await SchemaEngine.runAction(todo, name: "add", input: "buy milk", io: io)
251 XCTAssertTrue(ran)
252 XCTAssertEqual(io.emitted, [.object(["type": .string("todo.add"), "id": .string("id-1"), "text": .string("buy milk")])])
253 ran = await SchemaEngine.runAction(todo, name: "add", input: "", io: io)
254 XCTAssertFalse(ran) // empty input fails `require`
255 io.emitted = []
256 let open = AppJSON.object(["id": .string("a"), "done": .bool(false)])
257 let done = AppJSON.object(["id": .string("a"), "done": .bool(true)])
258 _ = await SchemaEngine.runAction(todo, name: "toggle", item: open, io: io)
259 _ = await SchemaEngine.runAction(todo, name: "toggle", item: done, io: io)
260 _ = await SchemaEngine.runAction(todo, name: "remove", item: open, io: io)
261 XCTAssertEqual(io.emitted.map { $0["type"].stringValue }, ["todo.done", "todo.reopen", "todo.remove"])
262 }
263
264 func testRemindAddSharesIdAndRecordsSid() async throws {
265 let remind = try schema("remind")
266 let io = FakeIO()
267 let ran = await SchemaEngine.runAction(remind, name: "add", input: "call mum in 2h", io: io)
268 XCTAssertTrue(ran)
269 XCTAssertEqual(io.scheduled.count, 1)
270 let fire = io.scheduled[0].event
271 XCTAssertEqual(fire["type"].stringValue, "remind.fire")
272 XCTAssertEqual(fire["id"].stringValue, "id-1")
273 XCTAssertEqual(fire["notice"]["body"].stringValue, "call mum")
274 XCTAssertEqual(io.scheduled[0].atMs, 1_700_007_300_000)
275 XCTAssertEqual(io.emitted.count, 1)
276 let add = io.emitted[0]
277 XCTAssertEqual(add["id"].stringValue, "id-1") // the fire and the add share the minted id
278 XCTAssertEqual(add["sid"].stringValue, "sid-9")
279 XCTAssertEqual(add["when"], .number(1_700_007_300_000))
280 }
281
282 func testRemindAddParseFailureHints() async throws {
283 let remind = try schema("remind")
284 let io = FakeIO()
285 io.parseResult = nil
286 let ran = await SchemaEngine.runAction(remind, name: "add", input: "call mum", io: io)
287 XCTAssertFalse(ran)
288 XCTAssertTrue(io.emitted.isEmpty)
289 XCTAssertTrue(io.scheduled.isEmpty)
290 XCTAssertEqual(io.hints.count, 1)
291 }
292
293 func testRemindAddOfflineSchedule() async throws {
294 let remind = try schema("remind")
295 let io = FakeIO()
296 io.scheduleResult = nil
297 _ = await SchemaEngine.runAction(remind, name: "add", input: "call mum in 2h", io: io)
298 XCTAssertEqual(io.emitted[0]["sid"], .string(""))
299 }
300
301 func testRemindDismiss() async throws {
302 let remind = try schema("remind")
303 var io = FakeIO()
304 let unfired = AppJSON.object(["id": .string("a"), "fired": .bool(false), "sid": .string("sch-1")])
305 _ = await SchemaEngine.runAction(remind, name: "dismiss", item: unfired, io: io)
306 XCTAssertEqual(io.unscheduled, ["sch-1"])
307 XCTAssertEqual(io.emitted.map { $0["type"].stringValue }, ["remind.done"])
308 io = FakeIO()
309 let fired = AppJSON.object(["id": .string("a"), "fired": .bool(true), "sid": .string("sch-1")])
310 _ = await SchemaEngine.runAction(remind, name: "dismiss", item: fired, io: io)
311 XCTAssertTrue(io.unscheduled.isEmpty)
312 io = FakeIO()
313 let sidless = AppJSON.object(["id": .string("a"), "fired": .bool(false), "sid": .string("")])
314 _ = await SchemaEngine.runAction(remind, name: "dismiss", item: sidless, io: io)
315 XCTAssertTrue(io.unscheduled.isEmpty)
316 }
317
318 func testTickerFiresSidlessDueOnce() throws {
319 let remind = try schema("remind")
320 var s = SchemaEngine.initialState(remind)
321 for (id, when, sid) in [("a", NOW - 1000, ""), ("b", NOW - 1000, "sch"), ("c", NOW + 1000, "")] {
322 s = SchemaEngine.fold(remind, state: s, event: .object([
323 "type": .string("remind.add"), "id": .string(id), "text": .string(id),
324 "when": .number(when), "ts": .number(1), "sid": .string(sid),
325 ]))
326 }
327 var fired = Set<String>()
328 let due = SchemaEngine.tickerRules(remind, state: s, now: NOW, fired: &fired)
329 XCTAssertEqual(due.count, 1)
330 XCTAssertEqual(due[0]["id"].stringValue, "a")
331 XCTAssertEqual(due[0]["notice"]["body"].stringValue, "a")
332 XCTAssertTrue(SchemaEngine.tickerRules(remind, state: s, now: NOW, fired: &fired).isEmpty)
333 }
334}
335