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