Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- 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 и найденные/починенные баги
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user