Добавлены тесты рендера и стилизации Markdown, реализован MarkdownStyle для самостоятельного управления стилями.

This commit is contained in:
2026-09-28 16:29:21 +03:00
parent 9c2e794a73
commit e9f3fe96fe
3 changed files with 461 additions and 0 deletions
@@ -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)
}
}