Files
aiturk-mobil/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt
T
728daa9acd release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
2026-10-01 11:19:09 +03:00

330 lines
16 KiB
Kotlin

package com.m57.hermescontrol.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.statusBadgeColors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Modifier
/**
* Guards the palette template's color invariants:
* 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode.
* 2. ThemeMode combinations are validated at construction time.
* 3. Every Material role is explicitly mapped; fixed roles are mode-independent.
* 4. One-mode themes resolve and fall back to the default theme correctly.
*/
class ThemePaletteTest {
@Test
fun registryCoversEveryPresetExactlyOnce() {
assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
}
@Test
fun textSlotPairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val c = resolveColorScheme(preset, dark)
val pairs =
listOf(
"onPrimary/primary" to (c.onPrimary to c.primary),
"onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
"onSecondary/secondary" to (c.onSecondary to c.secondary),
"onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
"onTertiary/tertiary" to (c.onTertiary to c.tertiary),
"onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer),
"onBackground/background" to (c.onBackground to c.background),
"onSurface/surface" to (c.onSurface to c.surface),
"onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant),
"onSurface/surfaceDim" to (c.onSurface to c.surfaceDim),
"onSurface/surfaceBright" to (c.onSurface to c.surfaceBright),
"onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest),
"onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow),
"onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer),
"onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh),
"onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest),
"inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface),
"onError/error" to (c.onError to c.error),
"onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer),
"onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed),
"onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed),
"onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim),
"onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim),
"onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed),
"onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed),
"onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim),
"onSecondaryFixedVariant/secondaryFixedDim" to
(c.onSecondaryFixedVariant to c.secondaryFixedDim),
"onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed),
"onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed),
"onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim),
"onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim),
)
pairs.forEach { (name, colors) ->
val ratio = contrast(colors.first, colors.second)
assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val colors = resolveStatusColors(preset, dark)
StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type ->
// PR #1417: test the mapping consumed by both reusable badge renderers.
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
val ratio = contrast(foreground, background)
assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun statusBadgesUseMatchingStatusFillsAndOnColors() {
val c = dummyColors().status
assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c))
assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c))
assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c))
assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c))
}
@Test
fun fixedRolesDoNotChangeBetweenShippedModes() {
ThemeRegistry.forEach { (preset, theme) ->
val dark = theme.darkScheme
val light = theme.lightScheme
if (dark != null && light != null) {
val darkRoles = colorFields(dark).filterKeys { "Fixed" in it }
val lightRoles = colorFields(light).filterKeys { "Fixed" in it }
assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles)
}
}
}
@Test
fun everyMaterialColorRoleIsMappedFromThePalette() {
// Distinct sentinel values catch swapped roles and accidental Material defaults.
val colors = dummyColors()
val expected = colorFields(colors).toMutableMap()
expected["surfaceTint"] = expected.getValue("primary")
expected.putAll(
colorFields(colors.status).filterKeys {
it in
setOf(
"error",
"onError",
"errorContainer",
"onErrorContainer",
)
},
)
val theme = buildTheme(colors, colors)
listOf(theme.darkScheme, theme.lightScheme).forEach { scheme ->
assertEquals(expected, colorFields(requireNotNull(scheme)))
}
}
private fun colorFields(value: Any): Map<String, Long> =
value.javaClass.declaredFields
.filter {
it.type == java.lang.Long.TYPE &&
!Modifier.isStatic(it.modifiers)
}.associate { field ->
field.isAccessible = true
field.name to field.getLong(value)
}
/**
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* Header text uses onSurfaceVariant independently of the theme's accent.
* The registry covers every shipped preset and its fallback mode.
*/
@Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark)
assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f,
)
assertTrue(
"$preset dark=$dark onSurfaceVariant/background contrast must be >= 3:1 (full-bleed header)",
contrast(scheme.onSurfaceVariant, scheme.background) >= 3f,
)
}
}
}
@Test
fun amoledShipsDarkOnly() {
assertTrue("AMOLED must ship a dark scheme", AmoledTheme.darkScheme != null)
assertTrue("AMOLED must not ship a light scheme", AmoledTheme.lightScheme == null)
assertTrue("AMOLED must ship dark status colors", AmoledTheme.darkStatus != null)
assertTrue("AMOLED must not ship light status colors", AmoledTheme.lightStatus == null)
}
@Test
fun everyPresetResolvesItsDeclaredModes() {
ThemeRegistry.forEach { (preset, theme) ->
listOf(true, false).forEach { dark ->
assertSame(
"$preset dark=$dark must resolve its palette or Default fallback",
theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
resolveColorScheme(preset, dark),
)
assertSame(
"$preset dark=$dark must resolve its status or Default fallback",
theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
resolveStatusColors(preset, dark),
)
}
}
}
@Test
fun amoledLightModeFallsBackToDefaultLight() {
val fallbackScheme = resolveColorScheme(ThemePreset.AMOLED, darkTheme = false)
val defaultLight = DefaultTheme.lightScheme!!
assertEquals(
"AMOLED light scheme must fall back to Default light background",
defaultLight.background,
fallbackScheme.background,
)
val fallbackStatus = resolveStatusColors(ThemePreset.AMOLED, darkTheme = false)
val defaultLightStatus = DefaultTheme.lightStatus!!
assertEquals(
"AMOLED light status must fall back to Default light success",
defaultLightStatus.success,
fallbackStatus.success,
)
// AMOLED dark resolves to its own palette, not the default.
val ownDark = AmoledTheme.darkScheme!!
assertEquals(
"AMOLED dark scheme must be its own",
ownDark.background,
resolveColorScheme(ThemePreset.AMOLED, darkTheme = true).background,
)
}
@Test
fun themePaletteRejectsIllegalModeCombinations() {
val colors = dummyColors()
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, colors, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, null, colors)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.DARK_ONLY, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.DARK_ONLY, null, colors)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.LIGHT_ONLY, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.LIGHT_ONLY, colors, null)
}
// Valid combinations must construct without throwing.
ThemePalette(ThemeMode.FULL, colors, colors)
ThemePalette(ThemeMode.DARK_ONLY, colors, null)
ThemePalette(ThemeMode.LIGHT_ONLY, null, colors)
}
private fun contrast(
a: Color,
b: Color,
): Float {
val lighter = maxOf(a.luminance(), b.luminance())
val darker = minOf(a.luminance(), b.luminance())
return (lighter + 0.05f) / (darker + 0.05f)
}
private fun dummyColors(): PaletteColors =
PaletteColors(
primary = Color(0xFF000000L + 1),
onPrimary = Color(0xFF000000L + 2),
primaryContainer = Color(0xFF000000L + 3),
onPrimaryContainer = Color(0xFF000000L + 4),
secondary = Color(0xFF000000L + 5),
onSecondary = Color(0xFF000000L + 6),
secondaryContainer = Color(0xFF000000L + 7),
onSecondaryContainer = Color(0xFF000000L + 8),
tertiary = Color(0xFF000000L + 9),
onTertiary = Color(0xFF000000L + 10),
tertiaryContainer = Color(0xFF000000L + 11),
onTertiaryContainer = Color(0xFF000000L + 12),
background = Color(0xFF000000L + 13),
onBackground = Color(0xFF000000L + 14),
surface = Color(0xFF000000L + 15),
onSurface = Color(0xFF000000L + 16),
surfaceVariant = Color(0xFF000000L + 17),
onSurfaceVariant = Color(0xFF000000L + 18),
surfaceDim = Color(0xFF000000L + 19),
surfaceBright = Color(0xFF000000L + 20),
primaryFixed = Color(0xFF000000L + 21),
primaryFixedDim = Color(0xFF000000L + 22),
onPrimaryFixed = Color(0xFF000000L + 23),
onPrimaryFixedVariant = Color(0xFF000000L + 24),
secondaryFixed = Color(0xFF000000L + 25),
secondaryFixedDim = Color(0xFF000000L + 26),
onSecondaryFixed = Color(0xFF000000L + 27),
onSecondaryFixedVariant = Color(0xFF000000L + 28),
tertiaryFixed = Color(0xFF000000L + 29),
tertiaryFixedDim = Color(0xFF000000L + 30),
onTertiaryFixed = Color(0xFF000000L + 31),
onTertiaryFixedVariant = Color(0xFF000000L + 32),
surfaceContainerLowest = Color(0xFF000000L + 33),
surfaceContainerLow = Color(0xFF000000L + 34),
surfaceContainer = Color(0xFF000000L + 35),
surfaceContainerHigh = Color(0xFF000000L + 36),
surfaceContainerHighest = Color(0xFF000000L + 37),
inverseSurface = Color(0xFF000000L + 38),
inverseOnSurface = Color(0xFF000000L + 39),
inversePrimary = Color(0xFF000000L + 40),
outline = Color(0xFF000000L + 41),
outlineVariant = Color(0xFF000000L + 42),
scrim = Color(0xFF000000L + 43),
status =
HermesStatusColors(
success = Color(0xFF000000L + 100),
successContainer = Color(0xFF000000L + 101),
onSuccess = Color(0xFF000000L + 102),
warning = Color(0xFF000000L + 103),
warningContainer = Color(0xFF000000L + 104),
onWarning = Color(0xFF000000L + 105),
error = Color(0xFF000000L + 106),
errorContainer = Color(0xFF000000L + 107),
onError = Color(0xFF000000L + 108),
onErrorContainer = Color(0xFF000000L + 109),
info = Color(0xFF000000L + 110),
infoContainer = Color(0xFF000000L + 111),
onInfo = Color(0xFF000000L + 112),
),
)
}