auto-lock on background + reasoning/sources tests

Clear locked chat messages when app goes to background
(onPauseOrDispose). Track viewingLockedChat state — reset on
new chat or opening a non-locked conversation.

Add tests: reasoning round-trip, null reasoning preserved,
sources round-trip, empty sources → null, reasoning+sources
together, SearchSource converter (empty/single/multi/malformed/
unknown fields).
This commit is contained in:
Bruno Deanoz 2026-06-22 00:03:59 +02:00
parent edbdbd8697
commit a16dfffd5c
3 changed files with 125 additions and 1 deletions

View file

@ -118,6 +118,7 @@ fun ChatScreen(
// Conversation persistence: sidebar list from Room (instant), active conversation id // Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var viewingLockedChat by remember { mutableStateOf(false) }
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList()) val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
@ -195,7 +196,12 @@ fun ChatScreen(
session.config?.let { cfg -> session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) } scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
} }
onPauseOrDispose { } onPauseOrDispose {
if (viewingLockedChat) {
messages.clear()
viewingLockedChat = false
}
}
} }
fun refreshConversations() { fun refreshConversations() {
@ -207,6 +213,7 @@ fun ChatScreen(
fun newChat() { fun newChat() {
messages.clear() messages.clear()
conversationId = null conversationId = null
viewingLockedChat = false
} }
fun openConversation(summary: ConversationSummary) { fun openConversation(summary: ConversationSummary) {
@ -244,6 +251,7 @@ fun ChatScreen(
) )
} }
conversationId = summary.id conversationId = summary.id
viewingLockedChat = true
} }
} }
} }
@ -255,6 +263,7 @@ fun ChatScreen(
} }
return return
} }
viewingLockedChat = false
scope.launch { scope.launch {
messages.clear() messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id) val cachedList = chat.messageRepo.getCached(summary.id)

View file

@ -2,6 +2,7 @@ package dev.kaizen.app
import dev.kaizen.app.db.Converters import dev.kaizen.app.db.Converters
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -61,4 +62,56 @@ class ConvertersTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url) assertEquals("https://x.com/f", result[0].url)
} }
// --- SearchSource converter tests ---
@Test
fun emptySourcesRoundTrip() {
val json = converters.sourcesToJson(emptyList())
val result = converters.jsonToSources(json)
assertTrue(result.isEmpty())
}
@Test
fun singleSourceRoundTrip() {
val original = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test article")
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("Wikipedia", result[0].title)
assertEquals("https://en.wikipedia.org/wiki/Test", result[0].url)
assertEquals("A test article", result[0].snippet)
}
@Test
fun multipleSourcesRoundTrip() {
val original = listOf(
SearchSource(title = "Source 1", url = "https://a.com"),
SearchSource(title = "Source 2", url = "https://b.com", snippet = "Details"),
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(2, result.size)
assertEquals("Source 1", result[0].title)
assertEquals("Source 2", result[1].title)
assertEquals("Details", result[1].snippet)
}
@Test
fun malformedSourcesJsonReturnsEmptyList() {
val result = converters.jsonToSources("{broken")
assertTrue(result.isEmpty())
}
@Test
fun sourcesWithUnknownFieldsIgnored() {
val json = """[{"title":"T","url":"https://x.com","snippet":"","futureField":"v"}]"""
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("T", result[0].title)
}
} }

View file

@ -7,8 +7,10 @@ import dev.kaizen.app.db.toStoredMessage
import dev.kaizen.app.db.toSummary import dev.kaizen.app.db.toSummary
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.StoredMessage import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -119,4 +121,64 @@ class MappersTest {
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType) assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size) assertEquals(original.attachments[0].size, roundTripped.attachments[0].size)
} }
@Test
fun reasoningPreservedInRoundTrip() {
val original = StoredMessage(
id = "msg-r", role = "assistant", content = "Answer",
reasoning = "Let me think about this step by step...",
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Let me think about this step by step...", roundTripped.reasoning)
}
@Test
fun nullReasoningPreserved() {
val original = StoredMessage(id = "msg-nr", role = "assistant", content = "Simple answer")
val entity = original.toEntity("c1", 0)
assertNull(entity.reasoning)
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.reasoning)
}
@Test
fun sourcesPreservedInRoundTrip() {
val sources = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test"),
SearchSource(title = "MDN", url = "https://developer.mozilla.org", snippet = "Web docs"),
)
val original = StoredMessage(
id = "msg-s", role = "assistant", content = "Search result",
sources = sources,
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals(2, roundTripped.sources?.size)
assertEquals("Wikipedia", roundTripped.sources!![0].title)
assertEquals("https://developer.mozilla.org", roundTripped.sources!![1].url)
}
@Test
fun emptySourcesBecomeNull() {
val original = StoredMessage(id = "msg-es", role = "assistant", content = "No sources")
val entity = original.toEntity("c1", 0)
assertTrue(entity.sources.isEmpty())
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.sources)
}
@Test
fun reasoningAndSourcesTogether() {
val original = StoredMessage(
id = "msg-rs", role = "assistant", content = "Full answer",
reasoning = "Thinking...",
sources = listOf(SearchSource(title = "Src", url = "https://src.com")),
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Thinking...", roundTripped.reasoning)
assertEquals(1, roundTripped.sources?.size)
assertEquals("Src", roundTripped.sources!![0].title)
}
} }