| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { ChatMessage, FeedNotification } from "@g1t/contracts"; |
| 5 | import { DEFAULT_SOUND_SETTINGS, type SoundSettings } from "@g1t/contracts/sounds"; |
| 6 | |
| 7 | import { |
| 8 | type Arrival, |
| 9 | SAME_MESSAGE_MS, |
| 10 | SOUND_GAP_MS, |
| 11 | type SoundContext, |
| 12 | SoundGate, |
| 13 | arrivalKey, |
| 14 | cueFor, |
| 15 | cueForNotificationKind, |
| 16 | finishedCard, |
| 17 | lookingAt, |
| 18 | mentionsMe, |
| 19 | wantsDesktopToast, |
| 20 | } from "./chat-sounds.ts"; |
| 21 | |
| 22 | const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null }; |
| 23 | const bea = { kind: "user" as const, id: "u2", name: "bea", display_name: "Bea", avatar: null, role: null }; |
| 24 | const margo = { kind: "agent" as const, id: "a1", name: "margo", display_name: "Margo", avatar: null, role: "Reviews pull requests" }; |
| 25 | |
| 26 | function message(over: Partial<ChatMessage> = {}): ChatMessage { |
| 27 | return { |
| 28 | id: over.id ?? "msg_1", |
| 29 | channel_id: "chn_general", |
| 30 | author: bea, |
| 31 | kind: "text", |
| 32 | body: "hello there", |
| 33 | card: null, |
| 34 | thread_root: null, |
| 35 | reply_count: 0, |
| 36 | last_reply_at: null, |
| 37 | created_at: "2026-10-10T10:00:00.000Z", |
| 38 | edited_at: null, |
| 39 | deleted_at: null, |
| 40 | ...over, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | function notification(over: Partial<FeedNotification> = {}): FeedNotification { |
| 45 | return { |
| 46 | id: "msg_9", |
| 47 | kind: "dm", |
| 48 | workspace: "acme", |
| 49 | title: "Bea", |
| 50 | body: "hi", |
| 51 | href: "/acme/-/chat/dm/chn_dm", |
| 52 | actor: { kind: "user", id: "u2", name: "Bea" }, |
| 53 | channel_id: "chn_dm", |
| 54 | created_at: "2026-10-10T10:00:00.000Z", |
| 55 | ...over, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | /** Ana, in #general, window in front: the quiet case every rule starts from. */ |
| 60 | function ctx(over: Partial<SoundContext> = {}, settings: Partial<SoundSettings> = {}): SoundContext { |
| 61 | return { |
| 62 | me: { id: "u1", username: "ana" }, |
| 63 | focus: { visible: true, focused: true, viewingChannel: "chn_general" }, |
| 64 | muted: new Set(), |
| 65 | dnd: false, |
| 66 | settings: { ...DEFAULT_SOUND_SETTINGS, ...settings, sound_cues: { ...DEFAULT_SOUND_SETTINGS.sound_cues, ...settings.sound_cues } }, |
| 67 | ...over, |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | const chat = (m: ChatMessage, channelKind: "channel" | "dm" = "channel"): Arrival => ({ source: "chat", message: m, channelKind }); |
| 72 | const feed = (n: FeedNotification): Arrival => ({ source: "feed", notification: n }); |
| 73 | |
| 74 | test("a message in the conversation on screen, in a window in front, is silent", () => { |
| 75 | assert.equal(cueFor(chat(message()), ctx()), null); |
| 76 | assert.ok(lookingAt("chn_general", ctx().focus)); |
| 77 | }); |
| 78 | |
| 79 | test("the same message plays `message` once the window is not in front, or the tab is hidden", () => { |
| 80 | assert.equal(cueFor(chat(message()), ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } })), "message"); |
| 81 | assert.equal(cueFor(chat(message()), ctx({ focus: { visible: false, focused: false, viewingChannel: "chn_general" } })), "message"); |
| 82 | }); |
| 83 | |
| 84 | test("a message in another conversation you have open plays `message`", () => { |
| 85 | assert.equal(cueFor(chat(message({ channel_id: "chn_other" })), ctx()), "message"); |
| 86 | assert.equal(cueFor(feed(notification({ kind: "thread_reply", channel_id: "chn_other" })), ctx()), "message"); |
| 87 | }); |
| 88 | |
| 89 | test("a direct message not from you plays `direct`; one from you plays nothing", () => { |
| 90 | const dm = message({ channel_id: "chn_dm" }); |
| 91 | assert.equal(cueFor(chat(dm, "dm"), ctx()), "direct"); |
| 92 | assert.equal(cueFor(chat({ ...dm, author: ana }, "dm"), ctx()), null); |
| 93 | assert.equal(cueFor(feed(notification()), ctx()), "direct"); |
| 94 | assert.equal(cueFor(feed(notification({ actor: { kind: "user", id: "u1", name: "Ana" } })), ctx()), null); |
| 95 | }); |
| 96 | |
| 97 | test("a mention of you plays `mention`, over `direct` and `message`", () => { |
| 98 | assert.equal(cueFor(chat(message({ channel_id: "chn_other", body: "hey @ana look" })), ctx()), "mention"); |
| 99 | assert.equal(cueFor(chat(message({ channel_id: "chn_dm", body: "@ana?" }), "dm"), ctx()), "mention"); |
| 100 | assert.equal(cueFor(feed(notification({ kind: "mention", channel_id: "chn_other" })), ctx()), "mention"); |
| 101 | // Looking at it, in front: still silent. |
| 102 | assert.equal(cueFor(chat(message({ body: "@ana" })), ctx()), null); |
| 103 | }); |
| 104 | |
| 105 | test("@you is a whole handle, outside code", () => { |
| 106 | assert.ok(mentionsMe("thanks @ana", "ana")); |
| 107 | assert.ok(mentionsMe("@Ana: see this", "ana")); |
| 108 | assert.ok(mentionsMe("(@ana)", "ana")); |
| 109 | assert.ok(!mentionsMe("@ana-marie did it", "ana")); |
| 110 | assert.ok(!mentionsMe("@anatole", "ana")); |
| 111 | assert.ok(!mentionsMe("mail me@ana.example", "ana")); |
| 112 | assert.ok(!mentionsMe("run `@ana` in code", "ana")); |
| 113 | assert.ok(!mentionsMe("```\n@ana\n```", "ana")); |
| 114 | assert.ok(!mentionsMe("@ana", "")); |
| 115 | }); |
| 116 | |
| 117 | test("your own messages never sound, whichever way they arrive", () => { |
| 118 | const unfocused = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } }); |
| 119 | assert.equal(cueFor(chat(message({ author: ana })), unfocused), null); |
| 120 | assert.equal(cueFor(chat(message({ author: ana, body: "@ana" })), unfocused), null); |
| 121 | assert.equal(cueFor(feed(notification({ kind: "mention", actor: { kind: "user", id: "u1", name: "Ana" } })), unfocused), null); |
| 122 | }); |
| 123 | |
| 124 | test("a muted conversation is silent, even for a mention or a DM", () => { |
| 125 | const muted = ctx({ muted: new Set(["chn_other", "chn_dm"]) }); |
| 126 | assert.equal(cueFor(chat(message({ channel_id: "chn_other", body: "@ana" })), muted), null); |
| 127 | assert.equal(cueFor(chat(message({ channel_id: "chn_dm" }), "dm"), muted), null); |
| 128 | assert.equal(cueFor(feed(notification()), muted), null); |
| 129 | assert.equal(cueFor(chat(message({ channel_id: "chn_third" })), muted), "message"); |
| 130 | }); |
| 131 | |
| 132 | test("Do not disturb silences everything", () => { |
| 133 | const quiet = ctx({ dnd: true, focus: { visible: false, focused: false, viewingChannel: null } }); |
| 134 | assert.equal(cueFor(chat(message({ body: "@ana" })), quiet), null); |
| 135 | assert.equal(cueFor(chat(message({ channel_id: "chn_dm" }), "dm"), quiet), null); |
| 136 | assert.equal(cueFor(feed(notification({ kind: "agent_waiting" })), quiet), null); |
| 137 | }); |
| 138 | |
| 139 | test("sounds off, or one cue off, is silent for that", () => { |
| 140 | const away = { visible: false, focused: false, viewingChannel: null }; |
| 141 | assert.equal(cueFor(chat(message()), ctx({ focus: away }, { sounds_enabled: false })), null); |
| 142 | assert.equal(cueFor(chat(message()), ctx({ focus: away }, { sound_cues: { message: false } })), null); |
| 143 | assert.equal(cueFor(chat(message({ body: "@ana" })), ctx({ focus: away }, { sound_cues: { message: false } })), "mention"); |
| 144 | assert.equal(cueFor(chat(message({ body: "@ana" })), ctx({ focus: away }, { sound_cues: { mention: false } })), null); |
| 145 | }); |
| 146 | |
| 147 | test("agents' messages count like people's, and a finished card plays `agent_done`", () => { |
| 148 | const away = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } }); |
| 149 | assert.equal(cueFor(chat(message({ author: margo })), away), "message"); |
| 150 | assert.equal(cueFor(chat(message({ author: margo, channel_id: "chn_dm" }), "dm"), away), "direct"); |
| 151 | assert.equal(cueFor(chat(message({ author: margo, body: "@ana done?" })), away), "mention"); |
| 152 | const card = message({ |
| 153 | author: margo, |
| 154 | kind: "card", |
| 155 | body: "", |
| 156 | card: { kind: "session", title: "Fix the flaky test", detail: "2 files changed", state: "Done", href: "/acme/web/pull/4" }, |
| 157 | }); |
| 158 | assert.ok(finishedCard(card)); |
| 159 | assert.equal(cueFor(chat(card), away), "agent_done"); |
| 160 | assert.equal(cueFor(chat(card), ctx()), null, "looking at it: silent"); |
| 161 | assert.equal(cueFor(chat(card), ctx({ focus: away.focus }, { sound_cues: { agent_done: false } })), null); |
| 162 | // A card still running, or a person's card, is a message. |
| 163 | assert.ok(!finishedCard({ ...card, card: { ...card.card!, state: "Running" } })); |
| 164 | assert.ok(!finishedCard({ ...card, author: bea })); |
| 165 | assert.equal(cueFor(chat({ ...card, card: { ...card.card!, state: "Running" } }), away), "message"); |
| 166 | // An agent waiting on you, over the feed, is the same cue. |
| 167 | assert.equal(cueFor(feed(notification({ kind: "agent_waiting", actor: { kind: "agent", id: "a1", name: "Margo" } })), away), "agent_done"); |
| 168 | }); |
| 169 | |
| 170 | test("a deleted message, and an inbox notification, make no sound", () => { |
| 171 | const away = ctx({ focus: { visible: false, focused: false, viewingChannel: null } }); |
| 172 | assert.equal(cueFor(chat(message({ deleted_at: "2026-10-10T10:01:00.000Z" })), away), null); |
| 173 | assert.equal(cueFor(feed(notification({ kind: "inbox", channel_id: null })), away), null); |
| 174 | assert.equal(cueForNotificationKind("inbox"), null); |
| 175 | assert.equal(cueForNotificationKind("approval"), "mention"); |
| 176 | }); |
| 177 | |
| 178 | test("sounds come at most one every 1.5 s, and one message sounds once however it arrives", () => { |
| 179 | const gate = new SoundGate(); |
| 180 | const t0 = 1_000_000; |
| 181 | assert.ok(gate.allow("msg:1", t0)); |
| 182 | assert.ok(!gate.allow("msg:2", t0 + 200), "a burst is one sound"); |
| 183 | assert.ok(!gate.allow("msg:3", t0 + SOUND_GAP_MS - 1)); |
| 184 | assert.ok(gate.allow("msg:4", t0 + SOUND_GAP_MS)); |
| 185 | // The same message, from the conversation's socket and then the feed. |
| 186 | assert.ok(!gate.allow("msg:4", t0 + SOUND_GAP_MS * 3)); |
| 187 | assert.ok(!gate.allow("msg:4", t0 + SOUND_GAP_MS + SAME_MESSAGE_MS - 1)); |
| 188 | assert.ok(gate.allow("msg:4", t0 + SOUND_GAP_MS + SAME_MESSAGE_MS + 1), "long after, it is a new message to the gate"); |
| 189 | // A key refused by the gap is still remembered, so the feed's copy a moment later stays quiet. |
| 190 | const again = new SoundGate(); |
| 191 | assert.ok(again.allow("msg:a", t0)); |
| 192 | assert.ok(!again.allow("msg:b", t0 + 100)); |
| 193 | assert.ok(!again.allow("msg:b", t0 + SOUND_GAP_MS + 100), "msg:b was heard of, if not sounded"); |
| 194 | // No key: only the gap applies. |
| 195 | assert.ok(again.allow(null, t0 + SOUND_GAP_MS * 2)); |
| 196 | assert.ok(!again.allow(null, t0 + SOUND_GAP_MS * 2 + 10)); |
| 197 | }); |
| 198 | |
| 199 | test("an arrival's key is its message id, the same over the socket and the feed", () => { |
| 200 | assert.equal(arrivalKey(chat(message({ id: "msg_7" }))), "msg:msg_7"); |
| 201 | assert.equal(arrivalKey(feed(notification({ id: "msg_7" }))), "msg:msg_7"); |
| 202 | }); |
| 203 | |
| 204 | test("a desktop notification shows only when wanted, allowed, out of front, and not already pushed", () => { |
| 205 | const base = { |
| 206 | notification: { kind: "dm" as const }, |
| 207 | toast: true, |
| 208 | ctx: ctx({ focus: { visible: true, focused: false, viewingChannel: null } }, { desktop_toasts: true }), |
| 209 | permission: "granted" as const, |
| 210 | pushOn: false, |
| 211 | }; |
| 212 | assert.ok(wantsDesktopToast(base)); |
| 213 | assert.ok(!wantsDesktopToast({ ...base, ctx: ctx({ focus: base.ctx.focus }) }), "off unless turned on"); |
| 214 | assert.ok(!wantsDesktopToast({ ...base, permission: "default" })); |
| 215 | assert.ok(!wantsDesktopToast({ ...base, permission: "denied" })); |
| 216 | assert.ok(!wantsDesktopToast({ ...base, pushOn: true }), "the push shows it instead"); |
| 217 | assert.ok(!wantsDesktopToast({ ...base, toast: false }), "what the feed would not toast"); |
| 218 | assert.ok(!wantsDesktopToast({ ...base, ctx: { ...base.ctx, dnd: true } })); |
| 219 | assert.ok(!wantsDesktopToast({ ...base, ctx: { ...base.ctx, focus: { visible: true, focused: true, viewingChannel: null } } }), "in front: the toast is enough"); |
| 220 | assert.ok(wantsDesktopToast({ ...base, ctx: { ...base.ctx, focus: { visible: false, focused: false, viewingChannel: null } } })); |
| 221 | assert.ok(!wantsDesktopToast({ ...base, notification: { kind: "inbox" } })); |
| 222 | }); |