Добавлены тесты рендера и стилизации 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