479 lines
21 KiB
Kotlin
479 lines
21 KiB
Kotlin
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
|
||
}
|
||
}
|