package pw.binom import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import pw.binom.agentik.content.Content import pw.binom.agentik.cursor.Cursor import pw.binom.db.ksqlite.SQLiteConnection import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant private const val EPOCH = 1_700_000_000_000L /** * Полный набор тестов [MessageStore] на in-memory SQLite: все ветки push, проекция, * курсорная фильтрация, слияние двух таблиц, пагинация, каскадное удаление, откат транзакции, * границы журнала и round-trip содержимого. */ class MessageStoreTest { private var counter = 0UL private val t0 = Instant.fromEpochMilliseconds(EPOCH) private fun nextCursor() = Cursor(createdAt = EPOCH, offset = ++counter) private fun cursorAt(offset: ULong) = Cursor(createdAt = EPOCH, offset = offset) private suspend fun withStore(block: suspend (MessageStore) -> Unit) { val conn = SQLiteConnection.memory("message-store-${kotlin.random.Random.nextLong()}") val store = MessageStore(connection = conn, ownsConnection = false) try { block(store) } finally { store.close() conn.close() } } // ---- пустое хранилище ------------------------------------------------------------------- @Test fun emptyStoreHasNoStateAndEmptyStreams() = runTest { withStore { store -> assertNull(store.minCursor) assertNull(store.maxCursor) assertTrue(store.getState(nextCursor()).toList().isEmpty()) assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty()) } } @Test fun clearOnEmptyStoreIsNoop() = runTest { withStore { store -> store.clear() assertNull(store.minCursor) assertNull(store.maxCursor) assertTrue(store.getState(nextCursor()).toList().isEmpty()) } } // ---- курсоры --------------------------------------------------------------------------- @Test fun minAndMaxCursorTrackPushes() = runTest { withStore { store -> assertNull(store.minCursor) assertNull(store.maxCursor) store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(3u)) assertEquals(cursorAt(3u), store.minCursor) assertEquals(cursorAt(3u), store.maxCursor) store.push(MessageEvent.RenameConversation("c", "T2"), cursorAt(7u)) assertEquals(cursorAt(3u), store.minCursor) assertEquals(cursorAt(7u), store.maxCursor) } } @Test fun pushRejectsNonIncreasingCursorAndRollsBackProjection() = runTest { withStore { store -> store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("a"))), cursorAt(5u)) assertFailsWith { store.push( MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))), cursorAt(5u), ) } assertFailsWith { store.push( MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))), cursorAt(4u), ) } // Проекция откатилась: только первое сообщение, курсор на месте. assertEquals( listOf(MessageRecord.UserMessage("m1", t0, listOf(Content.Text("a")), "c")), store.getState(cursorAt(5u)).toList(), ) assertEquals(cursorAt(5u), store.maxCursor) } } @Test fun clearResetsCursorSoLowerCursorAccepted() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(9u)) store.clear() assertNull(store.minCursor) assertNull(store.maxCursor) store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u)) assertEquals(cursorAt(1u), store.minCursor) assertEquals(cursorAt(1u), store.maxCursor) } } // ---- беседа: create / rename / delete -------------------------------------------------- @Test fun conversationLifecycleUpdatesCursorOnChange() = runTest { withStore { store -> val created = nextCursor() store.push(MessageEvent.CreateConversation("c1", "First"), created) assertEquals( listOf(MessageRecord.Conversation("c1", "First")), store.getState(created).toList(), ) val renamed = nextCursor() store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed) assertEquals( listOf(MessageRecord.Conversation("c1", "Renamed")), store.getState(renamed).toList(), ) // Изменённая позже строка выпадает из состояния на более раннем курсоре. assertTrue(store.getState(created).toList().isEmpty()) val deleted = nextCursor() store.push(MessageEvent.DeleteConversation("c1"), deleted) assertTrue(store.getState(deleted).toList().isEmpty()) } } @Test fun deletingConversationRemovesItsMessages() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("a", "A"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor()) store.push(MessageEvent.CreateConversation("b", "B"), nextCursor()) store.push(MessageEvent.AddUserMessage("m2", t0, "b", listOf(Content.Text("2"))), nextCursor()) store.push(MessageEvent.DeleteConversation("a"), nextCursor()) // Сообщения удалённой беседы не остаются сиротами в состоянии. assertEquals( listOf( MessageRecord.Conversation("b", "B"), MessageRecord.UserMessage("m2", t0, listOf(Content.Text("2")), "b"), ), store.getState(store.maxCursor!!).toList(), ) } } @Test fun deletingConversationKeepsJournalEvents() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("a", "A"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor()) store.push(MessageEvent.DeleteConversation("a"), nextCursor()) // Журнал append-only: удаление проекции не трогает историю событий. val events = store.get(cursorAt(1u)).getOrException().toList() assertEquals(3, events.size) assertTrue(events.last().data is MessageEvent.DeleteConversation) } } @Test fun deletingUnknownConversationIsNoop() = runTest { withStore { store -> store.push(MessageEvent.DeleteConversation("nope"), nextCursor()) assertTrue(store.getState(store.maxCursor!!).toList().isEmpty()) } } // ---- сообщения: все типы + содержимое -------------------------------------------------- @Test fun allMessageTypesInCursorOrder() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor()) store.push(MessageEvent.AddAssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))), nextCursor()) store.push(MessageEvent.AddToolCall("m3", t0, "c", "search", "{}"), nextCursor()) store.push(MessageEvent.AddToolResponse("m4", t0, "c", "m3", "ok"), nextCursor()) store.push(MessageEvent.AddToolFail("m5", t0, "c", "m3", "err"), nextCursor()) assertEquals( listOf( MessageRecord.Conversation("c", "T"), MessageRecord.UserMessage("m1", t0, listOf(Content.Text("hi")), "c"), MessageRecord.AssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))), MessageRecord.ToolCall("m3", t0, "c", "search", "{}"), MessageRecord.ToolResponse("m4", t0, "c", "m3", "ok"), MessageRecord.ToolFail("m5", t0, "c", "m3", "err"), ), store.getState(store.maxCursor!!).toList(), ) } } @Test fun imageContentRoundTrips() = runTest { withStore { store -> val image = Content.Image(byteArrayOf(1, 2, 3, -1, 0, 7), "image/png") store.push( MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("see"), image)), nextCursor(), ) assertEquals( listOf( MessageRecord.UserMessage("m", t0, listOf(Content.Text("see"), image), "c"), ), store.getState(store.maxCursor!!).toList(), ) } } @Test fun duplicateMessageIdIsReplacedAtLaterCursor() = runTest { withStore { store -> store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v1"))), nextCursor()) store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v2"))), nextCursor()) assertEquals( listOf(MessageRecord.UserMessage("m", t0, listOf(Content.Text("v2")), "c")), store.getState(store.maxCursor!!).toList(), ) } } // ---- состояние: фильтр по курсору и слияние -------------------------------------------- @Test fun stateIsFilteredByCursor() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("1"))), nextCursor()) store.push(MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("2"))), nextCursor()) assertEquals(3, store.getState(cursorAt(3u)).toList().size) assertEquals(2, store.getState(cursorAt(2u)).toList().size) assertTrue(store.getState(cursorAt(0u)).toList().isEmpty()) } } @Test fun stateCursorAboveMaxReturnsEverything() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("x"))), nextCursor()) assertEquals(2, store.getState(cursorAt(999u)).toList().size) // Курсор из другой (более старой) эпохи не видит строк текущей. assertTrue(store.getState(Cursor(EPOCH - 1, 999u)).toList().isEmpty()) } } @Test fun interleavedTablesMergeInCursorOrder() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("a", "A"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("x"))), nextCursor()) store.push(MessageEvent.CreateConversation("b", "B"), nextCursor()) store.push(MessageEvent.AddAssistantMessage("m2", t0, "b", listOf(Content.Text("y"))), nextCursor()) assertEquals( listOf( MessageRecord.Conversation("a", "A"), MessageRecord.UserMessage("m1", t0, listOf(Content.Text("x")), "a"), MessageRecord.Conversation("b", "B"), MessageRecord.AssistantMessage("m2", t0, "b", listOf(Content.Text("y"))), ), store.getState(store.maxCursor!!).toList(), ) } } // ---- пагинация ------------------------------------------------------------------------- @Test fun getStatePaginatesAcrossPageBoundary() = runTest { withStore { store -> repeat(100) { i -> store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor()) } assertEquals(100, store.getState(store.maxCursor!!).toList().size) store.push(MessageEvent.CreateConversation("c100", "T100"), nextCursor()) assertEquals(101, store.getState(store.maxCursor!!).toList().size) } } @Test fun interleavedTablesMergeAcrossPages() = runTest { withStore { store -> repeat(120) { i -> store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor()) store.push(MessageEvent.AddUserMessage("m$i", t0, "c$i", listOf(Content.Text("$i"))), nextCursor()) } val records = store.getState(store.maxCursor!!).toList() assertEquals(240, records.size) for (i in 0 until 120) { assertEquals( MessageRecord.Conversation("c$i", "T$i"), records[i * 2], ) assertEquals( MessageRecord.UserMessage("m$i", t0, listOf(Content.Text("$i")), "c$i"), records[i * 2 + 1], ) } } } // ---- журнал: get / expired ------------------------------------------------------------- @Test fun journalKeepsEventsInOrder() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u)) store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(2u)) val events = store.get(cursorAt(1u)).getOrException().toList() assertEquals(listOf(1uL, 2uL), events.map { it.seq.offset }) assertTrue(events[0].data is MessageEvent.CreateConversation) assertTrue(events[1].data is MessageEvent.AddUserMessage) } } @Test fun journalGetIsInclusiveAtMinAndExpiredBelow() = runTest { withStore { store -> store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(5u)) val atMin = store.get(cursorAt(5u)).getOrException().toList() assertEquals(1, atMin.size) assertEquals(5uL, atMin[0].seq.offset) assertTrue(store.get(cursorAt(1u)).isExpired) } } // ---- clear ----------------------------------------------------------------------------- @Test fun clearRemovesStateAndJournal() = runTest { withStore { store -> store.push(MessageEvent.CreateConversation("c", "T"), nextCursor()) store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor()) store.clear() assertNull(store.minCursor) assertNull(store.maxCursor) assertTrue(store.getState(nextCursor()).toList().isEmpty()) assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty()) } } }