package pw.binom.agentik.desktop.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.runComposeUiTest import pw.binom.agentik.desktop.media.AvatarStorage import pw.binom.agentik.desktop.model.UiConversation import pw.binom.agentik.desktop.model.UNTITLED_CONVERSATION import pw.binom.agentik.desktop.ui.components.LocalAvatarStorage import pw.binom.agentik.desktop.ui.screens.DialogInfoDialog import pw.binom.agentik.desktop.ui.screens.formatDateTime import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme import pw.binom.agentik.desktop.ui.theme.darkAgentikColors import pw.binom.agentik.desktop.ui.theme.toAgentikTheme import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant /** * Модалка «Параметры диалога»: контракт и регрессии. * * Покрывает: * - формат даты ([formatDateTime] — dd.MM.yyyy HH:mm, в системной тайм-зоне); * - базовые секции присутствуют (название, агент, тип, даты, опасная зона); * - кнопка ✎ переключает в режим редактирования, имя сохраняется через cb; * - «безымянный» диалог (title == UNTITLED_CONVERSATION) сразу в edit-режиме; * - чекбокс «Временный» отображает состояние, менять нельзя; * - кнопка «Удалить диалог» триггерит onDelete; * - ошибка сохранения показывает текст + переименовывает кнопку в «Повторить». */ class DialogInfoDialogTest { // ── формат даты ───────────────────────────────────────────────────────── @Test fun `formatDateTime renders dd MM yyyy HH mm`() { val instant = Instant.parse("2026-10-05T11:30:00Z") val rendered = formatDateTime(instant) assertTrue( rendered.matches(Regex("""\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}""")), "формат должен быть dd.MM.yyyy HH:mm, а получилось: $rendered", ) } @Test fun `formatDateTime applies system zone offset`() { // 2026-10-05 11:30 UTC -> в Москве (UTC+3, без перевода часов) 14:30. // Проверяем, что форматирование учитывает системную тайм-зону — иначе // час был бы равен UTC-часу в любой системе. val instant = Instant.parse("2026-10-05T11:30:00Z") val rendered = formatDateTime(instant) val zone = java.time.ZoneId.systemDefault() val offsetSeconds = zone.rules.getOffset(java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())) .totalSeconds val expectedHour = ((11 + offsetSeconds / 3600) + 24) % 24 // Проверяем, что в строке встречается « <час>:» — тогда смещение применено. assertTrue( rendered.contains(" ${expectedHour.toString().padStart(2, '0')}:"), "должно учитываться смещение $zone (UTC${offsetSeconds / 3600} ч): ожидался час $expectedHour, получилось $rendered", ) } // ── рендер ───────────────────────────────────────────────────────────── @OptIn(ExperimentalTestApi::class) @Test fun `dialog renders all required sections`() = runComposeUiTest { val conversation = sampleConversation(title = "Spring Boot · настройка логов") setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) { DialogInfoDialog( conversation = conversation, agentName = "agentik", agentAddress = "http://192.168.88.135:8080/agentik", agentColor = Color(0xFF6AB2F2), online = true, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } } // Заголовки секций onNodeWithText("Название").assertTextEquals("Название") onNodeWithText("Агент").assertTextEquals("Агент") onNodeWithText("Временный диалог").assertTextEquals("Временный диалог") onNodeWithText("Создан").assertTextEquals("Создан") onNodeWithText("Обновлён").assertTextEquals("Обновлён") onNodeWithText("Опасное").assertTextEquals("Опасное") // Имя диалога (крупно) и имя агента onNodeWithText("Spring Boot · настройка логов").assertTextEquals("Spring Boot · настройка логов") onNodeWithText("agentik").assertTextEquals("agentik") onNodeWithText("http://192.168.88.135:8080/agentik").assertTextEquals("http://192.168.88.135:8080/agentik") // Статус «на связи» onNodeWithText("на связи").assertTextEquals("на связи") // Кнопка «Закрыть» в футере onNodeWithText("Закрыть").assertTextEquals("Закрыть") // Кнопка «Удалить диалог» onNodeWithText("Удалить диалог").assertTextEquals("Удалить диалог") } @OptIn(ExperimentalTestApi::class) @Test fun `untitled conversation opens in edit mode by default`() = runComposeUiTest { // Конвенция: `title == UNTITLED_CONVERSATION` означает «сервер не дал имя», // и модалка сразу переходит в edit-режим. val conversation = sampleConversation(title = UNTITLED_CONVERSATION) setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = "agentik", agentAddress = "http://x:8080/agentik", agentColor = null, online = true, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } // Режим редактирования открыт: видна подсказка «До 120 символов», // кнопка «Отмена» (а не «Закрыть»). onNodeWithText("До 120 символов").assertTextEquals("До 120 символов") onNodeWithText("Отмена").assertTextEquals("Отмена") } @OptIn(ExperimentalTestApi::class) @Test fun `pencil button enters edit mode and saving empty title passes null to onRename`() = runComposeUiTest { val conversation = sampleConversation(title = "Spring Boot") var renamedTo: String? = "sentinel" setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { newTitle, cb -> renamedTo = newTitle cb(Result.success(Unit)) }, onDelete = {}, ) } } } // Режим просмотра → жмём ✎ → режим редактирования. onNodeWithText("✎").performClick() onNodeWithText("До 120 символов").assertTextEquals("До 120 символов") // Стираем имя — пустое допустимо: сервер получит null, клиентский // маппер отрисует его как UNTITLED_CONVERSATION. onNodeWithText("Spring Boot").performTextReplacement("") onNodeWithText("Сохранить").performClick() assertNull(renamedTo, "пустое имя → null в onRename") } @OptIn(ExperimentalTestApi::class) @Test fun `saving unchanged title does NOT call onRename`() = runComposeUiTest { val conversation = sampleConversation(title = "Spring Boot") var renameCalled = false setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, _ -> renameCalled = true }, onDelete = {}, ) } } } onNodeWithText("✎").performClick() // Не меняем — жмём «Сохранить»: оптимизация — сетевого вызова быть не должно. onNodeWithText("Сохранить").performClick() assertFalse(renameCalled, "если имя не меняли, onRename вызываться не должен") } @OptIn(ExperimentalTestApi::class) @Test fun `temporal dialog shows short hint`() = runComposeUiTest { val conversation = sampleConversation(title = "Черновик ТЗ", temporal = true) setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = "coder", agentAddress = "http://coder:8080/agentik", agentColor = Color(0xFFC9A886), online = true, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } onNodeWithText("Временный диалог").assertTextEquals("Временный диалог") } @OptIn(ExperimentalTestApi::class) @Test fun `regular dialog shows long hint about immutability`() = runComposeUiTest { val conversation = sampleConversation(title = "Обычный", temporal = false) setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } onNodeWithText("Временный диалог").assertTextEquals("Временный диалог") } @OptIn(ExperimentalTestApi::class) @Test fun `rename failure shows error and retry label`() = runComposeUiTest { val conversation = sampleConversation(title = "Имя") setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { var failedOnce by remember { mutableStateOf(false) } DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, cb -> if (!failedOnce) { cb(Result.failure(RuntimeException("агент не отвечает"))) failedOnce = true } else { cb(Result.success(Unit)) } }, onDelete = {}, ) } } } onNodeWithText("✎").performClick() // Вводим реально новое имя, иначе оптимизация «имя не менялось» не пустит запрос. onNodeWithText("Имя").performTextReplacement("Новое имя") onNodeWithText("Сохранить").performClick() onNodeWithText("агент не отвечает").assertTextEquals("агент не отвечает") onNodeWithText("Повторить").assertTextEquals("Повторить") } @OptIn(ExperimentalTestApi::class) @Test fun `delete button fires onDelete`() = runComposeUiTest { val conversation = sampleConversation(title = "X") var deleted = false setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, _ -> }, onDelete = { deleted = true }, ) } } } onNodeWithText("Удалить диалог").performClick() assertTrue(deleted, "onDelete должен сработать") } @OptIn(ExperimentalTestApi::class) @Test fun `pixels include red from delete button in dark theme`() = runComposeUiTest { val conversation = sampleConversation(title = "X") setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } } val histogram = pixelHistogram(onRoot().captureToImage().toPixelMap()) val dangerPresent = histogram.keys.any { c -> c.red > 0.7f && c.green < 0.4f && c.blue < 0.4f } assertTrue( dangerPresent, "в гистограмме должен быть пиксель красного: ${histogram.entries.sortedByDescending { it.value }.take(3)}", ) } /** * Регрессия на вертикальное центрирование подписи «Временный диалог» * относительно квадратика чекбокса. Рендерит строку в битмап и сравнивает * вертикальные центры синего квадратика (accent) и белых глифов текста. */ @OptIn(ExperimentalTestApi::class) @Test fun `temporal checkbox label is vertically centered with the box`() = runComposeUiTest { val conversation = sampleConversation(title = "X", temporal = true) setContent { withAvatarStorage { ProvideAgentikTheme("dark".toAgentikTheme()) { Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) { DialogInfoDialog( conversation = conversation, agentName = null, agentAddress = null, agentColor = null, online = false, onClose = {}, onRename = { _, _ -> }, onDelete = {}, ) } } } } val pixels = onNodeWithTag("temporal-checkbox").captureToImage().toPixelMap() var sqMinY = Int.MAX_VALUE; var sqMaxY = -1 var sqMaxX = -1 var txMinY = Int.MAX_VALUE; var txMaxY = -1 var sawText = false for (y in 0 until pixels.height) { for (x in 0 until pixels.width) { val c = pixels[x, y] val isAccent = kotlin.math.abs(c.red - 0.416f) < 0.12f && kotlin.math.abs(c.green - 0.698f) < 0.12f && kotlin.math.abs(c.blue - 0.949f) < 0.12f if (isAccent) { if (y < sqMinY) sqMinY = y if (y > sqMaxY) sqMaxY = y if (x > sqMaxX) sqMaxX = x } } } // Текст ищем только правее квадратика, чтобы ✓ внутри квадрата не мешал. for (y in 0 until pixels.height) { for (x in (sqMaxX + 3) until pixels.width) { val c = pixels[x, y] val isText = c.red > 0.55f && c.green > 0.55f && c.blue > 0.55f && (maxOf(c.red, c.green, c.blue) - minOf(c.red, c.green, c.blue)) < 0.2f if (isText) { sawText = true if (y < txMinY) txMinY = y if (y > txMaxY) txMaxY = y } } } assertTrue(sawText, "глифы подписи не найдены в битмапе") val sqCenter = (sqMinY + sqMaxY) / 2f val txCenter = (txMinY + txMaxY) / 2f val delta = txCenter - sqCenter assertTrue( kotlin.math.abs(delta) <= 1.0f, "центр подписи (y=$txCenter) должен совпадать с центром квадратика " + "(y=$sqCenter); смещение=${delta}px (sqY=$sqMinY..$sqMaxY, txY=$txMinY..$txMaxY)", ) } // ── helpers ──────────────────────────────────────────────────────────── private fun sampleConversation( title: String = "Диалог", temporal: Boolean = false, ): UiConversation = UiConversation( id = "conv-1", title = title, isTemporal = temporal, createdAt = Instant.parse("2026-10-05T11:30:00Z"), updatedAt = Instant.parse("2026-10-05T11:31:00Z"), groupId = null, preview = null, unread = 0, ) /** * Оборачивает [content] в провайдер [AvatarStorage] с временной папкой. */ @Composable private fun withAvatarStorage(content: @Composable () -> Unit) { CompositionLocalProvider(LocalAvatarStorage provides AvatarStorage()) { content() } } private fun pixelHistogram(pixels: androidx.compose.ui.graphics.PixelMap): Map { val out = HashMap() for (y in 0 until pixels.height) { for (x in 0 until pixels.width) { val c = pixels[x, y] out[c] = (out[c] ?: 0) + 1 } } return out } }