Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/DialogInfoDialogTest.kt
T

479 lines
21 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Color, Int> {
val out = HashMap<Color, Int>()
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
}
}