import XCTest @testable import LChatCore // ๐Ÿค– The Swift half of the app-schema drift gate: the same event vectors and // the same semantic checks the JS engine runs in test/schema-engine.test.mjs. // The vector fixtures (Fixtures/schema-vectors-*.json) are byte-identical // copies of test/event-vectors/*.json โ€” the node test enforces that, so both // engines provably interpret the same schemas the same way. @MainActor final class SchemaEngineTests: XCTestCase { let NOW: Double = 1_700_000_100_000 func schema(_ name: String) throws -> AppJSON { try XCTUnwrap(AppSchemaRegistry.schema(for: name), "bundled schema \(name).json") } func fixture(_ name: String) throws -> AppJSON { let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures")) return try XCTUnwrap(AppJSON.parse(try Data(contentsOf: url))) } // MARK: the shared event vectors func runVectors(schema: AppJSON, vectors: AppJSON) { for v in vectors["vectors"].arrayValue { let events: [AppJSON] if case .array(let seq) = v["event_sequence"] { events = seq } else if case .object = v["event"] { events = [v["event"]] } else { continue } // doc-only vectors (grace constants) var state = v["initial_state"].isNull ? SchemaEngine.initialState(schema) : v["initial_state"] for e in events { state = SchemaEngine.fold(schema, state: state, event: e) } let expected = v["expected_final_state"].isNull ? v["expected_state_update"] : v["expected_final_state"] XCTAssertEqual(state, expected, "vector \(v["name"].stringValue ?? "?")") } } func testTodoVectors() throws { runVectors(schema: try schema("todo"), vectors: try fixture("schema-vectors-todo")) } func testRemindVectors() throws { runVectors(schema: try schema("remind"), vectors: try fixture("schema-vectors-remind")) } // MARK: fold semantics func testFoldGuardsAndIdempotence() throws { let todo = try schema("todo") var state = SchemaEngine.initialState(todo) // A textless add fails `require` and no-ops. state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("")])) XCTAssertEqual(state["items"].arrayValue.count, 0) // Unknown types no-op. state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("mystery.event")])) XCTAssertEqual(state, SchemaEngine.initialState(todo)) // Adds dedupe by id (history refolds must not duplicate). let add = AppJSON.object(["type": .string("todo.add"), "id": .string("a"), "text": .string("x"), "name": .string("Ana")]) state = SchemaEngine.fold(todo, state: state, event: add) state = SchemaEngine.fold(todo, state: state, event: add) XCTAssertEqual(state["items"].arrayValue.count, 1) // Missing transport fields coerce to declared zero values. let item = state["items"].arrayValue[0] XCTAssertEqual(item["done"], .bool(false)) XCTAssertEqual(item["doneAt"], .number(0)) // Update on a missing id no-ops (a fire racing a dismiss). let before = state state = SchemaEngine.fold(todo, state: state, event: .object(["type": .string("todo.done"), "id": .string("ghost"), "ts": .number(NOW)])) XCTAssertEqual(state, before) } func testRemindSortIsStableSoonestFirst() throws { let remind = try schema("remind") var state = SchemaEngine.initialState(remind) for (id, when) in [("late", NOW + 9000), ("soon", NOW + 1000), ("mid", NOW + 5000)] { state = SchemaEngine.fold(remind, state: state, event: .object([ "type": .string("remind.add"), "id": .string(id), "text": .string(id), "when": .number(when), "ts": .number(1), "sid": .string("s"), ])) } XCTAssertEqual(state["items"].arrayValue.map { $0["id"].stringValue }, ["soon", "mid", "late"]) } // MARK: partition + list model func todoState(_ todo: AppJSON) -> AppJSON { var s = SchemaEngine.initialState(todo) for (id, text) in [("a", "one"), ("b", "two"), ("c", "three")] { s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string(id), "text": .string(text), "name": .string("Ana")])) } s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("b"), "ts": .number(NOW - 60000)])) s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("c"), "ts": .number(NOW - 200)])) return s } func testTodoListModel() throws { let todo = try schema("todo") let model = SchemaEngine.listModel(todo, state: todoState(todo), now: NOW) XCTAssertEqual(model.summary, "1 left") let open = try XCTUnwrap(model.sections.first { $0.id == "open" }) // Newest-first, in place: c (grace, still visible) then a. XCTAssertEqual(open.rows.map(\.id), ["c", "a"]) XCTAssertEqual(open.rows.map(\.grace), [true, false]) let done = try XCTUnwrap(model.sections.first { $0.id == "completed" }) XCTAssertEqual(done.title, "Completed (1)") XCTAssertTrue(done.collapsed) XCTAssertEqual(done.rows.map(\.id), ["b"]) } func testTodoReorderMoveOpAndSurface() throws { let todo = try schema("todo") var s = SchemaEngine.initialState(todo) for (id, text) in [("a", "one"), ("b", "two"), ("c", "three")] { s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string(id), "text": .string(text), "name": .string("Ana")])) } // Visual (newest-first): c, b, a. Drag c below b = c now follows a in // the array โ†’ array a, c, b โ†’ visual b, c, a. s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("c"), "after": .string("a")])) XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["a", "c", "b"]) let m = SchemaEngine.listModel(todo, state: s, now: NOW) let open = try XCTUnwrap(m.sections.first { $0.id == "open" }) XCTAssertEqual(open.rows.map(\.id), ["b", "c", "a"]) // The open section is reorderable (newest-first); Completed is not. XCTAssertEqual(open.reorder, "move") XCTAssertTrue(open.descending) XCTAssertEqual(m.sections.first { $0.id == "completed" }?.reorder, "") // Searching suppresses reorder โ€” a filtered list has no meaningful drop. let filtered = SchemaEngine.listModel(todo, state: s, now: NOW, query: "t") XCTAssertEqual(filtered.sections.first { $0.id == "open" }?.reorder, "") // Anchor to the start, and an unknown anchor no-ops. s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("b"), "after": .string("")])) XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["b", "a", "c"]) s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.move"), "id": .string("a"), "after": .string("gone")])) XCTAssertEqual(s["items"].arrayValue.map { $0["id"].stringValue }, ["b", "a", "c"]) } func testTodoSummaryCases() throws { let todo = try schema("todo") XCTAssertEqual(SchemaEngine.listModel(todo, state: SchemaEngine.initialState(todo), now: NOW).summary, "No to-dos yet โ€” add one above") var s = SchemaEngine.fold(todo, state: SchemaEngine.initialState(todo), event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("x")])) s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.done"), "id": .string("a"), "ts": .number(NOW - 60000)])) XCTAssertEqual(SchemaEngine.listModel(todo, state: s, now: NOW).summary, "all done ๐ŸŽ‰") } func testSearchFiltersRows() throws { let todo = try schema("todo") var s = SchemaEngine.initialState(todo) s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string("a"), "text": .string("buy milk")])) s = SchemaEngine.fold(todo, state: s, event: .object(["type": .string("todo.add"), "id": .string("b"), "text": .string("call mum")])) let model = SchemaEngine.listModel(todo, state: s, now: NOW, query: "MILK") XCTAssertEqual(model.sections[0].rows.map(\.id), ["a"]) } func testRemindSectionsAndEmpty() throws { let remind = try schema("remind") var s = SchemaEngine.initialState(remind) for (id, when) in [("late", NOW + 7_200_000), ("soon", NOW + 60_000), ("old", NOW - 90_000)] { s = SchemaEngine.fold(remind, state: s, event: .object([ "type": .string("remind.add"), "id": .string(id), "text": .string(id), "when": .number(when), "ts": .number(1), "sid": .string("s"), ])) } s = SchemaEngine.fold(remind, state: s, event: .object(["type": .string("remind.fire"), "id": .string("old"), "ts": .number(NOW - 60_000)])) let model = SchemaEngine.listModel(remind, state: s, now: NOW) XCTAssertEqual(model.sections.first { $0.id == "upcoming" }?.rows.map(\.id), ["soon", "late"]) XCTAssertEqual(model.sections.first { $0.id == "previous" }?.title, "Previous reminders (1)") XCTAssertEqual(model.empty, "") var s2 = SchemaEngine.fold(remind, state: s, event: .object(["type": .string("remind.done"), "id": .string("late")])) s2 = SchemaEngine.fold(remind, state: s2, event: .object(["type": .string("remind.done"), "id": .string("soon")])) XCTAssertEqual(SchemaEngine.listModel(remind, state: s2, now: NOW).empty, "No upcoming reminders.") XCTAssertEqual(SchemaEngine.listModel(remind, state: SchemaEngine.initialState(remind), now: NOW).empty, "No reminders yet. Add one above, or type /remind in any chat.") } func testRemindRowModel() throws { let remind = try schema("remind") let upcoming = AppJSON.object([ "id": .string("a"), "text": .string("call"), "when": .number(NOW + 2 * 86_400_000), "ts": .number(1), "sid": .string("s"), "fired": .bool(false), "firedAt": .number(0), ]) var row = SchemaEngine.rowModel(remind, item: upcoming, now: NOW) XCTAssertEqual(row.title, "call") XCTAssertEqual(row.subtitle, "in 2d") XCTAssertFalse(row.accent) XCTAssertFalse(row.note.isEmpty) // absolute time shown when >1d out var due = upcoming.objectValue! due["when"] = .number(NOW - 120_000) row = SchemaEngine.rowModel(remind, item: .object(due), now: NOW) XCTAssertEqual(row.subtitle, "2m ago") XCTAssertTrue(row.accent) XCTAssertEqual(row.note, "") var fired = upcoming.objectValue! fired["fired"] = .bool(true) fired["firedAt"] = .number(NOW - 300_000) row = SchemaEngine.rowModel(remind, item: .object(fired), now: NOW) XCTAssertEqual(row.subtitle, "Reminded ยท 5m ago") XCTAssertTrue(row.strike) } func testTodoDetailModel() throws { let todo = try schema("todo") let item = AppJSON.object(["id": .string("a"), "text": .string("buy milk"), "done": .bool(false), "doneAt": .number(0), "by": .string("Ana")]) let model = try XCTUnwrap(SchemaEngine.detailModel(todo, item: item, now: NOW)) XCTAssertEqual(model.title, "buy milk") guard case .definitions(let defs)? = model.sections.first else { return XCTFail("definitions first") } XCTAssertEqual(defs.map(\.term), ["Status", "Added by"]) XCTAssertEqual(defs.map(\.value), ["To do", "Ana"]) guard case .buttons(let buttons)? = model.sections.last else { return XCTFail("buttons last") } XCTAssertEqual(buttons.map(\.label), ["Mark done", "Remove"]) } func testRelTime() { XCTAssertEqual(SchemaEngine.relTime(when: NOW + 20_000, now: NOW), "now") XCTAssertEqual(SchemaEngine.relTime(when: NOW + 45 * 60_000, now: NOW), "in 45m") XCTAssertEqual(SchemaEngine.relTime(when: NOW - 3 * 3_600_000, now: NOW), "3h ago") XCTAssertEqual(SchemaEngine.relTime(when: NOW + 3 * 86_400_000, now: NOW), "in 3d") } // MARK: actions final class FakeIO: SchemaActionIO { var emitted: [AppJSON] = [] var scheduled: [(event: AppJSON, atMs: Double)] = [] var unscheduled: [String] = [] var hints: [String] = [] var scheduleResult: String? = "sid-9" var parseResult: AppJSON? = .object(["text": .string("call mum"), "when": .number(1_700_007_300_000)]) func emit(_ event: AppJSON) async -> Bool { emitted.append(event) return true } func schedule(event: AppJSON, atMs: Double) async -> String? { scheduled.append((event, atMs)) return scheduleResult } func unschedule(_ sid: String) async { unscheduled.append(sid) } func newId() -> String { "id-1" } func parse(kind: String, text: String) -> AppJSON? { kind == "reminder" ? parseResult : nil } func hint(_ text: String) { hints.append(text) } } func testTodoActions() async throws { let todo = try schema("todo") let io = FakeIO() var ran = await SchemaEngine.runAction(todo, name: "add", input: "buy milk", io: io) XCTAssertTrue(ran) XCTAssertEqual(io.emitted, [.object(["type": .string("todo.add"), "id": .string("id-1"), "text": .string("buy milk")])]) ran = await SchemaEngine.runAction(todo, name: "add", input: "", io: io) XCTAssertFalse(ran) // empty input fails `require` io.emitted = [] let open = AppJSON.object(["id": .string("a"), "done": .bool(false)]) let done = AppJSON.object(["id": .string("a"), "done": .bool(true)]) _ = await SchemaEngine.runAction(todo, name: "toggle", item: open, io: io) _ = await SchemaEngine.runAction(todo, name: "toggle", item: done, io: io) _ = await SchemaEngine.runAction(todo, name: "remove", item: open, io: io) XCTAssertEqual(io.emitted.map { $0["type"].stringValue }, ["todo.done", "todo.reopen", "todo.remove"]) } func testRemindAddSharesIdAndRecordsSid() async throws { let remind = try schema("remind") let io = FakeIO() let ran = await SchemaEngine.runAction(remind, name: "add", input: "call mum in 2h", io: io) XCTAssertTrue(ran) XCTAssertEqual(io.scheduled.count, 1) let fire = io.scheduled[0].event XCTAssertEqual(fire["type"].stringValue, "remind.fire") XCTAssertEqual(fire["id"].stringValue, "id-1") XCTAssertEqual(fire["notice"]["body"].stringValue, "call mum") XCTAssertEqual(io.scheduled[0].atMs, 1_700_007_300_000) XCTAssertEqual(io.emitted.count, 1) let add = io.emitted[0] XCTAssertEqual(add["id"].stringValue, "id-1") // the fire and the add share the minted id XCTAssertEqual(add["sid"].stringValue, "sid-9") XCTAssertEqual(add["when"], .number(1_700_007_300_000)) } func testRemindAddParseFailureHints() async throws { let remind = try schema("remind") let io = FakeIO() io.parseResult = nil let ran = await SchemaEngine.runAction(remind, name: "add", input: "call mum", io: io) XCTAssertFalse(ran) XCTAssertTrue(io.emitted.isEmpty) XCTAssertTrue(io.scheduled.isEmpty) XCTAssertEqual(io.hints.count, 1) } func testRemindAddOfflineSchedule() async throws { let remind = try schema("remind") let io = FakeIO() io.scheduleResult = nil _ = await SchemaEngine.runAction(remind, name: "add", input: "call mum in 2h", io: io) XCTAssertEqual(io.emitted[0]["sid"], .string("")) } func testRemindDismiss() async throws { let remind = try schema("remind") var io = FakeIO() let unfired = AppJSON.object(["id": .string("a"), "fired": .bool(false), "sid": .string("sch-1")]) _ = await SchemaEngine.runAction(remind, name: "dismiss", item: unfired, io: io) XCTAssertEqual(io.unscheduled, ["sch-1"]) XCTAssertEqual(io.emitted.map { $0["type"].stringValue }, ["remind.done"]) io = FakeIO() let fired = AppJSON.object(["id": .string("a"), "fired": .bool(true), "sid": .string("sch-1")]) _ = await SchemaEngine.runAction(remind, name: "dismiss", item: fired, io: io) XCTAssertTrue(io.unscheduled.isEmpty) io = FakeIO() let sidless = AppJSON.object(["id": .string("a"), "fired": .bool(false), "sid": .string("")]) _ = await SchemaEngine.runAction(remind, name: "dismiss", item: sidless, io: io) XCTAssertTrue(io.unscheduled.isEmpty) } func testTickerFiresSidlessDueOnce() throws { let remind = try schema("remind") var s = SchemaEngine.initialState(remind) for (id, when, sid) in [("a", NOW - 1000, ""), ("b", NOW - 1000, "sch"), ("c", NOW + 1000, "")] { s = SchemaEngine.fold(remind, state: s, event: .object([ "type": .string("remind.add"), "id": .string(id), "text": .string(id), "when": .number(when), "ts": .number(1), "sid": .string(sid), ])) } var fired = Set() let due = SchemaEngine.tickerRules(remind, state: s, now: NOW, fired: &fired) XCTAssertEqual(due.count, 1) XCTAssertEqual(due[0]["id"].stringValue, "a") XCTAssertEqual(due[0]["notice"]["body"].stringValue, "a") XCTAssertTrue(SchemaEngine.tickerRules(remind, state: s, now: NOW, fired: &fired).isEmpty) } }