1package soy.iko.opencode.data.repo
3import org.junit.Assert.assertEquals
4import org.junit.Assert.assertFalse
5import org.junit.Assert.assertTrue
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) =
28 MessagePartUpdated.Props(
29 part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
31 messageID = messageId,
35 private fun partRemoved(messageId: String, partId: String, sid: String = session) =
37 MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
40 private fun messageRemoved(messageId: String, sid: String = session) =
41 MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
46 fun seedPopulatesSnapshot() {
47 val store = MessageStore()
50 MessageWithParts(UserMessage("u1", session)),
51 MessageWithParts(AssistantMessage("a1", session)),
54 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
57 // --- seed merge (race between streamed part and initial load) ---
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.
69 AssistantMessage(id = "a1", sessionID = session),
70 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
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)
81 fun seedAppendsSnapshotOnlyPartsAfterStreamedOnes() {
82 val store = MessageStore()
83 store.reduce(session, partUpdated("a1", "t2", "streamed-t2"))
87 AssistantMessage(id = "a1", sessionID = session),
89 TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snap-t1"),
90 TextPart(id = "t2", messageID = "a1", sessionID = session, text = "snap-t2"),
95 val msg = store.snapshot().single()
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)
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.
112 UnknownMessage(id = "a1", sessionID = session),
113 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
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)
122 // --- MessageUpdated ---
125 fun messageUpdatedInsertsNewMessage() {
126 val store = MessageStore()
127 val changed = store.reduce(session, msgUpdated("m1"))
129 assertEquals("m1", store.snapshot().single().info.id)
133 fun messageUpdatedOverwritesExistingInfo() {
134 val store = MessageStore()
135 store.reduce(session, msgUpdated("m1"))
136 val changed = store.reduce(session, msgUpdated("m1"))
138 assertEquals(1, store.snapshot().size)
142 fun messageUpdatedIgnoredForOtherSession() {
143 val store = MessageStore()
144 val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
146 assertTrue(store.snapshot().isEmpty())
149 // --- MessagePartUpdated ---
152 fun partUpdatedAppendsToExistingMessage() {
153 val store = MessageStore()
154 store.reduce(session, msgUpdated("m1"))
155 val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
157 val parts = store.snapshot().single().parts
158 assertEquals(1, parts.size)
159 assertEquals("hello", (parts[0] as TextPart).text)
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)
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)
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 })
192 fun partUpdatedIgnoredForOtherSession() {
193 val store = MessageStore()
194 val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
196 assertTrue(store.snapshot().isEmpty())
199 // --- MessagePartRemoved ---
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"))
208 val parts = store.snapshot().single().parts
209 assertEquals(listOf("p2"), parts.map { it.id })
213 fun partRemovedReturnsFalseWhenPartNotFound() {
214 val store = MessageStore()
215 store.reduce(session, partUpdated("m1", "p1", "one"))
216 val changed = store.reduce(session, partRemoved("m1", "p2"))
220 // --- MessageRemoved ---
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"))
229 assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
233 fun messageRemovedReturnsFalseWhenMissing() {
234 val store = MessageStore()
235 val changed = store.reduce(session, messageRemoved("nope"))
239 // --- unrelated events ---
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)))
249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
252 // --- seed return value (skip unnecessary snapshot on reconnect) ---
255 fun seedReturnsTrueWhenAddingNewMessages() {
256 val store = MessageStore()
257 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
262 fun seedReturnsFalseWhenReSeedingIdenticalMessages() {
263 val store = MessageStore()
264 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
266 val changed = store.seed(messages)
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")))),
281 fun seedPruneReturnsTrueWhenMessagesRemoved() {
282 val store = MessageStore()
284 MessageWithParts(UserMessage("u1", session)),
285 MessageWithParts(AssistantMessage("a1", session)),
287 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))), prune = true)
292 fun seedPruneReturnsFalseWhenNoMessagesRemoved() {
293 val store = MessageStore()
294 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
296 val changed = store.seed(messages, prune = true)
300 // --- seed prune (re-seed after SSE reconnect) ---
303 fun seedPruneRemovesMessagesNotInSnapshot() {
304 val store = MessageStore()
306 MessageWithParts(UserMessage("u1", session)),
307 MessageWithParts(AssistantMessage("a1", session)),
309 // Re-seed with only u1 — a1 was deleted on the server during the gap.
311 listOf(MessageWithParts(UserMessage("u1", session))),
314 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
318 fun seedWithoutPruneKeepsAllMessages() {
319 val store = MessageStore()
321 MessageWithParts(UserMessage("u1", session)),
322 MessageWithParts(AssistantMessage("a1", session)),
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 })
329 // --- end-to-end ordering ---
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 })
343 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
344 assertEquals("final", (a1.parts[0] as TextPart).text)
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))
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}"))
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