@@ -0,0 +1,203 @@
1+package soy.iko.opencode.data.repo
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
7+import soy.iko.opencode.data.model.AssistantMessage
8+import soy.iko.opencode.data.model.MessagePartRemoved
9+import soy.iko.opencode.data.model.MessagePartUpdated
10+import soy.iko.opencode.data.model.MessageRemoved
11+import soy.iko.opencode.data.model.MessageUpdated
12+import soy.iko.opencode.data.model.MessageWithParts
13+import soy.iko.opencode.data.model.SessionError
14+import soy.iko.opencode.data.model.SessionIdle
15+import soy.iko.opencode.data.model.TextPart
16+import soy.iko.opencode.data.model.UserMessage
18+class MessageStoreTest {
20+ private val session = "s1"
22+ private fun msgUpdated(id: String, sid: String = session) =
23+ MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
25+ private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
27+ MessagePartUpdated.Props(
28+ part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
30+ messageID = messageId,
34+ private fun partRemoved(messageId: String, partId: String, sid: String = session) =
36+ MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
39+ private fun messageRemoved(messageId: String, sid: String = session) =
40+ MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
45+ fun seedPopulatesSnapshot() {
46+ val store = MessageStore()
49+ MessageWithParts(UserMessage("u1", session)),
50+ MessageWithParts(AssistantMessage("a1", session)),
53+ assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
56+ // --- MessageUpdated ---
59+ fun messageUpdatedInsertsNewMessage() {
60+ val store = MessageStore()
61+ val changed = store.reduce(session, msgUpdated("m1"))
63+ assertEquals("m1", store.snapshot().single().info.id)
67+ fun messageUpdatedOverwritesExistingInfo() {
68+ val store = MessageStore()
69+ store.reduce(session, msgUpdated("m1"))
70+ val changed = store.reduce(session, msgUpdated("m1"))
72+ assertEquals(1, store.snapshot().size)
76+ fun messageUpdatedIgnoredForOtherSession() {
77+ val store = MessageStore()
78+ val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
80+ assertTrue(store.snapshot().isEmpty())
83+ // --- MessagePartUpdated ---
86+ fun partUpdatedAppendsToExistingMessage() {
87+ val store = MessageStore()
88+ store.reduce(session, msgUpdated("m1"))
89+ val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
91+ val parts = store.snapshot().single().parts
92+ assertEquals(1, parts.size)
93+ assertEquals("hello", (parts[0] as TextPart).text)
97+ fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
98+ val store = MessageStore()
99+ store.reduce(session, partUpdated("m1", "p1", "hello"))
100+ val msg = store.snapshot().single()
101+ assertEquals("m1", msg.info.id)
102+ assertEquals("hello", (msg.parts.single() as TextPart).text)
106+ fun partUpdatedReplacesPartWithSameId() {
107+ val store = MessageStore()
108+ store.reduce(session, partUpdated("m1", "p1", "hello"))
109+ store.reduce(session, partUpdated("m1", "p1", "hello world"))
110+ val parts = store.snapshot().single().parts
111+ assertEquals(1, parts.size)
112+ assertEquals("hello world", (parts[0] as TextPart).text)
116+ fun partUpdatedPreservesInsertionOrderAcrossParts() {
117+ val store = MessageStore()
118+ store.reduce(session, partUpdated("m1", "p1", "one"))
119+ store.reduce(session, partUpdated("m1", "p2", "two"))
120+ store.reduce(session, partUpdated("m1", "p3", "three"))
121+ val parts = store.snapshot().single().parts
122+ assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
126+ fun partUpdatedIgnoredForOtherSession() {
127+ val store = MessageStore()
128+ val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
129+ assertFalse(changed)
130+ assertTrue(store.snapshot().isEmpty())
133+ // --- MessagePartRemoved ---
136+ fun partRemovedDeletesMatchingPart() {
137+ val store = MessageStore()
138+ store.reduce(session, partUpdated("m1", "p1", "one"))
139+ store.reduce(session, partUpdated("m1", "p2", "two"))
140+ val changed = store.reduce(session, partRemoved("m1", "p1"))
142+ val parts = store.snapshot().single().parts
143+ assertEquals(listOf("p2"), parts.map { it.id })
147+ fun partRemovedReturnsFalseWhenPartNotFound() {
148+ val store = MessageStore()
149+ store.reduce(session, partUpdated("m1", "p1", "one"))
150+ val changed = store.reduce(session, partRemoved("m1", "p2"))
151+ assertFalse(changed)
154+ // --- MessageRemoved ---
157+ fun messageRemovedDeletesWholeMessage() {
158+ val store = MessageStore()
159+ store.reduce(session, msgUpdated("m1"))
160+ store.reduce(session, msgUpdated("m2"))
161+ val changed = store.reduce(session, messageRemoved("m1"))
163+ assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
167+ fun messageRemovedReturnsFalseWhenMissing() {
168+ val store = MessageStore()
169+ val changed = store.reduce(session, messageRemoved("nope"))
170+ assertFalse(changed)
173+ // --- unrelated events ---
176+ fun unrelatedEventsDoNotMutateState() {
177+ val store = MessageStore()
178+ store.reduce(session, msgUpdated("m1"))
179+ val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
180+ val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
183+ assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
186+ // --- end-to-end ordering ---
189+ fun fullReductionProducesOrderedMessagesWithParts() {
190+ val store = MessageStore()
191+ store.reduce(session, msgUpdated("u1"))
192+ store.reduce(session, msgUpdated("a1"))
193+ store.reduce(session, partUpdated("a1", "t1", "draft"))
194+ store.reduce(session, partUpdated("a1", "t1", "final"))
195+ store.reduce(session, partUpdated("a1", "t2", "second"))
197+ val snapshot = store.snapshot()
198+ assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
199+ val a1 = snapshot[1]
200+ assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
201+ assertEquals("final", (a1.parts[0] as TextPart).text)