git.iko.soy/opencode-android
on a40d3a2 help sign in
368 lines 14 kB history raw
1package soy.iko.opencode.data.repo
3import org.junit.Assert.assertEquals
4import org.junit.Assert.assertFalse
5import org.junit.Assert.assertTrue
6import org.junit.Test
7import soy.iko.opencode.data.model.AssistantMessage
8import soy.iko.opencode.data.model.MessagePartRemoved
9import soy.iko.opencode.data.model.MessagePartUpdated
10import soy.iko.opencode.data.model.MessageRemoved
11import soy.iko.opencode.data.model.MessageUpdated
12import soy.iko.opencode.data.model.MessageWithParts
13import soy.iko.opencode.data.model.SessionError
14import soy.iko.opencode.data.model.SessionIdle
15import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.UnknownMessage
17import soy.iko.opencode.data.model.UserMessage
19class MessageStoreTest {
21 private val session = "s1"
23 private fun msgUpdated(id: String, sid: String = session) =
24 MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
26 private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
27 MessagePartUpdated(
28 MessagePartUpdated.Props(
29 part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
30 sessionID = sid,
31 messageID = messageId,
32 ),
33 )
35 private fun partRemoved(messageId: String, partId: String, sid: String = session) =
36 MessagePartRemoved(
37 MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
38 )
40 private fun messageRemoved(messageId: String, sid: String = session) =
41 MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
43 // --- seed ---
45 @Test
46 fun seedPopulatesSnapshot() {
47 val store = MessageStore()
48 store.seed(
49 listOf(
50 MessageWithParts(UserMessage("u1", session)),
51 MessageWithParts(AssistantMessage("a1", session)),
52 ),
53 )
54 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
55 }
57 // --- seed merge (race between streamed part and initial load) ---
59 @Test
60 fun seedMergesStreamedPartsWithSnapshotInsteadOfOverwriting() {
61 val store = MessageStore()
62 // A part streams in before the initial load completes, creating a synthetic
63 // placeholder message.
64 store.reduce(session, partUpdated("a1", "t1", "streamed"))
65 // The REST snapshot for the same message arrives.
66 store.seed(
67 listOf(
68 MessageWithParts(
69 AssistantMessage(id = "a1", sessionID = session),
70 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
71 ),
72 ),
73 )
74 val msg = store.snapshot().single()
75 assertTrue("info should be the real one", msg.info is AssistantMessage)
76 // The streamed part (newer) must win over the snapshot's duplicate.
77 assertEquals("streamed", (msg.parts.single() as TextPart).text)
78 }
80 @Test
81 fun seedAppendsSnapshotOnlyPartsAfterStreamedOnes() {
82 val store = MessageStore()
83 store.reduce(session, partUpdated("a1", "t2", "streamed-t2"))
84 store.seed(
85 listOf(
86 MessageWithParts(
87 AssistantMessage(id = "a1", sessionID = session),
88 parts = listOf(
89 TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snap-t1"),
90 TextPart(id = "t2", messageID = "a1", sessionID = session, text = "snap-t2"),
91 ),
92 ),
93 ),
94 )
95 val msg = store.snapshot().single()
96 val parts = msg.parts
97 assertEquals(listOf("t1", "t2"), parts.map { it.id })
98 assertEquals("snap-t1", (parts[0] as TextPart).text)
99 assertEquals("streamed-t2", (parts[1] as TextPart).text)
100 }
102 @Test
103 fun seedKeepsEventResolvedInfoOverSyntheticSnapshot() {
104 val store = MessageStore()
105 // A MessageUpdated event resolved the real info, then a part streamed in.
106 store.reduce(session, msgUpdated("a1"))
107 store.reduce(session, partUpdated("a1", "t1", "streamed"))
108 // A stale snapshot arrives whose info is synthetic; it must not downgrade.
109 store.seed(
110 listOf(
111 MessageWithParts(
112 UnknownMessage(id = "a1", sessionID = session),
113 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
114 ),
115 ),
116 )
117 val msg = store.snapshot().single()
118 assertTrue("event-resolved info must be kept", msg.info is AssistantMessage)
119 assertEquals("streamed", (msg.parts.first() as TextPart).text)
120 }
122 // --- MessageUpdated ---
124 @Test
125 fun messageUpdatedInsertsNewMessage() {
126 val store = MessageStore()
127 val changed = store.reduce(session, msgUpdated("m1"))
128 assertTrue(changed)
129 assertEquals("m1", store.snapshot().single().info.id)
130 }
132 @Test
133 fun messageUpdatedOverwritesExistingInfo() {
134 val store = MessageStore()
135 store.reduce(session, msgUpdated("m1"))
136 val changed = store.reduce(session, msgUpdated("m1"))
137 assertTrue(changed)
138 assertEquals(1, store.snapshot().size)
139 }
141 @Test
142 fun messageUpdatedIgnoredForOtherSession() {
143 val store = MessageStore()
144 val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
145 assertFalse(changed)
146 assertTrue(store.snapshot().isEmpty())
147 }
149 // --- MessagePartUpdated ---
151 @Test
152 fun partUpdatedAppendsToExistingMessage() {
153 val store = MessageStore()
154 store.reduce(session, msgUpdated("m1"))
155 val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
156 assertTrue(changed)
157 val parts = store.snapshot().single().parts
158 assertEquals(1, parts.size)
159 assertEquals("hello", (parts[0] as TextPart).text)
160 }
162 @Test
163 fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
164 val store = MessageStore()
165 store.reduce(session, partUpdated("m1", "p1", "hello"))
166 val msg = store.snapshot().single()
167 assertEquals("m1", msg.info.id)
168 assertEquals("hello", (msg.parts.single() as TextPart).text)
169 }
171 @Test
172 fun partUpdatedReplacesPartWithSameId() {
173 val store = MessageStore()
174 store.reduce(session, partUpdated("m1", "p1", "hello"))
175 store.reduce(session, partUpdated("m1", "p1", "hello world"))
176 val parts = store.snapshot().single().parts
177 assertEquals(1, parts.size)
178 assertEquals("hello world", (parts[0] as TextPart).text)
179 }
181 @Test
182 fun partUpdatedPreservesInsertionOrderAcrossParts() {
183 val store = MessageStore()
184 store.reduce(session, partUpdated("m1", "p1", "one"))
185 store.reduce(session, partUpdated("m1", "p2", "two"))
186 store.reduce(session, partUpdated("m1", "p3", "three"))
187 val parts = store.snapshot().single().parts
188 assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
189 }
191 @Test
192 fun partUpdatedIgnoredForOtherSession() {
193 val store = MessageStore()
194 val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
195 assertFalse(changed)
196 assertTrue(store.snapshot().isEmpty())
197 }
199 // --- MessagePartRemoved ---
201 @Test
202 fun partRemovedDeletesMatchingPart() {
203 val store = MessageStore()
204 store.reduce(session, partUpdated("m1", "p1", "one"))
205 store.reduce(session, partUpdated("m1", "p2", "two"))
206 val changed = store.reduce(session, partRemoved("m1", "p1"))
207 assertTrue(changed)
208 val parts = store.snapshot().single().parts
209 assertEquals(listOf("p2"), parts.map { it.id })
210 }
212 @Test
213 fun partRemovedReturnsFalseWhenPartNotFound() {
214 val store = MessageStore()
215 store.reduce(session, partUpdated("m1", "p1", "one"))
216 val changed = store.reduce(session, partRemoved("m1", "p2"))
217 assertFalse(changed)
218 }
220 // --- MessageRemoved ---
222 @Test
223 fun messageRemovedDeletesWholeMessage() {
224 val store = MessageStore()
225 store.reduce(session, msgUpdated("m1"))
226 store.reduce(session, msgUpdated("m2"))
227 val changed = store.reduce(session, messageRemoved("m1"))
228 assertTrue(changed)
229 assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
230 }
232 @Test
233 fun messageRemovedReturnsFalseWhenMissing() {
234 val store = MessageStore()
235 val changed = store.reduce(session, messageRemoved("nope"))
236 assertFalse(changed)
237 }
239 // --- unrelated events ---
241 @Test
242 fun unrelatedEventsDoNotMutateState() {
243 val store = MessageStore()
244 store.reduce(session, msgUpdated("m1"))
245 val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
246 val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
247 assertFalse(idle)
248 assertFalse(error)
249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
250 }
252 // --- seed return value (skip unnecessary snapshot on reconnect) ---
254 @Test
255 fun seedReturnsTrueWhenAddingNewMessages() {
256 val store = MessageStore()
257 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
258 assertTrue(changed)
259 }
261 @Test
262 fun seedReturnsFalseWhenReSeedingIdenticalMessages() {
263 val store = MessageStore()
264 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
265 store.seed(messages)
266 val changed = store.seed(messages)
267 assertFalse(changed)
268 }
270 @Test
271 fun seedReturnsTrueWhenMessagePartsDiffer() {
272 val store = MessageStore()
273 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
274 val changed = store.seed(
275 listOf(MessageWithParts(UserMessage("u1", session), listOf(TextPart(id = "t1")))),
276 )
277 assertTrue(changed)
278 }
280 @Test
281 fun seedPruneReturnsTrueWhenMessagesRemoved() {
282 val store = MessageStore()
283 store.seed(listOf(
284 MessageWithParts(UserMessage("u1", session)),
285 MessageWithParts(AssistantMessage("a1", session)),
286 ))
287 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))), prune = true)
288 assertTrue(changed)
289 }
291 @Test
292 fun seedPruneReturnsFalseWhenNoMessagesRemoved() {
293 val store = MessageStore()
294 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
295 store.seed(messages)
296 val changed = store.seed(messages, prune = true)
297 assertFalse(changed)
298 }
300 // --- seed prune (re-seed after SSE reconnect) ---
302 @Test
303 fun seedPruneRemovesMessagesNotInSnapshot() {
304 val store = MessageStore()
305 store.seed(listOf(
306 MessageWithParts(UserMessage("u1", session)),
307 MessageWithParts(AssistantMessage("a1", session)),
308 ))
309 // Re-seed with only u1 — a1 was deleted on the server during the gap.
310 store.seed(
311 listOf(MessageWithParts(UserMessage("u1", session))),
312 prune = true,
313 )
314 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
315 }
317 @Test
318 fun seedWithoutPruneKeepsAllMessages() {
319 val store = MessageStore()
320 store.seed(listOf(
321 MessageWithParts(UserMessage("u1", session)),
322 MessageWithParts(AssistantMessage("a1", session)),
323 ))
324 // Without prune, messages absent from the snapshot are retained.
325 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
326 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
327 }
329 // --- end-to-end ordering ---
331 @Test
332 fun fullReductionProducesOrderedMessagesWithParts() {
333 val store = MessageStore()
334 store.reduce(session, msgUpdated("u1"))
335 store.reduce(session, msgUpdated("a1"))
336 store.reduce(session, partUpdated("a1", "t1", "draft"))
337 store.reduce(session, partUpdated("a1", "t1", "final"))
338 store.reduce(session, partUpdated("a1", "t2", "second"))
340 val snapshot = store.snapshot()
341 assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
342 val a1 = snapshot[1]
343 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
344 assertEquals("final", (a1.parts[0] as TextPart).text)
345 }
347 // --- eviction ---
349 @Test
350 fun evictionCapsMessageCountKeepingNewest() {
351 val store = MessageStore()
352 // Seed exactly at the cap.
353 val messages = (1..store.maxMessages).map { i ->
354 MessageWithParts(UserMessage("m$i", session))
355 }
356 store.seed(messages)
357 assertEquals(store.maxMessages, store.snapshot().size)
359 // Adding one more via reduce should evict the oldest.
360 store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
361 assertEquals(store.maxMessages, store.snapshot().size)
362 // The oldest (m1) should be gone; the newest should be present.
363 val ids = store.snapshot().map { it.info.id }
364 assertFalse("m1 should have been evicted", ids.contains("m1"))
365 assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
366 assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
367 }