1package com.parseable.android
3import com.parseable.android.data.model.ApiResult
4import com.parseable.android.data.model.SchemaField
5import com.parseable.android.data.model.StreamSchema
6import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.ui.screens.logviewer.LogViewerViewModel
8import com.parseable.android.ui.screens.logviewer.TimeRange
10import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.ExperimentalCoroutinesApi
12import kotlinx.coroutines.test.*
13import kotlinx.serialization.json.JsonObject
14import kotlinx.serialization.json.JsonPrimitive
16import org.junit.Assert.*
17import org.junit.Before
20@OptIn(ExperimentalCoroutinesApi::class)
21class LogViewerViewModelTest {
23 private lateinit var repository: ParseableRepository
24 private lateinit var viewModel: LogViewerViewModel
25 private val testDispatcher = UnconfinedTestDispatcher()
29 Dispatchers.setMain(testDispatcher)
30 repository = mockk(relaxed = true)
31 coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(
32 StreamSchema(fields = listOf(
33 SchemaField(name = "message"),
34 SchemaField(name = "level"),
35 SchemaField(name = "p_timestamp"),
38 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns ApiResult.Success(emptyList())
39 viewModel = LogViewerViewModel(repository)
44 Dispatchers.resetMain()
48 fun `initialize sets stream name and loads schema`() {
49 viewModel.initialize("test_stream")
51 val state = viewModel.state.value
52 assertEquals("test_stream", state.streamName)
53 assertEquals(3, state.columns.size)
54 assertTrue(state.columns.contains("message"))
58 fun `initialize is idempotent for same stream`() {
59 viewModel.initialize("test_stream")
60 viewModel.initialize("test_stream")
62 coVerify(exactly = 1) { repository.getStreamSchema("test_stream") }
66 fun `onTimeRangeChange updates state and refreshes`() {
67 viewModel.initialize("test")
68 viewModel.onTimeRangeChange(TimeRange.LAST_24H)
70 assertEquals(TimeRange.LAST_24H, viewModel.state.value.selectedTimeRange)
71 assertEquals(500, viewModel.state.value.currentLimit)
75 fun `loadMore increments limit by 500`() {
76 viewModel.initialize("test")
79 assertEquals(1000, viewModel.state.value.currentLimit)
83 fun `loadMore caps at MAX_LOAD_LIMIT of 5000`() {
84 viewModel.initialize("test")
85 // Load more 10 times (500 + 10*500 = 5500, but capped at 5000)
86 repeat(10) { viewModel.loadMore() }
88 assertEquals(5000, viewModel.state.value.currentLimit)
89 assertFalse(viewModel.state.value.hasMore) // Should be false once at cap
93 fun `addFilter adds clause and display to state`() {
94 viewModel.initialize("test")
95 viewModel.addFilter("level", "=", "ERROR")
97 val state = viewModel.state.value
98 assertEquals(1, state.activeFilters.size)
99 assertEquals("level = ERROR", state.activeFilters[0])
100 assertEquals(1, state.filterClauses.size)
104 fun `removeFilter removes correct filter by index`() {
105 viewModel.initialize("test")
106 viewModel.addFilter("level", "=", "ERROR")
107 viewModel.addFilter("message", "ILIKE", "timeout")
109 assertEquals(2, viewModel.state.value.activeFilters.size)
111 viewModel.removeFilter("level = ERROR")
113 val state = viewModel.state.value
114 assertEquals(1, state.activeFilters.size)
115 assertEquals("message ILIKE timeout", state.activeFilters[0])
119 fun `clearFilters removes all filters`() {
120 viewModel.initialize("test")
121 viewModel.addFilter("level", "=", "ERROR")
122 viewModel.addFilter("level", "=", "WARN")
123 viewModel.clearFilters()
125 assertTrue(viewModel.state.value.activeFilters.isEmpty())
126 assertTrue(viewModel.state.value.filterClauses.isEmpty())
130 fun `refresh with error sets error state`() {
131 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
132 ApiResult.Error("Server error", 500)
134 viewModel.initialize("test")
136 assertNotNull(viewModel.state.value.error)
140 fun `refresh with success clears error`() {
142 JsonObject(mapOf("message" to JsonPrimitive("hello")))
144 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
145 ApiResult.Success(logs)
147 viewModel.initialize("test")
149 assertNull(viewModel.state.value.error)
150 assertEquals(1, viewModel.state.value.logs.size)
154 fun `search builds ILIKE clause for all non-internal columns`() {
155 viewModel.initialize("test")
156 viewModel.onSearchQueryChange("error")
158 // Advance past debounce
159 testDispatcher.scheduler.advanceTimeBy(400)
162 repository.queryLogs(
167 it.contains("\"message\" ILIKE '%error%'") &&
168 it.contains("\"level\" ILIKE '%error%'") &&
169 !it.contains("p_timestamp ILIKE") // Internal columns excluded
177 fun `setCustomTimeRange updates state correctly`() {
178 viewModel.initialize("test")
179 viewModel.setCustomTimeRange(1000L, 2000L)
181 val state = viewModel.state.value
182 assertEquals(TimeRange.CUSTOM, state.selectedTimeRange)
183 assertEquals(1000L, state.customStartTime)
184 assertEquals(2000L, state.customEndTime)
185 assertEquals(500, state.currentLimit) // Reset
189 fun `toggleStreaming starts and stops`() {
190 viewModel.initialize("test")
192 viewModel.toggleStreaming()
193 assertTrue(viewModel.state.value.isStreaming)
195 viewModel.toggleStreaming()
196 assertFalse(viewModel.state.value.isStreaming)
200 fun `stopStreaming stops but preserves streaming error`() {
201 viewModel.initialize("test")
202 viewModel.toggleStreaming()
203 viewModel.stopStreaming()
205 val state = viewModel.state.value
206 assertFalse(state.isStreaming)
210 fun `dismissStreamingError clears the error`() {
211 viewModel.initialize("test")
212 viewModel.toggleStreaming()
213 viewModel.stopStreaming()
214 viewModel.dismissStreamingError()
216 assertNull(viewModel.state.value.streamingError)
220 fun `executeCustomSql rejects non-SELECT queries`() {
221 viewModel.initialize("test")
222 viewModel.executeCustomSql("DELETE FROM test_stream")
224 assertEquals("Only SELECT queries are allowed", viewModel.state.value.error)
228 fun `executeCustomSql appends LIMIT when missing`() {
229 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
231 viewModel.initialize("test")
232 viewModel.executeCustomSql("SELECT * FROM test_stream")
235 repository.queryLogsRaw(
236 match { it.contains("LIMIT 5000") },
244 fun `executeCustomSql preserves existing LIMIT`() {
245 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
247 viewModel.initialize("test")
248 viewModel.executeCustomSql("SELECT * FROM test_stream LIMIT 50")
251 repository.queryLogsRaw(
252 match { it.trim() == "SELECT * FROM test_stream LIMIT 50" },
260 fun `addFilter escapes column names`() {
261 viewModel.initialize("test")
262 viewModel.addFilter("col\"name", "=", "value")
264 val clause = viewModel.state.value.filterClauses[0]
265 assertTrue(clause.contains("col\"\"name")) // Double-escaped
269 fun `addFilter escapes SQL values`() {
270 viewModel.initialize("test")
271 viewModel.addFilter("col", "=", "it's")
273 val clause = viewModel.state.value.filterClauses[0]
274 assertTrue(clause.contains("it''s")) // Escaped quote
278 fun `addFilter handles IS NULL without value`() {
279 viewModel.initialize("test")
280 viewModel.addFilter("col", "IS NULL", "")
282 val clause = viewModel.state.value.filterClauses[0]
283 assertTrue(clause.endsWith("IS NULL"))
284 assertFalse(clause.contains("''"))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list