Skip to content
222 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { ChatMessage, FeedNotification } from "@g1t/contracts";
5import { DEFAULT_SOUND_SETTINGS, type SoundSettings } from "@g1t/contracts/sounds";
6
7import {
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
22const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null };
23const bea = { kind: "user" as const, id: "u2", name: "bea", display_name: "Bea", avatar: null, role: null };
24const margo = { kind: "agent" as const, id: "a1", name: "margo", display_name: "Margo", avatar: null, role: "Reviews pull requests" };
25
26function 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
44function 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. */
60function 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
71const chat = (m: ChatMessage, channelKind: "channel" | "dm" = "channel"): Arrival => ({ source: "chat", message: m, channelKind });
72const feed = (n: FeedNotification): Arrival => ({ source: "feed", notification: n });
73
74test("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
79test("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
84test("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
89test("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
97test("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
105test("@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
117test("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
124test("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
132test("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
139test("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
147test("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
170test("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
178test("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
199test("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
204test("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});