git.iko.soy/opencode-android
on 930859e help sign in
385 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 messageUpdatedOverwritesExistingInfoWhenChanged() {
134 val store = MessageStore()
135 store.reduce(session, msgUpdated("m1"))
136 // A second update carrying genuinely new info (e.g. cost/token totals filled in on
137 // completion) overwrites in place and reports a change.
138 val changed = store.reduce(
139 session,
140 MessageUpdated(MessageUpdated.Props(AssistantMessage(id = "m1", sessionID = session, cost = 0.42))),
141 )
142 assertTrue(changed)
143 assertEquals(1, store.snapshot().size)
144 assertEquals(0.42, (store.snapshot().single().info as AssistantMessage).cost!!, 1e-9)
145 }
147 @Test
148 fun messageUpdatedWithIdenticalInfoIsNoOp() {
149 val store = MessageStore()
150 store.reduce(session, msgUpdated("m1"))
151 // Re-sending byte-identical info publishes nothing, avoiding a redundant O(N) snapshot
152 // (mirrors upsertPart's identical-part guard).
153 val changed = store.reduce(session, msgUpdated("m1"))
154 assertFalse(changed)
155 assertEquals(1, store.snapshot().size)
156 }
158 @Test
159 fun messageUpdatedIgnoredForOtherSession() {
160 val store = MessageStore()
161 val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
162 assertFalse(changed)
163 assertTrue(store.snapshot().isEmpty())
164 }
166 // --- MessagePartUpdated ---
168 @Test
169 fun partUpdatedAppendsToExistingMessage() {
170 val store = MessageStore()
171 store.reduce(session, msgUpdated("m1"))
172 val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
173 assertTrue(changed)
174 val parts = store.snapshot().single().parts
175 assertEquals(1, parts.size)
176 assertEquals("hello", (parts[0] as TextPart).text)
177 }
179 @Test
180 fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
181 val store = MessageStore()
182 store.reduce(session, partUpdated("m1", "p1", "hello"))
183 val msg = store.snapshot().single()
184 assertEquals("m1", msg.info.id)
185 assertEquals("hello", (msg.parts.single() as TextPart).text)
186 }
188 @Test
189 fun partUpdatedReplacesPartWithSameId() {
190 val store = MessageStore()
191 store.reduce(session, partUpdated("m1", "p1", "hello"))
192 store.reduce(session, partUpdated("m1", "p1", "hello world"))
193 val parts = store.snapshot().single().parts
194 assertEquals(1, parts.size)
195 assertEquals("hello world", (parts[0] as TextPart).text)
196 }
198 @Test
199 fun partUpdatedPreservesInsertionOrderAcrossParts() {
200 val store = MessageStore()
201 store.reduce(session, partUpdated("m1", "p1", "one"))
202 store.reduce(session, partUpdated("m1", "p2", "two"))
203 store.reduce(session, partUpdated("m1", "p3", "three"))
204 val parts = store.snapshot().single().parts
205 assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
206 }
208 @Test
209 fun partUpdatedIgnoredForOtherSession() {
210 val store = MessageStore()
211 val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
212 assertFalse(changed)
213 assertTrue(store.snapshot().isEmpty())
214 }
216 // --- MessagePartRemoved ---
218 @Test
219 fun partRemovedDeletesMatchingPart() {
220 val store = MessageStore()
221 store.reduce(session, partUpdated("m1", "p1", "one"))
222 store.reduce(session, partUpdated("m1", "p2", "two"))
223 val changed = store.reduce(session, partRemoved("m1", "p1"))
224 assertTrue(changed)
225 val parts = store.snapshot().single().parts
226 assertEquals(listOf("p2"), parts.map { it.id })
227 }
229 @Test
230 fun partRemovedReturnsFalseWhenPartNotFound() {
231 val store = MessageStore()
232 store.reduce(session, partUpdated("m1", "p1", "one"))
233 val changed = store.reduce(session, partRemoved("m1", "p2"))
234 assertFalse(changed)
235 }
237 // --- MessageRemoved ---
239 @Test
240 fun messageRemovedDeletesWholeMessage() {
241 val store = MessageStore()
242 store.reduce(session, msgUpdated("m1"))
243 store.reduce(session, msgUpdated("m2"))
244 val changed = store.reduce(session, messageRemoved("m1"))
245 assertTrue(changed)
246 assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
247 }
249 @Test
250 fun messageRemovedReturnsFalseWhenMissing() {
251 val store = MessageStore()
252 val changed = store.reduce(session, messageRemoved("nope"))
253 assertFalse(changed)
254 }
256 // --- unrelated events ---
258 @Test
259 fun unrelatedEventsDoNotMutateState() {
260 val store = MessageStore()
261 store.reduce(session, msgUpdated("m1"))
262 val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
263 val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
264 assertFalse(idle)
265 assertFalse(error)
266 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
267 }
269 // --- seed return value (skip unnecessary snapshot on reconnect) ---
271 @Test
272 fun seedReturnsTrueWhenAddingNewMessages() {
273 val store = MessageStore()
274 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
275 assertTrue(changed)
276 }
278 @Test
279 fun seedReturnsFalseWhenReSeedingIdenticalMessages() {
280 val store = MessageStore()
281 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
282 store.seed(messages)
283 val changed = store.seed(messages)
284 assertFalse(changed)
285 }
287 @Test
288 fun seedReturnsTrueWhenMessagePartsDiffer() {
289 val store = MessageStore()
290 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
291 val changed = store.seed(
292 listOf(MessageWithParts(UserMessage("u1", session), listOf(TextPart(id = "t1")))),
293 )
294 assertTrue(changed)
295 }
297 @Test
298 fun seedPruneReturnsTrueWhenMessagesRemoved() {
299 val store = MessageStore()
300 store.seed(listOf(
301 MessageWithParts(UserMessage("u1", session)),
302 MessageWithParts(AssistantMessage("a1", session)),
303 ))
304 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))), prune = true)
305 assertTrue(changed)
306 }
308 @Test
309 fun seedPruneReturnsFalseWhenNoMessagesRemoved() {
310 val store = MessageStore()
311 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
312 store.seed(messages)
313 val changed = store.seed(messages, prune = true)
314 assertFalse(changed)
315 }
317 // --- seed prune (re-seed after SSE reconnect) ---
319 @Test
320 fun seedPruneRemovesMessagesNotInSnapshot() {
321 val store = MessageStore()
322 store.seed(listOf(
323 MessageWithParts(UserMessage("u1", session)),
324 MessageWithParts(AssistantMessage("a1", session)),
325 ))
326 // Re-seed with only u1 — a1 was deleted on the server during the gap.
327 store.seed(
328 listOf(MessageWithParts(UserMessage("u1", session))),
329 prune = true,
330 )
331 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
332 }
334 @Test
335 fun seedWithoutPruneKeepsAllMessages() {
336 val store = MessageStore()
337 store.seed(listOf(
338 MessageWithParts(UserMessage("u1", session)),
339 MessageWithParts(AssistantMessage("a1", session)),
340 ))
341 // Without prune, messages absent from the snapshot are retained.
342 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
343 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
344 }
346 // --- end-to-end ordering ---
348 @Test
349 fun fullReductionProducesOrderedMessagesWithParts() {
350 val store = MessageStore()
351 store.reduce(session, msgUpdated("u1"))
352 store.reduce(session, msgUpdated("a1"))
353 store.reduce(session, partUpdated("a1", "t1", "draft"))
354 store.reduce(session, partUpdated("a1", "t1", "final"))
355 store.reduce(session, partUpdated("a1", "t2", "second"))
357 val snapshot = store.snapshot()
358 assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
359 val a1 = snapshot[1]
360 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
361 assertEquals("final", (a1.parts[0] as TextPart).text)
362 }
364 // --- eviction ---
366 @Test
367 fun evictionCapsMessageCountKeepingNewest() {
368 val store = MessageStore()
369 // Seed exactly at the cap.
370 val messages = (1..store.maxMessages).map { i ->
371 MessageWithParts(UserMessage("m$i", session))
372 }
373 store.seed(messages)
374 assertEquals(store.maxMessages, store.snapshot().size)
376 // Adding one more via reduce should evict the oldest.
377 store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
378 assertEquals(store.maxMessages, store.snapshot().size)
379 // The oldest (m1) should be gone; the newest should be present.
380 val ids = store.snapshot().map { it.info.id }
381 assertFalse("m1 should have been evicted", ids.contains("m1"))
382 assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
383 assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
384 }