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 prune (re-seed after SSE reconnect) ---
255 fun seedPruneRemovesMessagesNotInSnapshot() {
256 val store = MessageStore()
258 MessageWithParts(UserMessage("u1", session)),
259 MessageWithParts(AssistantMessage("a1", session)),
261 // Re-seed with only u1 — a1 was deleted on the server during the gap.
263 listOf(MessageWithParts(UserMessage("u1", session))),
266 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
270 fun seedWithoutPruneKeepsAllMessages() {
271 val store = MessageStore()
273 MessageWithParts(UserMessage("u1", session)),
274 MessageWithParts(AssistantMessage("a1", session)),
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 })
281 // --- end-to-end ordering ---
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 })
295 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
296 assertEquals("final", (a1.parts[0] as TextPart).text)
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))
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}"))
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