git.iko.soy/opencode-android
on 598c306 help sign in
320 lines 12 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 prune (re-seed after SSE reconnect) ---
254 @Test
255 fun seedPruneRemovesMessagesNotInSnapshot() {
256 val store = MessageStore()
257 store.seed(listOf(
258 MessageWithParts(UserMessage("u1", session)),
259 MessageWithParts(AssistantMessage("a1", session)),
260 ))
261 // Re-seed with only u1 — a1 was deleted on the server during the gap.
262 store.seed(
263 listOf(MessageWithParts(UserMessage("u1", session))),
264 prune = true,
265 )
266 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
267 }
269 @Test
270 fun seedWithoutPruneKeepsAllMessages() {
271 val store = MessageStore()
272 store.seed(listOf(
273 MessageWithParts(UserMessage("u1", session)),
274 MessageWithParts(AssistantMessage("a1", session)),
275 ))
276 // Without prune, messages absent from the snapshot are retained.
277 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
278 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
279 }
281 // --- end-to-end ordering ---
283 @Test
284 fun fullReductionProducesOrderedMessagesWithParts() {
285 val store = MessageStore()
286 store.reduce(session, msgUpdated("u1"))
287 store.reduce(session, msgUpdated("a1"))
288 store.reduce(session, partUpdated("a1", "t1", "draft"))
289 store.reduce(session, partUpdated("a1", "t1", "final"))
290 store.reduce(session, partUpdated("a1", "t2", "second"))
292 val snapshot = store.snapshot()
293 assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
294 val a1 = snapshot[1]
295 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
296 assertEquals("final", (a1.parts[0] as TextPart).text)
297 }
299 // --- eviction ---
301 @Test
302 fun evictionCapsMessageCountKeepingNewest() {
303 val store = MessageStore()
304 // Seed exactly at the cap.
305 val messages = (1..store.maxMessages).map { i ->
306 MessageWithParts(UserMessage("m$i", session))
307 }
308 store.seed(messages)
309 assertEquals(store.maxMessages, store.snapshot().size)
311 // Adding one more via reduce should evict the oldest.
312 store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
313 assertEquals(store.maxMessages, store.snapshot().size)
314 // The oldest (m1) should be gone; the newest should be present.
315 val ids = store.snapshot().map { it.info.id }
316 assertFalse("m1 should have been evicted", ids.contains("m1"))
317 assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
318 assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
319 }