| 1 | // π€ Tests for the app-schema engine (web/static/schema-engine.js) and the |
| 2 | // todo/remind schema files. Three layers of proof: |
| 3 | // 1. The schema fold reproduces the shared event vectors (the cross-platform |
| 4 | // spec native also tests against). |
| 5 | // 2. The schema fold is EXACTLY the hand-written reducer: randomized event |
| 6 | // sequences through both must converge to deep-equal state. |
| 7 | // 3. Views/actions: partition, ordering, search, templates, and action steps |
| 8 | // (emit / schedule / unschedule / parse) do what the schema says. |
| 9 | // Plus a drift gate: the schema copies bundled into the native package |
| 10 | // (ios/LChatCore/Sources/LChatCore/Schemas/) must be byte-identical to the |
| 11 | // canonical files in web/static/apps/schemas/. |
| 12 | import assert from "assert"; |
| 13 | import { readFileSync } from "fs"; |
| 14 | import { fileURLToPath } from "url"; |
| 15 | import { dirname, join } from "path"; |
| 16 | import { |
| 17 | foldSchemaEvent, |
| 18 | initialState, |
| 19 | partitionState, |
| 20 | listModel, |
| 21 | rowModel, |
| 22 | detailModel, |
| 23 | chatProjection, |
| 24 | runAction, |
| 25 | tickerRules, |
| 26 | evalTemplate, |
| 27 | relTime, |
| 28 | } from "../web/static/schema-engine.js"; |
| 29 | import { todoVectors, remindVectors } from "./event-vectors/index.js"; |
| 30 | import { app as todoApp } from "../web/static/apps/todo.js"; |
| 31 | import { app as remindApp, parseReminder } from "../web/static/apps/remind.js"; |
| 32 | |
| 33 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 34 | const schemaDir = join(__dirname, "..", "web", "static", "apps", "schemas"); |
| 35 | const todoSchema = JSON.parse(readFileSync(join(schemaDir, "todo.json"), "utf8")); |
| 36 | const remindSchema = JSON.parse(readFileSync(join(schemaDir, "remind.json"), "utf8")); |
| 37 | |
| 38 | let passed = 0; |
| 39 | async function ok(name, fn) { |
| 40 | await fn(); |
| 41 | passed++; |
| 42 | console.log(" β " + name); |
| 43 | } |
| 44 | |
| 45 | // ---- 1. the schema fold reproduces the shared event vectors --------------- |
| 46 | |
| 47 | function runVectors(schema, vectors) { |
| 48 | for (const v of vectors) { |
| 49 | if (!v.event && !v.event_sequence) continue; // doc-only vectors (grace constants) |
| 50 | let state = v.initial_state || initialState(schema); |
| 51 | const events = v.event_sequence || [v.event]; |
| 52 | for (const e of events) state = foldSchemaEvent(schema, state, e); |
| 53 | const expected = v.expected_final_state || v.expected_state_update; |
| 54 | assert.deepStrictEqual(state, expected, `vector ${v.name}`); |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | console.log("schema fold vs event vectors:"); |
| 59 | await ok("todo vectors", () => runVectors(todoSchema, todoVectors.vectors)); |
| 60 | await ok("remind vectors", () => runVectors(remindSchema, remindVectors.vectors)); |
| 61 | |
| 62 | // ---- 2. the app's registered fold IS the schema fold ------------------------ |
| 63 | // todo.js/remind.js now WIRE their events map from the schema (schemaEvents), |
| 64 | // so this fuzz is no longer two independent implementations racing β it pins |
| 65 | // the defineApp wiring: every schema event type registered, folded through the |
| 66 | // engine, malformed events no-op'd identically. (It caught real drift back |
| 67 | // when the reducers were hand-written; keep it β it's the wiring's regression |
| 68 | // net, and it goes back to twin-hunting if an app ever hand-writes a fold.) |
| 69 | |
| 70 | function mulberry32(seed) { |
| 71 | let a = seed; |
| 72 | return () => { |
| 73 | a |= 0; |
| 74 | a = (a + 0x6d2b79f5) | 0; |
| 75 | let t = Math.imul(a ^ (a >>> 15), 1 | a); |
| 76 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
| 77 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 78 | }; |
| 79 | } |
| 80 | |
| 81 | function fuzzParity(schema, app, makeEvent, rounds = 400) { |
| 82 | const rnd = mulberry32(11); |
| 83 | let schemaState = initialState(schema); |
| 84 | let appState = app.initialState(); |
| 85 | for (let i = 0; i < rounds; i++) { |
| 86 | const e = makeEvent(rnd, i); |
| 87 | schemaState = foldSchemaEvent(schema, schemaState, e); |
| 88 | appState = app.reduce(appState, e); |
| 89 | assert.deepStrictEqual(schemaState, appState, `diverged at event ${i}: ${JSON.stringify(e)}`); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | console.log("schema fold vs hand-written reducer (fuzz):"); |
| 94 | await ok("todo parity", () => { |
| 95 | fuzzParity(todoSchema, todoApp, (rnd, i) => { |
| 96 | const id = "t" + Math.floor(rnd() * 12); |
| 97 | const pick = rnd(); |
| 98 | if (pick < 0.4) { |
| 99 | // Some adds are malformed (no text) and must no-op identically. |
| 100 | return { type: "todo.add", id, text: rnd() < 0.85 ? "task " + i : "", name: rnd() < 0.5 ? "Ana" : undefined, ts: 1700000000000 + i }; |
| 101 | } |
| 102 | if (pick < 0.6) return { type: "todo.done", id, ts: rnd() < 0.8 ? 1700000000000 + i : undefined }; |
| 103 | if (pick < 0.75) return { type: "todo.reopen", id }; |
| 104 | if (pick < 0.85) return { type: "todo.remove", id }; |
| 105 | if (pick < 0.95) { |
| 106 | // Moves anchor to another item, the start (""), or a missing anchor. |
| 107 | const roll = rnd(); |
| 108 | const after = roll < 0.5 ? "t" + Math.floor(rnd() * 12) : roll < 0.75 ? "" : "gone"; |
| 109 | return { type: "todo.move", id, after }; |
| 110 | } |
| 111 | return { type: "unknown.event", id }; |
| 112 | }); |
| 113 | }); |
| 114 | await ok("remind parity", () => { |
| 115 | fuzzParity(remindSchema, remindApp, (rnd, i) => { |
| 116 | const id = "r" + Math.floor(rnd() * 12); |
| 117 | const pick = rnd(); |
| 118 | if (pick < 0.5) { |
| 119 | return { |
| 120 | type: "remind.add", |
| 121 | id: rnd() < 0.9 ? id : "", |
| 122 | text: rnd() < 0.9 ? "thing " + i : "", |
| 123 | when: rnd() < 0.9 ? 1700000000000 + Math.floor(rnd() * 1e7) : 0, |
| 124 | ts: rnd() < 0.8 ? 1700000000000 + i : undefined, |
| 125 | sid: rnd() < 0.7 ? "sch-" + i : undefined, |
| 126 | }; |
| 127 | } |
| 128 | if (pick < 0.75) return { type: "remind.fire", id, ts: rnd() < 0.8 ? 1700000000000 + i : undefined }; |
| 129 | return { type: "remind.done", id }; |
| 130 | }); |
| 131 | }); |
| 132 | |
| 133 | // ---- chat projections match the app's chatMessage hook --------------------- |
| 134 | |
| 135 | console.log("chat projections:"); |
| 136 | await ok("todo.add projects, others silent", () => { |
| 137 | const e = { type: "todo.add", id: "a", text: "buy milk" }; |
| 138 | assert.deepStrictEqual(chatProjection(todoSchema, e), todoApp.chatMessage(e)); |
| 139 | assert.strictEqual(chatProjection(todoSchema, { type: "todo.add", id: "a", text: "" }), null); |
| 140 | assert.strictEqual(chatProjection(todoSchema, { type: "todo.done", id: "a" }), null); |
| 141 | }); |
| 142 | await ok("remind.fire personal vs group wording", () => { |
| 143 | const e = { type: "remind.fire", id: "r", notice: { title: "β° Reminder", body: "call mum" } }; |
| 144 | assert.strictEqual(chatProjection(remindSchema, e), remindApp.chatMessage(e)); |
| 145 | assert.strictEqual(chatProjection(remindSchema, e, { group: true }), remindApp.chatMessage(e, { group: true })); |
| 146 | }); |
| 147 | await ok("remind.add action line", () => { |
| 148 | const e = { type: "remind.add", id: "r", text: "call mum", when: 1893499200000 }; |
| 149 | assert.deepStrictEqual(chatProjection(remindSchema, e), remindApp.chatMessage(e)); |
| 150 | }); |
| 151 | |
| 152 | // ---- 3. views: partition, ordering, search, templates ---------------------- |
| 153 | |
| 154 | console.log("list model:"); |
| 155 | const NOW = 1700000100000; |
| 156 | |
| 157 | await ok("todo partition + section order", () => { |
| 158 | let s = initialState(todoSchema); |
| 159 | for (const [id, text] of [["a", "one"], ["b", "two"], ["c", "three"]]) { |
| 160 | s = foldSchemaEvent(todoSchema, s, { type: "todo.add", id, text, name: "Ana" }); |
| 161 | } |
| 162 | // b: archived (done long ago); c: in grace (done 200ms ago). |
| 163 | s = foldSchemaEvent(todoSchema, s, { type: "todo.done", id: "b", ts: NOW - 60000 }); |
| 164 | s = foldSchemaEvent(todoSchema, s, { type: "todo.done", id: "c", ts: NOW - 200 }); |
| 165 | const m = listModel(todoSchema, s, NOW); |
| 166 | assert.strictEqual(m.summary, "1 left"); |
| 167 | const open = m.sections.find((x) => x.id === "open"); |
| 168 | // Newest-first, in place: c (grace, still visible) then a. |
| 169 | assert.deepStrictEqual(open.rows.map((r) => r.item.id), ["c", "a"]); |
| 170 | assert.deepStrictEqual(open.rows.map((r) => r.grace), [true, false]); |
| 171 | const done = m.sections.find((x) => x.id === "completed"); |
| 172 | assert.strictEqual(done.title, "Completed (1)"); |
| 173 | assert.strictEqual(done.collapsed, true); |
| 174 | assert.deepStrictEqual(done.rows.map((r) => r.item.id), ["b"]); |
| 175 | }); |
| 176 | |
| 177 | await ok("todo reorder: move fold op + the section's reorder surface", () => { |
| 178 | let s = initialState(todoSchema); |
| 179 | for (const [id, text] of [["a", "one"], ["b", "two"], ["c", "three"]]) { |
| 180 | s = foldSchemaEvent(todoSchema, s, { type: "todo.add", id, text, name: "Ana" }); |
| 181 | } |
| 182 | // Visual (newest-first): c, b, a. Drag c below b = c now follows a in the |
| 183 | // array β array a, c, b β visual b, c, a. |
| 184 | s = foldSchemaEvent(todoSchema, s, { type: "todo.move", id: "c", after: "a" }); |
| 185 | assert.deepStrictEqual(s.items.map((i) => i.id), ["a", "c", "b"]); |
| 186 | const m = listModel(todoSchema, s, NOW); |
| 187 | const open = m.sections.find((x) => x.id === "open"); |
| 188 | assert.deepStrictEqual(open.rows.map((r) => r.item.id), ["b", "c", "a"]); |
| 189 | // The open section is reorderable (newest-first); Completed is not. |
| 190 | assert.strictEqual(open.reorder, "move"); |
| 191 | assert.strictEqual(open.descending, true); |
| 192 | assert.strictEqual(m.sections.find((x) => x.id === "completed").reorder, ""); |
| 193 | // Searching suppresses reorder β a filtered list has no meaningful drop. |
| 194 | const filtered = listModel(todoSchema, s, NOW, { query: "t" }); |
| 195 | assert.strictEqual(filtered.sections.find((x) => x.id === "open").reorder, ""); |
| 196 | // Anchor to the start, and an unknown anchor no-ops. |
| 197 | s = foldSchemaEvent(todoSchema, s, { type: "todo.move", id: "b", after: "" }); |
| 198 | assert.deepStrictEqual(s.items.map((i) => i.id), ["b", "a", "c"]); |
| 199 | s = foldSchemaEvent(todoSchema, s, { type: "todo.move", id: "a", after: "gone" }); |
| 200 | assert.deepStrictEqual(s.items.map((i) => i.id), ["b", "a", "c"]); |
| 201 | }); |
| 202 | |
| 203 | await ok("todo summary cases", () => { |
| 204 | const empty = listModel(todoSchema, initialState(todoSchema), NOW); |
| 205 | assert.strictEqual(empty.summary, "No to-dos yet β add one above"); |
| 206 | let s = foldSchemaEvent(todoSchema, initialState(todoSchema), { type: "todo.add", id: "a", text: "x" }); |
| 207 | s = foldSchemaEvent(todoSchema, s, { type: "todo.done", id: "a", ts: NOW - 60000 }); |
| 208 | assert.strictEqual(listModel(todoSchema, s, NOW).summary, "all done π"); |
| 209 | }); |
| 210 | |
| 211 | await ok("undefined state reads as the empty state (virgin thread)", () => { |
| 212 | // Regression: the host only seeds state on the first fold, so a never-used |
| 213 | // app thread hands the renderer `undefined` β the engine's entry points must |
| 214 | // treat that as empty, not throw ("undefined is not an object" on the |
| 215 | // Interface view of a fresh To-do/Reminders thread). |
| 216 | for (const schema of [todoSchema, remindSchema]) { |
| 217 | assert.deepStrictEqual(listModel(schema, undefined, NOW), listModel(schema, initialState(schema), NOW)); |
| 218 | assert.deepStrictEqual(partitionState(schema, undefined, NOW), { active: [], grace: [], archived: [] }); |
| 219 | assert.deepStrictEqual(tickerRules(schema, undefined, NOW, new Set()), []); |
| 220 | } |
| 221 | }); |
| 222 | |
| 223 | await ok("search filters rows across sections", () => { |
| 224 | let s = initialState(todoSchema); |
| 225 | s = foldSchemaEvent(todoSchema, s, { type: "todo.add", id: "a", text: "buy milk" }); |
| 226 | s = foldSchemaEvent(todoSchema, s, { type: "todo.add", id: "b", text: "call mum" }); |
| 227 | const m = listModel(todoSchema, s, NOW, { query: "milk" }); |
| 228 | assert.deepStrictEqual(m.sections[0].rows.map((r) => r.item.id), ["a"]); |
| 229 | }); |
| 230 | |
| 231 | await ok("remind sections: upcoming asc, settling, previous desc + empty lines", () => { |
| 232 | let s = initialState(remindSchema); |
| 233 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "late", text: "late", when: NOW + 7200000, ts: 1, sid: "s1" }); |
| 234 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "soon", text: "soon", when: NOW + 60000, ts: 2, sid: "s2" }); |
| 235 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "old", text: "old", when: NOW - 90000, ts: 3, sid: "s3" }); |
| 236 | s = foldSchemaEvent(remindSchema, s, { type: "remind.fire", id: "old", ts: NOW - 60000 }); // archived (grace 6s) |
| 237 | const m = listModel(remindSchema, s, NOW); |
| 238 | assert.deepStrictEqual(m.sections.find((x) => x.id === "upcoming").rows.map((r) => r.item.id), ["soon", "late"]); |
| 239 | assert.strictEqual(m.sections.find((x) => x.id === "previous").title, "Previous reminders (1)"); |
| 240 | assert.strictEqual(m.empty, ""); |
| 241 | // All dismissed β the archived-aware empty line. |
| 242 | let s2 = foldSchemaEvent(remindSchema, s, { type: "remind.done", id: "late" }); |
| 243 | s2 = foldSchemaEvent(remindSchema, s2, { type: "remind.done", id: "soon" }); |
| 244 | assert.strictEqual(listModel(remindSchema, s2, NOW).empty, "No upcoming reminders."); |
| 245 | assert.strictEqual(listModel(remindSchema, initialState(remindSchema), NOW).empty, "No reminders yet. Add one above, or type /remind in any chat."); |
| 246 | }); |
| 247 | |
| 248 | await ok("remind row model: reltime, due accent, abs note past a day", () => { |
| 249 | const upcoming = { id: "a", text: "call", when: NOW + 2 * 86400000, ts: 1, sid: "s", fired: false, firedAt: 0 }; |
| 250 | let r = rowModel(remindSchema, upcoming, NOW); |
| 251 | assert.strictEqual(r.title, "call"); |
| 252 | assert.strictEqual(r.subtitle, "in 2d"); |
| 253 | assert.strictEqual(r.accent, false); |
| 254 | assert.notStrictEqual(r.note, ""); // absolute time shown when >1d out |
| 255 | const due = { ...upcoming, when: NOW - 120000 }; |
| 256 | r = rowModel(remindSchema, due, NOW); |
| 257 | assert.strictEqual(r.subtitle, "2m ago"); |
| 258 | assert.strictEqual(r.accent, true); |
| 259 | assert.strictEqual(r.note, ""); |
| 260 | const fired = { ...upcoming, fired: true, firedAt: NOW - 300000 }; |
| 261 | r = rowModel(remindSchema, fired, NOW); |
| 262 | assert.strictEqual(r.subtitle, "Reminded Β· 5m ago"); |
| 263 | assert.strictEqual(r.strike, true); |
| 264 | }); |
| 265 | |
| 266 | await ok("todo detail model", () => { |
| 267 | const d = detailModel(todoSchema, { id: "a", text: "buy milk", done: false, doneAt: 0, by: "Ana" }, NOW); |
| 268 | assert.strictEqual(d.title, "buy milk"); |
| 269 | const defs = d.sections.find((x) => x.kind === "definitions"); |
| 270 | assert.deepStrictEqual(defs.rows, [ |
| 271 | { term: "Status", value: "To do" }, |
| 272 | { term: "Added by", value: "Ana" }, |
| 273 | ]); |
| 274 | const buttons = d.sections.find((x) => x.kind === "buttons"); |
| 275 | assert.deepStrictEqual(buttons.items.map((b) => b.label), ["Mark done", "Remove"]); |
| 276 | }); |
| 277 | |
| 278 | await ok("relTime shapes", () => { |
| 279 | assert.strictEqual(relTime(NOW + 20000, NOW), "now"); |
| 280 | assert.strictEqual(relTime(NOW + 45 * 60000, NOW), "in 45m"); |
| 281 | assert.strictEqual(relTime(NOW - 3 * 3600000, NOW), "3h ago"); |
| 282 | assert.strictEqual(relTime(NOW + 3 * 86400000, NOW), "in 3d"); |
| 283 | }); |
| 284 | |
| 285 | // ---- actions ---------------------------------------------------------------- |
| 286 | |
| 287 | console.log("actions:"); |
| 288 | |
| 289 | function fakeIO(overrides = {}) { |
| 290 | const io = { |
| 291 | emitted: [], |
| 292 | scheduled: [], |
| 293 | unscheduled: [], |
| 294 | hints: [], |
| 295 | nextId: "id-1", |
| 296 | emit(e) { |
| 297 | io.emitted.push(e); |
| 298 | return Promise.resolve(true); |
| 299 | }, |
| 300 | schedule(e, at) { |
| 301 | io.scheduled.push({ event: e, at }); |
| 302 | return Promise.resolve("sid-9"); |
| 303 | }, |
| 304 | unschedule(sid) { |
| 305 | io.unscheduled.push(sid); |
| 306 | return Promise.resolve(true); |
| 307 | }, |
| 308 | newId: () => io.nextId, |
| 309 | parse: (kind, text) => (kind === "reminder" ? parseReminder(text, NOW) : null), |
| 310 | hint: (t) => io.hints.push(t), |
| 311 | ...overrides, |
| 312 | }; |
| 313 | return io; |
| 314 | } |
| 315 | |
| 316 | await ok("todo add / toggle / remove", async () => { |
| 317 | const io = fakeIO(); |
| 318 | assert.strictEqual(await runAction(todoSchema, "add", { input: "buy milk" }, io), true); |
| 319 | assert.deepStrictEqual(io.emitted, [{ type: "todo.add", id: "id-1", text: "buy milk" }]); |
| 320 | assert.strictEqual(await runAction(todoSchema, "add", { input: "" }, io), false); |
| 321 | io.emitted.length = 0; |
| 322 | await runAction(todoSchema, "toggle", { item: { id: "a", done: false } }, io); |
| 323 | await runAction(todoSchema, "toggle", { item: { id: "a", done: true } }, io); |
| 324 | await runAction(todoSchema, "remove", { item: { id: "a", done: false } }, io); |
| 325 | assert.deepStrictEqual(io.emitted.map((e) => e.type), ["todo.done", "todo.reopen", "todo.remove"]); |
| 326 | }); |
| 327 | |
| 328 | await ok("remind add: parse β schedule β emit shares one id and records the sid", async () => { |
| 329 | const io = fakeIO(); |
| 330 | assert.strictEqual(await runAction(remindSchema, "add", { input: "call mum in 2h" }, io), true); |
| 331 | assert.strictEqual(io.scheduled.length, 1); |
| 332 | const fire = io.scheduled[0].event; |
| 333 | assert.strictEqual(fire.type, "remind.fire"); |
| 334 | assert.deepStrictEqual(fire.notice, { title: "β° Reminder", body: "call mum" }); |
| 335 | assert.strictEqual(io.scheduled[0].at, NOW + 2 * 3600000); |
| 336 | assert.deepStrictEqual(io.emitted, [ |
| 337 | { type: "remind.add", id: "id-1", text: "call mum", when: NOW + 2 * 3600000, sid: "sid-9" }, |
| 338 | ]); |
| 339 | assert.strictEqual(fire.id, "id-1"); // the scheduled fire and the add share the minted id |
| 340 | }); |
| 341 | |
| 342 | await ok("remind add: unparseable input hints and emits nothing", async () => { |
| 343 | const io = fakeIO(); |
| 344 | assert.strictEqual(await runAction(remindSchema, "add", { input: "call mum" }, io), false); |
| 345 | assert.strictEqual(io.emitted.length, 0); |
| 346 | assert.strictEqual(io.scheduled.length, 0); |
| 347 | assert.strictEqual(io.hints.length, 1); |
| 348 | }); |
| 349 | |
| 350 | await ok("remind add: failed schedule records an empty sid", async () => { |
| 351 | const io = fakeIO({ schedule: () => Promise.resolve(null) }); |
| 352 | await runAction(remindSchema, "add", { input: "call mum in 2h" }, io); |
| 353 | assert.strictEqual(io.emitted[0].sid, ""); |
| 354 | }); |
| 355 | |
| 356 | await ok("remind dismiss: unschedules only unfired items with a sid", async () => { |
| 357 | let io = fakeIO(); |
| 358 | await runAction(remindSchema, "dismiss", { item: { id: "a", fired: false, sid: "sch-1" } }, io); |
| 359 | assert.deepStrictEqual(io.unscheduled, ["sch-1"]); |
| 360 | assert.deepStrictEqual(io.emitted, [{ type: "remind.done", id: "a" }]); |
| 361 | io = fakeIO(); |
| 362 | await runAction(remindSchema, "dismiss", { item: { id: "a", fired: true, sid: "sch-1" } }, io); |
| 363 | assert.deepStrictEqual(io.unscheduled, []); |
| 364 | io = fakeIO(); |
| 365 | await runAction(remindSchema, "dismiss", { item: { id: "a", fired: false, sid: "" } }, io); |
| 366 | assert.deepStrictEqual(io.unscheduled, []); |
| 367 | }); |
| 368 | |
| 369 | await ok("ticker: sid-less due reminders client-fire once", () => { |
| 370 | let s = initialState(remindSchema); |
| 371 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "a", text: "due", when: NOW - 1000, ts: 1, sid: "" }); |
| 372 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "b", text: "scheduled", when: NOW - 1000, ts: 2, sid: "sch" }); |
| 373 | s = foldSchemaEvent(remindSchema, s, { type: "remind.add", id: "c", text: "future", when: NOW + 1000, ts: 3, sid: "" }); |
| 374 | const fired = new Set(); |
| 375 | const due = tickerRules(remindSchema, s, NOW, fired); |
| 376 | assert.deepStrictEqual(due, [{ type: "remind.fire", id: "a", notice: { title: "β° Reminder", body: "due" } }]); |
| 377 | assert.deepStrictEqual(tickerRules(remindSchema, s, NOW, fired), []); // once = once |
| 378 | }); |
| 379 | |
| 380 | // ---- native schema copies must not drift ------------------------------------ |
| 381 | |
| 382 | console.log("native bundle drift gate:"); |
| 383 | await ok("ios schema copies are byte-identical to canonical", () => { |
| 384 | const nativeDir = join(__dirname, "..", "ios", "LChatCore", "Sources", "LChatCore", "Schemas"); |
| 385 | for (const name of ["todo.json", "remind.json"]) { |
| 386 | const canonical = readFileSync(join(schemaDir, name), "utf8"); |
| 387 | const bundled = readFileSync(join(nativeDir, name), "utf8"); |
| 388 | assert.strictEqual(bundled, canonical, `${name}: run cp web/static/apps/schemas/${name} ios/LChatCore/Sources/LChatCore/Schemas/${name}`); |
| 389 | } |
| 390 | }); |
| 391 | |
| 392 | await ok("ios vector fixture copies are byte-identical to canonical", () => { |
| 393 | const fixtureDir = join(__dirname, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures"); |
| 394 | for (const name of ["todo", "remind"]) { |
| 395 | const canonical = readFileSync(join(__dirname, "event-vectors", `${name}.json`), "utf8"); |
| 396 | const bundled = readFileSync(join(fixtureDir, `schema-vectors-${name}.json`), "utf8"); |
| 397 | assert.strictEqual(bundled, canonical, `run cp test/event-vectors/${name}.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-${name}.json`); |
| 398 | } |
| 399 | }); |
| 400 | |
| 401 | console.log(`\nschema-engine: ${passed} checks passed`); |
| 402 | |