| 1 | import 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 |
| 10 | final 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 | |