| 1 | import assert from 'node:assert/strict'; |
| 2 | import { test } from 'node:test'; |
| 3 | import { |
| 4 | isRoomMuted, |
| 5 | muteSpacePlan, |
| 6 | mutingSpace, |
| 7 | parseMutedSpaces, |
| 8 | reconcilePlan, |
| 9 | spaceMutedRoomIds, |
| 10 | unmuteSpacePlan, |
| 11 | withSpaceMutes, |
| 12 | type MutedSpaces |
| 13 | } from '../src/lib/mute-model.ts'; |
| 14 | import { badgeTotal } from '../src/lib/notification-model.ts'; |
| 15 | import type { RoomSummary } from '../src/lib/types.ts'; |
| 16 | |
| 17 | function room(id: string, extra: Partial<RoomSummary> = {}): RoomSummary { |
| 18 | return { |
| 19 | room_id: id, |
| 20 | name: id, |
| 21 | membership: 'Joined', |
| 22 | space: false, |
| 23 | direct: false, |
| 24 | unread_messages: 0, |
| 25 | unread_mentions: 0, |
| 26 | notification_mode: 'AllMessages', |
| 27 | notification_mode_custom: false, |
| 28 | parent_spaces: [], |
| 29 | children: [], |
| 30 | ...extra |
| 31 | } as RoomSummary; |
| 32 | } |
| 33 | |
| 34 | const empty: MutedSpaces = { spaces: [], applied: {} }; |
| 35 | |
| 36 | const rooms = [ |
| 37 | room('!top', { space: true, children: ['!a', '!sub'] }), |
| 38 | room('!sub', { space: true, parent_spaces: ['!top'], children: ['!b'] }), |
| 39 | room('!a', { parent_spaces: ['!top'], unread_messages: 3 }), |
| 40 | room('!b', { parent_spaces: ['!sub'], unread_messages: 2, notification_mode: 'MentionsAndKeywordsOnly', notification_mode_custom: true }), |
| 41 | room('!c', { notification_mode: 'Mute', notification_mode_custom: true, parent_spaces: ['!top'] }), |
| 42 | room('!other', { unread_messages: 1 }) |
| 43 | ]; |
| 44 | |
| 45 | test('space mute covers nested rooms but not rooms elsewhere', () => { |
| 46 | assert.deepEqual([...spaceMutedRoomIds(rooms, ['!top'])].sort(), ['!a', '!b', '!c']); |
| 47 | assert.equal(spaceMutedRoomIds(rooms, []).size, 0); |
| 48 | }); |
| 49 | |
| 50 | test('space mute silences the badge total and leaves other rooms', () => { |
| 51 | const muted = withSpaceMutes(rooms, spaceMutedRoomIds(rooms, ['!top'])); |
| 52 | assert.equal(badgeTotal(muted), 1); |
| 53 | assert.equal(badgeTotal(rooms), 4); |
| 54 | assert.equal(withSpaceMutes(rooms, new Set()), rooms); |
| 55 | }); |
| 56 | |
| 57 | test('isRoomMuted covers explicit and inherited mute', () => { |
| 58 | const inherited = spaceMutedRoomIds(rooms, ['!top']); |
| 59 | assert.equal(isRoomMuted(rooms[2], inherited), true); |
| 60 | assert.equal(isRoomMuted(rooms[5], inherited), false); |
| 61 | assert.equal(isRoomMuted(rooms[4], new Set()), true); |
| 62 | }); |
| 63 | |
| 64 | test('muting a space claims unmuted children with their prior mode', () => { |
| 65 | const plan = muteSpacePlan(empty, '!top', rooms); |
| 66 | assert.deepEqual(plan.state.spaces, ['!top']); |
| 67 | assert.deepEqual(plan.state.applied['!top'], { '!a': null, '!b': 'MentionsAndKeywordsOnly' }); |
| 68 | assert.deepEqual(plan.changes.map((c) => c.roomId).sort(), ['!a', '!b']); |
| 69 | }); |
| 70 | |
| 71 | test('unmuting restores prior modes and leaves individual mutes alone', () => { |
| 72 | const muted = muteSpacePlan(empty, '!top', rooms).state; |
| 73 | const after = rooms.map((r) => (r.room_id === '!a' || r.room_id === '!b' ? { ...r, notification_mode: 'Mute' as const } : r)); |
| 74 | const plan = unmuteSpacePlan(muted, '!top', after); |
| 75 | assert.deepEqual(plan.state, empty); |
| 76 | assert.deepEqual(plan.changes, [ |
| 77 | { roomId: '!a', mode: null }, |
| 78 | { roomId: '!b', mode: 'MentionsAndKeywordsOnly' } |
| 79 | ]); |
| 80 | assert.equal(plan.changes.some((c) => c.roomId === '!c'), false); |
| 81 | }); |
| 82 | |
| 83 | test('a room a user unmuted inside a muted space is not reclaimed', () => { |
| 84 | const muted = muteSpacePlan(empty, '!top', rooms).state; |
| 85 | const after = rooms.map((r) => (r.room_id === '!a' ? { ...r, notification_mode: 'AllMessages' as const } : r)); |
| 86 | assert.deepEqual(reconcilePlan(muted, after).changes, []); |
| 87 | }); |
| 88 | |
| 89 | test('reconcile mutes children that joined later', () => { |
| 90 | const muted = muteSpacePlan(empty, '!top', rooms).state; |
| 91 | const joined = [...rooms, room('!new', { parent_spaces: ['!top'] })]; |
| 92 | const plan = reconcilePlan(muted, joined); |
| 93 | assert.deepEqual(plan.changes, [{ roomId: '!new', mode: 'Mute' }]); |
| 94 | assert.equal(reconcilePlan(plan.state, joined).changes.length, 0); |
| 95 | }); |
| 96 | |
| 97 | test('reconcile ignores rooms whose mode has not loaded', () => { |
| 98 | const muted: MutedSpaces = { spaces: ['!top'], applied: {} }; |
| 99 | const pending = rooms.map((r) => (r.room_id === '!a' ? { ...r, notification_mode: null } : r)); |
| 100 | assert.equal(reconcilePlan(muted, pending).changes.some((c) => c.roomId === '!a'), false); |
| 101 | }); |
| 102 | |
| 103 | test('overlapping muted spaces hand the room to the remaining space on unmute', () => { |
| 104 | const both = [...rooms, room('!shared', { parent_spaces: ['!top', '!x'] }), room('!x', { space: true, children: ['!shared'] })]; |
| 105 | let state = muteSpacePlan(empty, '!top', both).state; |
| 106 | state = muteSpacePlan(state, '!x', both).state; |
| 107 | const after = both.map((r) => (r.notification_mode === 'AllMessages' ? { ...r, notification_mode: 'Mute' as const } : r)); |
| 108 | const plan = unmuteSpacePlan(state, '!top', after); |
| 109 | assert.equal(plan.changes.some((c) => c.roomId === '!shared'), false); |
| 110 | assert.ok('!shared' in (plan.state.applied['!x'] ?? {})); |
| 111 | assert.equal(mutingSpace(plan.state, after, '!shared'), '!x'); |
| 112 | }); |
| 113 | |
| 114 | test('parseMutedSpaces tolerates junk', () => { |
| 115 | assert.deepEqual(parseMutedSpaces(null), empty); |
| 116 | assert.deepEqual(parseMutedSpaces({ spaces: ['!a', 3], applied: { '!a': { '!r': 'Mute', '!q': 'Bogus' }, '!gone': { '!r': null } } }), { |
| 117 | spaces: ['!a'], |
| 118 | applied: { '!a': { '!r': 'Mute' } } |
| 119 | }); |
| 120 | }); |