2c71542b13
- settings/: settings.json (агенты, тема, clientId), атомарная запись - persistence/: ConversationMetaRepository — группы, lastSeen, превью, watermark синхронизации (synced_at через PRAGMA table_info) - session/: AgentRegistry (параллельное подключение, деградация в офлайн), AgentConnection (журнал + мета + живой список диалогов conversationsFlow, refresh по SSE-потоку), ChatSession (history + backfill + live + preview, дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn - UI: рейка агентов, список диалогов (живое обновление), чат со стримингом, композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос), окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы - voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке; VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте - markdown/: вендоренный рендер из ai/assistent - ошибки в баннер через AppState.reportError (CancellationException игнорируется) - тесты 103 (реальная SQLite + фейковый транспорт); против живого агента — RealServerIT, RealAppStateIT, RealSpeechIT - LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
155 lines
6.6 KiB
Kotlin
155 lines
6.6 KiB
Kotlin
package pw.binom.agentik.desktop.ui
|
||
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.layout.Box
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.padding
|
||
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.unit.dp
|
||
import androidx.compose.ui.unit.sp
|
||
import pw.binom.agentik.desktop.ui.theme.AgentikColors
|
||
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
|
||
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertFalse
|
||
import kotlin.test.assertTrue
|
||
|
||
/**
|
||
* Палитра и реальный рендер Compose без открытия окна.
|
||
*
|
||
* Зачем: светлая тема до сих пор проверялась только глазами по скриншоту, а
|
||
* markdown-код-блок изначально хардкодил тёмный фон (`#1E1E1E`), из-за чего в
|
||
* светлой теме выглядел чужеродно. Здесь тема прогоняется через настоящий
|
||
* оффскрин-рендер, а цвета сверяются с проектными токенами.
|
||
*/
|
||
class ThemeRenderTest {
|
||
|
||
// ── токены (THEMES.md / sketches/style.css) ────────────────────────────
|
||
|
||
@Test
|
||
fun darkPaletteMatchesDesignTokens() {
|
||
val c = darkAgentikColors
|
||
assertEquals(Color(0xFF121218), c.background)
|
||
assertEquals(Color(0xFF17212B), c.panel)
|
||
assertEquals(Color(0xFF1C2733), c.panelElevated)
|
||
assertEquals(Color(0xFF6AB2F2), c.accent)
|
||
assertEquals(Color(0xFFEBEBEB), c.text)
|
||
assertEquals(Color(0xFF768C9E), c.textDim)
|
||
assertEquals(Color(0xFFEC3942), c.error)
|
||
assertEquals(Color(0xFF2B5278), c.userBubble)
|
||
}
|
||
|
||
@Test
|
||
fun lightPaletteKeepsSemanticSlots() {
|
||
val c = lightAgentikColors
|
||
assertEquals(Color(0xFFF5F6F8), c.background)
|
||
assertEquals(Color(0xFFFFFFFF), c.panel)
|
||
assertEquals(Color(0xFF2A6FB5), c.accent)
|
||
assertEquals(Color(0xFF1A1D23), c.text)
|
||
// светлый фон обязан быть светлым: контраст к тексту
|
||
assertTrue(luminance(c.background) > luminance(c.text), "фон светлее текста")
|
||
assertTrue(luminance(c.panel) > luminance(c.panelElevated), "panel светлее panelElevated")
|
||
assertTrue(luminance(c.botBubble) > luminance(c.text), "бот-бабл светлее текста")
|
||
}
|
||
|
||
@Test
|
||
fun opaqueSlotsStayOpaqueAndScrimTransparent() {
|
||
for (c in listOf(darkAgentikColors, lightAgentikColors)) {
|
||
assertEquals(1f, c.background.alpha)
|
||
assertEquals(1f, c.panel.alpha)
|
||
assertEquals(1f, c.text.alpha)
|
||
assertEquals(1f, c.accent.alpha)
|
||
// scrim/selection — прозрачные по замыслу
|
||
assertTrue(c.scrim.alpha < 1f)
|
||
assertTrue(c.selection.alpha < 1f)
|
||
}
|
||
}
|
||
|
||
// ── рендер ─────────────────────────────────────────────────────────────
|
||
|
||
@OptIn(ExperimentalTestApi::class)
|
||
@Test
|
||
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
|
||
setContent {
|
||
AgentikTheme("light") {
|
||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
|
||
Column {
|
||
renderMarkdownSegments(
|
||
content = "```kotlin\nval x = 1\n```",
|
||
textColor = AgentikTheme.colors.text,
|
||
fontSize = 14.sp,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
val pixels = onRoot().captureToImage().toPixelMap()
|
||
val histogram = HashMap<Color, Int>()
|
||
for (y in 0 until pixels.height) {
|
||
for (x in 0 until pixels.width) {
|
||
val color = pixels[x, y]
|
||
histogram[color] = (histogram[color] ?: 0) + 1
|
||
}
|
||
}
|
||
val top = histogram.entries.sortedByDescending { it.value }.take(4)
|
||
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
|
||
assertTrue(
|
||
histogram.containsKey(lightAgentikColors.codeBackground),
|
||
"код-блок должен краситься в codeBackground светлой темы, а покрасился в: $top",
|
||
)
|
||
assertFalse(
|
||
histogram.containsKey(Color(0xFF1E1E1E)),
|
||
"тёмный хардкод код-фона протёк в светлую тему: $top",
|
||
)
|
||
}
|
||
|
||
@OptIn(ExperimentalTestApi::class)
|
||
@Test
|
||
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
|
||
setContent {
|
||
AgentikTheme("dark") {
|
||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
|
||
Column {
|
||
renderMarkdownSegments(
|
||
content = "```kotlin\nval x = 1\n```",
|
||
textColor = AgentikTheme.colors.text,
|
||
fontSize = 14.sp,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
val pixels = onRoot().captureToImage().toPixelMap()
|
||
var found = false
|
||
for (y in 0 until pixels.height) {
|
||
for (x in 0 until pixels.width) {
|
||
if (pixels[x, y] == darkAgentikColors.codeBackground) {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
assertTrue(found, "в тёмной теме код-блок должен краситься в dark codeBackground")
|
||
}
|
||
|
||
private fun luminance(color: Color): Float =
|
||
0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue
|
||
|
||
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)
|
||
}
|
||
}
|