| 1 | // Tests for the mini-app framework + the /todo app. node test/apps.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { |
| 4 | parseCommand, |
| 5 | parseMeCommand, |
| 6 | runCommand, |
| 7 | commandCatalog, |
| 8 | isAppType, |
| 9 | foldEvent, |
| 10 | appStateFor, |
| 11 | resetThread, |
| 12 | defineApp, |
| 13 | namespace, |
| 14 | setEmitTransport, |
| 15 | setScheduleTransport, |
| 16 | setEphemeralTransport, |
| 17 | setAppThreadResolver, |
| 18 | setHostBridge, |
| 19 | setCurrentUser, |
| 20 | setThreadHasBot, |
| 21 | setThreadOwner, |
| 22 | defaultCommandFor, |
| 23 | setToast, |
| 24 | messageActionsFor, |
| 25 | notifyLive, |
| 26 | deliverEphemeral, |
| 27 | appPreview, |
| 28 | appUnread, |
| 29 | foldHostEvent, |
| 30 | seedThread, |
| 31 | takeSnapshot, |
| 32 | addItem, |
| 33 | updateItem, |
| 34 | removeItem, |
| 35 | partitionByGrace, |
| 36 | DONE_GRACE_MS, |
| 37 | keyedList, |
| 38 | appWantsChatView, |
| 39 | appChatOnly, |
| 40 | appDefaultView, |
| 41 | noticeParts, |
| 42 | noticeIsCompact, |
| 43 | noticeCalmsDown, |
| 44 | NOTICE_COMPACT_AFTER_MS, |
| 45 | awaitAnswer, |
| 46 | cancelAnswer, |
| 47 | setAnswerCancelledHook, |
| 48 | deliverAnswer, |
| 49 | groupAppsApp, |
| 50 | groupAppsFor, |
| 51 | groupCapableApps, |
| 52 | appGlyphInto, |
| 53 | installGroupApp, |
| 54 | spaceAppsApp, |
| 55 | spaceAppsFor, |
| 56 | spaceCapableApps, |
| 57 | spaceAppInfo, |
| 58 | installSpaceApp, |
| 59 | setSpaceInstallPolicy, |
| 60 | setSpaceThread, |
| 61 | setSpaceThreadResolver, |
| 62 | listedApps, |
| 63 | instanceThread, |
| 64 | parseAppMention, |
| 65 | parseHere, |
| 66 | parseEveryone, |
| 67 | parseMeFlag, |
| 68 | HERE_FLAG, |
| 69 | ME_FLAG, |
| 70 | EVERYONE_FLAG, |
| 71 | placePhrase, |
| 72 | placeRowPhrase, |
| 73 | installRowPhrase, |
| 74 | spaceRowTitle, |
| 75 | setInstanceOpener, |
| 76 | runAppMention, |
| 77 | setThreadIsGroup, |
| 78 | setCommandPanelHost, |
| 79 | openCommandPanel, |
| 80 | closeCommandPanel, |
| 81 | appIsQuiet, |
| 82 | commandTakenBy, |
| 83 | } from "../web/static/apps.js"; |
| 84 | import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| 85 | import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| 86 | import { app as remindApp, parseReminder } from "../web/static/apps/remind.js"; |
| 87 | import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link" |
| 88 | import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js"; |
| 89 | import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app |
| 90 | import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app |
| 91 | import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app |
| 92 | import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| 93 | import { app as chatApp, viewItems, threadItems, threadReplyCount, threadListItems, threadListSignature, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide, inlineChainIds, claimConverts, convertedRoots, applyConverts, convertConfirm } from "../web/static/apps/chat.js"; // the chat app |
| 94 | import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| 95 | import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| 96 | import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| 97 | import { app as diceApp, parseSides } from "../web/static/disabled-apps/dice.js"; // DISABLED quiet-app POC (issue #227) — not wired into shell.js |
| 98 | import { app as agentsApp } from "../web/static/apps/agents.js"; // the agents fleet board (session.* — remote Claude Code sessions) |
| 99 | |
| 100 | // Each app's fold is synthesised from its `events` map by defineApp — the same |
| 101 | // (state, event) → state reducer the framework folds with. Test it directly. |
| 102 | const reduce = todoApp.reduce; |
| 103 | const pollReduce = pollApp.reduce; |
| 104 | const remindReduce = remindApp.reduce; |
| 105 | const bookmarksReduce = bookmarksApp.reduce; |
| 106 | const boardReduce = boardApp.reduce; |
| 107 | |
| 108 | // todo is a "personal" app: its command routes to the user's app-thread. In tests |
| 109 | // we stub the resolver so it lands in a known thread, and give ctx a user id. |
| 110 | setCurrentUser("u1"); |
| 111 | setAppThreadResolver(async () => "todoThread"); |
| 112 | const tick = () => new Promise((r) => setTimeout(r, 0)); // let runCommand's async emit flush |
| 113 | |
| 114 | let failures = 0; |
| 115 | async function test(name, fn) { |
| 116 | try { |
| 117 | await fn(); |
| 118 | console.log("PASS " + name); |
| 119 | } catch (e) { |
| 120 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 121 | failures++; |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // ---- namespace helper --------------------------------------------------- |
| 126 | |
| 127 | await test("namespace() resolves event types with '.' and CSS classes with '-'", () => { |
| 128 | const N = namespace("todo"); |
| 129 | assert.equal(N.ns, "todo"); |
| 130 | assert.equal(N.ev("add"), "todo.add"); |
| 131 | assert.equal(N.cls("item"), "todo-item"); |
| 132 | assert.equal(N.cls("list--done"), "todo-list--done"); // modifier suffix rides along |
| 133 | // A name already carrying a "." passes through untouched — a full type or |
| 134 | // another app's type still resolves, so migration is a safe find-and-replace. |
| 135 | assert.equal(N.ev("chat.msg"), "chat.msg"); |
| 136 | // Tolerant of a missing/empty base and a nullish name (never throws). |
| 137 | assert.equal(namespace("").ev("x"), ".x"); |
| 138 | assert.equal(N.ev(null), "todo.null"); |
| 139 | }); |
| 140 | |
| 141 | await test("defineApp defaults namespace to name and exposes app.ns", () => { |
| 142 | // todo declared no explicit namespace → it defaults to its name, and the |
| 143 | // synthesised types match the wire contract the event vectors pin. |
| 144 | assert.equal(todoApp.namespace, "todo"); |
| 145 | assert.equal(todoApp.ns.ev("add"), "todo.add"); |
| 146 | assert.deepEqual([...todoApp.types].sort(), ["todo.add", "todo.done", "todo.move", "todo.remove", "todo.reopen"].sort()); |
| 147 | // An explicit namespace overrides the name (built-in apps only; member apps |
| 148 | // are name-prefix validated by the sandbox). |
| 149 | const custom = defineApp({ name: "nstest-app", namespace: "ns", initialState: () => ({}), events: { "ns.e": (s) => s } }); |
| 150 | assert.equal(custom.namespace, "ns"); |
| 151 | assert.equal(custom.ns.cls("box"), "ns-box"); |
| 152 | }); |
| 153 | |
| 154 | // ---- command parsing ---------------------------------------------------- |
| 155 | |
| 156 | await test("parseCommand splits a registered command and its args", () => { |
| 157 | const c = parseCommand("/todo buy milk"); |
| 158 | assert.equal(c.name, "/todo"); |
| 159 | assert.equal(c.args, "buy milk"); |
| 160 | assert.ok(c.app, "a registered command resolves its owning app"); |
| 161 | }); |
| 162 | |
| 163 | await test("parseCommand trims surrounding arg whitespace", () => { |
| 164 | const c = parseCommand("/todo buy milk "); |
| 165 | assert.equal(c.name, "/todo"); |
| 166 | assert.equal(c.args, "buy milk"); |
| 167 | }); |
| 168 | |
| 169 | await test("parseCommand allows a bare command with no args", () => { |
| 170 | const c = parseCommand("/todo"); |
| 171 | assert.equal(c.name, "/todo"); |
| 172 | assert.equal(c.args, ""); |
| 173 | assert.ok(c.app); |
| 174 | }); |
| 175 | |
| 176 | await test("parseCommand flags an unknown /command (app:null) but ignores plain text", () => { |
| 177 | assert.equal(parseCommand("hello"), null); // no leading "/" → not a command attempt |
| 178 | assert.equal(parseCommand(""), null); |
| 179 | assert.equal(parseCommand("/ hi"), null); // a lone "/" is punctuation, sent as chat |
| 180 | const c = parseCommand("/nope do a thing"); |
| 181 | assert.equal(c.name, "/nope"); |
| 182 | assert.equal(c.args, "do a thing"); |
| 183 | assert.equal(c.app, null); // unowned verb → a failed command, not chat (caller replies) |
| 184 | }); |
| 185 | |
| 186 | await test("parseMeCommand pulls the action out of a /me line", () => { |
| 187 | assert.equal(parseMeCommand("/me waves"), "waves"); |
| 188 | assert.equal(parseMeCommand("/me waves hello "), "waves hello"); |
| 189 | assert.equal(parseMeCommand("/ME waves"), "waves"); // case-insensitive, like other commands |
| 190 | }); |
| 191 | |
| 192 | await test("parseMeCommand allows a bare /me (composer treats it as nothing to say)", () => { |
| 193 | assert.equal(parseMeCommand("/me"), ""); |
| 194 | assert.equal(parseMeCommand("/me "), ""); |
| 195 | }); |
| 196 | |
| 197 | await test("parseMeCommand ignores non-/me text, including other commands", () => { |
| 198 | assert.equal(parseMeCommand("hello"), null); |
| 199 | assert.equal(parseMeCommand(""), null); |
| 200 | assert.equal(parseMeCommand("/todo buy milk"), null); |
| 201 | assert.equal(parseMeCommand("/mean thing"), null); // "/me" must be its own token, not a prefix |
| 202 | }); |
| 203 | |
| 204 | // ---- command execution → emitted event ---------------------------------- |
| 205 | |
| 206 | await test("runCommand routes a personal /todo into the app-thread and emits", async () => { |
| 207 | const sent = []; |
| 208 | setEmitTransport((threadId, event) => { |
| 209 | sent.push({ threadId, event }); |
| 210 | return Promise.resolve(true); |
| 211 | }); |
| 212 | // typed from some chat "chatX" — it must land in the resolved app-thread, not chatX |
| 213 | assert.equal(runCommand("/todo buy milk", "chatX"), true); |
| 214 | await tick(); |
| 215 | assert.equal(sent.length, 1); |
| 216 | assert.equal(sent[0].threadId, "todoThread"); // routed to the app-thread, not "chatX" |
| 217 | assert.equal(sent[0].event.type, "todo.add"); |
| 218 | assert.equal(sent[0].event.text, "buy milk"); |
| 219 | assert.ok(sent[0].event.id, "add carries a generated id"); |
| 220 | }); |
| 221 | |
| 222 | await test("runCommand returns false (and emits nothing) for non-commands", async () => { |
| 223 | const sent = []; |
| 224 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 225 | assert.equal(runCommand("just a message", "chatX"), false); |
| 226 | await tick(); |
| 227 | assert.equal(sent.length, 0); |
| 228 | }); |
| 229 | |
| 230 | await test("an empty /todo emits nothing", async () => { |
| 231 | const sent = []; |
| 232 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 233 | assert.equal(runCommand("/todo ", "chatX"), true); // handled (consumed), but no event |
| 234 | await tick(); |
| 235 | assert.equal(sent.length, 0); |
| 236 | }); |
| 237 | |
| 238 | // AUDIT (quiet apps, issue #227): runCommand resolves — and so creates — a |
| 239 | // personal app's own thread BEFORE the handler runs, no matter what the |
| 240 | // handler ends up doing. A reply-only turn (no ctx.emit at all) still pays |
| 241 | // for a thread. That's BY DESIGN (the thread is where any future ctx.emit |
| 242 | // from this app would land, so it has to exist) — `quiet` never changes this |
| 243 | // resolution. What `quiet` changes is entirely about the thread's VISIBILITY |
| 244 | // afterward (see shell.js's createAppThread + the "msg" WS handler, and |
| 245 | // apps/README.md § Quiet apps) — this test just pins the resolution itself so |
| 246 | // that distinction stays deliberate, not accidental. |
| 247 | await test("a reply-only personal command still resolves (creates) the app-thread — quiet only hides it", async () => { |
| 248 | let resolves = 0; |
| 249 | setAppThreadResolver(async () => { |
| 250 | resolves++; |
| 251 | return "remindThread"; |
| 252 | }); |
| 253 | const sent = []; |
| 254 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 255 | const replies = []; |
| 256 | // Unparseable time → remind.js only ctx.reply()s a clarifying question; it |
| 257 | // never calls addReminder/ctx.emit. Typed from an unrelated chat. |
| 258 | assert.equal(runCommand("/remind call mum", "chatX", (r) => replies.push(r)), true); |
| 259 | await tick(); |
| 260 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 261 | assert.equal(sent.length, 0, "the handler never emitted anything"); |
| 262 | assert.equal(replies.length, 1, "it only answered in place"); |
| 263 | // resolveAppThread ran regardless — the thread exists whether or not this |
| 264 | // particular turn emitted anything. Whether it's SHOWN is a separate |
| 265 | // decision, made in shell.js (createAppThread hides it on creation for a |
| 266 | // quiet app; the WS handler keeps it hidden through later activity too). |
| 267 | assert.equal(resolves, 1); |
| 268 | }); |
| 269 | |
| 270 | await test("/help is answered privately (client-side); other bot commands pass through", () => { |
| 271 | const replies = []; |
| 272 | const respond = (r) => replies.push(r); |
| 273 | // /help is caught + answered right here with a private notice — no bot needed. |
| 274 | assert.equal(runCommand("/help", "chatX", respond), true); |
| 275 | assert.equal(replies.length, 1); |
| 276 | assert.match(replies[0].text, /Commands you can use/); |
| 277 | assert.match(replies[0].text, /\/help/); |
| 278 | assert.match(replies[0].text, /\/todo/); // an app command, listed everywhere |
| 279 | // No bot injected → /help omits the bot's commands, exactly like the typeahead. |
| 280 | assert.doesNotMatch(replies[0].text, /\/theme/); |
| 281 | // /theme is still Eleven's — the client SENDS it (returns false so the composer |
| 282 | // posts it, and the bot answers), never caught locally. |
| 283 | assert.equal(runCommand("/theme lagoon", "chatX", respond), false); |
| 284 | assert.equal(replies.length, 1, "a bot command isn't answered locally"); |
| 285 | // A truly unknown "/word" is handled (not broadcast) and answered privately. |
| 286 | assert.equal(runCommand("/blahblah", "chatX", respond), true); |
| 287 | assert.equal(replies.length, 2); |
| 288 | assert.match(replies[1].text, /isn't a command/); |
| 289 | // The catalog flags /help non-bot (typeahead lists it everywhere); /theme stays |
| 290 | // bot-gated; app commands are non-bot. |
| 291 | const cat = commandCatalog(); |
| 292 | assert.ok(cat.some((c) => c.name === "/help" && c.bot === false)); |
| 293 | assert.ok(cat.some((c) => c.name === "/theme" && c.bot === true)); |
| 294 | assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false)); |
| 295 | // /me is listed for discoverability, but it's never routed through runCommand — |
| 296 | // the composer (chat.js) handles it directly via parseMeCommand, since (unlike |
| 297 | // every other command) it broadcasts a real message rather than replying privately. |
| 298 | assert.ok(cat.some((c) => c.name === "/me" && c.bot === false)); |
| 299 | }); |
| 300 | |
| 301 | await test("commandTakenBy names the owner of a taken slash command, '' when free", () => { |
| 302 | // An app command → the owning app's name (the member-app gates reject these). |
| 303 | assert.equal(commandTakenBy("/todo"), "todo"); |
| 304 | // The host set — bot verbs, /help, /me — reads as "host". |
| 305 | assert.equal(commandTakenBy("/theme"), "host"); |
| 306 | assert.equal(commandTakenBy("/help"), "host"); |
| 307 | assert.equal(commandTakenBy("/me"), "host"); |
| 308 | // A free name is takeable. |
| 309 | assert.equal(commandTakenBy("/neverregistered"), ""); |
| 310 | }); |
| 311 | |
| 312 | // ---- local commands + the default command -------------------------------- |
| 313 | |
| 314 | // todo's /add is `local: true` — it exists only inside the To-do thread. Stub the |
| 315 | // thread-owner lookup the way shell.js injects its threadApp map. |
| 316 | setThreadOwner((id) => (id === "todoThread" ? "todo" : id === "remindThread" ? "remind" : null)); |
| 317 | |
| 318 | await test("a local command resolves only in its app's own thread", () => { |
| 319 | const inThread = parseCommand("/add buy milk", "todoThread"); |
| 320 | assert.equal(inThread.name, "/add"); |
| 321 | assert.equal(inThread.args, "buy milk"); |
| 322 | assert.equal(inThread.app, todoApp); |
| 323 | assert.equal(inThread.local, true); |
| 324 | // Elsewhere (or with no thread context) /add is just an unknown verb. |
| 325 | assert.equal(parseCommand("/add buy milk", "chatX").app, null); |
| 326 | assert.equal(parseCommand("/add buy milk").app, null); |
| 327 | }); |
| 328 | |
| 329 | await test("a local command is never in the global catalog, and leads its thread's", () => { |
| 330 | const everywhere = commandCatalog(); |
| 331 | assert.ok(!everywhere.some((c) => c.name === "/add"), "/add stays out of other threads"); |
| 332 | assert.ok(everywhere.some((c) => c.name === "/todo")); |
| 333 | const here = commandCatalog("todoThread"); |
| 334 | assert.equal(here[0].name, "/add", "local commands lead in the owning thread"); |
| 335 | // The app's global routing alias is redundant noise in its own thread. |
| 336 | assert.ok(!here.some((c) => c.name === "/todo")); |
| 337 | // Other apps' global commands still work from anywhere, so they stay listed. |
| 338 | assert.ok(here.some((c) => c.name === "/remind")); |
| 339 | }); |
| 340 | |
| 341 | await test("runCommand runs a local command in place — no routing, no echo reply", async () => { |
| 342 | const sent = []; |
| 343 | const replies = []; |
| 344 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 345 | // Resolver poisoned: a local command must NOT consult it (it targets where you are). |
| 346 | setAppThreadResolver(async () => { |
| 347 | throw new Error("local commands never route"); |
| 348 | }); |
| 349 | assert.equal(runCommand("/add buy milk", "todoThread", (r) => replies.push(r)), true); |
| 350 | await tick(); |
| 351 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 352 | assert.equal(sent.length, 1); |
| 353 | assert.equal(sent[0].threadId, "todoThread"); |
| 354 | assert.equal(sent[0].event.type, "todo.add"); |
| 355 | assert.equal(sent[0].event.text, "buy milk"); |
| 356 | // In the app's own thread the event lands as a visible action line, so the |
| 357 | // private confirmation is suppressed (same rule as /todo typed in-thread). |
| 358 | assert.equal(replies.length, 0); |
| 359 | }); |
| 360 | |
| 361 | await test("an unknown command in an app-thread is answered by the app persona", () => { |
| 362 | const replies = []; |
| 363 | assert.equal(runCommand("/blahblah", "todoThread", (r) => replies.push(r)), true); |
| 364 | assert.equal(replies.length, 1); |
| 365 | assert.equal(replies[0].sender.name, "To-do"); |
| 366 | assert.match(replies[0].text, /isn't a command/); |
| 367 | }); |
| 368 | |
| 369 | await test("defaultCommandFor: To-do's thread runs /add; other threads have none", () => { |
| 370 | const dc = defaultCommandFor("todoThread"); |
| 371 | assert.ok(dc); |
| 372 | assert.equal(dc.name, "/add"); |
| 373 | assert.equal(dc.app, todoApp); |
| 374 | assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); |
| 375 | assert.equal(defaultCommandFor("chatX"), null); |
| 376 | }); |
| 377 | |
| 378 | await test("defaultCommandFor steps aside when a bot member is present", () => { |
| 379 | // With an LLM in the thread, plain text is a real message for it to answer. |
| 380 | setThreadHasBot((id) => id === "todoThread"); |
| 381 | assert.equal(defaultCommandFor("todoThread"), null); |
| 382 | setThreadHasBot(null); |
| 383 | assert.ok(defaultCommandFor("todoThread")); |
| 384 | }); |
| 385 | |
| 386 | await test("plain text through the default command becomes a to-do (the composer flow)", async () => { |
| 387 | const sent = []; |
| 388 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 389 | // What chat.js does for non-slash text in a default-command thread: |
| 390 | const dc = defaultCommandFor("todoThread"); |
| 391 | assert.equal(runCommand(dc.name + " " + "buy milk", "todoThread"), true); |
| 392 | await tick(); |
| 393 | assert.equal(sent.length, 1); |
| 394 | assert.equal(sent[0].event.type, "todo.add"); |
| 395 | assert.equal(sent[0].event.text, "buy milk"); |
| 396 | }); |
| 397 | |
| 398 | // ---- Reminders: a GLOBAL verb as the default command ---------------------- |
| 399 | |
| 400 | await test("Reminders' default is its global /remind (no local twin needed)", () => { |
| 401 | const dc = defaultCommandFor("remindThread"); |
| 402 | assert.ok(dc); |
| 403 | assert.equal(dc.name, "/remind"); |
| 404 | assert.equal(dc.app, remindApp); |
| 405 | assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); |
| 406 | }); |
| 407 | |
| 408 | await test("an app with no locals leads its own thread's catalog with its globals", () => { |
| 409 | const here = commandCatalog("remindThread"); |
| 410 | assert.equal(here[0].name, "/remind", "the global verb IS the in-thread verb — it leads"); |
| 411 | assert.equal(here.filter((c) => c.name === "/remind").length, 1, "listed once, not repeated"); |
| 412 | assert.ok(here.some((c) => c.name === "/todo"), "other apps' globals stay"); |
| 413 | assert.ok(!here.some((c) => c.name === "/add"), "another app's LOCAL never leaks in"); |
| 414 | }); |
| 415 | |
| 416 | await test("plain text with a time becomes a reminder; timeless text gets asked 'when?'", async () => { |
| 417 | const sent = []; |
| 418 | const replies = []; |
| 419 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 420 | setAppThreadResolver(async (name) => (name === "remind" ? "remindThread" : "todoThread")); |
| 421 | const dc = defaultCommandFor("remindThread"); |
| 422 | // "call mum in 10 minutes" → remind.add in this thread, and the confirmation |
| 423 | // (a `short`-carrying reply) is suppressed in-thread — the action line suffices. |
| 424 | assert.equal(runCommand(dc.name + " " + "call mum in 10 minutes", "remindThread", (r) => replies.push(r)), true); |
| 425 | await tick(); |
| 426 | assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1); |
| 427 | assert.equal(sent.find((e) => e.event.type === "remind.add").event.text, "call mum"); |
| 428 | assert.equal(replies.length, 0, "in-thread confirmation is suppressed"); |
| 429 | // No parseable time → the command pushes back, and THAT reply (no `short`) |
| 430 | // renders even in-thread: an error is the command's only feedback. |
| 431 | assert.equal(runCommand(dc.name + " " + "call mum", "remindThread", (r) => replies.push(r)), true); |
| 432 | await tick(); |
| 433 | assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1, "nothing added"); |
| 434 | assert.equal(replies.length, 1); |
| 435 | assert.match(replies[0].text, /When should I remind you/); |
| 436 | assert.equal(replies[0].sender.name, "Reminders"); |
| 437 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 438 | }); |
| 439 | |
| 440 | await test("bare /add in the To-do thread shows its usage line (error replies render in-thread)", async () => { |
| 441 | const replies = []; |
| 442 | assert.equal(runCommand("/add", "todoThread", (r) => replies.push(r)), true); |
| 443 | await tick(); // replies land after instanceThread's (uniform) await |
| 444 | assert.equal(replies.length, 1); |
| 445 | assert.match(replies[0].text, /Usage: \/add/); |
| 446 | }); |
| 447 | |
| 448 | // ---- type ownership (derived from each app's events map) ---------------- |
| 449 | |
| 450 | await test("isAppType recognises todo events, not chat", () => { |
| 451 | assert.equal(isAppType("todo.add"), true); |
| 452 | assert.equal(isAppType("todo.done"), true); |
| 453 | assert.equal(isAppType("chat"), false); |
| 454 | assert.equal(isAppType(null), false); |
| 455 | assert.equal(isAppType(undefined), false); |
| 456 | }); |
| 457 | |
| 458 | await test("an app's owned types are the keys of its events map", () => { |
| 459 | // defineApp derives app.types from Object.keys(events) — one source of truth, |
| 460 | // so a handled event can never be missing from the owned-type set. |
| 461 | assert.deepEqual(todoApp.types, ["todo.add", "todo.done", "todo.reopen", "todo.remove", "todo.move"]); |
| 462 | assert.deepEqual(pollApp.types, ["poll.create", "poll.vote", "poll.close"]); |
| 463 | }); |
| 464 | |
| 465 | await test("appWantsChatView: chatMessage opts into companion; its absence is takeover", () => { |
| 466 | // The presence of a chatMessage hook is the ONE gate between the two |
| 467 | // presentations — companion (chat + interface) vs takeover (full surface). |
| 468 | assert.equal(typeof todoApp.chatMessage, "function"); |
| 469 | assert.equal(appWantsChatView("todo"), true); // To-do projects events → companion |
| 470 | assert.equal(appWantsChatView("remind"), true); // Reminders too |
| 471 | // A takeover app defines no chatMessage — register one and confirm it's takeover. |
| 472 | defineApp({ name: "takeover-fixture", title: "Fixture", scope: "personal", events: {}, render() {} }); |
| 473 | assert.equal(appWantsChatView("takeover-fixture"), false); |
| 474 | assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion |
| 475 | }); |
| 476 | |
| 477 | await test("appWantsChatView: a groupFeedMessage-only app stays a personal takeover", () => { |
| 478 | // Vault projects add/remove into a GROUP's chat feed via groupFeedMessage |
| 479 | // (see below) and deliberately has NO chatMessage, so its OWN thread stays |
| 480 | // a pure takeover: no Chat view, no header toggle, ever. The hook shape IS |
| 481 | // the declaration — the old groupChatOnly flag is gone. |
| 482 | assert.equal(vaultApp.chatMessage, undefined); |
| 483 | assert.equal(typeof vaultApp.groupFeedMessage, "function"); |
| 484 | assert.equal(appWantsChatView("vault"), false); |
| 485 | // A chatMessage app is a companion, with no flag able to say otherwise. |
| 486 | assert.equal(appWantsChatView("todo"), true); |
| 487 | }); |
| 488 | |
| 489 | await test("vault: groupFeedMessage projects add/remove into a group's feed, never naming the secret", () => { |
| 490 | const add = { type: "vault.add", id: "s1", aid: "a1", label: "GITHUB_TOKEN", value: "hunter2" }; |
| 491 | const remove = { type: "vault.remove", id: "s1", aid: "a2" }; |
| 492 | const change = { type: "vault.change", id: "s1", aid: "a3", value: "newvalue" }; |
| 493 | // The host only calls this hook when folding a group/DM feed |
| 494 | // (foldGroupAppEventBubble) — with no chatMessage, personal-thread events |
| 495 | // have no projection path at all. |
| 496 | const groupAdd = vaultApp.groupFeedMessage(add); |
| 497 | assert.equal(groupAdd.action, true); |
| 498 | assert.equal(groupAdd.text, "added a secret to the Vault"); |
| 499 | assert.doesNotMatch(groupAdd.text, /GITHUB_TOKEN|hunter2/); |
| 500 | const groupRemove = vaultApp.groupFeedMessage(remove); |
| 501 | assert.equal(groupRemove.action, true); |
| 502 | assert.equal(groupRemove.text, "removed a secret from the Vault"); |
| 503 | // …but a value CHANGE (nothing new to notice, same name) stays silent. |
| 504 | assert.equal(vaultApp.groupFeedMessage(change), null); |
| 505 | }); |
| 506 | |
| 507 | await test("vault: preview names the actor ('You' for the reader's own event), never the secret", () => { |
| 508 | // setCurrentUser("u1") above makes u1 "the reader" for the rest of this file. |
| 509 | assert.equal(vaultApp.preview({ type: "vault.add", uid: "u1", name: "James" }), "🔒 You added a secret to the Vault"); |
| 510 | assert.equal(vaultApp.preview({ type: "vault.remove", uid: "u2", name: "Ciara" }), "🔒 Ciara removed a secret from the Vault"); |
| 511 | assert.equal(vaultApp.preview({ type: "vault.change", uid: "u1", name: "James" }), "🔒 You updated a secret in the Vault"); |
| 512 | // No uid (a legacy/malformed event) falls back to the sender's name, not "You". |
| 513 | assert.equal(vaultApp.preview({ type: "vault.add", name: "James" }), "🔒 James added a secret to the Vault"); |
| 514 | assert.equal(vaultApp.preview({ type: "vault.other" }), null); |
| 515 | }); |
| 516 | |
| 517 | await test("defineApp normalizes every legacy generation into places, then drops the old fields (#405)", () => { |
| 518 | const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec }); |
| 519 | const shape = (a) => [a.places, a.listed]; |
| 520 | // Legacy scope declarations (stored member-app manifests still carry them)… |
| 521 | assert.deepEqual(shape(mk({ name: "flag-legacy-personal", scope: "personal" })), [["you"], true]); |
| 522 | assert.deepEqual(shape(mk({ name: "flag-legacy-system", scope: "system" })), [[], false]); |
| 523 | // …the default archetype (nothing declared) is a personal, listed app… |
| 524 | assert.deepEqual(shape(mk({ name: "flag-default" })), [["you"], true]); |
| 525 | // …and the capability flags normalize the same way. |
| 526 | assert.deepEqual(shape(mk({ name: "flag-first-both", personal: true, groupCapable: true })), [["you", "chat"], true]); |
| 527 | assert.deepEqual(shape(mk({ name: "flag-first-grouponly", personal: false, groupCapable: true })), [["chat"], true]); |
| 528 | assert.deepEqual(shape(mk({ name: "flag-first-hostonly", listed: false })), [[], false]); |
| 529 | // The legacy fields are INPUT-ONLY: normalized in, then deleted, so a stale |
| 530 | // flag can never disagree with places — nothing downstream can read one. |
| 531 | const legacy = mk({ name: "flag-dropped", personal: true, groupCapable: true, spaceCapable: true, scope: "personal" }); |
| 532 | assert.deepEqual(shape(legacy), [["you", "chat", "space"], true]); |
| 533 | for (const f of ["personal", "groupCapable", "spaceCapable", "scope"]) assert.ok(!(f in legacy), f + " is dropped after normalization"); |
| 534 | // The real apps keep their classification (behaviour-neutral migration). |
| 535 | assert.deepEqual(shape(todoApp), [["you", "chat"], true]); |
| 536 | assert.deepEqual(shape(pollApp), [["chat"], true]); |
| 537 | }); |
| 538 | |
| 539 | // ---- collection helpers (the immutable list ops reducers build on) ------- |
| 540 | |
| 541 | await test("addItem appends, dedupes by id, and never mutates the input", () => { |
| 542 | const a = [{ id: "1", v: 1 }]; |
| 543 | const b = addItem(a, { id: "2", v: 2 }); |
| 544 | assert.deepEqual(b.map((i) => i.id), ["1", "2"]); |
| 545 | assert.equal(a.length, 1, "input array untouched"); |
| 546 | const c = addItem(b, { id: "2", v: 99 }); // duplicate id → no-op add |
| 547 | assert.deepEqual(c.map((i) => i.id), ["1", "2"]); |
| 548 | assert.equal(c.find((i) => i.id === "2").v, 2, "existing item wins, not overwritten"); |
| 549 | assert.notEqual(c, b, "still returns a fresh array (safe to sort in place)"); |
| 550 | }); |
| 551 | |
| 552 | await test("updateItem patches one item into a fresh array; removeItem drops it", () => { |
| 553 | const a = [{ id: "1", done: false }, { id: "2", done: false }]; |
| 554 | const b = updateItem(a, "2", { done: true }); |
| 555 | assert.equal(b.find((i) => i.id === "2").done, true); |
| 556 | assert.equal(a.find((i) => i.id === "2").done, false, "input untouched"); |
| 557 | assert.notEqual(b, a); |
| 558 | const c = removeItem(b, "1"); |
| 559 | assert.deepEqual(c.map((i) => i.id), ["2"]); |
| 560 | assert.equal(b.length, 2, "input untouched"); |
| 561 | }); |
| 562 | |
| 563 | // ---- the reducer (pure) ------------------------------------------------- |
| 564 | |
| 565 | await test("reduce adds, completes, reopens, and removes items", () => { |
| 566 | let s = reduce(undefined, { type: "todo.add", id: "a", text: "apples", name: "Jo" }); |
| 567 | assert.deepEqual(s.items, [{ id: "a", text: "apples", done: false, doneAt: 0, by: "Jo" }]); |
| 568 | |
| 569 | s = reduce(s, { type: "todo.add", id: "b", text: "bread" }); |
| 570 | assert.equal(s.items.length, 2); |
| 571 | |
| 572 | // done records WHEN (the event's send time) so the grace→archive split converges. |
| 573 | s = reduce(s, { type: "todo.done", id: "a", ts: 1700 }); |
| 574 | assert.equal(s.items.find((i) => i.id === "a").done, true); |
| 575 | assert.equal(s.items.find((i) => i.id === "a").doneAt, 1700); |
| 576 | |
| 577 | s = reduce(s, { type: "todo.reopen", id: "a" }); |
| 578 | assert.equal(s.items.find((i) => i.id === "a").done, false); |
| 579 | assert.equal(s.items.find((i) => i.id === "a").doneAt, 0, "reopen clears doneAt"); |
| 580 | |
| 581 | s = reduce(s, { type: "todo.remove", id: "a" }); |
| 582 | assert.deepEqual(s.items.map((i) => i.id), ["b"]); |
| 583 | }); |
| 584 | |
| 585 | await test("partitionByGrace: active / recently-done (grace) / archived by doneAt", () => { |
| 586 | const now = 100000; |
| 587 | const items = [ |
| 588 | { id: "a", doneAt: 0 }, // active (not done) |
| 589 | { id: "b", doneAt: now - 1000 }, // done 1s ago → grace (< DONE_GRACE_MS) |
| 590 | { id: "c", doneAt: now - (DONE_GRACE_MS + 5000) }, // done long ago → archived |
| 591 | { id: "d", doneAt: now - (DONE_GRACE_MS - 1) }, // just inside the window → grace |
| 592 | { id: "e", doneAt: now - DONE_GRACE_MS }, // exactly at the edge → archived |
| 593 | ]; |
| 594 | const { active, grace, archived } = partitionByGrace(items, (i) => i.doneAt, now); |
| 595 | assert.deepEqual(active.map((i) => i.id), ["a"]); |
| 596 | assert.deepEqual(grace.map((i) => i.id), ["b", "d"]); |
| 597 | assert.deepEqual(archived.map((i) => i.id), ["c", "e"]); |
| 598 | // A custom grace window is honoured. |
| 599 | assert.deepEqual( |
| 600 | partitionByGrace(items, (i) => i.doneAt, now, 500).grace.map((i) => i.id), |
| 601 | [], |
| 602 | "nothing is within a 500ms window", |
| 603 | ); |
| 604 | }); |
| 605 | |
| 606 | await test("reduce ignores a malformed add (missing text) and unknown events", () => { |
| 607 | let s = reduce(undefined, { type: "todo.add", id: "a" }); // no text |
| 608 | assert.deepEqual(s.items, []); |
| 609 | s = reduce(s, { type: "todo.done", id: "ghost" }); // unknown id — no-op |
| 610 | assert.deepEqual(s.items, []); |
| 611 | s = reduce(s, { type: "something.else" }); // not ours |
| 612 | assert.deepEqual(s.items, []); |
| 613 | }); |
| 614 | |
| 615 | await test("reduce does not mutate the previous state (snapshot-safe)", () => { |
| 616 | const s0 = reduce(undefined, { type: "todo.add", id: "a", text: "apples" }); |
| 617 | const s1 = reduce(s0, { type: "todo.done", id: "a" }); |
| 618 | assert.equal(s0.items[0].done, false, "prior state untouched"); |
| 619 | assert.equal(s1.items[0].done, true); |
| 620 | assert.notEqual(s0, s1); |
| 621 | }); |
| 622 | |
| 623 | // ---- folding a stream into per-thread state ----------------------------- |
| 624 | |
| 625 | await test("foldEvent accumulates a thread's app state and isolates threads", () => { |
| 626 | resetThread("t1"); |
| 627 | resetThread("t2"); |
| 628 | const app = foldEvent("t1", { type: "todo.add", id: "a", text: "apples" }, {}); |
| 629 | assert.equal(app.name, "todo"); |
| 630 | foldEvent("t1", { type: "todo.add", id: "b", text: "bread" }, {}); |
| 631 | foldEvent("t2", { type: "todo.add", id: "c", text: "cheese" }, {}); |
| 632 | foldEvent("t1", { type: "todo.done", id: "a" }, {}); |
| 633 | |
| 634 | const s1 = appStateFor("t1", "todo"); |
| 635 | assert.deepEqual(s1.items.map((i) => i.id), ["a", "b"]); |
| 636 | assert.equal(s1.items.find((i) => i.id === "a").done, true); |
| 637 | assert.deepEqual(appStateFor("t2", "todo").items.map((i) => i.id), ["c"]); |
| 638 | }); |
| 639 | |
| 640 | await test("foldEvent returns null for a type no app owns", () => { |
| 641 | assert.equal(foldEvent("t1", { type: "chat", text: "hi" }, {}), null); |
| 642 | }); |
| 643 | |
| 644 | await test("resetThread clears a thread's folded state", () => { |
| 645 | foldEvent("t3", { type: "todo.add", id: "x", text: "x" }, {}); |
| 646 | assert.ok(appStateFor("t3", "todo")); |
| 647 | resetThread("t3"); |
| 648 | assert.equal(appStateFor("t3", "todo"), undefined); |
| 649 | }); |
| 650 | |
| 651 | await test("foldEvent is idempotent per server message id (no dup on re-fold)", () => { |
| 652 | resetThread("t4"); |
| 653 | // The same message (msg id 42) folded twice — as happens when catch-up re-reads |
| 654 | // the window on every thread re-open — must add the item only once. |
| 655 | const first = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 656 | const again = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 657 | assert.equal(first.name, "todo"); // first fold applied, returns the app |
| 658 | assert.equal(again, null); // duplicate message → skipped, returns null (no line) |
| 659 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); |
| 660 | // A genuinely different message still folds. |
| 661 | foldEvent("t4", { type: "todo.add", id: "b", text: "bread" }, { id: 43 }); |
| 662 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a", "b"]); |
| 663 | // After reset, the same message id may fold again (fresh window rebuild). |
| 664 | resetThread("t4"); |
| 665 | const afterReset = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 666 | assert.equal(afterReset.name, "todo"); |
| 667 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); |
| 668 | }); |
| 669 | |
| 670 | // ---- the other apps' reducers (pure) ------------------------------------ |
| 671 | |
| 672 | await test("poll: create then votes tally, last-writer-wins per user", () => { |
| 673 | let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); |
| 674 | assert.equal(s.polls.length, 1); |
| 675 | s = pollReduce(s, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); // dup id |
| 676 | assert.equal(s.polls.length, 1, "poll.create is deduped by id"); |
| 677 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "a" }); |
| 678 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "b" }); |
| 679 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "a" }); // a changes vote |
| 680 | const votes = s.polls[0].votes; |
| 681 | assert.equal(votes["a"], 1, "last vote wins"); |
| 682 | assert.equal(votes["b"], 1); |
| 683 | s = pollReduce(s, { type: "poll.vote", pollId: "ghost", choice: 0, uid: "a" }); // unknown poll |
| 684 | assert.equal(Object.keys(s.polls[0].votes).length, 2); |
| 685 | }); |
| 686 | |
| 687 | await test("poll: close is creator-only in the fold; legacy polls accept anyone", () => { |
| 688 | let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"], uid: "alice" }); |
| 689 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "bob" }); |
| 690 | // A non-creator's close folds to a no-op — the poll stays. |
| 691 | s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "bob" }); |
| 692 | assert.equal(s.polls.length, 1, "only the creator may close"); |
| 693 | // The creator's close removes it from the panel. |
| 694 | s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "alice" }); |
| 695 | assert.deepEqual(s.polls, []); |
| 696 | // A pre-`by` poll (older event log) can be closed by anyone. |
| 697 | let legacy = pollReduce(undefined, { type: "poll.create", id: "p2", q: "Q?", options: ["a", "b"] }); |
| 698 | legacy = pollReduce(legacy, { type: "poll.close", pollId: "p2", uid: "whoever" }); |
| 699 | assert.deepEqual(legacy.polls, []); |
| 700 | }); |
| 701 | |
| 702 | await test("poll: pollSummary reports counts and the verdict (win / tie / no votes)", () => { |
| 703 | const base = { q: "Lunch?", options: ["Pizza", "Sushi"] }; |
| 704 | assert.equal( |
| 705 | pollSummary({ ...base, votes: { a: 0, b: 0, c: 1 } }), |
| 706 | "📊 Poll closed: Lunch?\n• Pizza — 2\n• Sushi — 1\nPizza wins (3 votes)", |
| 707 | ); |
| 708 | assert.equal( |
| 709 | pollSummary({ ...base, votes: { a: 0, b: 1 } }), |
| 710 | "📊 Poll closed: Lunch?\n• Pizza — 1\n• Sushi — 1\nTied: Pizza & Sushi (2 votes)", |
| 711 | ); |
| 712 | assert.equal(pollSummary({ ...base, votes: {} }), "📊 Poll closed: Lunch?\n• Pizza — 0\n• Sushi — 0\nNo votes were cast."); |
| 713 | }); |
| 714 | |
| 715 | // ---- board (shared whiteboard) ------------------------------------------- |
| 716 | |
| 717 | await test("board: sanitizePoints filters bad entries, clamps to canvas, requires 2+", () => { |
| 718 | assert.equal(sanitizePoints("nope"), null, "not an array"); |
| 719 | assert.equal(sanitizePoints([[1, 2]]), null, "fewer than 2 points"); |
| 720 | assert.equal( |
| 721 | sanitizePoints([ |
| 722 | [1, 2], |
| 723 | ["x", 2], |
| 724 | [3, 4], |
| 725 | ]).length, |
| 726 | 2, |
| 727 | "non-numeric entries are dropped", |
| 728 | ); |
| 729 | assert.deepEqual(sanitizePoints([[-5, -5], [9999, 9999]]), [ |
| 730 | [0, 0], |
| 731 | [640, 320], |
| 732 | ]); |
| 733 | }); |
| 734 | |
| 735 | await test("board: open is idempotent; stroke is dropped without valid points or id", () => { |
| 736 | let s = boardReduce(undefined, { type: "board.open" }); |
| 737 | assert.equal(s.open, true); |
| 738 | const same = boardReduce(s, { type: "board.open" }); |
| 739 | assert.equal(same, s, "re-opening returns the same state object"); |
| 740 | |
| 741 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0]] }); // 1 point |
| 742 | assert.deepEqual(s.strokes, [], "a single-point stroke is not a line"); |
| 743 | s = boardReduce(s, { type: "board.stroke", points: [[0, 0], [1, 1]] }); // no id |
| 744 | assert.deepEqual(s.strokes, [], "no id, no stroke"); |
| 745 | }); |
| 746 | |
| 747 | await test("board: stroke folds with sane color/width fallbacks; add is deduped by id", () => { |
| 748 | let s = boardReduce(undefined, { |
| 749 | type: "board.stroke", |
| 750 | id: "s1", |
| 751 | points: [[0, 0], [10, 10]], |
| 752 | color: "not-a-color", |
| 753 | width: 999, |
| 754 | }); |
| 755 | assert.equal(s.strokes.length, 1); |
| 756 | assert.equal(s.strokes[0].color, "#2a211b", "unknown color falls back to the default"); |
| 757 | assert.equal(s.strokes[0].width, 6, "unknown width falls back to the medium brush"); |
| 758 | |
| 759 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[5, 5], [6, 6]], color: "#c2410c" }); |
| 760 | assert.equal(s.strokes.length, 1, "duplicate id is a no-op add"); |
| 761 | assert.equal(s.strokes[0].color, "#2a211b", "the original stroke wins, not overwritten"); |
| 762 | }); |
| 763 | |
| 764 | await test("board: clear empties strokes but leaves the panel open", () => { |
| 765 | let s = boardReduce(undefined, { type: "board.open" }); |
| 766 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); |
| 767 | s = boardReduce(s, { type: "board.clear" }); |
| 768 | assert.deepEqual(s.strokes, []); |
| 769 | assert.equal(s.open, true, "clearing the drawing doesn't hide the panel"); |
| 770 | }); |
| 771 | |
| 772 | await test("board: close hides the panel without touching strokes; idempotent", () => { |
| 773 | let s = boardReduce(undefined, { type: "board.open" }); |
| 774 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); |
| 775 | s = boardReduce(s, { type: "board.close" }); |
| 776 | assert.equal(s.open, false); |
| 777 | assert.equal(s.strokes.length, 1, "closing preserves the drawing for next time"); |
| 778 | const same = boardReduce(s, { type: "board.close" }); |
| 779 | assert.equal(same, s, "re-closing returns the same state object"); |
| 780 | |
| 781 | // Reopening picks up right where it left off. |
| 782 | s = boardReduce(s, { type: "board.open" }); |
| 783 | assert.equal(s.open, true); |
| 784 | assert.equal(s.strokes.length, 1); |
| 785 | }); |
| 786 | |
| 787 | await test("installGroupApp adds the app and, for openEvent apps (board), opens it too", async () => { |
| 788 | resetThread("installGroupChat1"); |
| 789 | const sent = []; |
| 790 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 791 | assert.equal(await installGroupApp("installGroupChat1", boardApp), true); |
| 792 | assert.deepEqual(sent.map((s) => s.event.type), ["groupapps.add", "board.open"]); |
| 793 | assert.ok(sent.every((s) => s.threadId === "installGroupChat1")); |
| 794 | }); |
| 795 | |
| 796 | await test("installGroupApp is a no-op when the app is already added — no re-add, no re-open", async () => { |
| 797 | resetThread("installGroupChat2"); |
| 798 | foldEvent("installGroupChat2", { type: "groupapps.add", app: "board", name: "Ana" }, { id: 1 }); |
| 799 | assert.ok(groupAppsFor("installGroupChat2").some((a) => a.name === "board")); |
| 800 | const sent = []; |
| 801 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 802 | assert.equal(await installGroupApp("installGroupChat2", boardApp), true); |
| 803 | assert.equal(sent.length, 0, "already added — nothing re-emitted, including no re-open"); |
| 804 | }); |
| 805 | |
| 806 | await test("installGroupApp doesn't fire an openEvent for apps that don't declare one (poll)", async () => { |
| 807 | resetThread("installGroupChat3"); |
| 808 | const sent = []; |
| 809 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 810 | assert.equal(await installGroupApp("installGroupChat3", pollApp), true); |
| 811 | assert.deepEqual(sent.map((e) => e.type), ["groupapps.add"]); |
| 812 | }); |
| 813 | |
| 814 | await test("board: owned types are exactly open/close/stroke/clear", () => { |
| 815 | assert.deepEqual(boardApp.types, ["board.open", "board.close", "board.stroke", "board.clear"]); |
| 816 | }); |
| 817 | |
| 818 | await test("remind: parseReminder handles relative, clock, and explicit times", () => { |
| 819 | // Relative: text before "in <n><unit>", when = now + offset. |
| 820 | assert.deepEqual(parseReminder("Call mom in 2h", 0), { text: "Call mom", when: 2 * 3600000 }); |
| 821 | assert.deepEqual(parseReminder("stretch in 45m", 1000), { text: "stretch", when: 1000 + 45 * 60000 }); |
| 822 | // Clock time: next occurrence of 9:00, always in the future, within a day. |
| 823 | const at = parseReminder("Standup at 9:00", 0); |
| 824 | assert.equal(at.text, "Standup"); |
| 825 | assert.ok(at.when > 0 && at.when <= 24 * 3600000, "next 9:00 is within a day"); |
| 826 | // Explicit date after "|" (local midnight → 09:00 default), text preserved. |
| 827 | const abs = parseReminder("Renew passport | 2026-08-01", 0); |
| 828 | assert.equal(abs.text, "Renew passport"); |
| 829 | const d = new Date(abs.when); |
| 830 | assert.equal(d.getFullYear(), 2026); |
| 831 | assert.equal(d.getMonth(), 7); // August (0-based) |
| 832 | assert.equal(d.getDate(), 1); |
| 833 | }); |
| 834 | |
| 835 | await test("remind: parseReminder accepts Slack phrasing (me / front-loaded time / to)", () => { |
| 836 | // "/remind me in 10 seconds to do a thing" — leading "me" dropped, time leads. |
| 837 | assert.deepEqual(parseReminder("me in 10 seconds to do a thing", 0), { text: "do a thing", when: 10000 }); |
| 838 | assert.deepEqual(parseReminder("in 5 minutes to stretch", 1000), { text: "stretch", when: 1000 + 5 * 60000 }); |
| 839 | assert.deepEqual(parseReminder("me to water plants in 1 h", 0), { text: "water plants", when: 3600000 }); |
| 840 | // "meeting" must NOT be read as the "me" keyword. |
| 841 | assert.deepEqual(parseReminder("meeting in 10m", 0), { text: "meeting", when: 10 * 60000 }); |
| 842 | }); |
| 843 | |
| 844 | await test("remind: parseReminder rejects text with no time or a bad unit", () => { |
| 845 | assert.equal(parseReminder("just a note", 0), null); |
| 846 | assert.equal(parseReminder("", 0), null); |
| 847 | assert.equal(parseReminder("do it in 3x", 0), null); // unknown unit |
| 848 | assert.equal(parseReminder("in 2h", 0), null); // no text before the time |
| 849 | // Explicit dates/times must be rejected, not rolled over, when out of range. |
| 850 | assert.equal(parseReminder("bad | 2026-06-31", 0), null); // June has 30 days |
| 851 | assert.equal(parseReminder("bad | 2026-02-29", 0), null); // 2026 isn't a leap year |
| 852 | assert.equal(parseReminder("bad | 2026-13-01", 0), null); // month > 12 |
| 853 | assert.equal(parseReminder("bad | 2026-08-01 24:00", 0), null); // hour > 23 |
| 854 | assert.equal(parseReminder("bad | 2026-08-01 12:60", 0), null); // minute > 59 |
| 855 | }); |
| 856 | |
| 857 | await test("remind: reduce adds (soonest first), fires, and dismisses", () => { |
| 858 | let s = remindReduce(undefined, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); |
| 859 | s = remindReduce(s, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); // dup id |
| 860 | assert.equal(s.items.length, 1); |
| 861 | s = remindReduce(s, { type: "remind.add", id: "r2", text: "sooner", when: 100, ts: 2 }); |
| 862 | assert.equal(s.items[0].id, "r2", "sorted soonest-first"); |
| 863 | // fire records firedAt (the event's send time) so it can tuck into Previous. |
| 864 | s = remindReduce(s, { type: "remind.fire", id: "r2", ts: 555 }); |
| 865 | assert.equal(s.items.find((i) => i.id === "r2").fired, true); |
| 866 | assert.equal(s.items.find((i) => i.id === "r2").firedAt, 555); |
| 867 | s = remindReduce(s, { type: "remind.done", id: "r2" }); |
| 868 | assert.deepEqual(s.items.map((i) => i.id), ["r1"]); |
| 869 | }); |
| 870 | |
| 871 | await test("bookmarks: adds dedupe by id, newest first, carry `from`, removable", () => { |
| 872 | let s = bookmarksReduce(undefined, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); |
| 873 | s = bookmarksReduce(s, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); // dup |
| 874 | assert.equal(s.items.length, 1); |
| 875 | s = bookmarksReduce(s, { type: "bookmark.add", id: "b2", url: "https://b.com", from: "Bob", ts: 2 }); |
| 876 | assert.equal(s.items[0].id, "b2", "newest first"); |
| 877 | assert.equal(s.items[0].from, "Bob", "keeps who shared it"); |
| 878 | s = bookmarksReduce(s, { type: "bookmark.remove", id: "b1" }); |
| 879 | assert.deepEqual(s.items.map((i) => i.id), ["b2"]); |
| 880 | }); |
| 881 | |
| 882 | // ---- snapshots: state that outlives the message window ------------------- |
| 883 | |
| 884 | await test("seedThread + foldEvent: snapshot state, skip covered ids, fold newer", () => { |
| 885 | resetThread("s1"); |
| 886 | // Seed a snapshot covering message ids <= 100 with one existing item. |
| 887 | seedThread("s1", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); |
| 888 | assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old"]); |
| 889 | // An event AT or BELOW the horizon is recognised (returns the app — its inline |
| 890 | // line may render) but not re-reduced: the snapshot already contains it. |
| 891 | const covered = foldEvent("s1", { type: "todo.add", id: "old", text: "from snapshot" }, { id: 100 }); |
| 892 | assert.equal(covered && covered.name, "todo"); |
| 893 | assert.equal(appStateFor("s1", "todo").items.length, 1, "covered event not re-folded"); |
| 894 | // An event past the horizon folds normally on top of the seeded state. |
| 895 | foldEvent("s1", { type: "todo.add", id: "new", text: "after snapshot" }, { id: 101 }); |
| 896 | assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old", "new"]); |
| 897 | }); |
| 898 | |
| 899 | await test("seedThread reinstalls the snapshot as the authoritative base, superseding a stale live fold", () => { |
| 900 | resetThread("s2"); |
| 901 | // A live event folded before the snapshot loaded (the openThread race). Its id (5) |
| 902 | // is BELOW the snapshot horizon (99), so the snapshot at 99 already accounts for it |
| 903 | // — the snapshot is the authoritative floor and supersedes the partial live fold. |
| 904 | // (An event ABOVE the horizon is instead restored by the caller's catch-up; see the |
| 905 | // race test below.) The old guard kept the live fold and dropped the snapshot, which |
| 906 | // lost every item the interim events don't re-cover. |
| 907 | foldEvent("s2", { type: "todo.add", id: "a", text: "live" }, { id: 5 }); |
| 908 | seedThread("s2", { todo: { items: [] } }, 99); |
| 909 | assert.deepEqual(appStateFor("s2", "todo").items.map((i) => i.id), []); |
| 910 | }); |
| 911 | |
| 912 | await test("seedThread survives a live event that raced the snapshot load (no lost history)", () => { |
| 913 | resetThread("race"); |
| 914 | // The data-loss race: openThread resets, then awaits the snapshot; a live app event |
| 915 | // for the thread folds FIRST (id 150, above the eventual horizon), creating the store. |
| 916 | foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); |
| 917 | // The snapshot (older history, horizon 100) then loads and installs. It must NOT be |
| 918 | // skipped just because interim state exists — the older item has to survive. |
| 919 | seedThread("race", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); |
| 920 | // The caller's catch-up re-reads everything after the horizon and re-folds the interim |
| 921 | // event onto the base — seedThread cleared its dedupe mark, so this isn't skipped. |
| 922 | foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); |
| 923 | assert.deepEqual( |
| 924 | appStateFor("race", "todo").items.map((i) => i.id), |
| 925 | ["old", "live"], |
| 926 | "snapshot history survived AND the raced event was restored", |
| 927 | ); |
| 928 | // And the persisted snapshot is NOT truncated at an advanced horizon: it covers both, |
| 929 | // anchored at the highest folded id (150), so the next open won't skip the old history. |
| 930 | const snap = takeSnapshot("race"); |
| 931 | assert.equal(snap.upTo, 150); |
| 932 | assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["old", "live"]); |
| 933 | }); |
| 934 | |
| 935 | await test("takeSnapshot returns new state once, anchored at the highest folded id", () => { |
| 936 | resetThread("s3"); |
| 937 | assert.equal(takeSnapshot("s3"), null, "nothing folded → nothing to save"); |
| 938 | foldEvent("s3", { type: "todo.add", id: "a", text: "apples" }, { id: 7 }); |
| 939 | foldEvent("s3", { type: "todo.add", id: "b", text: "bread" }, { id: 9 }); |
| 940 | const snap = takeSnapshot("s3"); |
| 941 | assert.equal(snap.upTo, 9); |
| 942 | assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["a", "b"]); |
| 943 | assert.equal(takeSnapshot("s3"), null, "clean after taking — no re-save loop"); |
| 944 | foldEvent("s3", { type: "todo.done", id: "a" }, { id: 10 }); |
| 945 | const snap2 = takeSnapshot("s3"); |
| 946 | assert.equal(snap2.upTo, 10, "re-dirtied by the next fold"); |
| 947 | }); |
| 948 | |
| 949 | // ---- scheduling: ctx.schedule / ctx.unschedule ---------------------------- |
| 950 | |
| 951 | await test("/remind schedules a server-delivered fire, then records the sid", async () => { |
| 952 | const sent = []; |
| 953 | const scheduled = []; |
| 954 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 955 | setScheduleTransport({ |
| 956 | schedule: async (threadId, event, whenMs) => { |
| 957 | scheduled.push({ threadId, event, whenMs }); |
| 958 | return "sid-1"; |
| 959 | }, |
| 960 | cancel: async () => true, |
| 961 | }); |
| 962 | assert.equal(runCommand("/remind Call mom in 2h", "chatX"), true); |
| 963 | await tick(); |
| 964 | // The future remind.fire went to the scheduler, carrying its own notice |
| 965 | // (what the service worker shows when the app is closed). |
| 966 | assert.equal(scheduled.length, 1); |
| 967 | assert.equal(scheduled[0].threadId, "todoThread"); // the resolved app-thread |
| 968 | assert.equal(scheduled[0].event.type, "remind.fire"); |
| 969 | assert.equal(scheduled[0].event.notice.title, "⏰ Reminder"); |
| 970 | assert.equal(scheduled[0].event.notice.body, "Call mom"); |
| 971 | // The recorded reminder carries the schedule id, so ANY device can cancel it. |
| 972 | assert.equal(sent.length, 1); |
| 973 | assert.equal(sent[0].event.type, "remind.add"); |
| 974 | assert.equal(sent[0].event.sid, "sid-1"); |
| 975 | assert.equal(sent[0].event.id, scheduled[0].event.id, "fire targets the same reminder id"); |
| 976 | }); |
| 977 | |
| 978 | await test("a failed schedule still records the reminder, sid-less (client fallback)", async () => { |
| 979 | const sent = []; |
| 980 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 981 | setScheduleTransport({ schedule: async () => null, cancel: async () => false }); |
| 982 | runCommand("/remind stretch in 5m", "chatX"); |
| 983 | await tick(); |
| 984 | assert.equal(sent.length, 1); |
| 985 | assert.equal(sent[0].sid, "", "no sid — the render ticker owns firing"); |
| 986 | }); |
| 987 | |
| 988 | // ---- spontaneous app → user communication --------------------------------- |
| 989 | |
| 990 | await test("notifyLive: app events never floating-toast; a hidden page gets a system notification", () => { |
| 991 | // A hook-less app: the notice riding the event is what speaks. |
| 992 | defineApp({ |
| 993 | name: "notix", |
| 994 | title: "Notix", |
| 995 | glyph: "×", |
| 996 | scope: "system", |
| 997 | initialState: () => ({}), |
| 998 | events: { "notix.ping": (s) => s }, |
| 999 | }); |
| 1000 | const toasts = []; |
| 1001 | setToast((m) => toasts.push(m)); |
| 1002 | resetThread("n1"); |
| 1003 | |
| 1004 | // Stand in for the browser: a hidden page with notifications granted. |
| 1005 | const notes = []; |
| 1006 | const priorDoc = globalThis.document; |
| 1007 | const priorNote = globalThis.Notification; |
| 1008 | globalThis.document = { visibilityState: "hidden" }; |
| 1009 | globalThis.Notification = function (title, opts) { |
| 1010 | notes.push({ title, body: opts && opts.body }); |
| 1011 | }; |
| 1012 | globalThis.Notification.permission = "granted"; |
| 1013 | try { |
| 1014 | notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "Call mom" } }); |
| 1015 | // No floating toast — a live app event announces itself via its thread (which |
| 1016 | // floats up the chat list with an unread dot), never a pop-up over your reading. |
| 1017 | assert.deepEqual(toasts, []); |
| 1018 | // But a hidden page still gets the background system notification — the |
| 1019 | // in-session equivalent of the push a fully-closed app would receive. |
| 1020 | assert.deepEqual(notes, [{ title: "⏰ Reminder", body: "Call mom" }]); |
| 1021 | |
| 1022 | // A VISIBLE page raises no system notification (you're already here) — and still no toast. |
| 1023 | globalThis.document.visibilityState = "visible"; |
| 1024 | notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "again" } }); |
| 1025 | assert.equal(notes.length, 1); |
| 1026 | assert.equal(toasts.length, 0); |
| 1027 | |
| 1028 | // An event with no notice (and no notify hook) stays silent; so does a type no app owns. |
| 1029 | globalThis.document.visibilityState = "hidden"; |
| 1030 | notifyLive("n1", { type: "notix.ping" }); |
| 1031 | notifyLive("n1", { type: "mystery.event", notice: { title: "spoof" } }); |
| 1032 | assert.equal(notes.length, 1); |
| 1033 | } finally { |
| 1034 | globalThis.document = priorDoc; |
| 1035 | globalThis.Notification = priorNote; |
| 1036 | } |
| 1037 | }); |
| 1038 | |
| 1039 | await test("command reply: only confirmations (those with a `short`) calm down", () => { |
| 1040 | // A confirmation supplies a terse `short` → it counts down and shrinks. |
| 1041 | assert.equal(noticeCalmsDown({ text: "Added “milk” to your to-do list.", short: "added “milk”" }), true); |
| 1042 | // A genuine content reply like /help gives no `short` → it stays a real message. |
| 1043 | assert.equal(noticeCalmsDown({ sender: { name: "Eleven" }, text: "Commands you can use…" }), false); |
| 1044 | assert.equal(noticeCalmsDown({ short: " " }), false); // whitespace-only doesn't count |
| 1045 | assert.equal(noticeCalmsDown({}), false); |
| 1046 | assert.equal(noticeCalmsDown(), false); |
| 1047 | }); |
| 1048 | |
| 1049 | await test("command reply: compact one-liner parts + the loud→calm age threshold", () => { |
| 1050 | // The calmed-down one-liner prefers the reply's own terse `short`, else its text. |
| 1051 | assert.deepEqual( |
| 1052 | noticeParts({ sender: { name: "Reminders" }, text: "I'll remind you: “fart”.", short: "added “fart”" }), |
| 1053 | { from: "Reminders", what: "added “fart”" }, |
| 1054 | ); |
| 1055 | assert.deepEqual( |
| 1056 | noticeParts({ sender: { name: "Eleven" }, text: "Commands you can use…" }), |
| 1057 | { from: "Eleven", what: "Commands you can use…" }, |
| 1058 | ); |
| 1059 | assert.deepEqual(noticeParts({}), { from: "", what: "" }); |
| 1060 | |
| 1061 | // A fresh reply is loud; once aged past the threshold it renders compact. A reply |
| 1062 | // with no timestamp never auto-collapses (stays as first built). |
| 1063 | const now = 1_000_000; |
| 1064 | assert.equal(noticeIsCompact(now, now), false); |
| 1065 | assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS + 1, now), false); |
| 1066 | assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS, now), true); |
| 1067 | assert.equal(noticeIsCompact(0, now), false); |
| 1068 | }); |
| 1069 | |
| 1070 | await test("remind: chatMessage — fire is a bot message, setting is an action line", () => { |
| 1071 | // A fired reminder is a message from the bot (a string → a bubble), not a toast. |
| 1072 | assert.equal( |
| 1073 | remindApp.chatMessage({ type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "Call mom" } }), |
| 1074 | "You asked me to remind you: “Call mom”", |
| 1075 | ); |
| 1076 | // Setting one is an action you took → a /me-style line, not a bubble. |
| 1077 | const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "Call mom", when: 99 }); |
| 1078 | assert.equal(add.action, true); |
| 1079 | assert.match(add.text, /^set a reminder: “Call mom”/); |
| 1080 | // Housekeeping events produce no line. |
| 1081 | assert.equal(remindApp.chatMessage({ type: "remind.done", id: "r1" }), null); |
| 1082 | }); |
| 1083 | |
| 1084 | await test("todo: chatMessage renders an add as an action line, others silent", () => { |
| 1085 | const add = todoApp.chatMessage({ type: "todo.add", id: "t1", text: "buy milk" }); |
| 1086 | assert.equal(add.action, true); |
| 1087 | assert.match(add.text, /added “buy milk”/); |
| 1088 | assert.equal(todoApp.chatMessage({ type: "todo.done", id: "t1" }), null); |
| 1089 | }); |
| 1090 | |
| 1091 | await test("one message folds into TWO apps (owner state + chat action bubble)", () => { |
| 1092 | resetThread("dual"); |
| 1093 | // The SAME server message id (70) folds into the owning app (state) AND the chat |
| 1094 | // app (an action-line bubble). De-dupe must be per-app, or the chat fold is |
| 1095 | // silently dropped (the bug: the Interface updated but no action line appeared). |
| 1096 | foldEvent("dual", { type: "todo.add", id: "x1", text: "milk" }, { id: 70 }); |
| 1097 | foldHostEvent("dual", "chat", { type: "chat.msg", name: "To-do", text: "added milk", uid: "app:todo", action: true }, { id: 70 }); |
| 1098 | assert.equal(appStateFor("dual", "todo").items.length, 1, "owner app folded the event"); |
| 1099 | const chat = appStateFor("dual", "chat"); |
| 1100 | assert.equal(chat.msgs.length, 1, "chat app also folded the same message id"); |
| 1101 | assert.equal(chat.msgs[0].action, true, "and it's flagged as an action line"); |
| 1102 | }); |
| 1103 | |
| 1104 | await test("chat: an action line's raw payload keeps action:true (so editing it doesn't turn it into a bubble)", () => { |
| 1105 | const reduce = chatApp.reduce; |
| 1106 | const s = reduce(undefined, { type: "chat.msg", text: "Alice waves", name: "Alice", uid: "u-al", action: true }, { id: 90 }); |
| 1107 | assert.equal(s.msgs[0].raw.action, true, "{ ...raw, text } re-sends action:true on edit"); |
| 1108 | }); |
| 1109 | |
| 1110 | // ---- chat as an app (the lab) -------------------------------------------- |
| 1111 | // The chat app consumes the EXISTING protocol: original-shaped messages fold as host |
| 1112 | // events keyed by server message id, and reactions/edits/deletes are host-only |
| 1113 | // translations of the server's own frames. |
| 1114 | |
| 1115 | await test("chat: folds original-shaped messages (text, attachments, replies), dedupes by server id", () => { |
| 1116 | const reduce = chatApp.reduce; |
| 1117 | let s = reduce(undefined, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); |
| 1118 | s = reduce(s, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); // re-fold |
| 1119 | assert.equal(s.msgs.length, 1); |
| 1120 | assert.equal(s.msgs[0].id, "m10", "keyed by the server message id — the original identity"); |
| 1121 | // Attachment-only messages (a photo with no caption) still fold, with the RAW |
| 1122 | // metadata preserved (blob id, dimensions — needed to rebuild an edit payload). |
| 1123 | const att = { id: "blob1", name: "cat.jpg", type: "image/jpeg", size: 1024, w: 640, h: 480 }; |
| 1124 | s = reduce(s, { type: "chat.msg", attachments: [att], name: "Bo" }, { id: 11 }); |
| 1125 | assert.deepEqual(s.msgs[1].attachments, [att]); |
| 1126 | assert.deepEqual(s.msgs[1].raw.attachments, [att], "raw payload kept for { ...orig, text } edits"); |
| 1127 | // A reply carries its parent link + snippet through (the id feeds tallies). |
| 1128 | s = reduce(s, { type: "chat.msg", text: "yes!", replyTo: { id: 10, name: "Al", snippet: "hi" } }, { id: 12 }); |
| 1129 | // thread:false — the flag absent on the wire is an inline quote reply (inMainFeed). |
| 1130 | assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi", thread: false }); |
| 1131 | // A plain quote reply tallies NOTHING — it's already visible in the feed. |
| 1132 | // Only a thread reply (replyTo.thread) moves a root's "N Replies". |
| 1133 | let v = viewItems(s.msgs); |
| 1134 | assert.equal(v[0].replyCount, 0, "an inline quote reply leaves its parent tally-free"); |
| 1135 | s = reduce(s, { type: "chat.msg", text: "in thread", replyTo: { id: 10, name: "Al", snippet: "hi", thread: true } }, { id: 14 }); |
| 1136 | // The tally rides the SOFT rev (srev), so it patches the reply-count in |
| 1137 | // place — it must NOT be in the hard vrev, which would rebuild the whole |
| 1138 | // bubble (and reload a photo). |
| 1139 | v = viewItems(s.msgs); |
| 1140 | assert.equal(v[0].replyCount, 1, "the thread reply is what the parent counts"); |
| 1141 | assert.ok(v[0].srev.endsWith("|1"), "the tally rides the soft rev (patched in place)"); |
| 1142 | assert.equal(v[0].vrev, "0|D|0", "the tally is NOT in the hard rev — no bubble rebuild"); |
| 1143 | s = reduce(s, { type: "chat.msg", text: " " }, { id: 13 }); // nothing to say |
| 1144 | assert.equal(s.msgs.length, 4); |
| 1145 | }); |
| 1146 | |
| 1147 | await test("chat: a scroll-back page folded later still sorts before newer messages", () => { |
| 1148 | const reduce = chatApp.reduce; |
| 1149 | let s = reduce(undefined, { type: "chat.msg", text: "newer", ts: 5 }, { id: 30 }); |
| 1150 | s = reduce(s, { type: "chat.msg", text: "older", ts: 1 }, { id: 10 }); // paged in afterwards |
| 1151 | assert.deepEqual(s.msgs.map((m) => m.sid), [10, 30], "sorted by server id, not fold order"); |
| 1152 | }); |
| 1153 | |
| 1154 | await test("chat: a legacy row with no payload ts dates from the fold meta (created_at), not 1970", () => { |
| 1155 | const reduce = chatApp.reduce; |
| 1156 | // Rows predating ts-stamping carry no inline ts; chatAppFoldMessage passes the |
| 1157 | // server's created_at (ms) through as meta.ts so they date correctly, not Jan 1970. |
| 1158 | const s = reduce(undefined, { type: "chat.msg", text: "legacy" }, { id: 40, ts: 1720000000000 }); |
| 1159 | assert.equal(s.msgs[0].ts, 1720000000000, "falls back to meta.ts, not 0"); |
| 1160 | }); |
| 1161 | |
| 1162 | await test("chat: viewItems derives time separators and sender runs (original feed rules)", () => { |
| 1163 | const DAY = 24 * 3600 * 1000; |
| 1164 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1165 | const msgs = [ |
| 1166 | { id: "m1", sid: 1, name: "Al", ts: base, rev: 0 }, |
| 1167 | { id: "m2", sid: 2, name: "Al", ts: base + 60 * 1000, rev: 0 }, // same run: 1 min later |
| 1168 | { id: "m3", sid: 3, name: "Bo", ts: base + 2 * 60 * 1000, rev: 0 }, // new sender |
| 1169 | { id: "m4", sid: 4, name: "Bo", ts: base + 20 * 60 * 1000, rev: 0 }, // >15 min gap, same day |
| 1170 | { id: "m5", sid: 5, name: "Bo", ts: base + DAY, rev: 0 }, // next day |
| 1171 | ]; |
| 1172 | const v = viewItems(msgs); |
| 1173 | assert.deepEqual(v[0].timeSep, { withDay: true }, "the oldest message opens with a dated separator"); |
| 1174 | assert.equal(v[0].cont, false); |
| 1175 | assert.equal(v[1].timeSep, null); |
| 1176 | assert.equal(v[1].cont, true, "same sender, no separator — a continuation"); |
| 1177 | assert.equal(v[2].cont, false, "sender changed"); |
| 1178 | assert.deepEqual(v[3].timeSep, { withDay: false }, ">15 min gap gets a time-only separator"); |
| 1179 | assert.equal(v[3].cont, false, "a separator restarts the run even for the same sender"); |
| 1180 | assert.deepEqual(v[4].timeSep, { withDay: true }, "a calendar-day change gets a dated separator"); |
| 1181 | // Presentation rides the rev, so a neighbour change re-renders the right bubble. |
| 1182 | assert.notEqual(v[1].vrev, v[2].vrev); |
| 1183 | }); |
| 1184 | |
| 1185 | await test("chat: viewItems groups runs by uid — a rename doesn't split, nameless senders don't merge", () => { |
| 1186 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1187 | const v = viewItems([ |
| 1188 | { id: "m1", sid: 1, uid: "u1", name: "Dermo", ts: base, rev: 0 }, |
| 1189 | { id: "m2", sid: 2, uid: "u1", name: "Dermot", ts: base + 1000, rev: 0 }, |
| 1190 | { id: "m3", sid: 3, uid: "u2", name: "", ts: base + 2000, rev: 0 }, |
| 1191 | { id: "m4", sid: 4, uid: "u3", name: "", ts: base + 3000, rev: 0 }, |
| 1192 | ]); |
| 1193 | assert.equal(v[1].cont, true, "same uid continues the run across a rename"); |
| 1194 | assert.equal(v[2].cont, false); |
| 1195 | assert.equal(v[3].cont, false, "different uids never group, even both nameless"); |
| 1196 | }); |
| 1197 | |
| 1198 | await test("chat: threadItems selects a root's thread — the flagged replies and nothing else", () => { |
| 1199 | const msgs = [ |
| 1200 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| 1201 | { id: "m2", sid: 2, name: "Bo", text: "aside", ts: 2, reply: null, rev: 0 }, |
| 1202 | { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1203 | { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1204 | { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside", thread: true }, rev: 0 }, |
| 1205 | // A plain quote reply to the SAME root: it stacks inline in the feed and |
| 1206 | // is not in the thread — nobody chose to put it there. |
| 1207 | { id: "m6", sid: 6, name: "Di", text: "inline quote", ts: 6, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| 1208 | ]; |
| 1209 | assert.deepEqual( |
| 1210 | threadItems(msgs, 1).map((m) => m.sid), |
| 1211 | [1, 3, 4], |
| 1212 | "root first, then its thread in server order; asides, other threads and the inline quote reply stay out", |
| 1213 | ); |
| 1214 | assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2, 5], "the other thread folds on its own root"); |
| 1215 | // Root scrolled out of the loaded window: the thread still folds from the |
| 1216 | // replies we can see (the view renders the quoted snapshot as its header). |
| 1217 | assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only"); |
| 1218 | assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread"); |
| 1219 | assert.deepEqual(threadItems(msgs, 0), [], "sid 0 is the pending-copy key, never a thread root"); |
| 1220 | }); |
| 1221 | |
| 1222 | await test("chat: an inline reply chain is NOT a thread — no absorption, no tally", () => { |
| 1223 | // The regression the two-verb split exists to prevent: replying to a reply |
| 1224 | // used to root-resolve and silently join (or invent) a thread. |
| 1225 | const msgs = [ |
| 1226 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| 1227 | { id: "m2", sid: 2, name: "Bo", text: "re", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| 1228 | { id: "m3", sid: 3, name: "Cy", text: "re-re", ts: 3, reply: { id: 2, name: "Bo", snippet: "re" }, rev: 0 }, |
| 1229 | ]; |
| 1230 | assert.deepEqual(threadItems(msgs, 1).map((m) => m.sid), [1], "the root's thread is just the root — the chain isn't in it"); |
| 1231 | assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2], "and the middle reply has no thread of its own either"); |
| 1232 | const v = viewItems(msgs, { mainFeed: true }); |
| 1233 | assert.deepEqual(v.map((m) => m.sid), [1, 2, 3], "all three stack inline in the feed"); |
| 1234 | assert.deepEqual(v.map((m) => m.replyCount), [0, 0, 0], "and NOTHING wears a tally"); |
| 1235 | assert.deepEqual(v.map((m) => m.replyRoot), [0, 0, 0], "no message belongs to a thread"); |
| 1236 | assert.deepEqual(v.map((m) => threadLinkCount(m)), [0, 0, 0], "no quote card doubles as a thread link"); |
| 1237 | }); |
| 1238 | |
| 1239 | // ---- "Convert to thread": an inline chain moved into a thread after the fact. |
| 1240 | // The messages can't be rewritten (only their author may edit one, and only |
| 1241 | // briefly), so the conversion is a separate encrypted fold event and the whole |
| 1242 | // rule set lives in these four pure helpers. |
| 1243 | |
| 1244 | const convertChainFixture = () => [ |
| 1245 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, raw: { text: "root" }, rev: 0 }, |
| 1246 | { id: "m2", sid: 2, name: "Bo", text: "re", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, raw: { text: "re" }, rev: 0 }, |
| 1247 | { id: "m3", sid: 3, name: "Cy", text: "re-re", ts: 3, reply: { id: 2, name: "Bo", snippet: "re" }, raw: { text: "re-re" }, rev: 0 }, |
| 1248 | { id: "m4", sid: 4, name: "Di", text: "aside", ts: 4, reply: null, raw: { text: "aside" }, rev: 0 }, |
| 1249 | ]; |
| 1250 | |
| 1251 | await test("chat: inlineChainIds gathers the chain under a message — and only that", () => { |
| 1252 | const msgs = convertChainFixture(); |
| 1253 | assert.deepEqual(inlineChainIds(msgs, 1), [2, 3], "the reply and the reply to it; the unrelated aside stays put"); |
| 1254 | assert.deepEqual(inlineChainIds(msgs, 2), [3], "from mid-chain, only what hangs below"); |
| 1255 | assert.deepEqual(inlineChainIds(msgs, 3), [], "nothing under the last reply — so the verb isn't offered"); |
| 1256 | assert.deepEqual(inlineChainIds(msgs, 4), [], "and none at all under a message nobody answered"); |
| 1257 | assert.deepEqual(inlineChainIds(msgs, 99), [], "an unloaded message has no chain we can see"); |
| 1258 | assert.deepEqual(inlineChainIds(msgs, 0), [], "sid 0 is the pending-copy key, never a root"); |
| 1259 | // A reply someone made IN a thread is not inline conversation: it's already |
| 1260 | // somewhere, so it never gets swept up — and a bubble inside a thread has no |
| 1261 | // chain at all, which is what keeps the verb off the thread pane's menus. |
| 1262 | const withThread = [ |
| 1263 | ...msgs, |
| 1264 | { id: "m5", sid: 5, name: "Al", text: "in thread", ts: 5, reply: { id: 4, name: "Di", snippet: "aside", thread: true }, raw: { text: "in thread" }, rev: 0 }, |
| 1265 | { id: "m6", sid: 6, name: "Bo", text: "quotes it", ts: 6, reply: { id: 5, name: "Al", snippet: "in thread" }, raw: { text: "quotes it" }, rev: 0 }, |
| 1266 | ]; |
| 1267 | assert.deepEqual(inlineChainIds(withThread, 4), [], "the thread reply under it is in a thread already"); |
| 1268 | assert.deepEqual(inlineChainIds(withThread, 5), [], "and a message inside a thread has no inline chain to convert"); |
| 1269 | // Pending copies (no sid yet) can't be converted — the server has never |
| 1270 | // heard of them, so no other client could resolve the id. |
| 1271 | const pending = [...msgs, { id: "cx", sid: 0, cid: "cx", pending: true, name: "Al", text: "sending", ts: 9, reply: { id: 3, name: "Cy", snippet: "re-re" }, raw: {}, rev: 0 }]; |
| 1272 | assert.deepEqual(inlineChainIds(pending, 1), [2, 3], "the in-flight reply isn't in the chain until it confirms"); |
| 1273 | }); |
| 1274 | |
| 1275 | await test("chat: claimConverts — the oldest conversion owns a message, repeats are no-ops", () => { |
| 1276 | const first = claimConverts({}, 1, [2, 3], 50); |
| 1277 | assert.deepEqual(first, { 2: { root: 1, at: 50 }, 3: { root: 1, at: 50 } }); |
| 1278 | assert.equal(claimConverts(first, 1, [2, 3], 50), null, "re-folding the same event changes nothing (history + live echo)"); |
| 1279 | assert.equal(claimConverts(first, 9, [2], 70), null, "a LATER conversion can't steal a message from an earlier one"); |
| 1280 | const older = claimConverts(first, 9, [2], 40); |
| 1281 | assert.deepEqual(older[2], { root: 9, at: 40 }, "an EARLIER one wins, whichever order they arrive in"); |
| 1282 | assert.deepEqual(older[3], { root: 1, at: 50 }, "…and leaves its neighbours alone"); |
| 1283 | assert.equal(claimConverts({}, 1, [1], 50), null, "a root is never inside its own thread"); |
| 1284 | assert.equal(claimConverts({}, 1, [], 50), null, "an empty conversion is a no-op"); |
| 1285 | }); |
| 1286 | |
| 1287 | await test("chat: a conversion validates ids the way Swift's decoder does — no coercion", () => { |
| 1288 | // The cross-platform contract's hardest rule: web and native must fold the |
| 1289 | // SAME event into the same feed. Swift types root/ids as Int64, so a stringy |
| 1290 | // or fractional id fails to decode and native drops the whole payload — and |
| 1291 | // a `Number()` coercion here would convert on web and do nothing there. |
| 1292 | const fold = (payload, id = 9) => chatApp.reduce({ msgs: [] }, { type: "chat.convert", ...payload }, { id }); |
| 1293 | assert.equal(fold({ root: "400", ids: ["401", "402"] }).converts, undefined, "a stringy payload folds to nothing, as it does natively"); |
| 1294 | assert.equal(fold({ root: 400.5, ids: [401] }).converts, undefined, "…and a fractional root"); |
| 1295 | assert.equal(fold({ root: -1, ids: [401] }).converts, undefined, "…and a negative one: -1 would stamp a thread rooted on an id that can never exist"); |
| 1296 | assert.equal(fold({ root: 0, ids: [401] }).converts, undefined); |
| 1297 | assert.equal(fold({ root: 400, ids: [401] }, 0).converts, undefined, "no server id for the event itself — nothing to break ties with"); |
| 1298 | // Junk ids inside an otherwise valid event are dropped one by one. |
| 1299 | const mixed = fold({ root: 400, ids: [401, "402", 403.5, -404, null, [405], true, 406] }); |
| 1300 | assert.deepEqual(Object.keys(mixed.converts), ["401", "406"], "only the real ids claim anything"); |
| 1301 | // And the good case still works, so the guards aren't just rejecting. |
| 1302 | assert.deepEqual(Object.keys(fold({ root: 400, ids: [401, 402] }).converts), ["401", "402"]); |
| 1303 | }); |
| 1304 | |
| 1305 | await test("chat: a conversion's id list is capped, so one payload can't blow up the claim map", () => { |
| 1306 | // The server caps a blob at 64KB and can't read it, so a hostile client could |
| 1307 | // name thousands of ids in one event — and the claim map is never pruned. |
| 1308 | const huge = { type: "chat.convert", root: 1, ids: Array.from({ length: 5000 }, (_, i) => 1000 + i) }; |
| 1309 | const state = chatApp.reduce({ msgs: [] }, huge, { id: 9 }); |
| 1310 | assert.equal(Object.keys(state.converts).length, 2000, "capped, far above any real chain"); |
| 1311 | // A normal conversion is untouched by the cap. |
| 1312 | const ok = chatApp.reduce({ msgs: [] }, { type: "chat.convert", root: 1, ids: [2, 3] }, { id: 9 }); |
| 1313 | assert.deepEqual(Object.keys(ok.converts), ["2", "3"]); |
| 1314 | }); |
| 1315 | |
| 1316 | await test("chat: convertedRoots flattens nesting and drops cycles", () => { |
| 1317 | // Threads don't nest: a claim whose root was itself converted resolves |
| 1318 | // through to the outermost root. |
| 1319 | const nested = { 2: { root: 1, at: 10 }, 3: { root: 2, at: 20 }, 4: { root: 3, at: 30 } }; |
| 1320 | assert.deepEqual([...convertedRoots(nested).entries()], [[2, 1], [3, 1], [4, 1]]); |
| 1321 | // A cycle can only come from a made-up payload (a real reply's sid is always |
| 1322 | // higher than what it quotes). It terminates, and nothing moves. |
| 1323 | assert.deepEqual([...convertedRoots({ 1: { root: 2, at: 10 }, 2: { root: 1, at: 20 } }).entries()], []); |
| 1324 | assert.deepEqual([...convertedRoots({}).entries()], [], "no conversions, no claims"); |
| 1325 | assert.deepEqual([...convertedRoots(null).entries()], [], "and nothing to resolve before the first one"); |
| 1326 | }); |
| 1327 | |
| 1328 | await test("chat: applyConverts moves a chain into a thread — tally, feed and thread view together", () => { |
| 1329 | const msgs = convertChainFixture(); |
| 1330 | const claims = claimConverts({}, 1, [2, 3], 50); |
| 1331 | const moved = applyConverts(msgs, claims); |
| 1332 | assert.deepEqual(threadItems(moved, 1).map((m) => m.sid), [1, 2, 3], "the chain reads as a thread, root first, in server order"); |
| 1333 | const feed = viewItems(moved, { mainFeed: true }); |
| 1334 | assert.deepEqual(feed.map((m) => m.sid), [1, 4], "the replies leave the transcript; the root keeps its place"); |
| 1335 | assert.equal(feed[0].replyCount, 2, "and wears the tally they moved into"); |
| 1336 | assert.deepEqual(moved.map((m) => inMainFeed(m, bySidIndex(moved))), [true, false, false, true]); |
| 1337 | // The converted replies keep their own quote cards, so the chain's internal |
| 1338 | // "who answered whom" is still legible inside the thread. |
| 1339 | assert.deepEqual(moved[2].reply, { id: 2, name: "Bo", snippet: "re" }, "conversion never rewrites a message"); |
| 1340 | assert.deepEqual(threadTarget(moved, moved[2]), { id: 1, name: "Al", snippet: "root" }, "Reply in thread on a converted message continues the thread it moved into"); |
| 1341 | // Messages nothing moved keep their identity, so nothing else re-renders. |
| 1342 | assert.equal(applyConverts(moved, claims), moved, "re-applying the same claims is a no-op"); |
| 1343 | assert.equal(moved[3], msgs[3], "an untouched message is the very same object"); |
| 1344 | // A converted reply's root paging OUT degrades it back to an inline bubble, |
| 1345 | // exactly as a flagged thread reply does — the tally lives on the root. |
| 1346 | const rootless = moved.slice(1); |
| 1347 | assert.deepEqual(viewItems(rootless, { mainFeed: true }).map((m) => m.sid), [2, 3, 4], "with no root loaded to carry the tally, they show inline"); |
| 1348 | // …and the unloaded root is named by nothing rather than by the wrong |
| 1349 | // message: a flagged reply's ref IS its root's snapshot, but a converted |
| 1350 | // message's ref is whatever it quoted. |
| 1351 | assert.deepEqual(threadTarget(rootless, rootless[1]), { id: 1, name: "", snippet: "" }, "a converted message carries the root's id alone"); |
| 1352 | const flagged = [{ id: "m9", sid: 9, name: "Cy", text: "in thread", ts: 9, reply: { id: 1, name: "Al", snippet: "root", thread: true }, raw: { text: "in thread" }, rev: 0 }]; |
| 1353 | assert.deepEqual(threadTarget(flagged, flagged[0]), { id: 1, name: "Al", snippet: "root" }, "a flagged reply still stands in for its unloaded root"); |
| 1354 | }); |
| 1355 | |
| 1356 | await test("chat: a conversion can only move messages that actually hang off its root", () => { |
| 1357 | // The sender's id list is a CLAIM, checked by the receiver against the |
| 1358 | // messages themselves. Otherwise the event would be "move any N messages |
| 1359 | // under any root" — and since a conversion renders no bubble, the oldest |
| 1360 | // claim wins forever, and deleting the convert row is not an undo, one |
| 1361 | // crafted payload could quietly hide unrelated messages under an old root. |
| 1362 | const msgs = [ |
| 1363 | ...convertChainFixture(), |
| 1364 | { id: "m5", sid: 5, name: "Al", text: "elsewhere", ts: 5, reply: null, raw: { text: "elsewhere" }, rev: 0 }, |
| 1365 | ]; |
| 1366 | // 4 and 5 quote nothing; 2 quotes the root and 3 quotes 2. |
| 1367 | const claims = claimConverts({}, 1, [2, 3, 4, 5], 50); |
| 1368 | const moved = applyConverts(msgs, claims); |
| 1369 | assert.deepEqual(threadItems(moved, 1).map((m) => m.sid), [1, 2, 3], "only the real chain moved"); |
| 1370 | assert.deepEqual(viewItems(moved, { mainFeed: true }).map((m) => m.sid), [1, 4, 5], "the unrelated messages never left the feed"); |
| 1371 | assert.equal(viewItems(moved, { mainFeed: true })[0].replyCount, 2, "and the tally counts only what moved"); |
| 1372 | // A claimed message that hasn't arrived yet has no quote to check, so it |
| 1373 | // stays inline until it does — the same degradation as an unloaded root. |
| 1374 | const late = applyConverts(msgs.slice(0, 2), claims); // 3 not loaded |
| 1375 | assert.deepEqual(threadItems(late, 1).map((m) => m.sid), [1, 2], "3 isn't here to be checked"); |
| 1376 | assert.deepEqual(threadItems(applyConverts(msgs, claims), 1).map((m) => m.sid), [1, 2, 3], "…and joins the moment it arrives"); |
| 1377 | }); |
| 1378 | |
| 1379 | await test("chat: convertConfirm says what moves, pluralised, and that it's one-way", () => { |
| 1380 | assert.equal(convertConfirm(1), "Move this reply into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone."); |
| 1381 | assert.match(convertConfirm(3), /these 3 replies/, "never 'these 3 reply'"); |
| 1382 | assert.match(convertConfirm(1204), /these 1,204 replies/, "digits are locale-grouped, like every displayed count"); |
| 1383 | }); |
| 1384 | |
| 1385 | await test("chat: quoteTarget vs threadTarget — the two reply verbs", () => { |
| 1386 | const msgs = [ |
| 1387 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, raw: { text: "root" }, rev: 0 }, |
| 1388 | { id: "m2", sid: 2, name: "Bo", text: "inline", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, raw: { text: "inline" }, rev: 0 }, |
| 1389 | { id: "m3", sid: 3, name: "Cy", text: "in thread", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, raw: { text: "in thread" }, rev: 0 }, |
| 1390 | ]; |
| 1391 | // Plain Reply: the EXACT bubble, every time — no msgs needed, nothing walked. |
| 1392 | assert.deepEqual(quoteTarget(msgs[0]), { id: 1, name: "Al", snippet: "root" }); |
| 1393 | assert.deepEqual(quoteTarget(msgs[1]), { id: 2, name: "Bo", snippet: "inline" }, "replying to a reply quotes THAT reply, not its parent"); |
| 1394 | assert.deepEqual(quoteTarget(msgs[2]), { id: 3, name: "Cy", snippet: "in thread" }, "even inside a thread the plain verb quotes the bubble"); |
| 1395 | // Reply in thread: the root of the thread it's already in, else itself. |
| 1396 | assert.deepEqual(threadTarget(msgs, msgs[0]), { id: 1, name: "Al", snippet: "root" }, "a plain message roots its own new thread"); |
| 1397 | assert.deepEqual(threadTarget(msgs, msgs[1]), { id: 2, name: "Bo", snippet: "inline" }, "an inline reply is in no thread — it roots one on itself"); |
| 1398 | assert.deepEqual(threadTarget(msgs, msgs[2]), { id: 1, name: "Al", snippet: "root" }, "a thread reply continues its thread: the ROOT, snapshotted fresh"); |
| 1399 | // Root paged out: keep the frozen snapshot the reply carries, so the reply |
| 1400 | // still lands in the same thread. |
| 1401 | assert.deepEqual(threadTarget(msgs.slice(2), msgs[2]), { id: 1, name: "Al", snippet: "root" }, "unloaded root — the quoted snapshot stands in"); |
| 1402 | // Flagged but ref-less (a thread reply mid-send): it can only quote itself. |
| 1403 | assert.deepEqual(threadTarget(msgs, { sid: 9, name: "Di", raw: { text: "x" }, reply: { id: 0, thread: true } }), { id: 9, name: "Di", snippet: "x" }); |
| 1404 | }); |
| 1405 | |
| 1406 | await test("chat: threadItems keeps the root FIRST while an optimistic reply is still pending (the thread-view blink)", () => { |
| 1407 | // A just-sent reply folds pending with sid 0 until its echo lands. Sorted |
| 1408 | // by bare sid it landed AHEAD of the root, so refreshThread read "root not |
| 1409 | // loaded" for one round-trip — the header count jumped, the "original |
| 1410 | // message isn't available" note flashed, the root sat below the reply that |
| 1411 | // answers it, then all snapped back: the blink. |
| 1412 | const msgs = [ |
| 1413 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| 1414 | { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1415 | { id: "cabc", sid: 0, cid: "abc", pending: true, name: "Me", text: "sending…", ts: 9, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1416 | ]; |
| 1417 | const items = threadItems(msgs, 1); |
| 1418 | assert.deepEqual(items.map((m) => m.id), ["m1", "m3", "cabc"], "root, confirmed replies by sid, then the pending copy last — feed order"); |
| 1419 | assert.equal(items[0].sid, 1, "the root stays items[0], so the view never flips to its unloaded fallback mid-send"); |
| 1420 | }); |
| 1421 | |
| 1422 | await test("chat: inMainFeed — a reply made in a thread lives only there (while its root is loaded); quote replies and plain messages stay inline", () => { |
| 1423 | const root = { sid: 1, name: "Al", text: "hi", reply: null }; |
| 1424 | const withRoot = bySidIndex([root]); |
| 1425 | const noRoot = bySidIndex([]); |
| 1426 | assert.equal(inMainFeed({ sid: 1, reply: null }, withRoot), true, "a plain message shows"); |
| 1427 | assert.equal(inMainFeed({ sid: 2, reply: { id: 1, name: "Al", snippet: "hi" } }, withRoot), true, "a quote reply (no flag — old clients, existing history, the Reply verb) shows inline"); |
| 1428 | assert.equal(inMainFeed({ sid: 3, reply: { id: 1, name: "Al", snippet: "hi", thread: false } }, withRoot), true, "thread:false is the same as absent"); |
| 1429 | assert.equal(inMainFeed({ sid: 4, reply: { id: 1, name: "Al", snippet: "hi", thread: true } }, withRoot), false, "thread:true hides it from the main feed — its root carries the tally"); |
| 1430 | // Root-aware: with the root paged out there is no bubble to carry a tally, |
| 1431 | // so hiding would render the reply NOWHERE — it degrades to an inline bubble. |
| 1432 | assert.equal(inMainFeed({ sid: 4, reply: { id: 1, name: "Al", snippet: "hi", thread: true } }, noRoot), true, "root not loaded — the thread reply shows inline until the root pages in"); |
| 1433 | // The root is the one the flag POINTS AT — no walking. A thread reply |
| 1434 | // pointing at a loaded message is hidden by it; pointing at an unloaded one |
| 1435 | // it shows, whatever else happens to be loaded. |
| 1436 | const mid = { sid: 3, name: "Cy", text: "re", reply: { id: 1, name: "Al", snippet: "hi" } }; |
| 1437 | assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([mid])), false, "its root (3) is loaded — hidden, tallying on 3"); |
| 1438 | assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([root])), true, "root 3 not loaded — shown inline, whatever else is"); |
| 1439 | // Flagged but ref-less: id 0 is the pending-copy key, so without the guard |
| 1440 | // this would hide itself behind any in-flight send, with no root tally to |
| 1441 | // carry it. Native's threadRoots skips id 0 the same way. |
| 1442 | const pending = bySidIndex([{ id: "cabc", sid: 0, pending: true }]); |
| 1443 | assert.equal(inMainFeed({ sid: 6, reply: { id: 0, name: "", snippet: "", thread: true } }, pending), true, "flagged with no resolvable root shows inline, even mid-send"); |
| 1444 | }); |
| 1445 | |
| 1446 | await test("chat: threadListItems lists only deliberate threads, newest activity first", () => { |
| 1447 | const msgs = [ |
| 1448 | { id: "m1", sid: 1, name: "James", text: "trying lots of things", raw: { text: "trying lots of things" }, ts: 1, reply: null, rev: 0 }, |
| 1449 | { id: "m2", sid: 2, name: "Paul", text: "writing pipeline", raw: { text: "writing pipeline" }, ts: 2, reply: null, rev: 0 }, |
| 1450 | { id: "m3", sid: 3, name: "Ciara", text: "the Evan link", raw: { text: "the Evan link" }, ts: 3, reply: null, rev: 0 }, |
| 1451 | // A plain quote reply on m3 — an inline aside. It carries no tally on its |
| 1452 | // root and no thread to come back to, so it must NOT put m3 in the list. |
| 1453 | { id: "m4", sid: 4, name: "Paul", text: "nice", raw: { text: "nice" }, ts: 4, reply: { id: 3, name: "Ciara", snippet: "the Evan link" }, rev: 0 }, |
| 1454 | // James's thread: two replies, the older activity. |
| 1455 | { id: "m5", sid: 5, name: "Paul", text: "which ones?", raw: { text: "which ones?" }, ts: 5, reply: { id: 1, name: "James", snippet: "trying lots of things", thread: true }, rev: 0 }, |
| 1456 | { id: "m6", sid: 6, name: "James", text: "these", raw: { text: "these" }, ts: 6, reply: { id: 1, name: "James", snippet: "trying lots of things", thread: true }, rev: 0 }, |
| 1457 | // Paul's thread: one reply, but the LATEST one in the chat. |
| 1458 | { id: "m7", sid: 7, name: "Ciara", text: "shipping when?", raw: { text: "shipping when?" }, ts: 7, reply: { id: 2, name: "Paul", snippet: "writing pipeline", thread: true }, rev: 0 }, |
| 1459 | ]; |
| 1460 | const rows = threadListItems(msgs); |
| 1461 | assert.deepEqual(rows.map((r) => r.rootSid), [2, 1], "sorted by LAST REPLY, not root age — the quote-replied m3 never appears"); |
| 1462 | assert.deepEqual(rows.map((r) => r.count), [1, 2], "each root's tally counts only its thread's replies"); |
| 1463 | assert.deepEqual(rows.map((r) => r.name), ["Paul", "James"], "the row is headed by the ROOT's author, never the replier's"); |
| 1464 | assert.equal(rows[0].snippet, "writing pipeline", "and by the root's own snippet, re-derived from the loaded message"); |
| 1465 | assert.ok(rows.every((r) => r.rootLoaded), "both roots are in the window"); |
| 1466 | assert.deepEqual(rows.map((r) => r.last.sid), [7, 6], "`last` is the newest message in each thread — the sort key"); |
| 1467 | assert.deepEqual(threadListItems([]), [], "an empty window has no threads"); |
| 1468 | assert.deepEqual(threadListItems(msgs.slice(0, 4)), [], "a chat with only quote replies has no threads at all — the ▤ row stays away"); |
| 1469 | }); |
| 1470 | |
| 1471 | await test("chat: threadListItems keeps a thread whose root is unloaded, headed by the quoted snapshot", () => { |
| 1472 | // Cold-open a busy chat: the loaded page starts after the root, exactly the |
| 1473 | // case the thread view's own header falls back for. |
| 1474 | const page = [ |
| 1475 | { id: "m50", sid: 50, name: "Bo", text: "chatter", raw: { text: "chatter" }, ts: 1, reply: null, rev: 0 }, |
| 1476 | { id: "m51", sid: 51, name: "Cy", text: "re", raw: { text: "re" }, ts: 2, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| 1477 | // A second reply into the same thread. Every thread send derives its |
| 1478 | // target from the ROOT (threadTarget), so both point straight at sid 7 — |
| 1479 | // one row, not one per reply, even with nothing of the top loaded. |
| 1480 | { id: "m52", sid: 52, name: "Al", text: "re-re", raw: { text: "re-re" }, ts: 3, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| 1481 | ]; |
| 1482 | const rows = threadListItems(page); |
| 1483 | assert.equal(rows.length, 1, "one thread, not one row per reply"); |
| 1484 | assert.equal(rows[0].rootSid, 7); |
| 1485 | assert.equal(rows[0].rootLoaded, false, "the row knows it's showing a stand-in"); |
| 1486 | assert.equal(rows[0].name, "Al", "the quoted snapshot's author heads the row"); |
| 1487 | assert.equal(rows[0].snippet, "old root", "…and its snippet — the same fallback the thread view's header uses"); |
| 1488 | assert.equal(rows[0].count, 2, "both replies count"); |
| 1489 | // The root pages in: the row is now headed by the live message. |
| 1490 | const withRoot = [{ id: "m7", sid: 7, name: "Al", text: "old root, edited", raw: { text: "old root, edited" }, ts: 0, reply: null, rev: 1 }, ...page]; |
| 1491 | const after = threadListItems(withRoot); |
| 1492 | assert.equal(after[0].rootLoaded, true); |
| 1493 | assert.equal(after[0].snippet, "old root, edited", "a loaded root's snippet wins over the snapshot the replies carry"); |
| 1494 | }); |
| 1495 | |
| 1496 | await test("chat: threadListItems floats a thread with a still-pending reply to the top", () => { |
| 1497 | const msgs = [ |
| 1498 | { id: "m1", sid: 1, name: "Al", text: "a", raw: { text: "a" }, ts: 1, reply: null, rev: 0 }, |
| 1499 | { id: "m2", sid: 2, name: "Bo", text: "b", raw: { text: "b" }, ts: 2, reply: null, rev: 0 }, |
| 1500 | { id: "m9", sid: 9, name: "Cy", text: "re b", raw: { text: "re b" }, ts: 9, reply: { id: 2, name: "Bo", snippet: "b", thread: true }, rev: 0 }, |
| 1501 | // Just sent into a's thread — no sid until the echo lands, but visibly the |
| 1502 | // newest thing in the chat. bySidPendingLast already says so; the list |
| 1503 | // must agree, or your own reply would sort your thread to the bottom. |
| 1504 | { id: "cabc", sid: 0, cid: "abc", pending: true, name: "Me", text: "sending…", raw: { text: "sending…" }, ts: 20, reply: { id: 1, name: "Al", snippet: "a", thread: true }, rev: 0 }, |
| 1505 | ]; |
| 1506 | assert.deepEqual(threadListItems(msgs).map((r) => r.rootSid), [1, 2], "the thread you just replied in is top of the list"); |
| 1507 | }); |
| 1508 | |
| 1509 | await test("chat: threadListSignature changes exactly when the rendered list would", () => { |
| 1510 | const rows = [ |
| 1511 | { rootSid: 2, count: 1, name: "Paul", snippet: "writing pipeline" }, |
| 1512 | { rootSid: 1, count: 2, name: "James", snippet: "trying lots" }, |
| 1513 | ]; |
| 1514 | const sig = threadListSignature(rows); |
| 1515 | assert.equal(threadListSignature(rows.map((r) => ({ ...r }))), sig, "same list, same signature — no repaint, no stolen focus"); |
| 1516 | assert.notEqual(threadListSignature([rows[1], rows[0]]), sig, "a re-sort repaints"); |
| 1517 | assert.notEqual(threadListSignature([{ ...rows[0], count: 2 }, rows[1]]), sig, "a new reply repaints"); |
| 1518 | assert.notEqual(threadListSignature([{ ...rows[0], snippet: "edited" }, rows[1]]), sig, "an edited root (or one that just paged in) repaints"); |
| 1519 | assert.notEqual(threadListSignature(rows.slice(1)), sig, "a thread going away repaints"); |
| 1520 | }); |
| 1521 | |
| 1522 | await test("chat: the chat.msg fold carries the thread flag (default false) and keeps it in raw for edits", () => { |
| 1523 | const reduce = chatApp.reduce; |
| 1524 | let s = reduce(undefined, { type: "chat.msg", text: "root", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); |
| 1525 | s = reduce(s, { type: "chat.msg", text: "inline", name: "Bo", replyTo: { id: 10, name: "Al", snippet: "root" } }, { id: 11 }); |
| 1526 | s = reduce(s, { type: "chat.msg", text: "in thread", name: "Cy", replyTo: { id: 10, name: "Al", snippet: "root", thread: true } }, { id: 12 }); |
| 1527 | assert.equal(s.msgs[1].reply.thread, false, "absent on the wire folds to false — never undefined, so old history compares cleanly"); |
| 1528 | assert.equal(s.msgs[2].reply.thread, true); |
| 1529 | assert.equal(s.msgs[2].raw.replyTo.thread, true, "raw keeps the flag, so an edit ({ ...raw, text }) re-sends it and the reply stays in its thread"); |
| 1530 | }); |
| 1531 | |
| 1532 | await test("chat: viewItems({ mainFeed: true }) hides thread replies and tallies ONLY them on the root", () => { |
| 1533 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1534 | const msgs = [ |
| 1535 | { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }, |
| 1536 | { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "quote reply", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| 1537 | { id: "m3", sid: 3, uid: "u3", name: "Cy", text: "in thread", ts: base + 2000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1538 | { id: "m4", sid: 4, uid: "u1", name: "Al", text: "in thread too", ts: base + 3000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1539 | { id: "m5", sid: 5, uid: "u2", name: "Bo", text: "aside", ts: base + 4000, reply: null, rev: 0 }, |
| 1540 | ]; |
| 1541 | const main = viewItems(msgs, { mainFeed: true }); |
| 1542 | assert.deepEqual(main.map((m) => m.sid), [1, 2, 5], "thread replies drop out; the quote reply and the aside stay"); |
| 1543 | assert.equal(main[0].replyCount, 2, "the root tallies its two THREAD replies — the inline quote reply is not one of them"); |
| 1544 | assert.equal(main[1].replyCount, 0, "and the inline reply, visible right there, wears no tally of its own"); |
| 1545 | assert.ok(main[0].srev.endsWith("|2|ok|1"), "the tally still rides the soft rev — the root patches in place, never rebuilds"); |
| 1546 | const all = viewItems(msgs); |
| 1547 | assert.deepEqual(all.map((m) => m.sid), [1, 2, 3, 4, 5], "the plain call (the thread view) lists everything"); |
| 1548 | assert.equal(all[3].replyRoot, 1, "a thread reply carries its root either way"); |
| 1549 | assert.equal(all[1].replyRoot, 0, "an inline reply carries none — it's in no thread"); |
| 1550 | assert.equal(all[0].replyCount, 2, "same tally either way"); |
| 1551 | }); |
| 1552 | |
| 1553 | await test("chat: viewItems({ mainFeed: true }) shows a thread reply inline while its root is paged out, then collapses it into the tally", () => { |
| 1554 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1555 | // Cold-open a busy chat: the loaded page starts AFTER the old root a fresh |
| 1556 | // thread reply points at. Hiding it would leave the newest message of the |
| 1557 | // chat rendered nowhere. |
| 1558 | const page = [ |
| 1559 | { id: "m50", sid: 50, uid: "u2", name: "Bo", text: "recent chatter", ts: base, reply: null, rev: 0 }, |
| 1560 | { id: "m51", sid: 51, uid: "u3", name: "Cy", text: "reply to an old root", ts: base + 1000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| 1561 | ]; |
| 1562 | const main = viewItems(page, { mainFeed: true }); |
| 1563 | assert.deepEqual(main.map((m) => m.sid), [50, 51], "root not loaded — the thread reply degrades to an ordinary inline bubble"); |
| 1564 | assert.equal(main[1].replyRoot, 7, "it still carries its root, so its quote card opens the thread (and pages the root in)"); |
| 1565 | assert.equal(main[1].reply.thread, true, "the flag is untouched — only the render decision changed"); |
| 1566 | // The root pages in (scroll-back / the thread view's pageInRoot): the same |
| 1567 | // messages now hide the reply and the root's bubble carries the tally. |
| 1568 | const withRoot = [{ id: "m7", sid: 7, uid: "u1", name: "Al", text: "old root", ts: base - 86400000, reply: null, rev: 0 }, ...page]; |
| 1569 | const collapsed = viewItems(withRoot, { mainFeed: true }); |
| 1570 | assert.deepEqual(collapsed.map((m) => m.sid), [7, 50], "root loaded — the reply collapses out of the transcript"); |
| 1571 | assert.equal(collapsed[0].replyCount, 1, "and the root's tally carries it"); |
| 1572 | }); |
| 1573 | |
| 1574 | await test("chat: viewItems({ mainFeed: true }) derives separators and sender runs over the VISIBLE list", () => { |
| 1575 | const DAY = 24 * 3600 * 1000; |
| 1576 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1577 | const msgs = [ |
| 1578 | { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }, |
| 1579 | // Bo's hidden reply sits between Al's two messages… |
| 1580 | { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "in thread", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1581 | { id: "m3", sid: 3, uid: "u1", name: "Al", text: "more", ts: base + 2000, reply: null, rev: 0 }, |
| 1582 | // …and a hidden reply alone opens the next day: the separator must land |
| 1583 | // on the next VISIBLE message, not vanish with the hidden one. |
| 1584 | { id: "m4", sid: 4, uid: "u2", name: "Bo", text: "next-day thread reply", ts: base + DAY, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| 1585 | { id: "m5", sid: 5, uid: "u2", name: "Bo", text: "next-day aside", ts: base + DAY + 1000, reply: null, rev: 0 }, |
| 1586 | ]; |
| 1587 | const main = viewItems(msgs, { mainFeed: true }); |
| 1588 | assert.deepEqual(main.map((m) => m.sid), [1, 3, 5]); |
| 1589 | assert.equal(main[1].cont, true, "Al's second message continues Al's run — the hidden reply between them doesn't break it"); |
| 1590 | assert.equal(main[1].timeSep, null); |
| 1591 | assert.deepEqual(main[2].timeSep, { withDay: true }, "the day separator lands on the first VISIBLE message of the day"); |
| 1592 | assert.equal(main[2].cont, false, "a separator restarts the run"); |
| 1593 | // Unfiltered (the thread view) the same messages break the run as before. |
| 1594 | const all = viewItems(msgs); |
| 1595 | assert.equal(all[2].cont, false, "with the reply shown, Bo's bubble splits Al's run"); |
| 1596 | assert.deepEqual(all[3].timeSep, { withDay: true }, "and the hidden reply itself opens the day there"); |
| 1597 | }); |
| 1598 | |
| 1599 | await test("chat: threadLinkCount — only a THREAD reply's card links into a thread, and only when the thread holds more than it", () => { |
| 1600 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1601 | const root = { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }; |
| 1602 | // An ordinary quote reply — no thread flag. Its card is a quote and a jump |
| 1603 | // back to what it quotes, never a way into a thread it isn't in. |
| 1604 | const quote = { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "quote reply", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }; |
| 1605 | const lone = viewItems([root, quote], { mainFeed: true }); |
| 1606 | assert.equal(lone[1].threadCount, 0, "an inline reply is in no thread, so there is no size to read"); |
| 1607 | assert.equal(threadLinkCount(lone[1]), 0, "…and its card stays a plain quote — no chip, no noise"); |
| 1608 | assert.equal(threadLinkCount(lone[0]), 0, "a root never wears one either — it has the tally itself"); |
| 1609 | assert.equal(threadLinkCount({}), 0, "a plain message has no thread to link to"); |
| 1610 | // Thread replies land. The inline reply's card is STILL untouched — the |
| 1611 | // thread is not its conversation. |
| 1612 | const inThread = { id: "m3", sid: 3, uid: "u3", name: "Cy", text: "in thread", ts: base + 2000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }; |
| 1613 | const two = viewItems([root, quote, inThread], { mainFeed: true }); |
| 1614 | assert.equal(threadLinkCount(two[1]), 0, "a sibling thread landing changes nothing about an inline quote card"); |
| 1615 | assert.equal(two[0].replyCount, 1, "the root's tally counts the thread reply only"); |
| 1616 | assert.equal(two[1].srev, lone[1].srev, "and the inline reply's soft rev is unmoved — nothing to repaint"); |
| 1617 | // Where the link DOES appear: a thread reply rendered inline because its |
| 1618 | // root is paged out. Its card is the one way back into its thread. |
| 1619 | const page = [ |
| 1620 | { id: "m50", sid: 50, uid: "u2", name: "Bo", text: "recent", ts: base, reply: null, rev: 0 }, |
| 1621 | { id: "m51", sid: 51, uid: "u3", name: "Cy", text: "in an old thread", ts: base + 1000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| 1622 | { id: "m52", sid: 52, uid: "u1", name: "Al", text: "also in it", ts: base + 2000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| 1623 | ]; |
| 1624 | const orphans = viewItems(page, { mainFeed: true }); |
| 1625 | assert.deepEqual(orphans.map((m) => m.sid), [50, 51, 52], "root paged out — both thread replies degrade to inline bubbles"); |
| 1626 | assert.deepEqual(orphans.map((m) => threadLinkCount(m)), [0, 2, 2], "and each card reads the whole thread's size"); |
| 1627 | }); |
| 1628 | |
| 1629 | await test("chat: a running feed glide claims 'at bottom' — and feedCancelGlide is what releases it", () => { |
| 1630 | // The bug this pins: a jump that pages history back re-renders the feed on |
| 1631 | // every page, and render() decides "was at bottom" BEFORE the prepend — so |
| 1632 | // from the bottom of a busy chat each page starts a follow glide. While that |
| 1633 | // glide is registered, feedNearBottom reports "at bottom" REGARDLESS of |
| 1634 | // geometry, so every later render re-arms the follow, and its rAF step keeps |
| 1635 | // writing scrollTop — cancelling the smooth scrollIntoView the landing uses. |
| 1636 | // Net: the note flashed and the feed never moved. (Only with Reduce Motion |
| 1637 | // OFF: the reduced-motion path jumps instead of gliding, so it never armed.) |
| 1638 | const frames = []; |
| 1639 | const feed = { scrollTop: 0, scrollHeight: 10000, clientHeight: 500, addEventListener() {} }; |
| 1640 | const g = globalThis; |
| 1641 | const priorMM = g.matchMedia, priorRAF = g.requestAnimationFrame; |
| 1642 | g.matchMedia = () => ({ matches: false }); // Reduce Motion OFF — the failing case |
| 1643 | g.requestAnimationFrame = (fn) => frames.push(fn); // hold the steps; never run a frame |
| 1644 | try { |
| 1645 | assert.equal(feedNearBottom(feed, 60), false, "geometry alone says nowhere near the bottom"); |
| 1646 | feedGlideToBottom(feed, false); |
| 1647 | assert.equal(frames.length, 1, "Reduce Motion off arms a real rAF glide"); |
| 1648 | assert.equal(feedNearBottom(feed, 60), true, "…and while it runs the feed claims to be at the bottom, whatever the geometry — this is what re-arms the follow on every render"); |
| 1649 | feedCancelGlide(feed); |
| 1650 | assert.equal(feedNearBottom(feed, 60), false, "cancelling releases the claim, so the next render won't re-arm it"); |
| 1651 | // The already-scheduled step must be inert too, or it writes scrollTop on |
| 1652 | // the very next frame and kills an in-flight smooth scroll. |
| 1653 | frames[0](); |
| 1654 | assert.equal(feed.scrollTop, 0, "a cancelled glide's pending frame must not touch scrollTop"); |
| 1655 | assert.equal(frames.length, 1, "and must not schedule another"); |
| 1656 | } finally { |
| 1657 | g.matchMedia = priorMM; |
| 1658 | g.requestAnimationFrame = priorRAF; |
| 1659 | } |
| 1660 | }); |
| 1661 | |
| 1662 | await test("chat: pageResult — an EMPTY history page and a FAILED fetch are never the same thing", () => { |
| 1663 | assert.equal(pageResult(100), "page", "rows folded — keep walking"); |
| 1664 | assert.equal(pageResult(1), "page"); |
| 1665 | // 0 is the server's answer: there is nothing older. This is the ONLY result |
| 1666 | // that may latch `exhausted` or tell a reader a message is gone. |
| 1667 | assert.equal(pageResult(0), "end"); |
| 1668 | // -1 is host.loadOlder saying the fetch failed (offline, 5xx mid-deploy, |
| 1669 | // bad JSON). Latching on this would kill scroll-back for the whole mount |
| 1670 | // AND report a perfectly-alive message as permanently deleted. |
| 1671 | assert.equal(pageResult(-1), "error"); |
| 1672 | assert.notEqual(pageResult(-1), pageResult(0), "an errored page must never read as the start of history"); |
| 1673 | }); |
| 1674 | |
| 1675 | await test("chat: jumpReachNote — a jump that can't land always says why, and says WHICH kind of can't", () => { |
| 1676 | assert.equal(jumpReachNote("found"), "", "a landing has nothing to say"); |
| 1677 | assert.ok(jumpReachNote("gone"), "a deleted target is answered, never a silent no-op"); |
| 1678 | assert.ok(jumpReachNote("capped"), "a target further back than one jump walks is answered too"); |
| 1679 | assert.notEqual(jumpReachNote("gone"), jumpReachNote("capped"), "'gone' is final; 'capped' means keep scrolling — never the same sentence"); |
| 1680 | assert.equal(jumpReachNote("anything else"), "", "an unknown outcome says nothing rather than inventing an answer"); |
| 1681 | // Byte-identical to the Swift twin (ChatFeedModel.jumpReachNote) — the two |
| 1682 | // are pinned here so web, Mac and iOS can't drift into different wording. |
| 1683 | assert.equal(jumpReachNote("gone"), "That message isn't available any more."); |
| 1684 | assert.equal(jumpReachNote("capped"), "That message is further back than this jump reaches — keep scrolling up for it."); |
| 1685 | }); |
| 1686 | |
| 1687 | await test("chat: replyCountLabel pluralises and localises — one wording for the tally and the quote card's thread link", () => { |
| 1688 | assert.equal(replyCountLabel(1), "1 Reply", "never '1 Replies'"); |
| 1689 | assert.equal(replyCountLabel(2), "2 Replies"); |
| 1690 | assert.equal(replyCountLabel(0), "0 Replies", "the zero case reads as a plural too — the thread view's header shows it (a message's tally hides at 0)"); |
| 1691 | assert.equal(replyCountLabel(1204), (1204).toLocaleString() + " Replies", "digits are locale-grouped"); |
| 1692 | }); |
| 1693 | |
| 1694 | await test("chat: threadReplyCount counts the ANSWERS, not the rows — an unanswered thread reads 0", () => { |
| 1695 | const root = { sid: 1 }; |
| 1696 | const a = { sid: 2 }; |
| 1697 | const b = { sid: 3 }; |
| 1698 | // The root is a row in the thread's list now, so rows ≠ replies: a thread |
| 1699 | // nobody has answered is one row and must still read "0 Replies". |
| 1700 | assert.equal(threadReplyCount([root], 1), 0, "just the root — nobody has replied"); |
| 1701 | assert.equal(replyCountLabel(threadReplyCount([root], 1)), "0 Replies", "never '1 Reply' for an empty thread"); |
| 1702 | assert.equal(threadReplyCount([root, a, b], 1), 2, "root + two answers"); |
| 1703 | // Root paged out (threadItems drops it): every loaded row IS a reply. |
| 1704 | assert.equal(threadReplyCount([a, b], 1), 2, "unloaded root — the whole list is replies"); |
| 1705 | assert.equal(threadReplyCount([], 1), 0, "nothing loaded at all"); |
| 1706 | }); |
| 1707 | |
| 1708 | await test("chat: reactions, edits, deletes fold as host translations of server frames", () => { |
| 1709 | const reduce = chatApp.reduce; |
| 1710 | let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1, uid: "alice" }, { id: 20 }); |
| 1711 | const rev0 = s.msgs[0].rev; |
| 1712 | s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "bob", on: true, ts: 2 }, {}, { me: "alice" }); |
| 1713 | assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 1, mine: false }]); |
| 1714 | assert.equal(s.msgs[0].rev, rev0 + 1, "a reaction bumps the soft rev"); |
| 1715 | s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "charlie", on: true, ts: 3 }, {}, { me: "alice" }); |
| 1716 | assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 2, mine: false }]); |
| 1717 | const revBeforeEdit = s.msgs[0].rev; |
| 1718 | s = reduce(s, { type: "chat.edit", msgId: 20, text: "hello again" }); |
| 1719 | assert.equal(s.msgs[0].text, "hello again"); |
| 1720 | assert.equal(s.msgs[0].edited, true); |
| 1721 | assert.ok(s.msgs[0].rev > revBeforeEdit, "an edit DOES bump the hard rev — the bubble re-renders its text"); |
| 1722 | s = reduce(s, { type: "chat.edit", msgId: 999, text: "ghost" }); // unknown target — no-op |
| 1723 | assert.equal(s.msgs.length, 1); |
| 1724 | s = reduce(s, { type: "chat.delete", msgId: 20 }); |
| 1725 | assert.deepEqual(s.msgs, []); |
| 1726 | }); |
| 1727 | |
| 1728 | await test("chat mutations are host-only: wire payloads can't spoof them", () => { |
| 1729 | resetThread("c0"); |
| 1730 | // A member posting {type:"chat.delete"} as a message must fold NOTHING — the |
| 1731 | // type is not wire-owned (deletes are server-verified, author-only). Same for |
| 1732 | // chat.reaction and chat.edit — only the host (shell.js) can emit those. |
| 1733 | assert.equal(foldEvent("c0", { type: "chat.delete", msgId: 1 }, { id: 2 }), null); |
| 1734 | assert.equal(foldEvent("c0", { type: "chat.reaction", targetSid: 1, emoji: "👍", uid: "x", on: true }, { id: 3 }), null); |
| 1735 | assert.equal(foldEvent("c0", { type: "chat.edit", msgId: 1, text: "edited" }, { id: 4 }), null); |
| 1736 | assert.equal(foldEvent("c0", { type: "chat.msg", text: "spoof" }, { id: 5 }), null); |
| 1737 | // The host doorway reaches them. |
| 1738 | const app = foldHostEvent("c0", "chat", { type: "chat.msg", text: "real" }, { id: 6 }); |
| 1739 | assert.equal(app && app.name, "chat"); |
| 1740 | assert.equal(appStateFor("c0", "chat").msgs.length, 1); |
| 1741 | }); |
| 1742 | |
| 1743 | await test("chat: opts out of snapshots — its state IS the log", () => { |
| 1744 | resetThread("c1"); |
| 1745 | foldHostEvent("c1", "chat", { type: "chat.msg", text: "hi" }, { id: 5 }); |
| 1746 | assert.equal(appStateFor("c1", "chat").msgs.length, 1); |
| 1747 | assert.equal(takeSnapshot("c1"), null, "chat folds never dirty the snapshot"); |
| 1748 | // A snapshotting app in the SAME thread still snapshots — without chat state. |
| 1749 | foldEvent("c1", { type: "poll.create", id: "p", q: "Q?", options: ["a", "b"] }, { id: 6 }); |
| 1750 | const snap = takeSnapshot("c1"); |
| 1751 | assert.ok(snap.states.poll, "poll state saved"); |
| 1752 | assert.equal(snap.states.chat, undefined, "chat state excluded"); |
| 1753 | }); |
| 1754 | |
| 1755 | await test("another app's snapshot horizon never blocks chat folds", () => { |
| 1756 | resetThread("c2"); |
| 1757 | // A poll snapshot covers message ids <= 100 — chat isn't in any snapshot, so |
| 1758 | // an untyped chat message at id 50 must still fold into the chat feed. |
| 1759 | seedThread("c2", { poll: { polls: [] } }, 100); |
| 1760 | foldHostEvent("c2", "chat", { type: "chat.msg", text: "history" }, { id: 50 }); |
| 1761 | assert.equal(appStateFor("c2", "chat").msgs.length, 1, "folded despite the horizon"); |
| 1762 | }); |
| 1763 | |
| 1764 | // ---- sidebar presence hooks (preview / unread) ----------------------------- |
| 1765 | |
| 1766 | await test("apps opt into sidebar presence: poll previews + marks unread on create", () => { |
| 1767 | assert.equal(appPreview({ type: "poll.create", q: "Lunch?", options: ["a", "b"] }), "📊 Lunch?"); |
| 1768 | assert.equal(appUnread({ type: "poll.create", q: "Lunch?" }), true); |
| 1769 | // Votes stay silent — the tally updates live anyway. |
| 1770 | assert.equal(appPreview({ type: "poll.vote", pollId: "p", choice: 0 }), null); |
| 1771 | assert.equal(appUnread({ type: "poll.vote", pollId: "p" }), false); |
| 1772 | // To-do opts in for adds (how a group notices shared-list activity); its |
| 1773 | // quiet events (done/reopen/remove) stay silent. |
| 1774 | assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk” to the to-dos"); |
| 1775 | assert.equal(appUnread({ type: "todo.add", id: "a" }), true); |
| 1776 | assert.equal(appPreview({ type: "todo.done", id: "a" }), null); |
| 1777 | assert.equal(appUnread({ type: "todo.done", id: "a" }), false); |
| 1778 | // Unowned types are silent too. |
| 1779 | assert.equal(appPreview({ type: "mystery.event" }), null); |
| 1780 | }); |
| 1781 | |
| 1782 | await test("board previews + marks unread on open; strokes stay silent", () => { |
| 1783 | assert.equal(appPreview({ type: "board.open" }), "🎨 opened the whiteboard"); |
| 1784 | assert.equal(appUnread({ type: "board.open" }), true); |
| 1785 | assert.equal(appPreview({ type: "board.stroke", id: "s1" }), null); |
| 1786 | assert.equal(appUnread({ type: "board.stroke", id: "s1" }), false); |
| 1787 | }); |
| 1788 | |
| 1789 | // ---- ephemeral events (relay-only, never folded) --------------------------- |
| 1790 | |
| 1791 | await test("deliverEphemeral routes to the owning app's hook; fold state untouched", () => { |
| 1792 | const pings = []; |
| 1793 | defineApp({ |
| 1794 | name: "probe", |
| 1795 | title: "Probe", |
| 1796 | glyph: "×", |
| 1797 | scope: "system", |
| 1798 | initialState: () => ({ n: 0 }), |
| 1799 | events: { "probe.ping": (s) => s }, // owned via a no-op fold, like chat.typing |
| 1800 | ephemeral(e, ctx) { |
| 1801 | pings.push({ from: e.name, thread: ctx.threadId }); |
| 1802 | }, |
| 1803 | }); |
| 1804 | resetThread("e1"); |
| 1805 | deliverEphemeral("e1", { type: "probe.ping", name: "Al" }); |
| 1806 | assert.deepEqual(pings, [{ from: "Al", thread: "e1" }]); |
| 1807 | deliverEphemeral("e1", { type: "unowned.type", name: "Al" }); // silently ignored |
| 1808 | assert.equal(pings.length, 1); |
| 1809 | assert.equal(takeSnapshot("e1"), null, "nothing folded, nothing to save"); |
| 1810 | }); |
| 1811 | |
| 1812 | await test("ctx.emitEphemeral goes through the ephemeral transport, not emit", async () => { |
| 1813 | const stored = []; |
| 1814 | const relayed = []; |
| 1815 | setEmitTransport((tid, e) => (stored.push(e), Promise.resolve(true))); |
| 1816 | setEphemeralTransport((tid, e) => (relayed.push({ tid, e }), Promise.resolve(true))); |
| 1817 | // The probe app registered above owns "probe.ping"; drive its hook via a |
| 1818 | // second app? No — reach ctx directly through a command on a fresh app. |
| 1819 | defineApp({ |
| 1820 | name: "probe2", |
| 1821 | title: "Probe2", |
| 1822 | glyph: "×", |
| 1823 | scope: "shared", |
| 1824 | initialState: () => ({}), |
| 1825 | events: { "probe2.beat": (s) => s }, |
| 1826 | commands: { "/beat": (args, ctx) => ctx.emitEphemeral({ type: "probe2.beat" }) }, |
| 1827 | }); |
| 1828 | runCommand("/beat", "chatY"); |
| 1829 | await tick(); |
| 1830 | assert.equal(stored.length, 0, "nothing persisted"); |
| 1831 | assert.deepEqual(relayed[0].tid, "chatY"); |
| 1832 | assert.equal(relayed[0].e.type, "probe2.beat"); |
| 1833 | }); |
| 1834 | |
| 1835 | // ---- keyedList (incremental list reconciliation) --------------------------- |
| 1836 | |
| 1837 | // A minimal DOM stand-in: just the surface keyedList touches (children, |
| 1838 | // firstChild, insertBefore, replaceWith, remove, dataset, nextSibling). |
| 1839 | function fakeContainer() { |
| 1840 | const el = { |
| 1841 | children: [], |
| 1842 | get firstChild() { |
| 1843 | return el.children[0] || null; |
| 1844 | }, |
| 1845 | insertBefore(node, ref) { |
| 1846 | const cur = el.children.indexOf(node); |
| 1847 | if (cur !== -1) el.children.splice(cur, 1); |
| 1848 | const i = ref ? el.children.indexOf(ref) : el.children.length; |
| 1849 | el.children.splice(i === -1 ? el.children.length : i, 0, node); |
| 1850 | node.parent = el; |
| 1851 | }, |
| 1852 | }; |
| 1853 | return el; |
| 1854 | } |
| 1855 | function fakeNode(label) { |
| 1856 | return { |
| 1857 | label, |
| 1858 | dataset: {}, |
| 1859 | parent: null, |
| 1860 | get nextSibling() { |
| 1861 | const sib = this.parent ? this.parent.children : []; |
| 1862 | const i = sib.indexOf(this); |
| 1863 | return i === -1 ? null : sib[i + 1] || null; |
| 1864 | }, |
| 1865 | replaceWith(other) { |
| 1866 | const sib = this.parent.children; |
| 1867 | sib[sib.indexOf(this)] = other; |
| 1868 | other.parent = this.parent; |
| 1869 | this.parent = null; |
| 1870 | }, |
| 1871 | remove() { |
| 1872 | if (!this.parent) return; |
| 1873 | const sib = this.parent.children; |
| 1874 | const i = sib.indexOf(this); |
| 1875 | if (i !== -1) sib.splice(i, 1); |
| 1876 | this.parent = null; |
| 1877 | }, |
| 1878 | }; |
| 1879 | } |
| 1880 | |
| 1881 | await test("keyedList appends without rebuilding, re-renders on rev, removes gone", () => { |
| 1882 | const el = fakeContainer(); |
| 1883 | let built = 0; |
| 1884 | const draw = (items) => |
| 1885 | keyedList(el, items, { |
| 1886 | key: (i) => i.id, |
| 1887 | rev: (i) => i.rev || 0, |
| 1888 | render: (i) => (built++, fakeNode(i.id + ":" + (i.rev || 0))), |
| 1889 | }); |
| 1890 | |
| 1891 | draw([{ id: "a" }, { id: "b" }]); |
| 1892 | assert.deepEqual(el.children.map((n) => n.label), ["a:0", "b:0"]); |
| 1893 | assert.equal(built, 2); |
| 1894 | const [nodeA, nodeB] = el.children; |
| 1895 | |
| 1896 | // Append: existing nodes are reused untouched (scroll/focus/images survive). |
| 1897 | draw([{ id: "a" }, { id: "b" }, { id: "c" }]); |
| 1898 | assert.equal(el.children[0], nodeA, "a reused"); |
| 1899 | assert.equal(el.children[1], nodeB, "b reused"); |
| 1900 | assert.equal(built, 3, "only c was built"); |
| 1901 | |
| 1902 | // A rev change re-renders just that item, in place. |
| 1903 | draw([{ id: "a" }, { id: "b", rev: 1 }, { id: "c" }]); |
| 1904 | assert.equal(el.children[0], nodeA, "a still reused"); |
| 1905 | assert.equal(el.children[1].label, "b:1", "b re-rendered"); |
| 1906 | assert.equal(built, 4); |
| 1907 | |
| 1908 | // Removal drops the node; order holds. |
| 1909 | draw([{ id: "a" }, { id: "c" }]); |
| 1910 | assert.deepEqual(el.children.map((n) => n.label), ["a:0", "c:0"]); |
| 1911 | }); |
| 1912 | |
| 1913 | await test("keyedList patches a retained node in place when only its soft rev changed", () => { |
| 1914 | const el = fakeContainer(); |
| 1915 | let built = 0; |
| 1916 | const patched = []; |
| 1917 | const draw = (items) => |
| 1918 | keyedList(el, items, { |
| 1919 | key: (i) => i.id, |
| 1920 | rev: (i) => i.rev || 0, |
| 1921 | srev: (i) => i.srev || 0, |
| 1922 | render: (i) => (built++, fakeNode(i.id)), |
| 1923 | update: (node, i) => patched.push([node.label, i.srev]), |
| 1924 | }); |
| 1925 | |
| 1926 | draw([{ id: "a", srev: 0 }]); |
| 1927 | const nodeA = el.children[0]; |
| 1928 | assert.equal(built, 1); |
| 1929 | |
| 1930 | // A soft-rev change patches the SAME node — no rebuild (a loaded image survives). |
| 1931 | draw([{ id: "a", srev: 1 }]); |
| 1932 | assert.equal(el.children[0], nodeA, "node reused, not replaced"); |
| 1933 | assert.equal(built, 1, "nothing rebuilt"); |
| 1934 | assert.deepEqual(patched, [["a", 1]], "update() ran once with the new item"); |
| 1935 | |
| 1936 | // A hard-rev change still rebuilds — and does NOT also fire update(). |
| 1937 | draw([{ id: "a", rev: 1, srev: 2 }]); |
| 1938 | assert.notEqual(el.children[0], nodeA, "rebuilt on the hard rev"); |
| 1939 | assert.equal(built, 2); |
| 1940 | assert.deepEqual(patched, [["a", 1]], "update() not called on a rebuild"); |
| 1941 | |
| 1942 | // No change at all → neither a rebuild nor a patch. |
| 1943 | draw([{ id: "a", rev: 1, srev: 2 }]); |
| 1944 | assert.equal(built, 2); |
| 1945 | assert.deepEqual(patched, [["a", 1]]); |
| 1946 | }); |
| 1947 | |
| 1948 | // ---- app-contributed message actions ------------------------------------ |
| 1949 | |
| 1950 | await test("messageActionsFor offers Save link only on messages with a link", () => { |
| 1951 | assert.deepEqual(messageActionsFor({ text: "no links here", from: "Bob" }), []); |
| 1952 | const acts = messageActionsFor({ text: "look at https://x.com now", from: "Bob" }); |
| 1953 | assert.equal(acts.length, 1); |
| 1954 | assert.equal(acts[0].label, "Save link"); |
| 1955 | }); |
| 1956 | |
| 1957 | await test("Save link routes into the bookmarks app-thread with attribution", async () => { |
| 1958 | const sent = []; |
| 1959 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1960 | const acts = messageActionsFor({ text: "read https://x.com/post today", from: "Bob" }); |
| 1961 | await acts[0].run(); // resolves the app-thread (stubbed → "todoThread") and emits |
| 1962 | assert.equal(sent.length, 1); |
| 1963 | assert.equal(sent[0].event.type, "bookmark.add"); |
| 1964 | assert.equal(sent[0].event.url, "https://x.com/post"); |
| 1965 | assert.equal(sent[0].event.from, "Bob", "records who posted the link"); |
| 1966 | }); |
| 1967 | |
| 1968 | // ---- DNSimple companion app --------------------------------------------- |
| 1969 | |
| 1970 | const dnsimpleReduce = dnsimpleApp.reduce; |
| 1971 | |
| 1972 | await test("dnsimple: a fetched list replaces state; normalises + drops malformed rows", () => { |
| 1973 | const s0 = dnsimpleApp.initialState(); |
| 1974 | assert.deepEqual(s0.domains, []); |
| 1975 | // Structured rows (name + table fields) are stored as-is. |
| 1976 | const rows = [ |
| 1977 | { name: "a.com", expires_on: "2027-01-01", auto_renew: true }, |
| 1978 | { name: "b.net", expires_on: "", auto_renew: false }, |
| 1979 | ]; |
| 1980 | const s1 = dnsimpleReduce(s0, { type: "dnsimple.domains", domains: rows, ts: 100 }); |
| 1981 | assert.deepEqual(s1.domains.map((d) => d.name), ["a.com", "b.net"]); |
| 1982 | assert.equal(s1.domains[0].auto_renew, true, "table fields survive the fold"); |
| 1983 | assert.equal(s1.fetchedAt, 100); |
| 1984 | // The latest refresh WINS (replace, not append) — dropped domains disappear. |
| 1985 | const s2 = dnsimpleReduce(s1, { type: "dnsimple.domains", domains: [{ name: "a.com" }], ts: 200 }); |
| 1986 | assert.deepEqual(s2.domains.map((d) => d.name), ["a.com"]); |
| 1987 | assert.equal(s2.fetchedAt, 200); |
| 1988 | // Old string form is normalised to objects (back-compat with pre-table events). |
| 1989 | const s3 = dnsimpleReduce(s2, { type: "dnsimple.domains", domains: ["x.com", "y.io"], ts: 300 }); |
| 1990 | assert.deepEqual(s3.domains.map((d) => d.name), ["x.com", "y.io"]); |
| 1991 | // Hostile / malformed inputs fold to a no-op or drop bad entries (never crash). |
| 1992 | assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains" }), s3, "missing domains → unchanged"); |
| 1993 | assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains", domains: "nope", ts: 400 }), s3, "non-array → unchanged"); |
| 1994 | const s4 = dnsimpleReduce(s3, { type: "dnsimple.domains", domains: [{ name: "ok.com" }, 42, null, {}], ts: 500 }); |
| 1995 | assert.deepEqual(s4.domains.map((d) => d.name), ["ok.com"], "entries without a string name are dropped"); |
| 1996 | }); |
| 1997 | |
| 1998 | await test("dnsimple: takeover app — the table is the whole thread, no chat echo", () => { |
| 1999 | // No chatMessage → takeover (Interface only), so a fetched list never echoes as |
| 2000 | // a chat bubble. The domain list lives in the table, nowhere else. |
| 2001 | assert.equal(dnsimpleApp.chatMessage, undefined, "no chatMessage hook (takeover, not companion)"); |
| 2002 | assert.equal(appWantsChatView("dnsimple"), false, "no chatMessage → not a companion (takeover)"); |
| 2003 | assert.equal(dnsimpleApp.requiresService, "dnsimple", "gated on the connected service"); |
| 2004 | }); |
| 2005 | |
| 2006 | await test("dnsimple: registers no slash command (the Interface IS the list)", () => { |
| 2007 | // No `/list` (or any) command — the domains live in the app's own view, so a |
| 2008 | // verb would be redundant and only risks routing through (and waking) the bot. |
| 2009 | assert.deepEqual(Object.keys(dnsimpleApp.commands || {}), []); |
| 2010 | assert.equal(parseCommand("/list").app, null, "nothing owns /list"); |
| 2011 | }); |
| 2012 | |
| 2013 | await test("setup: steps fold idempotently and hostile events are no-ops", () => { |
| 2014 | const r = setupApp.reduce; |
| 2015 | let s = r(undefined, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| 2016 | assert.equal(s.steps.group.done, true); |
| 2017 | assert.equal(s.steps.group.value, "C"); |
| 2018 | const again = r(s, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| 2019 | assert.equal(again.steps.group.done, true, "refold converges"); |
| 2020 | assert.equal(r(s, { type: "setup.step" }), s, "missing step → unchanged"); |
| 2021 | const two = r(s, { type: "setup.step", step: "keys", label: "skipped the key backup", value: "skipped" }); |
| 2022 | assert.equal(two.steps.group.done, true, "earlier steps survive"); |
| 2023 | assert.equal(two.steps.keys.value, "skipped"); |
| 2024 | }); |
| 2025 | |
| 2026 | await test("setup: chat-only projection — completed steps become action lines", () => { |
| 2027 | assert.equal(appWantsChatView("setup"), true, "chatMessage → the chat view is the surface"); |
| 2028 | assert.equal(appChatOnly("setup"), true, "no Interface view or toggle — the conversation is the thread"); |
| 2029 | assert.equal(setupApp.render, undefined, "the progress card is gone (the Admin panel superseded it)"); |
| 2030 | const line = setupApp.chatMessage({ type: "setup.step", step: "name", label: "noted: you're Paul" }); |
| 2031 | assert.deepEqual(line, { text: "noted: you're Paul", action: true }); |
| 2032 | assert.equal(setupApp.chatMessage({ type: "setup.step", step: "x" }), null, "no label → silent"); |
| 2033 | assert.equal(setupApp.chatMessage({ type: "other" }), null); |
| 2034 | assert.equal(setupApp.listed, false, "never offered in the Apps menu"); |
| 2035 | }); |
| 2036 | |
| 2037 | await test("admin: interface-first companion with a read-only update stream", () => { |
| 2038 | assert.equal(adminApp.listed, false, "never offered in the Apps menu"); |
| 2039 | // The panel leads: a companion (chatMessage → Chat view exists) that opens in |
| 2040 | // its Interface, with the update stream one toggle away — and read-only there. |
| 2041 | assert.equal(appWantsChatView("admin"), true, "chatMessage → companion (the update stream is the Chat view)"); |
| 2042 | assert.equal(appChatOnly("admin"), false, "the Interface (settings panel) is a real view"); |
| 2043 | assert.equal(appDefaultView("admin"), "interface", "opens as the app, not the split"); |
| 2044 | assert.equal(appDefaultView("todo"), "split", "companions without a preference keep the split default"); |
| 2045 | assert.equal(adminApp.chatReadOnly, true, "the app posts into the stream; the admin doesn't"); |
| 2046 | assert.equal(adminApp.senderKind, undefined, "Admin reads as an APP; only setup overrides to BOT"); |
| 2047 | assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP"); |
| 2048 | }); |
| 2049 | |
| 2050 | await test("admin: release notes fold as a seen-watermark and project as Admin bubbles", () => { |
| 2051 | const r = adminApp.reduce; |
| 2052 | // A posted note records its id; re-folding the same id is a no-op (two devices |
| 2053 | // may race the same entry). |
| 2054 | let s = r(undefined, { type: "admin.note", id: "2026-07-08-read-sync", text: "Unread dots now sync." }); |
| 2055 | assert.deepEqual(s.seen, ["2026-07-08-read-sync"]); |
| 2056 | assert.equal(r(s, { type: "admin.note", id: "2026-07-08-read-sync", text: "dup" }), s, "duplicate id → unchanged"); |
| 2057 | assert.equal(r(s, { type: "admin.note", text: "no id" }), s, "id-less note → unchanged"); |
| 2058 | // First-run baseline: everything current marked seen, and merges are a union. |
| 2059 | s = r(undefined, { type: "admin.baseline", ids: ["a", "b"] }); |
| 2060 | assert.deepEqual(s.seen, ["a", "b"]); |
| 2061 | assert.deepEqual(r(s, { type: "admin.baseline", ids: ["b", "c"] }).seen, ["a", "b", "c"], "second baseline merges"); |
| 2062 | // Projections: a note is a bubble from Admin (a string, not an action line); |
| 2063 | // the baseline is silent housekeeping. |
| 2064 | assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x", text: "Shipped a thing." }), "Shipped a thing."); |
| 2065 | assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x" }), null, "text-less note → silent"); |
| 2066 | assert.equal(adminApp.chatMessage({ type: "admin.baseline", ids: ["a"] }), null); |
| 2067 | }); |
| 2068 | |
| 2069 | await test("welcome: the joiner conversation mirrors setup's shape", () => { |
| 2070 | const r = welcomeApp.reduce; |
| 2071 | const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" }); |
| 2072 | assert.equal(s.steps.name.done, true); |
| 2073 | assert.equal(r(s, { type: "welcome.step" }), s, "missing step → unchanged"); |
| 2074 | assert.equal(appWantsChatView("welcome"), true); |
| 2075 | assert.equal(appChatOnly("welcome"), true, "conversation-only, no Interface"); |
| 2076 | assert.equal(welcomeApp.senderKind, "bot", "presents as Eleven (BOT) in the sidebar"); |
| 2077 | assert.equal(welcomeApp.listed, false, "never offered in the Apps menu"); |
| 2078 | const line = welcomeApp.chatMessage({ type: "welcome.step", step: "name", label: "noted: you're Ada" }); |
| 2079 | assert.deepEqual(line, { text: "noted: you're Ada", action: true }); |
| 2080 | }); |
| 2081 | |
| 2082 | await test("conversational answers: one waiter per thread, consumed on delivery", () => { |
| 2083 | const got = []; |
| 2084 | awaitAnswer("t1", (text) => got.push(text)); |
| 2085 | assert.equal(deliverAnswer("t1", "Paul", () => {}), true, "waiter consumes the answer"); |
| 2086 | assert.deepEqual(got, ["Paul"]); |
| 2087 | assert.equal(deliverAnswer("t1", "again", () => {}), false, "consumed — nothing listening"); |
| 2088 | awaitAnswer("t2", () => {}); |
| 2089 | cancelAnswer("t2"); |
| 2090 | assert.equal(deliverAnswer("t2", "x", () => {}), false, "cancelled waiter never fires"); |
| 2091 | }); |
| 2092 | |
| 2093 | await test("conversational answers: every cancel fires the cancelled hook (#1375)", () => { |
| 2094 | // chat.js registers this to clear the labelled composer — the dismiss X, a |
| 2095 | // chip's cancelInput, and the folded re-derive all ride the same path. |
| 2096 | const cleared = []; |
| 2097 | setAnswerCancelledHook((threadId) => cleared.push(threadId)); |
| 2098 | awaitAnswer("t9", () => {}); |
| 2099 | cancelAnswer("t9"); |
| 2100 | cancelAnswer("t-idle"); // no waiter parked — the label still clears |
| 2101 | assert.deepEqual(cleared, ["t9", "t-idle"]); |
| 2102 | setAnswerCancelledHook(null); |
| 2103 | }); |
| 2104 | |
| 2105 | await test("conversational answers: attachments ride the payload (the photo step)", () => { |
| 2106 | const got = []; |
| 2107 | awaitAnswer("t3", (text, payload) => got.push([text, ((payload && payload.attachments) || []).length])); |
| 2108 | deliverAnswer("t3", { text: "", attachments: [{ id: "b1", type: "image/jpeg" }] }, () => {}); |
| 2109 | assert.deepEqual(got, [["", 1]], "an attachment-only send still answers the waiter"); |
| 2110 | }); |
| 2111 | |
| 2112 | // ---- group-scoped apps (groupapps registry + @-mention routing) ------------ |
| 2113 | |
| 2114 | const groupAppsReduce = groupAppsApp.reduce; |
| 2115 | |
| 2116 | await test("groupapps: adds fold idempotently, removes drop, malformed no-op", () => { |
| 2117 | let s = groupAppsReduce(undefined, { type: "groupapps.add", app: "todo", name: "Ana", ts: 5 }); |
| 2118 | assert.deepEqual(s, { apps: [{ id: "todo", by: "Ana", ts: 5 }] }); |
| 2119 | // Re-adding (another member, a refold) is a no-op — the item id IS the app name. |
| 2120 | const s2 = groupAppsReduce(s, { type: "groupapps.add", app: "todo", name: "Bob", ts: 9 }); |
| 2121 | assert.deepEqual(s2.apps, s.apps); |
| 2122 | // Unknown app names still fold (state converges across client versions). |
| 2123 | s = groupAppsReduce(s, { type: "groupapps.add", app: "flarp", name: "Bob", ts: 9 }); |
| 2124 | assert.equal(s.apps.length, 2); |
| 2125 | s = groupAppsReduce(s, { type: "groupapps.remove", app: "todo" }); |
| 2126 | assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); |
| 2127 | // Hostile / malformed events are no-ops, never a crash (Invariant #8). |
| 2128 | assert.equal(groupAppsReduce(s, { type: "groupapps.add" }), s); |
| 2129 | assert.equal(groupAppsReduce(s, { type: "groupapps.add", app: 7 }), s); |
| 2130 | assert.deepEqual(groupAppsReduce(s, { type: "groupapps.remove", app: "missing" }).apps, s.apps); |
| 2131 | }); |
| 2132 | |
| 2133 | await test("groupapps: add/remove project as action lines; previews name the actor", () => { |
| 2134 | // groupFeedMessage, not chatMessage — the registry narrates group feeds |
| 2135 | // and must never read as wanting a Chat view of its own. |
| 2136 | assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "todo" }), { text: "added To-do to this chat", action: true }); |
| 2137 | assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.remove", app: "todo" }), { text: "removed To-do from this chat", action: true }); |
| 2138 | // An app this client doesn't know still reads sensibly. |
| 2139 | assert.equal(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "flarp" }).text, "added flarp to this chat"); |
| 2140 | assert.equal(appPreview({ type: "groupapps.add", app: "todo", name: "Ana" }), "Ana added To-do"); |
| 2141 | assert.equal(appPreview({ type: "groupapps.remove", app: "todo", name: "Ana" }), "Ana removed To-do"); |
| 2142 | }); |
| 2143 | |
| 2144 | await test("groupAppsFor resolves added apps, filtering unknown and non-group-capable", () => { |
| 2145 | resetThread("grp1"); |
| 2146 | foldEvent("grp1", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2147 | foldEvent("grp1", { type: "groupapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert |
| 2148 | foldEvent("grp1", { type: "groupapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not groupCapable → inert |
| 2149 | assert.deepEqual(groupAppsFor("grp1").map((a) => a.name), ["todo"]); |
| 2150 | foldEvent("grp1", { type: "groupapps.remove", app: "todo" }, { id: 4 }); |
| 2151 | assert.deepEqual(groupAppsFor("grp1"), []); |
| 2152 | resetThread("grp1"); |
| 2153 | }); |
| 2154 | |
| 2155 | await test("the group-capable roster: shared-list apps in, credential-backed apps out", () => { |
| 2156 | const names = groupCapableApps().map((a) => a.name); |
| 2157 | for (const n of ["todo", "remind", "bookmarks"]) assert.ok(names.includes(n), n + " is addable to groups"); |
| 2158 | // Their state derives from one member's private service credentials — sharing |
| 2159 | // those needs its own design, so they stay personal-only on purpose. |
| 2160 | for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); |
| 2161 | // Poll/Whiteboard are chat-place apps since the ambient panel retired (#405). |
| 2162 | for (const n of ["poll", "board"]) assert.ok(names.includes(n), n + " lives in chats now"); |
| 2163 | }); |
| 2164 | |
| 2165 | await test("Reminders' fire bubble drops 'you asked me' in a group", () => { |
| 2166 | const e = { type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "call mum" } }; |
| 2167 | assert.equal(remindApp.chatMessage(e), "You asked me to remind you: “call mum”"); |
| 2168 | assert.equal(remindApp.chatMessage(e, { group: true }), "Reminder: “call mum”"); |
| 2169 | // The add action line is host-prefixed with the actor either way. |
| 2170 | const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "call mum", when: 0 }, { group: true }); |
| 2171 | assert.equal(add.action, true); |
| 2172 | }); |
| 2173 | |
| 2174 | await test("parseAppMention matches an added app by title or name, leading-@ only", () => { |
| 2175 | resetThread("grp2"); |
| 2176 | foldEvent("grp2", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2177 | let m = parseAppMention("@To-do buy milk", "grp2"); |
| 2178 | assert.equal(m.app.name, "todo"); |
| 2179 | assert.equal(m.args, "buy milk"); |
| 2180 | m = parseAppMention("@to-DO buy milk", "grp2"); // case-insensitive |
| 2181 | assert.equal(m.args, "buy milk"); |
| 2182 | m = parseAppMention("@todo buy milk", "grp2"); // the app name works too |
| 2183 | assert.equal(m.args, "buy milk"); |
| 2184 | assert.equal(parseAppMention("@To-do", "grp2").args, "", "a bare mention parses with empty args"); |
| 2185 | // Must break on whitespace — "@To-dos…" is conversation, not an address. |
| 2186 | assert.equal(parseAppMention("@To-dos are great", "grp2"), null); |
| 2187 | assert.equal(parseAppMention("hey @To-do buy milk", "grp2"), null, "leading-@ only"); |
| 2188 | assert.equal(parseAppMention("@To-do buy milk", "grpEmpty"), null, "no apps added → no match"); |
| 2189 | resetThread("grp2"); |
| 2190 | }); |
| 2191 | |
| 2192 | await test("runAppMention emits the default command's event into the GROUP thread", async () => { |
| 2193 | resetThread("grp3"); |
| 2194 | foldEvent("grp3", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2195 | const sent = []; |
| 2196 | const replies = []; |
| 2197 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2198 | // Resolver poisoned: a group mention must NOT route to the personal app-thread. |
| 2199 | setAppThreadResolver(async () => { |
| 2200 | throw new Error("group mentions never route"); |
| 2201 | }); |
| 2202 | assert.equal(runAppMention("@To-do buy milk", "grp3", (r) => replies.push(r)), true); |
| 2203 | await tick(); |
| 2204 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2205 | assert.equal(sent.length, 1); |
| 2206 | assert.equal(sent[0].threadId, "grp3", "the event lands in the group thread"); |
| 2207 | assert.equal(sent[0].event.type, "todo.add"); |
| 2208 | assert.equal(sent[0].event.text, "buy milk"); |
| 2209 | // The success confirmation is suppressed — the folded action line is the record. |
| 2210 | assert.deepEqual(replies, []); |
| 2211 | resetThread("grp3"); |
| 2212 | }); |
| 2213 | |
| 2214 | await test("/todo typed in a chat with To-do ADDED targets the GROUP instance", async () => { |
| 2215 | resetThread("grp5"); |
| 2216 | foldEvent("grp5", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2217 | const sent = []; |
| 2218 | const replies = []; |
| 2219 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2220 | // Resolver poisoned: with the app added here, /todo must NOT open a personal |
| 2221 | // app-thread — the group's shared instance is the nearer scope. |
| 2222 | setAppThreadResolver(async () => { |
| 2223 | throw new Error("group-scoped commands never route to the personal thread"); |
| 2224 | }); |
| 2225 | assert.equal(runCommand("/todo group milk", "grp5", (r) => replies.push(r)), true); |
| 2226 | await tick(); |
| 2227 | assert.equal(sent.length, 1); |
| 2228 | assert.equal(sent[0].threadId, "grp5", "the event lands in the group thread"); |
| 2229 | assert.equal(sent[0].event.type, "todo.add"); |
| 2230 | assert.equal(sent[0].event.text, "group milk"); |
| 2231 | // In-thread the folded action line is the record — no separate confirmation. |
| 2232 | assert.deepEqual(replies, []); |
| 2233 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2234 | // Without the app added (any other chat), /todo still routes personally. |
| 2235 | const sent2 = []; |
| 2236 | setEmitTransport((threadId, event) => (sent2.push({ threadId, event }), Promise.resolve(true))); |
| 2237 | assert.equal(runCommand("/todo personal milk", "chatX"), true); |
| 2238 | await tick(); |
| 2239 | assert.equal(sent2[0].threadId, "todoThread"); |
| 2240 | resetThread("grp5"); |
| 2241 | }); |
| 2242 | |
| 2243 | await test("parseHere: leading @here only, breaking on whitespace", () => { |
| 2244 | assert.deepEqual(parseHere("@here buy milk"), { rest: "buy milk" }); |
| 2245 | assert.deepEqual(parseHere("@here"), { rest: "" }); |
| 2246 | assert.deepEqual(parseHere(" @here buy milk "), { rest: "buy milk" }); |
| 2247 | assert.equal(parseHere("@heresy is text"), null); |
| 2248 | assert.equal(parseHere("buy milk @here"), null); |
| 2249 | assert.equal(parseHere(""), null); |
| 2250 | }); |
| 2251 | |
| 2252 | await test("parseMeFlag: leading @me only, breaking on whitespace", () => { |
| 2253 | assert.deepEqual(parseMeFlag("@me dentist"), { rest: "dentist" }); |
| 2254 | assert.deepEqual(parseMeFlag("@me"), { rest: "" }); |
| 2255 | assert.deepEqual(parseMeFlag(" @me dentist "), { rest: "dentist" }); |
| 2256 | assert.equal(parseMeFlag("@mention is text"), null); |
| 2257 | assert.equal(parseMeFlag("dentist @me"), null); |
| 2258 | assert.equal(parseMeFlag(""), null); |
| 2259 | }); |
| 2260 | |
| 2261 | await test("parseEveryone accepts @space as an alias", () => { |
| 2262 | assert.deepEqual(parseEveryone("@space tada"), { rest: "tada" }); |
| 2263 | assert.deepEqual(parseEveryone("@space"), { rest: "" }); |
| 2264 | assert.equal(parseEveryone("@spacecraft launch"), null); |
| 2265 | }); |
| 2266 | |
| 2267 | await test("/todo @me files personally even where the chat copy claims the bare verb", async () => { |
| 2268 | resetThread("grpMe"); |
| 2269 | setThreadIsGroup((id) => id === "grpMe"); |
| 2270 | setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); |
| 2271 | foldEvent("grpMe", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2272 | const sent = []; |
| 2273 | const replies = []; |
| 2274 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2275 | // The bare verb goes to the chat copy… |
| 2276 | assert.equal(runCommand("/todo team snacks", "grpMe", (r) => replies.push(r)), true); |
| 2277 | await tick(); |
| 2278 | assert.equal(sent[0].threadId, "grpMe"); |
| 2279 | // …and @me overrides toward privacy. |
| 2280 | assert.equal(runCommand("/todo @me dentist", "grpMe", (r) => replies.push(r)), true); |
| 2281 | await tick(); |
| 2282 | assert.equal(sent[1].threadId, "todoThread", "@me reaches the personal copy past the chat claim"); |
| 2283 | assert.equal(sent[1].event.text, "dentist"); |
| 2284 | const mine = replies[replies.length - 1]; |
| 2285 | assert.equal(mine.place, "you", "the routed reply is stamped with the personal place"); |
| 2286 | assert.match(mine.text, /your to-do list/); |
| 2287 | setThreadOwner((id) => null); |
| 2288 | setThreadIsGroup(null); |
| 2289 | resetThread("grpMe"); |
| 2290 | }); |
| 2291 | |
| 2292 | await test("@me for an app with no personal copy explains instead of mis-filing", async () => { |
| 2293 | const sent = []; |
| 2294 | const replies = []; |
| 2295 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2296 | assert.equal(runCommand("/poll @me free pizza?", "chatX", (r) => replies.push(r)), true); |
| 2297 | await tick(); |
| 2298 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 2299 | assert.match(replies[0].text, /needs an audience/, "the app's own placeNote is the explanation"); |
| 2300 | }); |
| 2301 | |
| 2302 | await test("@here on a chat-only app is stripped, never folded into the command's content", async () => { |
| 2303 | resetThread("grpHere"); |
| 2304 | setThreadIsGroup((id) => id === "grpHere"); |
| 2305 | const sent = []; |
| 2306 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2307 | assert.equal(runCommand("/poll @here Pizza?", "grpHere", () => {}), true); |
| 2308 | await tick(); |
| 2309 | // The poll.create carries "Pizza?", not "@here Pizza?" — the redundant flag |
| 2310 | // (poll auto-adopts anyway) is removed, not baked into the title. |
| 2311 | const create = sent.find((s) => s.event.type === "poll.create"); |
| 2312 | assert.ok(create, "the poll was created"); |
| 2313 | assert.equal(create.event.q, "Pizza?", "@here stripped from the poll question"); |
| 2314 | setThreadIsGroup(null); |
| 2315 | resetThread("grpHere"); |
| 2316 | }); |
| 2317 | |
| 2318 | // A synthetic all-places fixture (you+chat+space) for the flag/alternate and |
| 2319 | // space-routing machinery below: the SHIPPED apps are parked at you+chat |
| 2320 | // while the places UI is hidden (docs/space-apps.md § The pullback), so a |
| 2321 | // stand-in keeps the space paths covered — they must still work the day an |
| 2322 | // app adds "space" back to its places line. |
| 2323 | const triple = defineApp({ |
| 2324 | name: "triple", |
| 2325 | title: "Triple", |
| 2326 | glyph: "▲", |
| 2327 | places: ["you", "chat", "space"], |
| 2328 | initialState: () => ({ items: [] }), |
| 2329 | events: { |
| 2330 | "triple.add": (s, e) => (e.id && e.text ? { items: [...s.items, { id: e.id, text: e.text }] } : s), |
| 2331 | }, |
| 2332 | commands: { |
| 2333 | "/triple": { |
| 2334 | desc: "Add a triple", |
| 2335 | args: "<text>", |
| 2336 | run: (args, ctx) => { |
| 2337 | if (!args) return ctx.reply({ text: "Usage: /triple <text>" }); |
| 2338 | ctx.emit({ type: "triple.add", id: ctx.id(), text: args }); |
| 2339 | // Words the destination via ctx.place, like todo does in production — |
| 2340 | // the routed-reply test asserts both the app wording and the stamp. |
| 2341 | const dest = ctx.place === "space" ? "the space triple list" : ctx.place === "chat" ? "this chat's triple list" : "your triple list"; |
| 2342 | ctx.reply({ text: `Added to ${dest}.`, short: "added", actions: [{ label: "View Triple", run: ctx.open }] }); |
| 2343 | }, |
| 2344 | }, |
| 2345 | }, |
| 2346 | render() {}, |
| 2347 | }); |
| 2348 | |
| 2349 | await test("the / catalog offers flagged alternates when several copies are reachable", async () => { |
| 2350 | resetThread("grpAlt"); |
| 2351 | resetThread("spaceT"); |
| 2352 | setThreadIsGroup((id) => id === "grpAlt"); |
| 2353 | setSpaceThread(() => "spaceT"); |
| 2354 | foldEvent("grpAlt", { type: "groupapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2355 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2356 | const cat = commandCatalog("grpAlt"); |
| 2357 | const names = cat.map((c) => c.name); |
| 2358 | assert.ok(names.includes("/triple"), "the bare verb (the default route) leads"); |
| 2359 | assert.equal(cat.find((c) => c.name === "/triple").place, "chat"); |
| 2360 | const everyoneAlt = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); |
| 2361 | assert.ok(everyoneAlt && everyoneAlt.alt && everyoneAlt.place === "space", "the space copy rides as a flagged alternate"); |
| 2362 | assert.equal(everyoneAlt.desc, "Add a triple for this space", "an existing copy gets the addressing words — no install language"); |
| 2363 | const meAlt = cat.find((c) => c.name === `/triple ${ME_FLAG}`); |
| 2364 | assert.ok(meAlt && meAlt.alt && meAlt.place === "you", "the personal place rides as a flagged alternate"); |
| 2365 | assert.match(meAlt.desc, /just for you$/); |
| 2366 | // /help lists each verb once and teaches the flags in its closing sentence. |
| 2367 | const replies = []; |
| 2368 | assert.equal(runCommand("/help", "grpAlt", (r) => replies.push(r)), true); |
| 2369 | await tick(); |
| 2370 | // One list line per verb (alt rows filtered): the desc appears once even |
| 2371 | // though the closing sentence itself demos "/todo @me". |
| 2372 | assert.equal((replies[0].text.match(/Add a triple/g) || []).length, 1, "alt rows stay out of the /help list"); |
| 2373 | assert.ok(replies[0].text.includes(`${ME_FLAG}, ${HERE_FLAG}, or ${EVERYONE_FLAG}`), "/help teaches the canonical spellings"); |
| 2374 | setThreadIsGroup(null); |
| 2375 | setSpaceThread(null); |
| 2376 | resetThread("grpAlt"); |
| 2377 | resetThread("spaceT"); |
| 2378 | // With no nearer claim anywhere, no ADDRESSING alternates — the bare verb |
| 2379 | // already files personally, so @me would be a no-op row. (The fresh case |
| 2380 | // still offers the INSTALL rows — the next test.) |
| 2381 | assert.ok(!commandCatalog("chatX").some((c) => c.name === `/triple ${ME_FLAG}`), "no @me alternate without a nearer claim"); |
| 2382 | }); |
| 2383 | |
| 2384 | await test("the / catalog offers install rows for the fresh case — the whole flag family, not a suffix", () => { |
| 2385 | resetThread("grpF"); |
| 2386 | setThreadIsGroup((id) => id === "grpF"); |
| 2387 | const cat = commandCatalog("grpF"); |
| 2388 | const here = cat.find((c) => c.name === `/triple ${HERE_FLAG}`); |
| 2389 | assert.ok(here && here.alt && here.place === "chat", "@here rides as an install offer in a group"); |
| 2390 | assert.equal(here.desc, "Add a triple to a new Triple app for this group", "an install row names the new app it creates"); |
| 2391 | const everyone = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); |
| 2392 | assert.ok(everyone && everyone.alt && everyone.place === "space", "@everyone rides as an install offer"); |
| 2393 | assert.equal(everyone.desc, "Add a triple to a new Triple app for this space"); |
| 2394 | // sortPlace carries the BARE verb's place, so the typeahead's nearness sort |
| 2395 | // never ranks an install offer above the route the bare verb takes. |
| 2396 | assert.equal(here.sortPlace, "you"); |
| 2397 | assert.equal(everyone.sortPlace, "you"); |
| 2398 | // A parked (you+chat) app never offers @everyone — the pullback's whole |
| 2399 | // point: the flag rows follow the places declaration, no UI enforcement. |
| 2400 | assert.ok(!cat.some((c) => c.name === `/todo ${EVERYONE_FLAG}`), "no @everyone offer for a you+chat app"); |
| 2401 | assert.ok(cat.some((c) => c.name === `/todo ${HERE_FLAG}`), "the chat offer stays for a you+chat app"); |
| 2402 | // Outside a group @here would only refuse — but @everyone works from any chat. |
| 2403 | const dm = commandCatalog("chatX"); |
| 2404 | assert.ok(!dm.some((c) => c.name === `/triple ${HERE_FLAG}`), "@here only offered in a group"); |
| 2405 | assert.ok(dm.some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "@everyone offered from any chat"); |
| 2406 | // A member the policy bars from space installs isn't offered a row that |
| 2407 | // would only refuse. |
| 2408 | setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: () => false })); |
| 2409 | assert.ok(!commandCatalog("grpF").some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "no @everyone offer when installs are admins-only"); |
| 2410 | setSpaceInstallPolicy(null); |
| 2411 | setThreadIsGroup(null); |
| 2412 | resetThread("grpF"); |
| 2413 | }); |
| 2414 | |
| 2415 | await test("/todo @here installs the app into the group AND files the item there", async () => { |
| 2416 | resetThread("grp6"); |
| 2417 | setThreadIsGroup((id) => id === "grp6"); |
| 2418 | const sent = []; |
| 2419 | const replies = []; |
| 2420 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2421 | setAppThreadResolver(async () => { |
| 2422 | throw new Error("@here never routes to the personal thread"); |
| 2423 | }); |
| 2424 | assert.equal(runCommand("/todo @here camp chairs", "grp6", (r) => replies.push(r)), true); |
| 2425 | await tick(); |
| 2426 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2427 | assert.equal(sent.length, 2); |
| 2428 | assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["grp6", "groupapps.add", "todo"]); |
| 2429 | assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["grp6", "todo.add", "camp chairs"]); |
| 2430 | // In-thread the install + add action lines are the record — no extra reply. |
| 2431 | assert.deepEqual(replies, []); |
| 2432 | // A bare "/todo @here" is JUST the install; already-added → no duplicate add. |
| 2433 | foldEvent("grp6", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2434 | const sent2 = []; |
| 2435 | setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); |
| 2436 | assert.equal(runCommand("/todo @here", "grp6", (r) => replies.push(r)), true); |
| 2437 | await tick(); |
| 2438 | assert.deepEqual(sent2, [], "already added + no text → nothing to emit"); |
| 2439 | setThreadIsGroup(null); |
| 2440 | resetThread("grp6"); |
| 2441 | }); |
| 2442 | |
| 2443 | await test("@here outside a group (or for an unshareable app) explains instead of mis-filing", async () => { |
| 2444 | setThreadIsGroup(() => false); |
| 2445 | const sent = []; |
| 2446 | const replies = []; |
| 2447 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2448 | assert.equal(runCommand("/todo @here buy milk", "chatX", (r) => replies.push(r)), true); |
| 2449 | await tick(); |
| 2450 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 2451 | assert.match(replies[0].text, /only works in a group chat/); |
| 2452 | setThreadIsGroup(null); |
| 2453 | }); |
| 2454 | |
| 2455 | await test("the / catalog says where a group-capable command lands", () => { |
| 2456 | resetThread("grp7"); |
| 2457 | setThreadIsGroup((id) => id === "grp7"); |
| 2458 | const desc = (list) => list.find((c) => c.name === "/todo").desc; |
| 2459 | // Fresh instance: the bare verb names its place plainly — the sharing hint |
| 2460 | // is no longer a suffix but the flagged install rows (tested below). |
| 2461 | assert.match(desc(commandCatalog("grp7")), /just for you$/); |
| 2462 | foldEvent("grp7", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2463 | assert.match(desc(commandCatalog("grp7")), /for this group$/); |
| 2464 | // Outside a group too — every app command names where its data lands (the |
| 2465 | // places grammar), reading on from the desc as one sentence. |
| 2466 | assert.equal(desc(commandCatalog("chatX")), "Add a to-do just for you"); |
| 2467 | setThreadIsGroup(null); |
| 2468 | resetThread("grp7"); |
| 2469 | }); |
| 2470 | |
| 2471 | // ---- space-wide apps (spaceapps registry + space routing) ------------------- |
| 2472 | // docs/space-apps.md. A synthetic app stands in for the member-built case (a |
| 2473 | // sticker library): personal + groupCapable + spaceCapable, so the routing |
| 2474 | // precedence — local → this chat's instance → the space instance → personal — |
| 2475 | // is exercised end to end. |
| 2476 | |
| 2477 | const stickers = defineApp({ |
| 2478 | name: "stickers", |
| 2479 | title: "Stickers", |
| 2480 | glyph: "🖼️", |
| 2481 | personal: true, |
| 2482 | groupCapable: true, |
| 2483 | spaceCapable: true, |
| 2484 | initialState: () => ({ items: [] }), |
| 2485 | events: { |
| 2486 | "stickers.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 2487 | }, |
| 2488 | commands: { |
| 2489 | "/sticker": { |
| 2490 | desc: "Post a sticker", |
| 2491 | args: "<text>", |
| 2492 | run: (args, ctx) => { |
| 2493 | if (args) ctx.emit({ type: "stickers.add", id: ctx.id(), text: args }); |
| 2494 | }, |
| 2495 | }, |
| 2496 | }, |
| 2497 | defaultCommand: "/sticker", |
| 2498 | render() {}, |
| 2499 | }); |
| 2500 | const spaceReduce = spaceAppsApp.reduce; |
| 2501 | |
| 2502 | await test("spaceapps: adds fold idempotently, removes drop, malformed no-op", () => { |
| 2503 | let s = spaceReduce(undefined, { type: "spaceapps.add", app: "stickers", name: "Ana", uid: "uA", ts: 5 }); |
| 2504 | assert.deepEqual(s, { apps: [{ id: "stickers", by: "Ana", uid: "uA", ts: 5 }] }); |
| 2505 | // Re-installing (another member, a refold) is a no-op — the id IS the app name. |
| 2506 | const s2 = spaceReduce(s, { type: "spaceapps.add", app: "stickers", name: "Bob", ts: 9 }); |
| 2507 | assert.deepEqual(s2.apps, s.apps); |
| 2508 | // Unknown app names still fold (state converges across client versions). |
| 2509 | s = spaceReduce(s, { type: "spaceapps.add", app: "flarp", name: "Bob", ts: 9 }); |
| 2510 | assert.equal(s.apps.length, 2); |
| 2511 | s = spaceReduce(s, { type: "spaceapps.remove", app: "stickers" }); |
| 2512 | assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); |
| 2513 | // Hostile / malformed events are no-ops, never a crash (Invariant #8). |
| 2514 | assert.equal(spaceReduce(s, { type: "spaceapps.add" }), s); |
| 2515 | assert.equal(spaceReduce(s, { type: "spaceapps.add", app: 7 }), s); |
| 2516 | assert.deepEqual(spaceReduce(s, { type: "spaceapps.remove", app: "missing" }).apps, s.apps); |
| 2517 | }); |
| 2518 | |
| 2519 | await test("parseEveryone: leading @everyone only, breaking on whitespace", () => { |
| 2520 | assert.deepEqual(parseEveryone("@everyone tada"), { rest: "tada" }); |
| 2521 | assert.deepEqual(parseEveryone("@everyone"), { rest: "" }); |
| 2522 | assert.deepEqual(parseEveryone(" @everyone tada "), { rest: "tada" }); |
| 2523 | assert.equal(parseEveryone("@everyones party"), null); |
| 2524 | assert.equal(parseEveryone("tada @everyone"), null); |
| 2525 | assert.equal(parseEveryone(""), null); |
| 2526 | }); |
| 2527 | |
| 2528 | await test("spaceAppsFor resolves installs against the registry, filtering unknown and non-space-capable", () => { |
| 2529 | resetThread("spaceT"); |
| 2530 | setSpaceThread(() => "spaceT"); |
| 2531 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana", ts: 5 }, { id: 1 }); |
| 2532 | foldEvent("spaceT", { type: "spaceapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert |
| 2533 | foldEvent("spaceT", { type: "spaceapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not spaceCapable → inert |
| 2534 | assert.deepEqual(spaceAppsFor().map((a) => a.name), ["stickers"]); |
| 2535 | assert.deepEqual(spaceAppInfo("stickers"), { by: "Ana", ts: 5 }); |
| 2536 | assert.equal(spaceAppInfo("connect"), null, "an inert install still isn't attributed as one"); |
| 2537 | // No space thread known (fresh boot, key not here yet) → nothing is installed. |
| 2538 | setSpaceThread(null); |
| 2539 | assert.deepEqual(spaceAppsFor(), []); |
| 2540 | setSpaceThread(() => "spaceT"); |
| 2541 | foldEvent("spaceT", { type: "spaceapps.remove", app: "stickers" }, { id: 4 }); |
| 2542 | assert.deepEqual(spaceAppsFor(), []); |
| 2543 | setSpaceThread(null); |
| 2544 | resetThread("spaceT"); |
| 2545 | }); |
| 2546 | |
| 2547 | await test("the space-capable roster: shipped apps parked at you+chat, the stand-ins keep it covered", () => { |
| 2548 | const names = spaceCapableApps().map((a) => a.name); |
| 2549 | assert.ok(names.includes("stickers"), "the synthetic member-app stand-in"); |
| 2550 | assert.ok(names.includes("triple"), "the synthetic all-places stand-in"); |
| 2551 | // Parked while the places UI is hidden (docs/space-apps.md § The pullback) — |
| 2552 | // each is one places-line edit away from returning. |
| 2553 | for (const n of ["todo", "remind", "bookmarks", "vault"]) assert.ok(!names.includes(n), n + " is parked at you+chat"); |
| 2554 | for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); |
| 2555 | }); |
| 2556 | |
| 2557 | await test("a space-installed app's command targets the SPACE instance from any chat", async () => { |
| 2558 | resetThread("spaceT"); |
| 2559 | setSpaceThread(() => "spaceT"); |
| 2560 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2561 | const sent = []; |
| 2562 | const replies = []; |
| 2563 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2564 | // Resolver poisoned: a space-installed app's command must NOT open a |
| 2565 | // personal app-thread — the shared space instance claims the verb. |
| 2566 | setAppThreadResolver(async () => { |
| 2567 | throw new Error("space-scoped commands never route to the personal thread"); |
| 2568 | }); |
| 2569 | assert.equal(runCommand("/sticker high five", "chatX", (r) => replies.push(r)), true); |
| 2570 | await tick(); |
| 2571 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2572 | assert.equal(sent.length, 1); |
| 2573 | assert.equal(sent[0].threadId, "spaceT", "the event lands in the space thread"); |
| 2574 | assert.equal(sent[0].event.type, "stickers.add"); |
| 2575 | assert.equal(sent[0].event.text, "high five"); |
| 2576 | setSpaceThread(null); |
| 2577 | resetThread("spaceT"); |
| 2578 | }); |
| 2579 | |
| 2580 | await test("a space install never steals the verb inside the app's OWN thread", async () => { |
| 2581 | resetThread("spaceT"); |
| 2582 | setSpaceThread(() => "spaceT"); |
| 2583 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2584 | // Typed inside the stickers app-thread itself, /sticker addresses THAT |
| 2585 | // instance — the one you're looking at — not the space copy. |
| 2586 | setThreadOwner((id) => (id === "stickersThread" ? "stickers" : null)); |
| 2587 | const sent = []; |
| 2588 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2589 | assert.equal(runCommand("/sticker mine", "stickersThread"), true); |
| 2590 | await tick(); |
| 2591 | assert.equal(sent[0].threadId, "stickersThread", "the own thread wins over the space instance"); |
| 2592 | setThreadOwner((id) => null); |
| 2593 | setSpaceThread(null); |
| 2594 | resetThread("spaceT"); |
| 2595 | }); |
| 2596 | |
| 2597 | await test("a group add is the NEARER scope: it beats the space install in that chat", async () => { |
| 2598 | resetThread("spaceT"); |
| 2599 | resetThread("grp8"); |
| 2600 | setSpaceThread(() => "spaceT"); |
| 2601 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2602 | foldEvent("grp8", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2603 | const sent = []; |
| 2604 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2605 | assert.equal(runCommand("/sticker in the group", "grp8"), true); |
| 2606 | await tick(); |
| 2607 | assert.equal(sent[0].threadId, "grp8", "the chat's own instance wins where it exists"); |
| 2608 | // …and elsewhere the space instance still claims the verb. |
| 2609 | assert.equal(runCommand("/sticker elsewhere", "chatX"), true); |
| 2610 | await tick(); |
| 2611 | assert.equal(sent[1].threadId, "spaceT"); |
| 2612 | setSpaceThread(null); |
| 2613 | resetThread("spaceT"); |
| 2614 | resetThread("grp8"); |
| 2615 | }); |
| 2616 | |
| 2617 | await test("/sticker @everyone installs for the space AND files the trailing text there", async () => { |
| 2618 | resetThread("spaceT"); |
| 2619 | setSpaceThread(() => "spaceT"); |
| 2620 | setSpaceThreadResolver(async () => "spaceT"); |
| 2621 | const sent = []; |
| 2622 | const replies = []; |
| 2623 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2624 | setAppThreadResolver(async () => { |
| 2625 | throw new Error("@everyone never routes to the personal thread"); |
| 2626 | }); |
| 2627 | assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); |
| 2628 | await tick(); |
| 2629 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2630 | assert.equal(sent.length, 2); |
| 2631 | assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["spaceT", "spaceapps.add", "stickers"]); |
| 2632 | assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["spaceT", "stickers.add", "tada"]); |
| 2633 | // A bare "@everyone" is JUST the install, confirmed privately (no chat |
| 2634 | // surface shows the space log, so the reply is the feedback). |
| 2635 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2636 | const sent2 = []; |
| 2637 | setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); |
| 2638 | assert.equal(runCommand("/sticker @everyone", "chatX", (r) => replies.push(r)), true); |
| 2639 | await tick(); |
| 2640 | assert.deepEqual(sent2, [], "already installed + no text → nothing to emit"); |
| 2641 | assert.match(replies[replies.length - 1].text, /added for everyone/i); |
| 2642 | setSpaceThreadResolver(null); |
| 2643 | setSpaceThread(null); |
| 2644 | resetThread("spaceT"); |
| 2645 | }); |
| 2646 | |
| 2647 | await test("@everyone for an app that isn't space-capable explains instead of mis-filing", async () => { |
| 2648 | const sent = []; |
| 2649 | const replies = []; |
| 2650 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2651 | // Poll is chat-place-only (its placeNote says why) — the refusal example. |
| 2652 | assert.equal(runCommand("/poll @everyone free pizza?", "chatX", (r) => replies.push(r)), true); |
| 2653 | await tick(); |
| 2654 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 2655 | assert.match(replies[0].text, /can't be installed for the whole space/); |
| 2656 | }); |
| 2657 | |
| 2658 | await test("installSpaceApp answers false when the space thread can't be resolved (keys syncing)", async () => { |
| 2659 | setSpaceThread(null); |
| 2660 | setSpaceThreadResolver(async () => null); |
| 2661 | assert.equal(await installSpaceApp(stickers), false); |
| 2662 | setSpaceThreadResolver(null); |
| 2663 | }); |
| 2664 | |
| 2665 | await test("the / catalog labels a space-installed command in EVERY chat", () => { |
| 2666 | resetThread("spaceT"); |
| 2667 | setSpaceThread(() => "spaceT"); |
| 2668 | const desc = (list) => list.find((c) => c.name === "/sticker").desc; |
| 2669 | assert.equal(desc(commandCatalog("chatX")), "Post a sticker just for you"); |
| 2670 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2671 | assert.match(desc(commandCatalog("chatX")), /for this space$/); |
| 2672 | // In a group that ALSO added it, the nearer scope's label wins. |
| 2673 | resetThread("grp9"); |
| 2674 | setThreadIsGroup((id) => id === "grp9"); |
| 2675 | foldEvent("grp9", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2676 | assert.match(desc(commandCatalog("grp9")), /for this group$/); |
| 2677 | setThreadIsGroup(null); |
| 2678 | setSpaceThread(null); |
| 2679 | resetThread("spaceT"); |
| 2680 | resetThread("grp9"); |
| 2681 | }); |
| 2682 | |
| 2683 | await test("placePhrase: the one vocabulary every surface shares", () => { |
| 2684 | assert.equal(placePhrase("you"), "just for you"); |
| 2685 | assert.equal(placePhrase("chat"), "shared in this chat"); |
| 2686 | assert.equal(placePhrase("space"), "for everyone in this space"); |
| 2687 | assert.equal(placePhrase("elsewhere"), ""); |
| 2688 | assert.equal(placePhrase(), ""); |
| 2689 | }); |
| 2690 | |
| 2691 | await test("placeRowPhrase: the flowing forms a typeahead row reads with", () => { |
| 2692 | assert.equal(placeRowPhrase("you"), "just for you"); |
| 2693 | assert.equal(placeRowPhrase("chat"), "for this group"); |
| 2694 | assert.equal(placeRowPhrase("space"), "for this space"); |
| 2695 | assert.equal(placeRowPhrase("elsewhere"), ""); |
| 2696 | assert.equal(placeRowPhrase(), ""); |
| 2697 | }); |
| 2698 | |
| 2699 | await test("installRowPhrase: a fresh-case row names the new app it would create", () => { |
| 2700 | assert.equal(installRowPhrase("To-do", "chat"), "to a new To-do app for this group"); |
| 2701 | assert.equal(installRowPhrase("To-do", "space"), "to a new To-do app for this space"); |
| 2702 | assert.equal(installRowPhrase("To-do", "you"), "", "nothing installs into the personal place"); |
| 2703 | assert.equal(installRowPhrase("To-do"), ""); |
| 2704 | }); |
| 2705 | |
| 2706 | await test("spaceRowTitle: a space copy is named after the space, never a twin of the personal row", () => { |
| 2707 | assert.equal(spaceRowTitle("To-do", "Lazyatom"), "Lazyatom To-do"); |
| 2708 | assert.equal(spaceRowTitle("To-do", " The Campbells "), "The Campbells To-do", "names are trimmed"); |
| 2709 | // Unset (the compiled-in default — callers pass "") and over-long names |
| 2710 | // fall back to the plain word, keeping the app title visible. |
| 2711 | assert.equal(spaceRowTitle("To-do", ""), "Space To-do"); |
| 2712 | assert.equal(spaceRowTitle("To-do", "The Extended Johnson Family Reunion"), "Space To-do"); |
| 2713 | assert.equal(spaceRowTitle("To-do"), "Space To-do"); |
| 2714 | }); |
| 2715 | |
| 2716 | await test("a routed reply is stamped with its place — and the app words the destination", async () => { |
| 2717 | resetThread("spaceT"); |
| 2718 | setSpaceThread(() => "spaceT"); |
| 2719 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2720 | const sent = []; |
| 2721 | const replies = []; |
| 2722 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2723 | assert.equal(runCommand("/triple space milk", "chatX", (r) => replies.push(r)), true); |
| 2724 | await tick(); |
| 2725 | assert.equal(sent[0].threadId, "spaceT"); |
| 2726 | assert.equal(replies.length, 1); |
| 2727 | assert.equal(replies[0].place, "space", "the framework stamps the routed place — no app cooperation needed"); |
| 2728 | assert.match(replies[0].text, /the space triple list/, "the app names the destination via ctx.place"); |
| 2729 | // Routed personally (no installs anywhere): stamped "you", worded "your" — |
| 2730 | // todo is the shipped reference for the wording. ctx.place learns "you" |
| 2731 | // from the thread-owner map, so bind it here. |
| 2732 | resetThread("spaceT"); |
| 2733 | setSpaceThread(null); |
| 2734 | setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); |
| 2735 | const replies2 = []; |
| 2736 | assert.equal(runCommand("/todo my milk", "chatX", (r) => replies2.push(r)), true); |
| 2737 | await tick(); |
| 2738 | assert.equal(replies2[0].place, "you"); |
| 2739 | assert.match(replies2[0].text, /your to-do list/); |
| 2740 | setThreadOwner((id) => null); // the ambient state the surrounding tests expect |
| 2741 | }); |
| 2742 | |
| 2743 | await test("ctx.open shows the INSTANCE through the host's opener — never a raw thread navigation", async () => { |
| 2744 | resetThread("spaceT"); |
| 2745 | setSpaceThread(() => "spaceT"); |
| 2746 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2747 | const opened = []; |
| 2748 | setInstanceOpener((appName, tid) => opened.push([appName, tid])); |
| 2749 | const replies = []; |
| 2750 | setEmitTransport(() => Promise.resolve(true)); |
| 2751 | assert.equal(runCommand("/triple view me", "chatX", (r) => replies.push(r)), true); |
| 2752 | await tick(); |
| 2753 | const view = (replies[0].actions || []).find((a) => a.label === "View Triple"); |
| 2754 | assert.ok(view, "the reply offers a View chip"); |
| 2755 | view.run(); |
| 2756 | assert.deepEqual(opened, [["triple", "spaceT"]], "the chip names the app + its instance; the host picks the surface"); |
| 2757 | setInstanceOpener(null); |
| 2758 | setSpaceThread(null); |
| 2759 | resetThread("spaceT"); |
| 2760 | }); |
| 2761 | |
| 2762 | await test("places: the one declaration — passed through, with listed derived", () => { |
| 2763 | const p = defineApp({ name: "ptest", title: "P", glyph: "▣", places: ["you", "space"], initialState: () => ({}), events: { "ptest.x": (s) => s } }); |
| 2764 | assert.deepEqual(p.places, ["you", "space"]); |
| 2765 | assert.equal(p.listed, true); |
| 2766 | // The shipped apps sit at you+chat while the places UI is hidden (§ The |
| 2767 | // pullback). |
| 2768 | assert.deepEqual(todoApp.places, ["you", "chat"]); |
| 2769 | assert.deepEqual(bookmarksApp.places, ["you", "chat"]); |
| 2770 | assert.deepEqual(emojiApp.places, [], "a utility app lives nowhere — host-mounted only"); |
| 2771 | }); |
| 2772 | |
| 2773 | await test("the catalog carries a machine-readable place for ranking", () => { |
| 2774 | resetThread("spaceT"); |
| 2775 | setSpaceThread(() => "spaceT"); |
| 2776 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2777 | const by = (list, name) => list.find((c) => c.name === name); |
| 2778 | const cat = commandCatalog("chatX"); |
| 2779 | assert.equal(by(cat, "/sticker").place, "space"); |
| 2780 | assert.equal(by(cat, "/todo").place, "you"); |
| 2781 | assert.equal(by(cat, "/emoji").place, "", "a stateless utility command names no place"); |
| 2782 | resetThread("grp10"); |
| 2783 | foldEvent("grp10", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2784 | assert.equal(by(commandCatalog("grp10"), "/sticker").place, "chat", "the nearer copy wins the label too"); |
| 2785 | setSpaceThread(null); |
| 2786 | resetThread("spaceT"); |
| 2787 | resetThread("grp10"); |
| 2788 | }); |
| 2789 | |
| 2790 | await test("admins-only policy: non-admin adds refuse with honest copy; reads filter by adder", async () => { |
| 2791 | resetThread("spaceT"); |
| 2792 | setSpaceThread(() => "spaceT"); |
| 2793 | // Non-admin adds folded into the registry: uid-carrying items filter at |
| 2794 | // read time while the policy holds; pre-uid items are grandfathered. |
| 2795 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Mal", uid: "uMal" }, { id: 1 }); |
| 2796 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Old" }, { id: 2 }); // pre-uid log |
| 2797 | setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: (uid) => uid === "uAdmin" })); |
| 2798 | assert.deepEqual(spaceAppsFor().map((a) => a.name), ["triple"], "non-admin install inert; grandfathered kept"); |
| 2799 | // …and @everyone from a non-admin explains instead of emitting. |
| 2800 | const sent = []; |
| 2801 | const replies = []; |
| 2802 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2803 | assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); |
| 2804 | await tick(); |
| 2805 | assert.equal(sent.length, 0); |
| 2806 | assert.match(replies[0].text, /Only admins can add apps for everyone/); |
| 2807 | // Policy off again: the filtered install reappears — read-time is reversible. |
| 2808 | setSpaceInstallPolicy(null); |
| 2809 | assert.deepEqual(spaceAppsFor().map((a) => a.name).sort(), ["stickers", "triple"]); |
| 2810 | setSpaceThread(null); |
| 2811 | resetThread("spaceT"); |
| 2812 | }); |
| 2813 | |
| 2814 | await test("/help teaches the routing sentence in the same words the typeahead uses", async () => { |
| 2815 | const replies = []; |
| 2816 | assert.equal(runCommand("/help", "chatX", (r) => replies.push(r)), true); |
| 2817 | await tick(); |
| 2818 | assert.match(replies[0].text, /nearest copy of its app/, "the one routing sentence rides every /help"); |
| 2819 | assert.match(replies[0].text, /just for you/, "personal commands name their place in the list"); |
| 2820 | }); |
| 2821 | |
| 2822 | await test("@-mentioning a space-installed app works in any chat and targets the space instance", async () => { |
| 2823 | resetThread("spaceT"); |
| 2824 | setSpaceThread(() => "spaceT"); |
| 2825 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2826 | const m = parseAppMention("@Stickers party time", "chatX"); |
| 2827 | assert.equal(m.app.name, "stickers"); |
| 2828 | assert.equal(m.args, "party time"); |
| 2829 | const sent = []; |
| 2830 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2831 | assert.equal(runAppMention("@Stickers party time", "chatX", () => {}), true); |
| 2832 | await tick(); |
| 2833 | assert.equal(sent[0].threadId, "spaceT", "the mention acts on the space instance"); |
| 2834 | assert.equal(sent[0].event.type, "stickers.add"); |
| 2835 | setSpaceThread(null); |
| 2836 | assert.equal(parseAppMention("@Stickers party time", "chatX"), null, "no space thread → no space mentions"); |
| 2837 | resetThread("spaceT"); |
| 2838 | }); |
| 2839 | |
| 2840 | await test("runAppMention: a bare mention gets the usage reply; plain text never matches", async () => { |
| 2841 | resetThread("grp4"); |
| 2842 | foldEvent("grp4", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2843 | const sent = []; |
| 2844 | const replies = []; |
| 2845 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2846 | assert.equal(runAppMention("@To-do", "grp4", (r) => replies.push(r)), true); |
| 2847 | await tick(); |
| 2848 | assert.equal(sent.length, 0, "nothing to add"); |
| 2849 | assert.equal(replies.length, 1, "the usage line still shows (it's content, not a confirmation)"); |
| 2850 | assert.equal(replies[0].sender.name, "To-do"); |
| 2851 | assert.equal(replies[0].text, "Usage: @To-do <task>", "usage speaks in mention form, not a slash verb"); |
| 2852 | // Ordinary conversation sails past — an app must never eat a group's messages. |
| 2853 | assert.equal(runAppMention("who's around for lunch?", "grp4", (r) => replies.push(r)), false); |
| 2854 | assert.equal(runAppMention("@Ana can you look?", "grp4", (r) => replies.push(r)), false); |
| 2855 | resetThread("grp4"); |
| 2856 | }); |
| 2857 | |
| 2858 | // ---- command panels (ctx.panel) + the /emoji reference app ----------------- |
| 2859 | |
| 2860 | await test("searchEmoji filters by name substring and collapses aliases", () => { |
| 2861 | const cats = searchEmoji("cat"); |
| 2862 | assert.ok(cats.some((r) => r.ch === "🐱"), "cat matches"); |
| 2863 | assert.ok(cats.every((r) => r.name.includes("cat")), "only matching names"); |
| 2864 | // laughing/satisfied are aliases of one glyph — a bare search dedupes them. |
| 2865 | const all = searchEmoji(""); |
| 2866 | assert.equal(all.filter((r) => r.ch === "😆").length, 1, "aliases collapse to one tile"); |
| 2867 | assert.ok(all.length > 100, "a bare query browses the whole set"); |
| 2868 | assert.deepEqual(searchEmoji("zzzznope"), [], "no match → empty"); |
| 2869 | }); |
| 2870 | |
| 2871 | await test("ctx.panel opens a command panel in the thread you TYPED in", async () => { |
| 2872 | const opened = []; |
| 2873 | setCommandPanelHost((threadId, spec) => { |
| 2874 | opened.push({ threadId, spec }); |
| 2875 | return true; |
| 2876 | }); |
| 2877 | const replies = []; |
| 2878 | assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| 2879 | await tick(); |
| 2880 | assert.equal(opened.length, 1); |
| 2881 | assert.equal(opened[0].threadId, "chatX", "the panel opens where you typed, not a routed app-thread"); |
| 2882 | assert.equal(typeof opened[0].spec.render, "function"); |
| 2883 | assert.match(opened[0].spec.title, /cat/); |
| 2884 | assert.equal(opened[0].spec.sender.name, "Emoji", "runCommand injects the app persona as the panel's sender"); |
| 2885 | assert.equal(replies.length, 0, "the panel IS the answer — no reply too"); |
| 2886 | }); |
| 2887 | |
| 2888 | await test("a command falls back to a reply when no chat surface can draw a panel", async () => { |
| 2889 | setCommandPanelHost(() => false); // the thread has no mounted chat pane |
| 2890 | const replies = []; |
| 2891 | assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| 2892 | await tick(); |
| 2893 | assert.equal(replies.length, 1, "ctx.panel returned false → the command replied instead"); |
| 2894 | assert.match(replies[0].text, /🐱/); |
| 2895 | }); |
| 2896 | |
| 2897 | await test("openCommandPanel guards: no host / no render → false; close reaches the host", () => { |
| 2898 | const calls = []; |
| 2899 | setCommandPanelHost((threadId, spec) => (calls.push({ threadId, spec }), true)); |
| 2900 | assert.equal(openCommandPanel("t1", { title: "no render" }), false, "a panel must have render()"); |
| 2901 | assert.equal(calls.length, 0); |
| 2902 | assert.equal(openCommandPanel("t1", { render() {} }), true); |
| 2903 | // The spec passes through verbatim — animate/position are the host's to read. |
| 2904 | assert.equal(openCommandPanel("t1", { render() {}, animate: false }), true); |
| 2905 | assert.equal(calls[calls.length - 1].spec.animate, false); |
| 2906 | closeCommandPanel("t1"); |
| 2907 | assert.equal(calls.length, 3); // two opens + the close |
| 2908 | assert.equal(calls[2].spec, null, "close is a null spec to the same host"); |
| 2909 | setCommandPanelHost(null); |
| 2910 | assert.equal(openCommandPanel("t1", { render() {} }), false, "no host → false (caller falls back to a reply)"); |
| 2911 | }); |
| 2912 | |
| 2913 | await test("gif: pickRendition prefers downsized, skips oversize, tolerates missing sizes", () => { |
| 2914 | const images = { |
| 2915 | downsized: { url: "https://media0.giphy.com/a.gif", width: "320", height: "240", size: "900000" }, |
| 2916 | fixed_height: { url: "https://media0.giphy.com/b.gif", width: "356", height: "200", size: "1500000" }, |
| 2917 | original: { url: "https://media0.giphy.com/c.gif", width: "480", height: "360", size: "9000000" }, |
| 2918 | }; |
| 2919 | assert.equal(pickRendition(images).url, "https://media0.giphy.com/a.gif"); |
| 2920 | // downsized too big → the next acceptable rendition wins |
| 2921 | assert.equal(pickRendition({ ...images, downsized: { ...images.downsized, size: String(GIF_MAX_BYTES + 1) } }).url, "https://media0.giphy.com/b.gif"); |
| 2922 | // unknown size sails through — the byte fetch re-checks the real length |
| 2923 | assert.equal(pickRendition({ downsized: { url: "https://media0.giphy.com/d.gif", width: "1", height: "1" } }).url, "https://media0.giphy.com/d.gif"); |
| 2924 | // nothing acceptable → null |
| 2925 | assert.equal(pickRendition({ original: { url: "https://media0.giphy.com/c.gif", size: String(GIF_MAX_BYTES * 2) } }), null); |
| 2926 | assert.equal(pickRendition(undefined), null); |
| 2927 | // The post walks ALL acceptable renditions (fallback when one download |
| 2928 | // fails), deduped by url, best first. |
| 2929 | const cands = renditionCandidates(images); |
| 2930 | assert.deepEqual(cands.map((c) => c.url.split("/").pop()), ["a.gif", "b.gif"], "oversize original dropped; order kept"); |
| 2931 | assert.deepEqual(renditionCandidates(undefined), []); |
| 2932 | }); |
| 2933 | |
| 2934 | await test("gif: gifItems maps a Giphy page and drops unusable results", () => { |
| 2935 | const data = [ |
| 2936 | { id: "g1", title: "Cat", images: { |
| 2937 | fixed_height_small: { url: "https://media0.giphy.com/s.gif", width: "89", height: "100" }, |
| 2938 | downsized: { url: "https://media0.giphy.com/f.gif", width: "320", height: "240", size: "500000" }, |
| 2939 | } }, |
| 2940 | { id: "g2", title: "No preview", images: { downsized: { url: "https://media0.giphy.com/x.gif", size: "1" } } }, |
| 2941 | { id: "g3", title: "No postable rendition", images: { fixed_height_small: { url: "https://media0.giphy.com/y.gif" } } }, |
| 2942 | null, |
| 2943 | ]; |
| 2944 | const items = gifItems(data); |
| 2945 | assert.equal(items.length, 1); // g2 has no preview rendition, g3 nothing postable |
| 2946 | assert.equal(items[0].id, "g1"); |
| 2947 | assert.equal(items[0].preview.url, "https://media0.giphy.com/s.gif"); |
| 2948 | assert.deepEqual(gifItems(null), []); |
| 2949 | }); |
| 2950 | |
| 2951 | await test("gif: /gif opens a panel where you typed; no host doorway → a reply", async () => { |
| 2952 | resetThread("spaceT"); |
| 2953 | setSpaceThread(() => "spaceT"); |
| 2954 | setSpaceThreadResolver(async () => "spaceT"); |
| 2955 | const opened = []; |
| 2956 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 2957 | setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); |
| 2958 | const replies = []; |
| 2959 | assert.equal(runCommand("/gif office party", "chatX", (r) => replies.push(r)), true); |
| 2960 | await tick(); |
| 2961 | assert.equal(opened.length, 1); |
| 2962 | // The SPLIT: ctx addresses the space (its key), but the panel opens in the |
| 2963 | // chat you typed in — the two threads a space-placed command app straddles. |
| 2964 | assert.equal(opened[0].threadId, "chatX"); |
| 2965 | assert.match(opened[0].spec.title, /office party/); |
| 2966 | assert.equal(replies.length, 0); |
| 2967 | // Without the host doorway (e.g. a stripped-down embed) it explains itself. |
| 2968 | setHostBridge(null); |
| 2969 | assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); |
| 2970 | await tick(); |
| 2971 | assert.equal(replies.length, 1); |
| 2972 | assert.match(replies[0].text, /isn't available/); |
| 2973 | assert.equal(opened.length, 1, "no panel without the doorway"); |
| 2974 | setCommandPanelHost(null); |
| 2975 | setSpaceThreadResolver(null); |
| 2976 | setSpaceThread(null); |
| 2977 | resetThread("spaceT"); |
| 2978 | assert.ok(!gifApp.places.includes("you"), "gif keeps no personal copy"); |
| 2979 | }); |
| 2980 | |
| 2981 | await test("gif is implicitly space-placed: unlisted + spaceCapable routes to the space with no install", async () => { |
| 2982 | resetThread("spaceT"); |
| 2983 | // Deliberately NO spaceapps.add for gif: an unlisted app has no Add button, |
| 2984 | // so routing must not depend on an install event that can never arrive. |
| 2985 | setSpaceThread(() => "spaceT"); |
| 2986 | setSpaceThreadResolver(async () => "spaceT"); |
| 2987 | assert.deepEqual(gifApp.places, ["space"], "gif lives only in the space place"); |
| 2988 | assert.equal(gifApp.listed, false, "explicit listed:false survives normalizePlaces"); |
| 2989 | assert.equal( |
| 2990 | listedApps().some((a) => a.name === "gif"), |
| 2991 | false, |
| 2992 | "an unlisted app stays out of the Apps catalogue", |
| 2993 | ); |
| 2994 | assert.equal(!!gifApp.render, false, "no app-level render → off the widget bar and sidebar band for free"); |
| 2995 | assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); |
| 2996 | // The whole point of the rule: WITHOUT it this falls through to the chat and |
| 2997 | // the space's Giphy key folds into whatever conversation you typed in. |
| 2998 | assert.notEqual(await instanceThread(gifApp, "chatX"), "chatX"); |
| 2999 | setSpaceThread(null); |
| 3000 | setSpaceThreadResolver(null); |
| 3001 | resetThread("spaceT"); |
| 3002 | }); |
| 3003 | |
| 3004 | await test("gif: the space's key mints the space thread on first use, and folds last-writer-wins", async () => { |
| 3005 | resetThread("spaceT"); |
| 3006 | let minted = 0; |
| 3007 | setSpaceThread(() => null); // not loaded/created yet — the first /gif in a space |
| 3008 | setSpaceThreadResolver(async () => (minted++, "spaceT")); |
| 3009 | assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); |
| 3010 | assert.equal(minted, 1, "resolved (minted) rather than falling through to the chat"); |
| 3011 | setSpaceThread(() => "spaceT"); |
| 3012 | // The key is ordinary fold state on the space thread — every member reads it, |
| 3013 | // which is the point; the server only ever holds the ciphertext. |
| 3014 | foldEvent("spaceT", { type: "gif.key", key: " abc123 " }, { id: 1 }); |
| 3015 | assert.equal(appStateFor("spaceT", "gif").key, "abc123", "trimmed"); |
| 3016 | foldEvent("spaceT", { type: "gif.key", key: "xyz789" }, { id: 2 }); |
| 3017 | assert.equal(appStateFor("spaceT", "gif").key, "xyz789", "last writer wins — re-running the form just re-keys"); |
| 3018 | foldEvent("spaceT", { type: "gif.key" }, { id: 3 }); |
| 3019 | assert.equal(appStateFor("spaceT", "gif").key, "", "a malformed event folds to empty, never undefined"); |
| 3020 | setSpaceThread(null); |
| 3021 | setSpaceThreadResolver(null); |
| 3022 | resetThread("spaceT"); |
| 3023 | }); |
| 3024 | |
| 3025 | await test("an unroutable command explains itself instead of doing nothing", async () => { |
| 3026 | // gif with no space thread: a native embed (bootEmbed doesn't load it) or a |
| 3027 | // member whose instance key hasn't arrived. The old build would have asked for |
| 3028 | // a key and stashed it on the device; the key is the space's now, so the |
| 3029 | // honest answer is to say so — never to resurrect a silent per-device key, and |
| 3030 | // never (as this used to) to return without a word. |
| 3031 | setSpaceThread(() => null); |
| 3032 | setSpaceThreadResolver(null); |
| 3033 | const opened = []; |
| 3034 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 3035 | setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); |
| 3036 | const replies = []; |
| 3037 | assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); |
| 3038 | await tick(); |
| 3039 | assert.equal(opened.length, 0, "no panel with nowhere to read the key from"); |
| 3040 | assert.equal(replies.length, 1); |
| 3041 | assert.match(replies[0].text, /isn't available here yet/i); |
| 3042 | setCommandPanelHost(null); |
| 3043 | setHostBridge(null); |
| 3044 | setSpaceThread(null); |
| 3045 | }); |
| 3046 | |
| 3047 | await test("the declarative panel form: ctx.panel({data}) + a panel() hook synthesizes render", async () => { |
| 3048 | const seen = []; |
| 3049 | const fixture = defineApp({ |
| 3050 | name: "paneldemo", |
| 3051 | title: "Panel Demo", |
| 3052 | glyph: "▣", |
| 3053 | personal: false, |
| 3054 | listed: false, |
| 3055 | initialState: () => ({}), |
| 3056 | events: {}, |
| 3057 | commands: { "/paneldemo": (args, ctx) => ctx.panel({ title: "Demo", data: { args } }) }, |
| 3058 | panel(data, el, pctx) { |
| 3059 | seen.push({ data, el, keys: Object.keys(pctx).sort() }); |
| 3060 | }, |
| 3061 | }); |
| 3062 | const opened = []; |
| 3063 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 3064 | assert.equal(runCommand("/paneldemo hello", "chatX"), true); |
| 3065 | await tick(); |
| 3066 | assert.equal(opened.length, 1); |
| 3067 | const spec = opened[0].spec; |
| 3068 | assert.equal(typeof spec.render, "function", "the hook synthesizes the host render"); |
| 3069 | // The host invokes render(el, handle); the hook gets (data, el, panelCtx) |
| 3070 | // with the handle's members plus the app ctx's emit/id/me/getState. |
| 3071 | const el = {}; |
| 3072 | spec.render(el, { close() {}, insertText() {}, refresh() {}, attachFiles() {}, threadId: "chatX" }); |
| 3073 | assert.equal(seen.length, 1); |
| 3074 | assert.deepEqual(seen[0].data, { args: "hello" }); |
| 3075 | assert.equal(seen[0].el, el); |
| 3076 | for (const k of ["close", "insertText", "emit", "id", "me", "getState"]) { |
| 3077 | assert.ok(seen[0].keys.includes(k), `panelCtx offers ${k}`); |
| 3078 | } |
| 3079 | assert.ok(fixture.panel, "the hook stays on the app object"); |
| 3080 | setCommandPanelHost(null); |
| 3081 | }); |
| 3082 | |
| 3083 | await test("a no-match /emoji answers with a reply, and the app stays command-only", async () => { |
| 3084 | setCommandPanelHost((threadId, spec) => true); |
| 3085 | const replies = []; |
| 3086 | assert.equal(runCommand("/emoji zzzznope", "chatX", (r) => replies.push(r)), true); |
| 3087 | await tick(); |
| 3088 | assert.equal(replies.length, 1); |
| 3089 | assert.match(replies[0].text, /No emoji match/); |
| 3090 | // Command-only shape: no personal thread to route to, nothing to instantiate. |
| 3091 | assert.deepEqual(emojiApp.places, [], "a command-only utility lives nowhere"); |
| 3092 | assert.equal(emojiApp.listed, false); |
| 3093 | setCommandPanelHost(null); |
| 3094 | }); |
| 3095 | |
| 3096 | // ---- quiet apps (issue #227) --------------------------------------------- |
| 3097 | |
| 3098 | await test("appIsQuiet reads the flag off the app registry", () => { |
| 3099 | assert.equal(appIsQuiet("dice"), true); |
| 3100 | assert.equal(appIsQuiet("todo"), false, "an ordinary personal app is not quiet"); |
| 3101 | assert.equal(appIsQuiet("nope-not-a-real-app"), false); |
| 3102 | assert.equal(appIsQuiet(undefined), false, "no app name (e.g. a non-app thread) → not quiet"); |
| 3103 | }); |
| 3104 | |
| 3105 | // quiet and groupCapable are orthogonal (like personal/groupCapable already |
| 3106 | // are) — quiet only ever governs a PERSONAL thread's visibility. Prove both |
| 3107 | // halves of that claim against a single app that's both. |
| 3108 | defineApp({ |
| 3109 | name: "questpin", |
| 3110 | title: "Quest Pin", |
| 3111 | glyph: "📌", |
| 3112 | personal: true, |
| 3113 | groupCapable: true, |
| 3114 | quiet: true, |
| 3115 | initialState: () => ({}), |
| 3116 | events: { "questpin.set": (s) => s }, |
| 3117 | commands: { "/questpin": (args, ctx) => ctx.emit({ type: "questpin.set", id: ctx.id() }) }, |
| 3118 | render() {}, // groupAppsFor requires a render to count as addable to a group |
| 3119 | }); |
| 3120 | |
| 3121 | await test("quiet + groupCapable: PERSONAL routing (not added here) still resolves the app-thread", async () => { |
| 3122 | let resolves = 0; |
| 3123 | setAppThreadResolver(async () => { |
| 3124 | resolves++; |
| 3125 | return "questpinThread"; |
| 3126 | }); |
| 3127 | const sent = []; |
| 3128 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 3129 | assert.equal(runCommand("/questpin", "chatX"), true); |
| 3130 | await tick(); |
| 3131 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 3132 | assert.equal(resolves, 1, "quiet doesn't change WHETHER the personal thread resolves, only its visibility"); |
| 3133 | assert.equal(sent[0].threadId, "questpinThread"); |
| 3134 | }); |
| 3135 | |
| 3136 | await test("quiet + groupCapable: ADDED to a group targets the group thread — quiet's create/hide path never runs", async () => { |
| 3137 | resetThread("questpinGrp"); |
| 3138 | foldEvent("questpinGrp", { type: "groupapps.add", app: "questpin", name: "Ana" }, { id: 1 }); |
| 3139 | // Resolver poisoned: the group's shared instance is the nearer scope, so |
| 3140 | // resolveAppThread (and therefore createAppThread's hide-on-create logic) |
| 3141 | // must never even be consulted — quiet has nothing to do here. |
| 3142 | setAppThreadResolver(async () => { |
| 3143 | throw new Error("group-scoped commands never route to the personal thread"); |
| 3144 | }); |
| 3145 | const sent = []; |
| 3146 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 3147 | assert.equal(runCommand("/questpin", "questpinGrp"), true); |
| 3148 | await tick(); |
| 3149 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 3150 | assert.equal(sent.length, 1); |
| 3151 | assert.equal(sent[0].threadId, "questpinGrp", "targets the GROUP thread, not a personal one"); |
| 3152 | }); |
| 3153 | |
| 3154 | await test("parseSides reads a die size, defaults to d6, rejects out-of-range/garbage", () => { |
| 3155 | assert.equal(parseSides(""), 6); |
| 3156 | assert.equal(parseSides(" "), 6); |
| 3157 | assert.equal(parseSides("20"), 20); |
| 3158 | assert.equal(parseSides("d20"), 20); |
| 3159 | assert.equal(parseSides("D20"), 20); |
| 3160 | assert.equal(parseSides("1"), null, "below the 2-side floor"); |
| 3161 | assert.equal(parseSides("5000"), null, "above the 1000-side ceiling"); |
| 3162 | assert.equal(parseSides("banana"), null); |
| 3163 | }); |
| 3164 | |
| 3165 | await test("dice.js's reducer folds a roll and ignores malformed events", () => { |
| 3166 | const reduce = diceApp.reduce; |
| 3167 | let s = reduce(undefined, { type: "dice.roll", id: "r1", sides: 20, result: 17, name: "Ana" }); |
| 3168 | assert.deepEqual(s.rolls, [{ id: "r1", sides: 20, result: 17, by: "Ana" }]); |
| 3169 | // A second, well-formed roll appends; a malformed one (missing result) no-ops. |
| 3170 | s = reduce(s, { type: "dice.roll", id: "r2", sides: 6, result: 3 }); |
| 3171 | s = reduce(s, { type: "dice.roll", id: "r3", sides: 6 }); |
| 3172 | assert.equal(s.rolls.length, 2); |
| 3173 | assert.equal(s.rolls[1].result, 3); |
| 3174 | }); |
| 3175 | |
| 3176 | await test("/roll emits a dice.roll event and replies in place with an Open action", async () => { |
| 3177 | setAppThreadResolver(async () => "diceThread"); |
| 3178 | const sent = []; |
| 3179 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 3180 | const replies = []; |
| 3181 | assert.equal(runCommand("/roll d20", "chatX", (r) => replies.push(r)), true); |
| 3182 | await tick(); |
| 3183 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 3184 | assert.equal(sent.length, 1); |
| 3185 | assert.equal(sent[0].threadId, "diceThread"); |
| 3186 | assert.equal(sent[0].event.type, "dice.roll"); |
| 3187 | assert.equal(sent[0].event.sides, 20); |
| 3188 | assert.ok(Number.isInteger(sent[0].event.result) && sent[0].event.result >= 1 && sent[0].event.result <= 20); |
| 3189 | assert.equal(replies.length, 1); |
| 3190 | assert.match(replies[0].text, /Rolled a \d+ on a d20/); |
| 3191 | assert.equal(replies[0].actions[0].label, "Roll history"); |
| 3192 | }); |
| 3193 | |
| 3194 | // ---- 🤖 the vault lock layer (docs/locked-vaults.md) -------------------------- |
| 3195 | |
| 3196 | // Well-formed keyring wraps for reuse below. `wrapped` is an opaque b64url blob |
| 3197 | // to the reducer — no crypto runs in a fold. W = a durable passkey wrap; M = a |
| 3198 | // `member` handoff (the vault key sealed to member `uid`'s roster key). |
| 3199 | const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra }); |
| 3200 | const M = (id, uid, label) => ({ id, kind: "member", uid, label: label || "", wrapped: "handoff-" + id }); |
| 3201 | const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1", { uid: "u1" }), sealed: {}, ...over }); |
| 3202 | const vreduce = vaultApp.reduce; |
| 3203 | |
| 3204 | await test("vault.lock installs the keyring, seals covered items, keeps uncovered plaintext", () => { |
| 3205 | let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 3206 | s = vreduce(s, { type: "vault.add", id: "s2", aid: "a2", label: "Door", value: "0000" }); |
| 3207 | s = vreduce(s, LOCK({ sealed: { s1: "sealed-s1" } })); |
| 3208 | assert.equal(s.keyring.pub, "vaultpub"); |
| 3209 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1"]); |
| 3210 | const s1 = s.items.find((i) => i.id === "s1"); |
| 3211 | assert.equal(s1.sealed, "sealed-s1"); |
| 3212 | assert.equal("value" in s1, false, "sealed item drops its plaintext"); |
| 3213 | assert.equal(s.items.find((i) => i.id === "s2").value, "0000", "uncovered item keeps plaintext (render flags it)"); |
| 3214 | assert.equal(s.log.at(-1).action, "lock"); |
| 3215 | }); |
| 3216 | |
| 3217 | await test("vault.lock is one-way and malformed locks fold to no-ops", () => { |
| 3218 | const locked = vreduce(undefined, LOCK()); |
| 3219 | const again = vreduce(locked, LOCK({ aid: "al2", pub: "otherpub", wrap: W("w9") })); |
| 3220 | assert.equal(again, locked, "a second lock never replaces the keyring"); |
| 3221 | for (const bad of [LOCK({ pub: "" }), LOCK({ wrap: null }), LOCK({ wrap: { id: "x" } }), LOCK({ aid: "" })]) { |
| 3222 | assert.deepEqual(vreduce(undefined, bad).keyring ?? null, null); |
| 3223 | } |
| 3224 | }); |
| 3225 | |
| 3226 | await test("vault.lock does not mutate the prior state (snapshot-safe)", () => { |
| 3227 | const s0 = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 3228 | const s1 = vreduce(s0, LOCK({ sealed: { s1: "sealed-s1" } })); |
| 3229 | assert.equal(s0.items[0].value, "hunter2", "prior state untouched"); |
| 3230 | assert.equal(s1.items[0].sealed, "sealed-s1"); |
| 3231 | }); |
| 3232 | |
| 3233 | await test("vault.add accepts exactly one of value/sealed", () => { |
| 3234 | let s = vreduce(undefined, LOCK()); |
| 3235 | s = vreduce(s, { type: "vault.add", id: "s1", aid: "a1", label: "Token", sealed: "sealed-blob" }); |
| 3236 | assert.equal(s.items[0].sealed, "sealed-blob"); |
| 3237 | // both forms, neither form, empty sealed — all fold to no-ops |
| 3238 | for (const bad of [ |
| 3239 | { type: "vault.add", id: "x", aid: "ax", label: "B", value: "v", sealed: "s" }, |
| 3240 | { type: "vault.add", id: "y", aid: "ay", label: "B" }, |
| 3241 | { type: "vault.add", id: "z", aid: "az", label: "B", sealed: "" }, |
| 3242 | ]) { |
| 3243 | assert.equal(vreduce(s, bad).items.length, 1); |
| 3244 | } |
| 3245 | }); |
| 3246 | |
| 3247 | await test("vault.change carrying sealed replaces a plaintext value (the Seal path) and vice versa", () => { |
| 3248 | let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 3249 | s = vreduce(s, LOCK()); // lock WITHOUT covering s1 — the stale-plaintext case |
| 3250 | assert.equal(s.items[0].value, "hunter2"); |
| 3251 | s = vreduce(s, { type: "vault.change", id: "s1", aid: "a2", sealed: "sealed-now" }); |
| 3252 | assert.equal(s.items[0].sealed, "sealed-now"); |
| 3253 | assert.equal(s.items[0].value, undefined, "sealing a change drops the plaintext"); |
| 3254 | }); |
| 3255 | |
| 3256 | await test("vault.lock folds member handoffs; vault.enroll turns one into a durable wrap, dropping it by uid", () => { |
| 3257 | // Lock hands James (u2) a member handoff alongside the locker's own wrap. |
| 3258 | let s = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James")] })); |
| 3259 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); |
| 3260 | assert.equal(s.keyring.wraps.find((w) => w.id === "h2").kind, "member"); |
| 3261 | // James self-enrols: his member handoff drops, his durable wrap goes in. |
| 3262 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2", { uid: "u2", label: "James" }), uid: "u2", name: "James" }); |
| 3263 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "w2"]); |
| 3264 | assert.equal(s.keyring.wraps.some((w) => w.kind === "member"), false, "the handoff is gone once enrolled"); |
| 3265 | assert.equal(s.log.at(-1).action, "enroll"); |
| 3266 | // enrolling a member's wrap NEVER touches another member's handoff (drop is by uid) |
| 3267 | let two = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James"), M("h3", "u3", "Carol")] })); |
| 3268 | two = vreduce(two, { type: "vault.enroll", aid: "b1", wrap: W("w2", { uid: "u2" }), uid: "u2", name: "James" }); |
| 3269 | assert.deepEqual(two.keyring.wraps.map((w) => w.id), ["w1", "h3", "w2"], "only the enroller's handoff drops"); |
| 3270 | // duplicate wrap id dedupes; a `member`-kind enroll is rejected (must be durable) |
| 3271 | assert.equal(vreduce(s, { type: "vault.enroll", aid: "a3", wrap: W("w2", { uid: "u2" }) }).keyring.wraps.length, 2, "duplicate wrap id dedupes"); |
| 3272 | assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: M("w9", "u2") }).keyring.wraps.length, 2, "a member wrap can't enroll"); |
| 3273 | // no keyring → enroll no-ops |
| 3274 | assert.equal(vreduce(undefined, { type: "vault.enroll", aid: "a5", wrap: W("w9") }).keyring ?? null, null); |
| 3275 | }); |
| 3276 | |
| 3277 | await test("vault.give hands a member a handoff; junk gives fold to no-ops", () => { |
| 3278 | let s = vreduce(undefined, LOCK()); |
| 3279 | s = vreduce(s, { type: "vault.give", aid: "a1", wrap: M("h2", "u2", "James"), uid: "u1", name: "Paul" }); |
| 3280 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); |
| 3281 | assert.equal(s.log.at(-1).action, "give"); |
| 3282 | assert.equal(s.log.at(-1).name, "James", "the give logs the recipient"); |
| 3283 | // a give without a recipient uid, or of a non-member wrap, is rejected |
| 3284 | assert.equal(vreduce(s, { type: "vault.give", aid: "a2", wrap: { id: "g1", kind: "member", wrapped: "x" } }).keyring.wraps.length, 2, "give needs a uid"); |
| 3285 | assert.equal(vreduce(s, { type: "vault.give", aid: "a3", wrap: W("g2", { uid: "u3" }) }).keyring.wraps.length, 2, "a give must be a member wrap"); |
| 3286 | // no keyring → give no-ops |
| 3287 | assert.equal(vreduce(undefined, { type: "vault.give", aid: "a4", wrap: M("g", "u2") }).keyring ?? null, null); |
| 3288 | }); |
| 3289 | |
| 3290 | await test("vault.revoke drops a wrap but never the last one", () => { |
| 3291 | let s = vreduce(undefined, LOCK()); |
| 3292 | assert.equal(vreduce(s, { type: "vault.revoke", aid: "a1", wrapId: "w1" }), s, "the last wrap is unrevokable"); |
| 3293 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2") }); |
| 3294 | s = vreduce(s, { type: "vault.revoke", aid: "a3", wrapId: "w1" }); |
| 3295 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w2"]); |
| 3296 | assert.equal(s.log.at(-1).action, "revoke"); |
| 3297 | }); |
| 3298 | |
| 3299 | await test("vault: a legacy recovery wrap (#995, removed) never folds in", () => { |
| 3300 | let s = vreduce(undefined, LOCK()); |
| 3301 | // The passphrase-derived break-glass was removed 2026-07-25 — recovery is a |
| 3302 | // passkey, never a passphrase. wrapShape rejects the kind like any unknown one. |
| 3303 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: { id: "rec", kind: "recovery", label: "Recovery key (1Password)", wrapped: "recov-blob" } }); |
| 3304 | assert.deepEqual(s.keyring.wraps.map((w) => w.kind), ["passkey"], "recovery kind never folds in"); |
| 3305 | // a bogus kind is still rejected by wrapShape |
| 3306 | const bad = vreduce(vreduce(undefined, LOCK()), { type: "vault.enroll", aid: "a5", wrap: { id: "x", kind: "usb-fob", wrapped: "b" } }); |
| 3307 | assert.equal(bad.keyring.wraps.length, 1, "unknown wrap kind never folds in"); |
| 3308 | }); |
| 3309 | |
| 3310 | await test("vault: keyring wraps are sanitised — a stranger's spare fields never fold in", () => { |
| 3311 | const s = vreduce(undefined, LOCK({ wrap: { ...W("w1"), evil: "payload", credId: 42 } })); |
| 3312 | const w = s.keyring.wraps[0]; |
| 3313 | assert.equal("evil" in w, false); |
| 3314 | assert.equal("credId" in w, false, "non-string credId dropped"); |
| 3315 | assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "uid", "wrapped"]); |
| 3316 | }); |
| 3317 | |
| 3318 | await test("vault: lock surfaces in previews and the group feed projection; give/enroll stay quiet", () => { |
| 3319 | assert.equal(vaultApp.preview({ type: "vault.lock", uid: "u2", name: "Ciara" }), "🔒 Ciara locked the Vault"); |
| 3320 | // give/enroll are routine key housekeeping — never previewed, never in the feed. |
| 3321 | assert.equal(vaultApp.preview({ type: "vault.give", uid: "u1", name: "me" }), null); |
| 3322 | assert.equal(vaultApp.preview({ type: "vault.enroll", uid: "u2", name: "Ciara" }), null); |
| 3323 | // groupFeedMessage (not chatMessage) is the feed-only projection — the Vault |
| 3324 | // has no companion Chat view of its own (the hook split). |
| 3325 | const line = vaultApp.groupFeedMessage({ type: "vault.lock" }); |
| 3326 | assert.equal(line.action, true); |
| 3327 | assert.match(line.text, /locked the Vault/); |
| 3328 | assert.equal(vaultApp.groupFeedMessage({ type: "vault.enroll" }), null); |
| 3329 | assert.equal(vaultApp.groupFeedMessage({ type: "vault.give" }), null); |
| 3330 | }); |
| 3331 | |
| 3332 | await test("appGlyphInto clears a leftover photo background before painting the mark", () => { |
| 3333 | // The header/avatar elements are reused across thread switches: paintAvatar |
| 3334 | // leaves a photo as an inline background-image (e.g. Eleven's app icon), and |
| 3335 | // switching to a logo-app (Admin) must not leave it showing behind the mark. |
| 3336 | const el = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; |
| 3337 | appGlyphInto(el, { logo: "<svg><!--wrench--></svg>" }); |
| 3338 | assert.equal(el.style.backgroundImage, "", "logo path must clear the leftover background"); |
| 3339 | assert.match(el.innerHTML, /wrench/); |
| 3340 | // The glyph fallback path clears it too. |
| 3341 | const g = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; |
| 3342 | appGlyphInto(g, { glyph: "🛠️" }); |
| 3343 | assert.equal(g.style.backgroundImage, "", "glyph path must clear the leftover background"); |
| 3344 | assert.equal(g.textContent, "🛠️"); |
| 3345 | }); |
| 3346 | |
| 3347 | await test("newlyDirtyMentionSids: matches, cap, and the already-dirty gate (the boot-freeze regression)", () => { |
| 3348 | const msgs = [ |
| 3349 | { sid: 1, text: "hello @Ana" }, |
| 3350 | { sid: 2, text: "no mention here" }, |
| 3351 | { sid: 3 }, // deleted/attachment-only row: no text |
| 3352 | { sid: 4, text: "ping @Bob and @Ana" }, |
| 3353 | ]; |
| 3354 | // Only rows whose text references a given name are marked. |
| 3355 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], new Set()), [1, 4]); |
| 3356 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], new Set()), [4]); |
| 3357 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Zoe"], new Set()), []); |
| 3358 | // Already-dirty rows are never re-reported: a repeat call over the same feed |
| 3359 | // finds nothing, so repaintMentionsOf triggers no re-render — the guarantee |
| 3360 | // (with the mountedCtx snapshot) that the repaint loop always terminates. |
| 3361 | // (Nothing is lost: a dirty row's rev is the fixed "|mention" tag, so a |
| 3362 | // second resolving name couldn't rebuild it anyway.) |
| 3363 | const dirty = new Set(newlyDirtyMentionSids(msgs, ["Ana"], new Set())); |
| 3364 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], dirty), []); |
| 3365 | // …even when a DIFFERENT name resolves in an already-dirty row (sid 4 has both). |
| 3366 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], dirty), []); |
| 3367 | // Only the most recent 100 messages are ever considered. |
| 3368 | const long = Array.from({ length: 250 }, (_, i) => ({ sid: i + 1, text: "@Ana" })); |
| 3369 | const marked = newlyDirtyMentionSids(long, ["Ana"], new Set()); |
| 3370 | assert.equal(marked.length, 100); |
| 3371 | assert.equal(marked[0], 151); // the cap keeps the newest, drops the oldest |
| 3372 | assert.equal(marked[99], 250); |
| 3373 | }); |
| 3374 | |
| 3375 | await test("agents: the session cap evicts oldest ended sessions first, then oldest of all", () => { |
| 3376 | // Vectors (test/event-vectors/agents.json) pin the per-event folds; the cap |
| 3377 | // is a cross-event property so it lives here. 50 live sessions + one more: |
| 3378 | // with an ended one present, the ended one goes; with none, the oldest goes. |
| 3379 | let s; |
| 3380 | for (let i = 0; i < 50; i++) s = agentsApp.reduce(s, { type: "session.spawn", id: "s" + i, session: "s" + i, ts: i }); |
| 3381 | s = agentsApp.reduce(s, { type: "session.end", id: "s7", ts: 100 }); |
| 3382 | s = agentsApp.reduce(s, { type: "session.spawn", id: "fresh", session: "fresh", ts: 200 }); |
| 3383 | assert.equal(s.sessions.length, 50); |
| 3384 | assert.ok(!s.sessions.some((x) => x.id === "s7"), "the ended session is the one evicted"); |
| 3385 | assert.ok(s.sessions.some((x) => x.id === "fresh")); |
| 3386 | s = agentsApp.reduce(s, { type: "session.spawn", id: "fresh2", session: "fresh2", ts: 300 }); |
| 3387 | assert.equal(s.sessions.length, 50); |
| 3388 | assert.ok(!s.sessions.some((x) => x.id === "s0"), "with no ended sessions, the oldest goes"); |
| 3389 | }); |
| 3390 | |
| 3391 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 3392 | process.exit(failures === 0 ? 0 : 1); |
| 3393 | |