Добавлены тесты рендера и стилизации Markdown, реализован MarkdownStyle для самостоятельного управления стилями.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package pw.binom.agentik.desktop.ui
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.agentik.desktop.ui.theme.AgentikColors
|
||||
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
|
||||
|
||||
/**
|
||||
* Стиль отрисовки markdown: цвета, шрифты и отступы, из которых рендер
|
||||
* ([renderMarkdownSegments]) берёт решительно всё.
|
||||
*
|
||||
* Зачем отдельный контракт: рендер не должен знать ни про тему приложения, ни
|
||||
* про конкретные числа. Раньше он получал только `textColor` + `fontSize`, а
|
||||
* остальное (фон код-блока, цвет ссылки, отступы, фон таблицы) тянул из
|
||||
* `AgentikTheme`/`MaterialTheme` напрямую — из-за чего часть слотов жила вне
|
||||
* темы, а тело сообщения могло уехать в цвет по умолчанию (чёрный).
|
||||
*
|
||||
* Реализация под наш клиент — [AgentikMarkdownStyle]; текущий стиль лежит в
|
||||
* [LocalMarkdownStyle] (его подкладывает `AgentikTheme`), поэтому вызову
|
||||
* достаточно `renderMarkdownSegments(content)`.
|
||||
*/
|
||||
@Immutable
|
||||
interface MarkdownStyle {
|
||||
/** Цвет основного текста сообщения (в тёмной теме — светлый). */
|
||||
val textColor: Color
|
||||
|
||||
/** Кегль тела сообщения. */
|
||||
val bodyFontSize: TextUnit
|
||||
val bodyFontFamily: FontFamily
|
||||
|
||||
/** Интервал строк тела; [TextUnit.Unspecified] — на усмотрение движка. */
|
||||
val bodyLineHeight: TextUnit
|
||||
|
||||
/** Ссылки: цвет + подчёркивание. */
|
||||
val linkColor: Color
|
||||
|
||||
/** Inline-код: `code`. */
|
||||
val monospaceFamily: FontFamily
|
||||
val inlineCodeFontSize: TextUnit
|
||||
val inlineCodeBackground: Color
|
||||
|
||||
/** Блок кода (``` … ```). */
|
||||
val codeBackground: Color
|
||||
val codeTextColor: Color
|
||||
val codeHeaderBackground: Color
|
||||
val codeFontSize: TextUnit
|
||||
|
||||
/** Таблица. */
|
||||
val tableSurface: Color
|
||||
val tableHeaderBackground: Color
|
||||
val tableStripeBackground: Color
|
||||
val tableFontSize: TextUnit
|
||||
|
||||
/** Мелкие подписи (язык код-блока, иконка копирования) и разделитель `---`. */
|
||||
val labelColor: Color
|
||||
val labelFontSize: TextUnit
|
||||
val divider: Color
|
||||
|
||||
/** Отступы. */
|
||||
val paragraphSpacing: Dp
|
||||
val blockSpacing: Dp
|
||||
val tableSpacing: Dp
|
||||
val codePadding: Dp
|
||||
val codeHeaderPaddingHorizontal: Dp
|
||||
val codeHeaderPaddingVertical: Dp
|
||||
val tableCellPaddingHorizontal: Dp
|
||||
val tableCellPaddingVertical: Dp
|
||||
val copyIconSize: Dp
|
||||
|
||||
/** Ширина контента, когда сообщение лежит в горизонтально скроллящемся пузыре. */
|
||||
val maxContentWidth: Dp
|
||||
}
|
||||
|
||||
/**
|
||||
* База, накладываемая на ВЕСЬ текстовый сегмент.
|
||||
*
|
||||
* Парсер заводит стилевые спаны только для размеченных участков (жирный/код/
|
||||
* ссылка), поэтому без этой базы «простой» текст остаётся без цвета и рисуется
|
||||
* цветом по умолчанию (чёрным) — это и был баг «текст чёрный вместо белого».
|
||||
*
|
||||
* Именно функция, а не свойство интерфейса: с делегированием
|
||||
* (`object : MarkdownStyle by otherStyle`) свойство с реализацией по умолчанию
|
||||
* уезжает в делегат и перекрытый `textColor` в базе не учитывается.
|
||||
* [bodyLineHeight] сюда не входит — интервал строк задаётся у абзаца
|
||||
* (`Text(lineHeight = …)`), в `SpanStyle` такого слота нет.
|
||||
*/
|
||||
fun MarkdownStyle.bodySpan(): SpanStyle = SpanStyle(
|
||||
color = textColor,
|
||||
fontSize = bodyFontSize,
|
||||
fontFamily = bodyFontFamily,
|
||||
)
|
||||
|
||||
/**
|
||||
* Наш стиль: цвета — из [AgentikColors] активной темы, типографика и отступы —
|
||||
* из макетов клиента (`sketches/index.html`).
|
||||
*/
|
||||
@Immutable
|
||||
class AgentikMarkdownStyle(private val colors: AgentikColors) : MarkdownStyle {
|
||||
override val textColor: Color get() = colors.text
|
||||
|
||||
override val bodyFontSize: TextUnit = 13.5.sp
|
||||
override val bodyFontFamily: FontFamily = FontFamily.Default
|
||||
override val bodyLineHeight: TextUnit = 20.sp
|
||||
|
||||
override val linkColor: Color get() = colors.accent
|
||||
|
||||
override val monospaceFamily: FontFamily = FontFamily.Monospace
|
||||
override val inlineCodeFontSize: TextUnit = 13.sp
|
||||
override val inlineCodeBackground: Color get() = colors.panelElevated
|
||||
|
||||
override val codeBackground: Color get() = colors.codeBackground
|
||||
override val codeTextColor: Color get() = colors.codeText
|
||||
override val codeHeaderBackground: Color get() = colors.panelElevated
|
||||
override val codeFontSize: TextUnit = 13.sp
|
||||
|
||||
override val tableSurface: Color get() = colors.panel.copy(alpha = 0.3f)
|
||||
override val tableHeaderBackground: Color get() = colors.accent.copy(alpha = 0.1f)
|
||||
override val tableStripeBackground: Color get() = colors.text.copy(alpha = 0.03f)
|
||||
override val tableFontSize: TextUnit = 13.5.sp
|
||||
|
||||
override val labelColor: Color get() = colors.textDim
|
||||
override val labelFontSize: TextUnit = 12.sp
|
||||
override val divider: Color get() = colors.text.copy(alpha = 0.25f)
|
||||
|
||||
override val paragraphSpacing: Dp = 12.dp
|
||||
override val blockSpacing: Dp = 4.dp
|
||||
override val tableSpacing: Dp = 6.dp
|
||||
override val codePadding: Dp = 12.dp
|
||||
override val codeHeaderPaddingHorizontal: Dp = 12.dp
|
||||
override val codeHeaderPaddingVertical: Dp = 6.dp
|
||||
override val tableCellPaddingHorizontal: Dp = 8.dp
|
||||
override val tableCellPaddingVertical: Dp = 6.dp
|
||||
override val copyIconSize: Dp = 16.dp
|
||||
|
||||
override val maxContentWidth: Dp = 600.dp
|
||||
}
|
||||
|
||||
/** Стиль по умолчанию — тёмная тема (эталонная); совпадает с дефолтом [AgentikColors]. */
|
||||
private val DefaultMarkdownStyle: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
|
||||
|
||||
/**
|
||||
* Текущий стиль markdown. Подкладывается `AgentikTheme`, поэтому экраны читают
|
||||
* его неявно: `renderMarkdownSegments(content)`.
|
||||
*/
|
||||
val LocalMarkdownStyle = staticCompositionLocalOf<MarkdownStyle> { DefaultMarkdownStyle }
|
||||
@@ -0,0 +1,136 @@
|
||||
package pw.binom.agentik.desktop.net
|
||||
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.desktop.settings.AgentConfig
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import pw.binom.agentik.server.agentikAgent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Проверка связи не должна иметь побочных эффектов: единственный запрос —
|
||||
* информация об агенте (`GET {baseUrl}`), никаких пробных диалогов.
|
||||
*
|
||||
* Регрессия: раньше после ответа сервера чекер создавал временный диалог и
|
||||
* удалял его — сервер получал мусорную запись `ConversationRecord`, а
|
||||
* пользователь, проверяя адрес, оставлял след в истории агента.
|
||||
*/
|
||||
class ConnectionCheckerTest {
|
||||
|
||||
private lateinit var server: io.ktor.server.engine.EmbeddedServer<*, *>
|
||||
private var port: Int = 0
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
if (::server.isInitialized) server.stop(100, 300)
|
||||
}
|
||||
|
||||
private fun startServer(agent: Agent) {
|
||||
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
|
||||
routing { agentikAgent(agent, path = "/agentik", token = null) }
|
||||
}.start(wait = false)
|
||||
port = runBlocking { server.engine.resolvedConnectors().first().port }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check asks for agent info and creates no conversation`() = runBlocking {
|
||||
val stub = StubAgent()
|
||||
startServer(stub)
|
||||
|
||||
val report = ConnectionChecker().check(
|
||||
config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"),
|
||||
clientId = "checker-test",
|
||||
)
|
||||
|
||||
assertTrue(report.ok, "живой сервер обязан пройти проверку: ${report.steps}")
|
||||
assertEquals(1, report.steps.size, "ровно один шаг — запрос информации: ${report.steps}")
|
||||
assertEquals(ConnectionChecker.STEP_AGENT_INFO, report.steps.single().title)
|
||||
assertEquals("Стенд", report.agentName, "имя агента берётся из AgentInfo")
|
||||
assertEquals(0, stub.created, "проверка связи НЕ должна создавать диалог")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check fails on an unreachable agent`() = runBlocking {
|
||||
val report = ConnectionChecker().check(
|
||||
config = AgentConfig.create(baseUrl = "http://127.0.0.1:1/agentik"),
|
||||
clientId = "checker-test",
|
||||
)
|
||||
|
||||
assertTrue(!report.ok, "мёртвый адрес не должен проходить проверку: $report")
|
||||
assertEquals(1, report.steps.size)
|
||||
assertTrue(!report.steps.single().ok, "шаг помечен неуспешным")
|
||||
assertNull(report.agentName, "имя неизвестно, если агент не ответил")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный агент для стенда: [ConnectionChecker] ходит только за
|
||||
* [Agent.info], поэтому всё остальное — заглушки, а [createConversation]
|
||||
* считает вызовы, чтобы тест поймал попытку создать пробный диалог.
|
||||
*/
|
||||
private class StubAgent : Agent {
|
||||
|
||||
override val id: String = "checker-stub"
|
||||
|
||||
override val info: AgentInfo = AgentInfo(
|
||||
name = "Стенд",
|
||||
description = "Агент для теста проверки связи",
|
||||
)
|
||||
|
||||
override val journal = InMemoryJournalStore()
|
||||
|
||||
override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
|
||||
private val store = InMemoryMutableConversationStore()
|
||||
|
||||
override val conversationStore: ConversationStore get() = store
|
||||
|
||||
/** Сколько раз кто-то пытался создать диалог (должно остаться нулём). */
|
||||
var created: Int = 0
|
||||
private set
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
created++
|
||||
val now = Clock.System.now()
|
||||
return StubConversation("stub-$created", now)
|
||||
}
|
||||
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
|
||||
override suspend fun deleteConversation(id: String): Boolean = false
|
||||
|
||||
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
private class StubConversation(override val id: String, createdAt: Instant) : Conversation {
|
||||
override val isSupportImageInput: Boolean = false
|
||||
override val isSupportImageOutput: Boolean = false
|
||||
override val isTemporal: Boolean = false
|
||||
override val title: String? = null
|
||||
override val updatedAt: Instant = createdAt
|
||||
|
||||
override suspend fun rename(title: String) {}
|
||||
override suspend fun send(content: List<Content>, context: MessageContext?) {}
|
||||
override suspend fun interrupt() {}
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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.CompositionLocalProvider
|
||||
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.captureToImage
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.agentik.desktop.markdown.MarkdownSegment
|
||||
import pw.binom.agentik.desktop.markdown.parseMarkdown
|
||||
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||||
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
|
||||
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
|
||||
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
|
||||
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Стиль отрисовки markdown: [MarkdownStyle] + [AgentikMarkdownStyle].
|
||||
*
|
||||
* Регрессия, ради которой всё это: парсер заводит спаны только для размеченных
|
||||
* участков (жирный/код/ссылка), а база накладывалась ВНУТРИ спанов — поэтому
|
||||
* «простой» текст оставался без цвета и рисовался чёрным (дефолт
|
||||
* `LocalContentColor`) вместо цвета темы.
|
||||
*/
|
||||
class MarkdownStyleTest {
|
||||
|
||||
private val darkStyle = AgentikMarkdownStyle(darkAgentikColors)
|
||||
|
||||
// ── цвет текста ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `plain text is painted with the style text color`() {
|
||||
val segment = parseMarkdown("Просто текст без разметки").single() as MarkdownSegment.Text
|
||||
assertTrue(segment.spans.isEmpty(), "в простом тексте нет стилевых спанов")
|
||||
|
||||
val annotated = segment.toAnnotatedString(darkStyle)
|
||||
val base = annotated.spanStyles.singleOrNull()
|
||||
assertNotNull(base, "база накладывается на весь текст, даже без спанов")
|
||||
assertEquals(0, base.start)
|
||||
assertEquals(segment.text.length, base.end)
|
||||
assertEquals(darkAgentikColors.text, base.item.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `colors come from the theme`() {
|
||||
val light = AgentikMarkdownStyle(lightAgentikColors)
|
||||
|
||||
assertEquals(darkAgentikColors.text, darkStyle.textColor)
|
||||
assertEquals(darkAgentikColors.accent, darkStyle.linkColor)
|
||||
assertEquals(darkAgentikColors.codeBackground, darkStyle.codeBackground)
|
||||
assertEquals(darkAgentikColors.codeText, darkStyle.codeTextColor)
|
||||
assertEquals(darkAgentikColors.panelElevated, darkStyle.inlineCodeBackground)
|
||||
|
||||
assertEquals(lightAgentikColors.text, light.textColor)
|
||||
assertEquals(lightAgentikColors.codeBackground, light.codeBackground)
|
||||
|
||||
// стиль реально зависит от темы, а не хардкодит тёмные значения
|
||||
assertNotEquals(darkStyle.textColor, light.textColor)
|
||||
assertNotEquals(darkStyle.codeBackground, light.codeBackground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline code and links take their slots from the style`() {
|
||||
val segment = parseMarkdown("код `val x` и [ссылка](http://example.com)")
|
||||
.single() as MarkdownSegment.Text
|
||||
val annotated = segment.toAnnotatedString(darkStyle)
|
||||
|
||||
val code = annotated.spanStyles.firstOrNull {
|
||||
it.item.fontFamily == FontFamily.Monospace && it.item.background == darkAgentikColors.panelElevated
|
||||
}
|
||||
assertNotNull(code, "inline-код берёт моноширинный шрифт и фон из стиля")
|
||||
|
||||
val link = annotated.spanStyles.firstOrNull {
|
||||
it.item.color == darkAgentikColors.accent && it.item.textDecoration == TextDecoration.Underline
|
||||
}
|
||||
assertNotNull(link, "ссылка берёт цвет из стиля")
|
||||
|
||||
val url = annotated.getStringAnnotations("URL", 0, annotated.length).singleOrNull()
|
||||
assertNotNull(url, "ссылка помечается аннотацией URL")
|
||||
assertEquals("http://example.com", url.item)
|
||||
}
|
||||
|
||||
// ── рендер ─────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun `plain text renders light, never black`() = runComposeUiTest {
|
||||
// Крупный кегль — чтобы в гистограмме гарантированно были пиксели,
|
||||
// полностью залитые цветом текста (не только сглаженные края).
|
||||
val big = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) {
|
||||
override val bodyFontSize = 30.sp
|
||||
}
|
||||
setContent {
|
||||
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) {
|
||||
renderMarkdownSegments("Текст сообщения", style = big)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val histogram = onRoot().captureToImage().toPixelMap().histogram()
|
||||
assertTrue(
|
||||
histogram.keys.any { it.red > 0.8f && it.green > 0.8f && it.blue > 0.8f },
|
||||
"текст должен быть светлым (цвет темы ${darkAgentikColors.text}), а не чёрным: ${histogram.top()}",
|
||||
)
|
||||
assertFalse(
|
||||
histogram.containsKey(Color.Black),
|
||||
"в тёмной теме чёрного текста быть не должно: ${histogram.top()}",
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun `renderer follows the style from the composition local`() = runComposeUiTest {
|
||||
val magenta = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) {
|
||||
override val textColor = Color(0xFFFF00FF)
|
||||
}
|
||||
setContent {
|
||||
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||
CompositionLocalProvider(LocalMarkdownStyle provides magenta) {
|
||||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) {
|
||||
renderMarkdownSegments("Текст сообщения")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val histogram = onRoot().captureToImage().toPixelMap().histogram()
|
||||
assertTrue(
|
||||
histogram.keys.any { it.red > 0.5f && it.blue > 0.5f && it.green < 0.3f },
|
||||
"рендер обязан брать стиль из LocalMarkdownStyle: ${histogram.top()}",
|
||||
)
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun androidx.compose.ui.graphics.PixelMap.histogram(): Map<Color, Int> {
|
||||
val out = HashMap<Color, Int>()
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val color = this[x, y]
|
||||
out[color] = (out[color] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun Map<Color, Int>.top(): String =
|
||||
entries.sortedByDescending { it.value }.take(5)
|
||||
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
|
||||
|
||||
private fun Color.toHexArgb(): String {
|
||||
val a = (alpha * 255).toInt()
|
||||
val r = (red * 255).toInt()
|
||||
val g = (green * 255).toInt()
|
||||
val b = (blue * 255).toInt()
|
||||
return "%02X%02X%02X%02X".format(a, r, g, b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user