git.iko.soy/opencode-android
on 44de46e help sign in
269 lines 10 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 // --- end-to-end ordering ---
254 @Test
255 fun fullReductionProducesOrderedMessagesWithParts() {
256 val store = MessageStore()
257 store.reduce(session, msgUpdated("u1"))
258 store.reduce(session, msgUpdated("a1"))
259 store.reduce(session, partUpdated("a1", "t1", "draft"))
260 store.reduce(session, partUpdated("a1", "t1", "final"))
261 store.reduce(session, partUpdated("a1", "t2", "second"))
263 val snapshot = store.snapshot()
264 assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
265 val a1 = snapshot[1]
266 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
267 assertEquals("final", (a1.parts[0] as TextPart).text)
268 }