Добавлены тесты рендера и стилизации Markdown, реализован MarkdownStyle для самостоятельного управления стилями.
This commit is contained in:
@@ -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