package social.grain import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.ui.theme.GrainTheme import java.io.File /** * The fallback palettes — what Android 11 and below get, and anyone with * dynamic colour off. * * A ColorScheme role that is never passed silently keeps Material's baseline * value, and the baseline neutrals are tinted toward Material's own purple. So * leaving surfaceContainer unset didn't fail anything; it just made the * navigation bar and the comment sheet faintly lavender against surfaces that * are otherwise pure white or near-black. Nothing catches that but eyes on an * old device, which is what this test replaces. */ @RunWith(AndroidJUnit4::class) class FallbackPaletteTest { @get:Rule val composeRule = createComposeRule() /** * The surface and outline roles Grain means to own. Every one of these * draws a real surface in the app — surfaceContainer is the navigation * bar, surfaceContainerLow the comment sheet, surfaceContainerHighest a * filled text field, outline the chip and outlined-button borders. */ private val ownedRoles: List Color>> = listOf( "surfaceContainerLowest" to { surfaceContainerLowest }, "surfaceContainerLow" to { surfaceContainerLow }, "surfaceContainer" to { surfaceContainer }, "surfaceContainerHigh" to { surfaceContainerHigh }, "surfaceContainerHighest" to { surfaceContainerHighest }, "surfaceBright" to { surfaceBright }, "surfaceDim" to { surfaceDim }, "inverseSurface" to { inverseSurface }, "inverseOnSurface" to { inverseOnSurface }, "outline" to { outline }, ) /** * The subset that paints a surface. `outline` is excluded on purpose: it * is a border, not a background, and Grain's own is a deliberately cool * grey well past the neutrality bar below. */ private val surfaceRoles = ownedRoles.filterNot { it.first == "outline" } /** Both fallback schemes, captured in one composition. */ private fun schemes(): Pair { lateinit var light: ColorScheme lateinit var dark: ColorScheme composeRule.setContent { GrainTheme(darkTheme = false, dynamicColor = false) { light = MaterialTheme.colorScheme } GrainTheme(darkTheme = true, dynamicColor = false) { dark = MaterialTheme.colorScheme } } composeRule.waitForIdle() return light to dark } /** * How far apart a colour's strongest and weakest channels are — 0 is a * true grey. A cheap stand-in for chroma, and unlike HSV saturation it * stays meaningful down at the near-black end of the dark palette. */ private fun Color.channelSpread(): Int { val argb = toArgb() val r = (argb shr 16) and 255 val g = (argb shr 8) and 255 val b = argb and 255 return maxOf(r, g, b) - minOf(r, g, b) } private fun assertSurfacesAreNeutral(scheme: ColorScheme, label: String) { val tinted = surfaceRoles .map { (name, role) -> name to scheme.role().channelSpread() } .filter { (_, spread) -> spread > MAX_SPREAD } assertTrue( "$label surfaces carry a colour cast: " + tinted.joinToString { "${it.first} spread ${it.second}" }, tinted.isEmpty(), ) } @Test fun fallbackSurfacesAreNeutral() { val (light, dark) = schemes() assertSurfacesAreNeutral(light, "light") assertSurfacesAreNeutral(dark, "dark") } /** * Dumps both palettes beside Material's baseline so the difference can be * looked at rather than only asserted. Pull it with: * * adb shell run-as social.grain.debug cat files/fallback_palette.png */ @Test fun dumpPaletteComparison() { val (lightScheme, darkScheme) = schemes() val rows = listOf( "light grain" to lightScheme, "light baseline" to lightColorScheme(), "dark grain" to darkScheme, "dark baseline" to darkColorScheme(), ) val swatch = 120 val labelWidth = 420 val bitmap = Bitmap.createBitmap( labelWidth + swatch * ownedRoles.size, swatch * rows.size, Bitmap.Config.ARGB_8888, ) val canvas = Canvas(bitmap) canvas.drawColor(android.graphics.Color.DKGRAY) val text = Paint().apply { color = android.graphics.Color.WHITE textSize = 32f isAntiAlias = true } rows.forEachIndexed { row, (label, scheme) -> val top = row * swatch canvas.drawText(label, 16f, top + swatch / 2f + 12f, text) ownedRoles.forEachIndexed { col, (_, role) -> val left = labelWidth + col * swatch canvas.drawRect( left.toFloat(), top.toFloat(), (left + swatch).toFloat(), (top + swatch).toFloat(), Paint().apply { color = scheme.role().toArgb() }, ) } } val context = InstrumentationRegistry.getInstrumentation().targetContext File(context.filesDir, "fallback_palette.png").outputStream() .use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } } private companion object { /** * Grain's surface roles measure 0-6 apart; Material's baseline ones * measure 8-10 in light (surfaceContainer #F3EDF7, surfaceBright * #FEF7FF) and 5-9 in dark. Six separates the two, so dropping any of * the overrides fails this rather than merely looking slightly wrong * on a device nobody tests on. */ const val MAX_SPREAD = 6 } }