1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
package social.grain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import social.grain.data.model.GrainNotification
import social.grain.data.model.GrainProfile
import social.grain.data.model.GroupedNotification
import java.time.Instant
/**
* Grouping is what turns a wall of "X favorited your gallery" into one row.
* Getting it wrong either buries real events or merges things that aren't the
* same event, and neither is visible from the code alone.
*/
class NotificationGroupingTest {
private val base = Instant.parse("2026-08-21T12:00:00.000Z")
private fun notif(
uri: String,
reason: String,
author: String,
gallery: String? = null,
hoursAgo: Long = 0,
) = GrainNotification(
uri = uri,
reason = reason,
createdAt = base.minusSeconds(hoursAgo * 3600).toString(),
author = GrainProfile(did = "did:plc:$author", handle = "$author.test"),
galleryUri = gallery,
)
@Test
fun `favorites on the same gallery collapse into one row`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-favorite", "bob", gallery = "at://g/1", hoursAgo = 1),
notif("at://3", "gallery-favorite", "carol", gallery = "at://g/1", hoursAgo = 2),
),
)
assertEquals(1, groups.size)
assertEquals(3, groups.single().authorCount)
assertTrue(groups.single().isGrouped)
}
@Test
fun `favorites on different galleries stay separate`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-favorite", "bob", gallery = "at://g/2"),
),
)
assertEquals(2, groups.size)
}
@Test
fun `the same person favoriting twice is counted once`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-favorite", "alice", gallery = "at://g/1", hoursAgo = 1),
),
)
assertEquals(1, groups.size)
assertEquals(1, groups.single().authorCount)
}
@Test
fun `events more than two days apart are separate rows`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-favorite", "bob", gallery = "at://g/1", hoursAgo = 72),
),
)
assertEquals("a favorite three days later is its own event", 2, groups.size)
}
@Test
fun `follows group across subjects because they have none`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "follow", "alice"),
notif("at://2", "follow", "bob", hoursAgo = 1),
),
)
assertEquals(1, groups.size)
assertEquals(2, groups.single().authorCount)
}
@Test
fun `comments never group even on the same gallery`() {
// Each comment carries its own text; collapsing them would hide it.
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-comment", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-comment", "bob", gallery = "at://g/1"),
),
)
assertEquals(2, groups.size)
assertTrue(groups.all { !it.isGrouped })
}
@Test
fun `a favorite and a comment on one gallery do not merge`() {
val groups = GroupedNotification.group(
listOf(
notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1"),
notif("at://2", "gallery-comment", "bob", gallery = "at://g/1"),
),
)
assertEquals(2, groups.size)
}
@Test
fun `merging a page extends existing groups rather than reordering`() {
val groups = GroupedNotification.group(
listOf(notif("at://1", "gallery-favorite", "alice", gallery = "at://g/1")),
).toMutableList()
GroupedNotification.merge(
listOf(notif("at://2", "gallery-favorite", "bob", gallery = "at://g/1", hoursAgo = 1)),
groups,
)
assertEquals(1, groups.size)
assertEquals(2, groups.single().authorCount)
assertEquals("the original stays the group's head", "at://1", groups.single().notification.uri)
}
@Test
fun `an unknown reason still renders as its own row`() {
val groups = GroupedNotification.group(
listOf(notif("at://1", "something-new-from-the-server", "alice")),
)
assertEquals(1, groups.size)
assertFalse(groups.single().isGrouped)
}
}