@@ -0,0 +1,203 @@
1+package soy.iko.opencode.data.repo
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
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
17+
18+class MessageStoreTest {
19+
20+ private val session = "s1"
21+
22+ private fun msgUpdated(id: String, sid: String = session) =
23+ MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
24+
25+ private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
26+ MessagePartUpdated(
27+ MessagePartUpdated.Props(
28+ part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
29+ sessionID = sid,
30+ messageID = messageId,
31+ ),
32+ )
33+
34+ private fun partRemoved(messageId: String, partId: String, sid: String = session) =
35+ MessagePartRemoved(
36+ MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
37+ )
38+
39+ private fun messageRemoved(messageId: String, sid: String = session) =
40+ MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
41+
42+ // --- seed ---
43+
44+ @Test
45+ fun seedPopulatesSnapshot() {
46+ val store = MessageStore()
47+ store.seed(
48+ listOf(
49+ MessageWithParts(UserMessage("u1", session)),
50+ MessageWithParts(AssistantMessage("a1", session)),
51+ ),
52+ )
53+ assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
54+ }
55+
56+ // --- MessageUpdated ---
57+
58+ @Test
59+ fun messageUpdatedInsertsNewMessage() {
60+ val store = MessageStore()
61+ val changed = store.reduce(session, msgUpdated("m1"))
62+ assertTrue(changed)
63+ assertEquals("m1", store.snapshot().single().info.id)
64+ }
65+
66+ @Test
67+ fun messageUpdatedOverwritesExistingInfo() {
68+ val store = MessageStore()
69+ store.reduce(session, msgUpdated("m1"))
70+ val changed = store.reduce(session, msgUpdated("m1"))
71+ assertTrue(changed)
72+ assertEquals(1, store.snapshot().size)
73+ }
74+
75+ @Test
76+ fun messageUpdatedIgnoredForOtherSession() {
77+ val store = MessageStore()
78+ val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
79+ assertFalse(changed)
80+ assertTrue(store.snapshot().isEmpty())
81+ }
82+
83+ // --- MessagePartUpdated ---
84+
85+ @Test
86+ fun partUpdatedAppendsToExistingMessage() {
87+ val store = MessageStore()
88+ store.reduce(session, msgUpdated("m1"))
89+ val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
90+ assertTrue(changed)
91+ val parts = store.snapshot().single().parts
92+ assertEquals(1, parts.size)
93+ assertEquals("hello", (parts[0] as TextPart).text)
94+ }
95+
96+ @Test
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)
103+ }
104+
105+ @Test
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)
113+ }
114+
115+ @Test
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 })
123+ }
124+
125+ @Test
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())
131+ }
132+
133+ // --- MessagePartRemoved ---
134+
135+ @Test
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"))
141+ assertTrue(changed)
142+ val parts = store.snapshot().single().parts
143+ assertEquals(listOf("p2"), parts.map { it.id })
144+ }
145+
146+ @Test
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)
152+ }
153+
154+ // --- MessageRemoved ---
155+
156+ @Test
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"))
162+ assertTrue(changed)
163+ assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
164+ }
165+
166+ @Test
167+ fun messageRemovedReturnsFalseWhenMissing() {
168+ val store = MessageStore()
169+ val changed = store.reduce(session, messageRemoved("nope"))
170+ assertFalse(changed)
171+ }
172+
173+ // --- unrelated events ---
174+
175+ @Test
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)))
181+ assertFalse(idle)
182+ assertFalse(error)
183+ assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
184+ }
185+
186+ // --- end-to-end ordering ---
187+
188+ @Test
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"))
196+
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)
202+ }
203+}