Skip to content

Commit ffa71ca

Browse files
test(chat): cover the unread marker boundary latch
Extracts the latch condition from buildChatItems into the pure findFirstUnreadMessageId (no behavior change) and covers it: the marker position is only derived when a message at or below lastReadMessage is visible, windows floating entirely above the boundary and temporary messages with negative ids yield no marker, and a deleted or expired boundary message is handled by any older visible message. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent c5e456a commit ffa71ca

2 files changed

Lines changed: 103 additions & 16 deletions

File tree

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,22 +1137,10 @@ class ChatViewModel @AssistedInject constructor(
11371137

11381138
return buildList {
11391139
if (firstUnreadMessageId == null && lastReadMessage > 0) {
1140-
// Latch the marker position only when the visible window provably reaches back to
1141-
// the unread boundary, i.e. a message at or below lastReadMessage is visible.
1142-
// Without that proof the oldest visible message may still be far above the true
1143-
// first unread message (e.g. after a capped fetch of only the newest messages) and
1144-
// the marker would be latched in the middle of the unread messages. Temporary
1145-
// messages carry negative ids and don't count as proof.
1146-
val unreadBoundaryIsVisible = uiMessages.any { it.id in 1..lastReadMessage }
1147-
if (unreadBoundaryIsVisible) {
1148-
firstUnreadMessageId =
1149-
uiMessages.firstOrNull {
1150-
it.id > lastReadMessage
1151-
}?.id
1152-
Log.d(TAG, "reversedMessages.size = ${uiMessages.size}")
1153-
Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId")
1154-
Log.d(TAG, "conversation.lastReadMessage = $lastReadMessage")
1155-
}
1140+
firstUnreadMessageId = findFirstUnreadMessageId(uiMessages, lastReadMessage)
1141+
Log.d(TAG, "reversedMessages.size = ${uiMessages.size}")
1142+
Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId")
1143+
Log.d(TAG, "conversation.lastReadMessage = $lastReadMessage")
11561144
}
11571145

11581146
for (uiMessage in uiMessages) {
@@ -2399,6 +2387,25 @@ class ChatViewModel @AssistedInject constructor(
23992387

24002388
companion object {
24012389
private val TAG = ChatViewModel::class.simpleName
2390+
2391+
/**
2392+
* Returns the id of the first unread message, or null when it cannot be determined (yet).
2393+
*
2394+
* The position is only trustworthy when the visible window provably reaches back to the
2395+
* unread boundary, i.e. a message at or below [lastReadMessage] is visible. Without that
2396+
* proof the oldest visible message may still be far above the true first unread message
2397+
* (e.g. after a capped fetch of only the newest messages) and a marker latched onto it
2398+
* would sit in the middle of the unread messages. Temporary messages carry negative ids
2399+
* and don't count as proof.
2400+
*/
2401+
internal fun findFirstUnreadMessageId(uiMessages: List<ChatMessageUi>, lastReadMessage: Int): Int? {
2402+
val unreadBoundaryIsVisible = uiMessages.any { it.id in 1..lastReadMessage }
2403+
if (!unreadBoundaryIsVisible) {
2404+
return null
2405+
}
2406+
return uiMessages.firstOrNull { it.id > lastReadMessage }?.id
2407+
}
2408+
24022409
const val JOIN_ROOM_RETRY_COUNT: Long = 3
24032410
const val HTTP_CODE_OK: Int = 200
24042411
private const val CONVERSATION_AND_USER_FLOW_SHARING_TIMEOUT_MS = 5_000L
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.chat.viewmodels
9+
10+
import com.nextcloud.talk.chat.ui.model.ChatMessageUi
11+
import com.nextcloud.talk.chat.ui.model.MessageStatusIcon
12+
import com.nextcloud.talk.chat.ui.model.MessageTypeContent
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Assert.assertNull
15+
import org.junit.Test
16+
import java.time.LocalDate
17+
18+
/**
19+
* Covers the unread marker latch: the marker position must only be derived from the visible
20+
* window when the window provably reaches back to the unread boundary — otherwise a window of
21+
* only-unread messages (e.g. after a capped fetch of the newest messages) would place the marker
22+
* in the middle of the unread messages.
23+
*/
24+
class ChatViewModelTest {
25+
26+
@Test
27+
fun `marker is placed at the first message above the boundary when the boundary is visible`() {
28+
val messages = listOf(uiMessage(39), uiMessage(40), uiMessage(41), uiMessage(42))
29+
30+
assertEquals(41, ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40))
31+
}
32+
33+
@Test
34+
fun `marker is placed correctly when the boundary message itself is missing`() {
35+
// the last read message may have been deleted or expired — an older read message is
36+
// equally valid proof that the window reaches the boundary
37+
val messages = listOf(uiMessage(38), uiMessage(41), uiMessage(42))
38+
39+
assertEquals(41, ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40))
40+
}
41+
42+
@Test
43+
fun `no marker is placed when the window floats entirely above the boundary`() {
44+
val messages = listOf(uiMessage(141), uiMessage(142), uiMessage(143))
45+
46+
assertNull(ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40))
47+
}
48+
49+
@Test
50+
fun `temporary messages with negative ids are no proof of the boundary`() {
51+
val messages = listOf(uiMessage(-5), uiMessage(141), uiMessage(142))
52+
53+
assertNull(ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40))
54+
}
55+
56+
@Test
57+
fun `no marker is placed when everything is read`() {
58+
val messages = listOf(uiMessage(38), uiMessage(39), uiMessage(40))
59+
60+
assertNull(ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40))
61+
}
62+
63+
private fun uiMessage(id: Int): ChatMessageUi =
64+
ChatMessageUi(
65+
id = id,
66+
message = "message $id",
67+
renderMarkdown = false,
68+
actorDisplayName = "Other User",
69+
isThread = false,
70+
threadTitle = "",
71+
threadReplies = 0,
72+
incoming = true,
73+
isDeleted = false,
74+
avatarUrl = null,
75+
statusIcon = MessageStatusIcon.SENT,
76+
timestamp = id.toLong(),
77+
date = LocalDate.of(2026, 8, 12),
78+
content = MessageTypeContent.RegularText
79+
)
80+
}

0 commit comments

Comments
 (0)