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