feat(chat): add paged history with stable transcript and scroll identity (#1211)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
83b2a8fa9b
commit
a78b20bc99
26 files changed
+3942
-884
No files matched your search
+379
@@ -0,0 +1,379 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.execSQL
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.UUID
|
||||
|
||||
/** Executes the generated Room DAO against SQLCipher, rather than reproducing its SQL in a fake. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatMessagePagingDeviceTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var database: HermesDatabase
|
||||
private lateinit var repository: ChatPersistenceRepository
|
||||
private val databaseName = "paging-regression-${UUID.randomUUID()}.db"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
System.loadLibrary("sqlcipher")
|
||||
openDatabase()
|
||||
}
|
||||
|
||||
private fun openDatabase() {
|
||||
database =
|
||||
Room
|
||||
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
|
||||
.setDriver(driver())
|
||||
.addMigrations(HermesDatabase.MIGRATION_8_9)
|
||||
.build()
|
||||
repository = ChatPersistenceRepository(database.chatMessageDao())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpaddedRestIdsSelectNumericLatestPageAndWalkEveryRowExactlyOnce() =
|
||||
runBlocking {
|
||||
val rows = (1..350).map { restRow(it, timestamp = 10L) }
|
||||
repository.persistMessages(rows, "session")
|
||||
repository.persistMessage(restRow(999, timestamp = 10L).copy(id = "rest-other-999"), "other")
|
||||
val latest = repository.loadPage("session", null, 150)
|
||||
assertEquals(rows.takeLast(150).map { it.id }, latest.messages.map { it.id })
|
||||
repository.persistMessage(restRow(351, timestamp = 1L), "session")
|
||||
val middle = repository.loadPage("session", latest.cursor, 150)
|
||||
val oldest = repository.loadPage("session", middle.cursor, 150)
|
||||
assertEquals(rows.map { it.id }, (oldest.messages + middle.messages + latest.messages).map { it.id })
|
||||
assertEquals(true, latest.hasOlder)
|
||||
assertEquals(true, middle.hasOlder)
|
||||
assertEquals(false, oldest.hasOlder)
|
||||
assertEquals(
|
||||
"rest-session-351",
|
||||
repository
|
||||
.loadPage("session", null, 1)
|
||||
.messages
|
||||
.single()
|
||||
.id,
|
||||
)
|
||||
assertEquals(
|
||||
"rest-other-999",
|
||||
repository
|
||||
.loadPage("other", null, 1)
|
||||
.messages
|
||||
.single()
|
||||
.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonMonotonicTimestampsKeepServerOrderInBothPageArrivalOrdersAfterReopen() =
|
||||
runBlocking {
|
||||
val rows =
|
||||
listOf(9, 10, 100, 200).mapIndexed { index, id ->
|
||||
restRow(id, listOf(900L, 100L, 800L, 200L)[index])
|
||||
}
|
||||
for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) {
|
||||
repository.clearMessagesForSession("session")
|
||||
pages.forEach { repository.persistMessages(it, "session") }
|
||||
database.close()
|
||||
openDatabase()
|
||||
val latest = repository.loadPage("session", null, 2)
|
||||
val older = repository.loadPage("session", latest.cursor, 2)
|
||||
assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id })
|
||||
assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id })
|
||||
assertEquals(false, older.hasOlder)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uuidOnlyRowsKeepInsertionOrderAfterUpdateAppendAndReopen() =
|
||||
runBlocking {
|
||||
val rows =
|
||||
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
|
||||
ChatMessage(
|
||||
id = "$prefix-0000-4000-8000-000000000000",
|
||||
role = MessageRole.USER,
|
||||
content = "message-$index",
|
||||
timestamp = listOf(900L, 100L, 800L)[index],
|
||||
)
|
||||
}
|
||||
rows.forEach { repository.persistMessage(it, "local") }
|
||||
repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local")
|
||||
database.close()
|
||||
openDatabase()
|
||||
val latest = repository.loadPage("local", null, 2)
|
||||
repository.persistMessage(
|
||||
rows.last().copy(id = "bbbbbbbb-0000-4000-8000-000000000000", content = "new", timestamp = 1L),
|
||||
"local",
|
||||
)
|
||||
val older = repository.loadPage("local", latest.cursor, 2)
|
||||
assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id })
|
||||
assertEquals(listOf(rows.first().id), older.messages.map { it.id })
|
||||
assertEquals("edited", older.messages.single().content)
|
||||
assertEquals(false, older.hasOlder)
|
||||
assertEquals(
|
||||
"new",
|
||||
repository
|
||||
.loadPage("local", null, 1)
|
||||
.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migration8to9ValidatesRealSchemaAndPreservesRowsMetadataAndPaging() =
|
||||
runBlocking {
|
||||
val canonical =
|
||||
listOf(9, 10, 100, 200).mapIndexed { index, id ->
|
||||
restRow(id, listOf(900L, 100L, 800L, 200L)[index]).toEntity("session").copy(
|
||||
reasoningText = "trace-$id",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call-$id",
|
||||
toolStatus = "COMPLETED",
|
||||
isStreaming = true,
|
||||
displayKind = "marker-$id",
|
||||
tokenCount = id,
|
||||
tps = 2.5,
|
||||
completionId = "completion-$id",
|
||||
)
|
||||
}
|
||||
val local =
|
||||
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
|
||||
ChatMessageEntity(
|
||||
id = "$prefix-0000-4000-8000-000000000000",
|
||||
sessionId = "local",
|
||||
role = "USER",
|
||||
content = "local-$index",
|
||||
timestamp = 100L - index,
|
||||
)
|
||||
}
|
||||
val malformed =
|
||||
listOf(
|
||||
"rest-session-",
|
||||
"rest-session-10x",
|
||||
"rest-other-50",
|
||||
"rest-session-9223372036854775808",
|
||||
).map {
|
||||
ChatMessageEntity(
|
||||
id = it,
|
||||
sessionId = "session",
|
||||
role = "USER",
|
||||
content = it,
|
||||
timestamp = 1L,
|
||||
)
|
||||
}
|
||||
// REST pages can be inserted backwards; UUID order is physical insertion, not timestamp or text.
|
||||
val seeded = canonical.asReversed() + local + malformed
|
||||
createVersion8(seeded)
|
||||
openDatabase()
|
||||
val dao = database.chatMessageDao()
|
||||
val restored = dao.getMessagesForSession("session") + dao.getMessagesForSession("local")
|
||||
assertEquals(seeded.size, restored.size)
|
||||
seeded.forEach { original ->
|
||||
val row = restored.single { it.id == original.id }
|
||||
assertEquals(original, row.copy(sortGroup = 1, sortOrder = 0))
|
||||
}
|
||||
assertEquals(canonical.map { it.id }, restored.filter { it.sortGroup == 0 }.map { it.id })
|
||||
assertTrue(restored.filter { it.id in malformed.map { row -> row.id } }.all { it.sortGroup == 1 })
|
||||
assertEquals(local.map { it.id }, dao.getMessagesForSession("local").map { it.id })
|
||||
val first = repository.loadPage("session", null, 2)
|
||||
var cursor = first.cursor
|
||||
val pages = mutableListOf(first.messages)
|
||||
var hasOlder = first.hasOlder
|
||||
while (hasOlder) {
|
||||
val page = repository.loadPage("session", cursor, 2)
|
||||
pages += page.messages
|
||||
cursor = page.cursor
|
||||
hasOlder = page.hasOlder
|
||||
}
|
||||
assertEquals((canonical + malformed).map { it.id }, pages.asReversed().flatten().map { it.id })
|
||||
repository.persistMessage(local.first().toUiModel().copy(content = "edited", timestamp = 9999L), "local")
|
||||
database.close()
|
||||
openDatabase()
|
||||
assertEquals(local.map { it.id }, repository.loadPage("local", null, 3).messages.map { it.id })
|
||||
assertEquals(
|
||||
"edited",
|
||||
repository
|
||||
.loadPage("local", null, 3)
|
||||
.messages
|
||||
.first()
|
||||
.content,
|
||||
)
|
||||
assertPagingIndexUsed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migration8to9ValidatesEmptyDatabaseAndAllowsNewWrites() =
|
||||
runBlocking {
|
||||
createVersion8(emptyList())
|
||||
openDatabase()
|
||||
assertTrue(repository.loadPage("session", null, 1).messages.isEmpty())
|
||||
repository.persistMessage(restRow(100, 1L), "session")
|
||||
assertEquals(100L, database.chatMessageDao().getMessage("rest-session-100")?.sortOrder)
|
||||
assertPagingIndexUsed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
|
||||
runBlocking {
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "ffffffff-0000-4000-8000-000000000000",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "newer WS payload",
|
||||
reasoningText = "live trace",
|
||||
completionId = "completion-100",
|
||||
)
|
||||
repository.persistMessage(live, "session")
|
||||
repository.confirmIdentities(
|
||||
listOf(live.copy(content = "stale snapshot", restId = "rest-session-100")),
|
||||
"session",
|
||||
)
|
||||
// A later WS write with no alias must retain the confirmed cache identity.
|
||||
repository.persistMessage(live.copy(content = "final payload"), "session")
|
||||
repository.persistMessage(restRow(9, 9999L), "session")
|
||||
database.close()
|
||||
openDatabase()
|
||||
val rows = repository.loadPage("session", null, 2).messages
|
||||
assertEquals(listOf("rest-session-9", live.id), rows.map { it.id })
|
||||
assertEquals("rest-session-100", rows.last().restId)
|
||||
assertEquals("final payload", rows.last().content)
|
||||
assertEquals("live trace", rows.last().reasoningText)
|
||||
assertEquals("completion-100", rows.last().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmingLastLocalRowDoesNotReuseItsCursorForALaterInsertion() =
|
||||
runBlocking {
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "ffffffff-0000-4000-8000-000000000000",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "first",
|
||||
)
|
||||
repository.persistMessage(live, "session")
|
||||
val cursor = repository.loadPage("session", null, 1).cursor
|
||||
repository.confirmIdentities(listOf(live.copy(restId = "rest-session-100")), "session")
|
||||
val next = live.copy(id = "00000000-0000-4000-8000-000000000000", content = "second")
|
||||
repository.persistMessage(next, "session")
|
||||
assertFalse(repository.loadPage("session", cursor, 10).messages.any { it.id == next.id })
|
||||
assertEquals(
|
||||
next.id,
|
||||
repository
|
||||
.loadPage("session", null, 1)
|
||||
.messages
|
||||
.single()
|
||||
.id,
|
||||
)
|
||||
}
|
||||
|
||||
private fun driver() = SQLCipherDriver("paging-test-only".toByteArray(), null, null)
|
||||
|
||||
/** Build the actual exported v8 schema, then let Room run AND validate its migration on open. */
|
||||
private fun createVersion8(rows: List<ChatMessageEntity>) {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
val schema =
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.context.assets
|
||||
.open("com.m57.hermescontrol.data.local.HermesDatabase/8.json")
|
||||
.bufferedReader()
|
||||
.use { JSONObject(it.readText()).getJSONObject("database") }
|
||||
val file = context.getDatabasePath(databaseName)
|
||||
file.parentFile?.mkdirs()
|
||||
driver().open(file.absolutePath).use { connection ->
|
||||
val entity = schema.getJSONArray("entities").getJSONObject(0)
|
||||
connection.execSQL(
|
||||
entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
|
||||
)
|
||||
val indices = entity.getJSONArray("indices")
|
||||
for (index in 0 until indices.length()) {
|
||||
connection.execSQL(
|
||||
indices
|
||||
.getJSONObject(index)
|
||||
.getString("createSql")
|
||||
.replace("\${TABLE_NAME}", "chat_messages"),
|
||||
)
|
||||
}
|
||||
val setup = schema.getJSONArray("setupQueries")
|
||||
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
|
||||
connection
|
||||
.prepare(
|
||||
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
|
||||
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, " +
|
||||
"token_count, tps, completion_id) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).use { insert ->
|
||||
rows.forEach { row ->
|
||||
insert.bindText(1, row.id)
|
||||
insert.bindText(2, row.sessionId)
|
||||
insert.bindText(3, row.role)
|
||||
insert.bindText(4, row.content)
|
||||
insert.bindText(5, row.reasoningText)
|
||||
insert.bindLong(6, row.timestamp)
|
||||
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
|
||||
insert.bindText(8, row.toolCallId)
|
||||
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
|
||||
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
|
||||
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
|
||||
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
|
||||
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
|
||||
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
|
||||
insert.step()
|
||||
insert.reset()
|
||||
insert.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.execSQL("PRAGMA user_version = 8")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPagingIndexUsed() {
|
||||
database.close()
|
||||
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
|
||||
for (bound in listOf("", "AND (sort_group, sort_order, id) < (1, 100, 'cursor')")) {
|
||||
connection
|
||||
.prepare(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM chat_messages WHERE session_id = 'session' $bound " +
|
||||
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT 151",
|
||||
).use { query ->
|
||||
val plan = buildList { while (query.step()) add(query.getText(3)) }.joinToString(" ")
|
||||
assertTrue(plan, plan.contains("index_chat_messages_session_id_sort_group_sort_order_id"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restRow(
|
||||
id: Int,
|
||||
timestamp: Long,
|
||||
) = ChatMessage(
|
||||
id = "rest-session-$id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "answer-$id",
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.launch
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Real lazy layout regression for the custom23 history prepend jump. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FullBleedChatAnchorTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun loadingOnly_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = false)
|
||||
|
||||
@Test
|
||||
fun loadingOnly_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = false)
|
||||
|
||||
@Test
|
||||
fun prepend_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = true)
|
||||
|
||||
@Test
|
||||
fun prepend_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = true)
|
||||
|
||||
@Test
|
||||
fun prepend_atInteriorRow_keepsContentPixel() = checkAnchor(index = 8, offset = 17, prepend = true)
|
||||
|
||||
@Test
|
||||
fun prepend_mergingAgentTurn_keepsToolPixel() =
|
||||
checkAnchor(index = 0, offset = 13, prepend = true, role = MessageRole.TOOL)
|
||||
|
||||
@Test
|
||||
fun loadingOnly_doesNotCancelAnActiveHistoryScroll() =
|
||||
checkAnchor(index = 8, offset = 17, prepend = false, holdScroll = true)
|
||||
|
||||
@Test
|
||||
fun prepend_usesTheReadersPositionWhenThePageArrives() =
|
||||
checkAnchor(index = 0, offset = 23, prepend = true, moveWhileLoading = true)
|
||||
|
||||
@Test
|
||||
fun prepend_afterReaderReachesAgentStatus_keepsTailPixel() = checkTailAnchor("agent_status")
|
||||
|
||||
@Test
|
||||
fun prepend_afterReaderReachesClarify_keepsTailPixel() = checkTailAnchor("clarify_bubble")
|
||||
|
||||
@Test
|
||||
fun prepend_afterReaderReachesVaultUnlock_keepsTailPixel() = checkTailAnchor("vault_unlock_card")
|
||||
|
||||
@Test
|
||||
fun prepend_afterReaderReachesVaultSave_keepsTailPixel() = checkTailAnchor("vault_save_login_card")
|
||||
|
||||
@Test
|
||||
fun prepend_afterReaderReachesVaultCode_keepsTailPixel() = checkTailAnchor("vault_code_card")
|
||||
|
||||
@Test
|
||||
fun prepend_duringActiveScroll_keepsGestureAndLiveTail_thenPublishesAtIdle() =
|
||||
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true)
|
||||
|
||||
@Test
|
||||
fun prepend_deferredDuringScroll_doesNotSurviveSessionSwitch() =
|
||||
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true, switchSession = true)
|
||||
|
||||
private fun checkTailAnchor(key: String) = checkAnchor(index = 0, offset = 0, prepend = true, tailKey = key)
|
||||
|
||||
private fun checkAnchor(
|
||||
index: Int,
|
||||
offset: Int,
|
||||
prepend: Boolean,
|
||||
role: MessageRole = MessageRole.USER,
|
||||
holdScroll: Boolean = false,
|
||||
moveWhileLoading: Boolean = false,
|
||||
tailKey: String? = null,
|
||||
switchSession: Boolean = false,
|
||||
) {
|
||||
fun message(id: Int) =
|
||||
ChatMessage(
|
||||
id = "row-$id",
|
||||
role = role,
|
||||
content = "Message $id\nSecond line\nThird line",
|
||||
toolName = if (role == MessageRole.TOOL) "terminal" else null,
|
||||
toolStatus = if (role == MessageRole.TOOL) ToolStatus.COMPLETED else null,
|
||||
)
|
||||
|
||||
val messages = mutableStateOf((150..189).map(::message))
|
||||
val loading = mutableStateOf(false)
|
||||
val session = mutableStateOf("session-one")
|
||||
val streaming = mutableStateOf(StreamingState())
|
||||
val tailKeys =
|
||||
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
|
||||
val listState = LazyListState(index, offset)
|
||||
val viewModel = mockk<ChatViewModel>(relaxed = true)
|
||||
lateinit var scope: CoroutineScope
|
||||
compose.setContent {
|
||||
scope = rememberCoroutineScope()
|
||||
val controller = rememberChatScrollController(listState, scope)
|
||||
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
|
||||
FullBleedChatList(
|
||||
messages = messages.value,
|
||||
streamingState = streaming.value,
|
||||
isAgentTyping = tailKey != null,
|
||||
searchState = ChatSearchState(),
|
||||
typingEffectEnabled = false,
|
||||
typingEffectDelayMs = 30,
|
||||
isLoading = false,
|
||||
isLoadingOlder = loading.value,
|
||||
listState = listState,
|
||||
scrollController = controller,
|
||||
lastAnimatedMessageId = null,
|
||||
onLastAnimatedMessageIdChange = {},
|
||||
viewModel = viewModel,
|
||||
pagingSessionId = session.value,
|
||||
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
|
||||
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
|
||||
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
|
||||
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
var before =
|
||||
compose.runOnIdle {
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.first { it.index == index }
|
||||
.let { it.key to it.offset }
|
||||
}
|
||||
|
||||
fun assertAnchor(stage: String) {
|
||||
compose.runOnIdle {
|
||||
val after = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == before.first }
|
||||
assertNotNull("$stage: anchor ${before.first} left viewport", after)
|
||||
assertEquals("$stage: anchor ${before.first} moved (pixels)", before.second, after!!.offset)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollJob =
|
||||
compose.runOnIdle {
|
||||
if (holdScroll) {
|
||||
scope.launch {
|
||||
listState.scroll(MutatePriority.UserInput) { awaitCancellation() }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
compose.runOnIdle {
|
||||
if (holdScroll) assertTrue("scroll started", listState.isScrollInProgress)
|
||||
loading.value = true
|
||||
}
|
||||
assertAnchor("loading started")
|
||||
if (tailKey != null) {
|
||||
compose.runOnIdle { scope.launch { listState.scrollToItem(40 + tailKeys.indexOf(tailKey)) } }
|
||||
before =
|
||||
compose.runOnIdle {
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.first { it.index == listState.firstVisibleItemIndex }
|
||||
.let { it.key to it.offset }
|
||||
}
|
||||
assertEquals("reader reached requested tail row", tailKey, before.first)
|
||||
}
|
||||
if (moveWhileLoading) {
|
||||
compose.runOnIdle { scope.launch { listState.scrollToItem(index + 3, 19) } }
|
||||
before =
|
||||
compose.runOnIdle {
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.first { it.index == listState.firstVisibleItemIndex }
|
||||
.let { it.key to it.offset }
|
||||
}
|
||||
}
|
||||
compose.runOnIdle {
|
||||
if (prepend) messages.value = (0..149).map(::message) + messages.value
|
||||
loading.value = false
|
||||
}
|
||||
assertAnchor(if (prepend) "page prepended" else "loading stopped without data")
|
||||
compose.runOnIdle {
|
||||
if (holdScroll) {
|
||||
assertTrue("loading must not cancel the gesture", scrollJob!!.isActive)
|
||||
assertTrue("scroll still in progress", listState.isScrollInProgress)
|
||||
}
|
||||
}
|
||||
if (holdScroll && prepend) {
|
||||
compose.runOnIdle {
|
||||
assertEquals("older prefix waits for idle", 40, listState.layoutInfo.totalItemsCount)
|
||||
messages.value = messages.value + message(190)
|
||||
streaming.value =
|
||||
StreamingState(streamingMessage = ChatMessage("stream", MessageRole.ASSISTANT, "Live answer"))
|
||||
}
|
||||
compose.runOnIdle {
|
||||
assertEquals("new settled and streaming tail remain live", 42, listState.layoutInfo.totalItemsCount)
|
||||
assertTrue("tail update must not cancel scrolling", scrollJob!!.isActive)
|
||||
listState.dispatchRawDelta(18f)
|
||||
}
|
||||
before =
|
||||
compose.runOnIdle {
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.first { it.index == listState.firstVisibleItemIndex }
|
||||
.let { it.key to it.offset }
|
||||
}
|
||||
if (switchSession) {
|
||||
compose.runOnIdle {
|
||||
messages.value = (200..239).map(::message)
|
||||
streaming.value = StreamingState()
|
||||
session.value = "session-two"
|
||||
}
|
||||
compose.runOnIdle {
|
||||
assertEquals(
|
||||
"session changes immediately",
|
||||
40,
|
||||
listState.layoutInfo.totalItemsCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { scrollJob?.cancel() }
|
||||
if (holdScroll && prepend) {
|
||||
compose.runOnIdle {
|
||||
assertEquals(
|
||||
"no lost or stale pending rows",
|
||||
if (switchSession) 40 else 192,
|
||||
listState.layoutInfo.totalItemsCount,
|
||||
)
|
||||
}
|
||||
if (!switchSession) assertAnchor("gesture ended after reader moved; prefix published")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user