Добавлен экран информации о диалоге: шапка с кнопкой ⋯, окно с редактированием названия, отображением агента, даты создания/обновления, признака временности и кнопкой удаления.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
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.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)}",
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user