Swap to generated pseudo-language for stress tests.

This commit is contained in:
Alex Hart 2026-10-27 12:12:26 -03:00
commit 5ade9c4817
23 changed files with 839 additions and 124 deletions

View file

@ -25,6 +25,7 @@ plugins {
id("com.squareup.wire")
id("translations")
id("licenses")
id("pseudo-locales")
}
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }

View file

@ -68,16 +68,6 @@ import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
private val DAY_TO_STARTING_LETTER: Map<DayOfWeek, Int> = mapOf(
DayOfWeek.SUNDAY to R.string.EditNotificationProfileSchedule__sunday_first_letter,
DayOfWeek.MONDAY to R.string.EditNotificationProfileSchedule__monday_first_letter,
DayOfWeek.TUESDAY to R.string.EditNotificationProfileSchedule__tuesday_first_letter,
DayOfWeek.WEDNESDAY to R.string.EditNotificationProfileSchedule__wednesday_first_letter,
DayOfWeek.THURSDAY to R.string.EditNotificationProfileSchedule__thursday_first_letter,
DayOfWeek.FRIDAY to R.string.EditNotificationProfileSchedule__friday_first_letter,
DayOfWeek.SATURDAY to R.string.EditNotificationProfileSchedule__saturday_first_letter
)
@VisibleForTesting
object EditNotificationProfileScheduleTestTags {
const val SCROLLER = "scroller"
@ -312,6 +302,7 @@ private fun DayToggle(
}
val textColor = if (checked && enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
val dayName = remember(day, locale) { day.getDisplayName(TextStyle.FULL, locale) }
val dayLetter = remember(day, locale) { day.getDisplayName(TextStyle.NARROW, locale) }
val textStyle = MaterialTheme.typography.bodyLarge
val circleSize = with(LocalDensity.current) { maxOf(32.dp, textStyle.lineHeight.toDp() + 8.dp) }
@ -336,7 +327,7 @@ private fun DayToggle(
.background(color = background, shape = CircleShape)
) {
Text(
text = stringResource(DAY_TO_STARTING_LETTER.getValue(day)),
text = dayLetter,
style = textStyle,
color = textColor
)

View file

@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
@ -117,8 +116,6 @@ fun AddGroupDetailsScreen(
}
}
private val MIN_HEIGHT_FOR_STICKY_HEADER = 480.dp
@Composable
private fun AddGroupDetailsContent(
state: AddGroupDetailsState,
@ -133,8 +130,6 @@ private fun AddGroupDetailsContent(
}
}
val isHeaderSticky = LocalConfiguration.current.screenHeightDp.dp >= MIN_HEIGHT_FOR_STICKY_HEADER
val header: @Composable () -> Unit = {
Column {
NameAndAvatar(state = state, focusRequester = focusRequester, onEvent = onEvent)
@ -151,22 +146,15 @@ private fun AddGroupDetailsContent(
}
}
if (isHeaderSticky) {
Column(modifier = Modifier.fillMaxSize()) {
header()
MemberList(state = state, onEvent = onEvent, modifier = Modifier.weight(1f))
}
} else {
MemberList(state = state, onEvent = onEvent, header = header, modifier = Modifier.fillMaxSize())
}
MemberList(state = state, onEvent = onEvent, header = header, modifier = Modifier.fillMaxSize())
}
@Composable
private fun MemberList(
state: AddGroupDetailsState,
onEvent: (AddGroupDetailsEvent) -> Unit,
modifier: Modifier = Modifier,
header: (@Composable () -> Unit)? = null
header: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 96.dp),
@ -176,10 +164,8 @@ private fun MemberList(
.imePadding()
.testTag(AddGroupDetailsTestTags.SCROLLER)
) {
if (header != null) {
item(key = "header") {
header()
}
item(key = "header") {
header()
}
if (state.members.isEmpty()) {

View file

@ -5934,20 +5934,6 @@
<string name="EditNotificationProfileSchedule__start">Start</string>
<!-- Label for showing the end time for the schedule -->
<string name="EditNotificationProfileSchedule__end">End</string>
<!-- First letter of Sunday -->
<string name="EditNotificationProfileSchedule__sunday_first_letter">S</string>
<!-- First letter of Monday -->
<string name="EditNotificationProfileSchedule__monday_first_letter">M</string>
<!-- First letter of Tuesday -->
<string name="EditNotificationProfileSchedule__tuesday_first_letter">T</string>
<!-- First letter of Wednesday -->
<string name="EditNotificationProfileSchedule__wednesday_first_letter">W</string>
<!-- First letter of Thursday -->
<string name="EditNotificationProfileSchedule__thursday_first_letter">T</string>
<!-- First letter of Friday -->
<string name="EditNotificationProfileSchedule__friday_first_letter">F</string>
<!-- First letter of Saturday -->
<string name="EditNotificationProfileSchedule__saturday_first_letter">S</string>
<!-- Title of select time dialog shown when setting start time for schedule -->
<string name="EditNotificationProfileSchedule__set_start_time">Set start time</string>
<!-- Title of select time dialog shown when setting end time for schedule -->

View file

@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.signal.buildtools.PseudoLanguage
/**
* Writes every [PseudoLanguage] translation of a module's English strings as a resource directory of its own.
*/
@CacheableTask
abstract class GeneratePseudoLocalesTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val englishStrings: RegularFileProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun generate() {
val english = englishStrings.get().asFile
val output = outputDirectory.get().asFile
output.deleteRecursively()
for (language in PseudoLanguage.entries) {
val directory = output.resolve(language.valuesDirectoryName).apply { mkdirs() }
directory.resolve("strings.xml").writeText(language.translateStringsXml(english))
}
}
}

View file

@ -0,0 +1,22 @@
import com.android.build.api.variant.AndroidComponentsExtension
/**
* Adds the [org.signal.buildtools.PseudoLanguage] translations of a module's English strings to its debug builds, which
* unit tests and previews use, so layouts can be stress tested against them without shipping them. Apply it after
* the Android plugin.
*/
val moduleEnglishStrings = layout.projectDirectory.file("src/main/res/values/strings.xml")
extensions.configure<AndroidComponentsExtension<*, *, *>>("androidComponents") {
onVariants(selector().withBuildType("debug")) { variant ->
if (!moduleEnglishStrings.asFile.exists()) {
return@onVariants
}
val task = tasks.register<GeneratePseudoLocalesTask>("generate${variant.name.replaceFirstChar { it.uppercase() }}PseudoLocales") {
englishStrings.set(moduleEnglishStrings)
}
variant.sources.res?.addGeneratedSourceDirectory(task, GeneratePseudoLocalesTask::outputDirectory)
}
}

View file

@ -13,6 +13,7 @@ plugins {
// Instead, plugin versions are controlled through the dependencies block in the build.gradle.kts.
id("com.android.library")
id("ktlint")
id("pseudo-locales")
}
android {

View file

@ -0,0 +1,281 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.buildtools
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.Text
import java.io.File
import java.io.StringWriter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
import kotlin.math.ceil
import kotlin.math.min
/**
* A stand-in for a family of translations, generated from the English string resources so that layouts can be
* stress tested against it before real translations land. Each is written to a locale from ISO 639's private-use
* range, [locale], which no real translation will ever use.
*
* Each string grows to the length our translations reach for an English string of its length, about the 99th
* percentile across every locale: short strings grow the most. Words grow by [wordGrowth], about how much longer the
* family's typical word is than English's, and words copied from the string make up the rest. Letters are accented so
* that tall glyphs are measured too. No word grows past [MAX_WORD_LENGTH], about the longest word in a German, Finnish
* or Dutch string at the 95th percentile.
*/
enum class PseudoLanguage(val locale: String, private val wordGrowth: Float) {
/** More words, a little longer, with the same places to break between them, like French or Dutch. */
EXPANSIVE("qaa", wordGrowth = 1.25f),
/** Longer words joined into compounds with nowhere to break, like German or Finnish. */
COMPOUND("qab", wordGrowth = 1.5f);
/** The resource directory the language is written to. */
val valuesDirectoryName: String = "values-b+$locale"
/**
* Leaves format specifiers, escapes, markup and URLs alone.
*/
fun translate(text: String): String {
return translate(text, protectedMask(text)).text
}
/**
* Translates a `strings.xml` file, leaving out the strings that aren't translatable. Each string's markup is kept,
* as is anything inside an `xliff:g`.
*/
fun translateStringsXml(englishXml: File): String {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(englishXml)
val resources = document.documentElement
for (node in resources.childNodes.toList()) {
if (node is Text && node.data.isBlank()) {
continue
}
val keep = node is Element && node.tagName in STRING_RESOURCES && node.getAttribute("translatable") != "false"
if (!keep) {
resources.removeChild(node)
continue
}
node as Element
if (node.tagName == "string") {
translateElement(node)
} else {
node.childNodes.toList().filterIsInstance<Element>().filter { it.tagName == "item" }.forEach(::translateElement)
}
}
val writer = StringWriter()
TransformerFactory.newInstance().newTransformer().apply {
setOutputProperty(OutputKeys.ENCODING, "UTF-8")
}.transform(DOMSource(document), StreamResult(writer))
return writer.toString()
}
/**
* Translates the text across all of [element]'s text nodes as one string, so that it grows by the right amount,
* then hands each node back its share.
*/
private fun translateElement(element: Element) {
val segments = mutableListOf<Pair<Text, Boolean>>()
collectText(element, insideXliff = false, segments)
val text = segments.joinToString("") { it.first.data }
if (text.trimStart().startsWith("@") || text.trimStart().startsWith("?")) {
return
}
val mask = protectedMask(text)
var offset = 0
for ((node, insideXliff) in segments) {
if (insideXliff) {
mask.fill(true, offset, offset + node.data.length)
}
offset += node.data.length
}
val translated = translate(text, mask)
offset = 0
for ((node, _) in segments) {
val end = offset + node.data.length
node.data = translated.text.substring(translated.offsets[offset], translated.offsets[end])
offset = end
}
}
private fun collectText(node: Node, insideXliff: Boolean, segments: MutableList<Pair<Text, Boolean>>) {
for (child in node.childNodes.toList()) {
when (child) {
is Text -> segments += child to insideXliff
is Element -> collectText(child, insideXliff || child.tagName == "xliff:g", segments)
else -> node.removeChild(child)
}
}
}
private fun translate(text: String, protected: BooleanArray): Translation {
val words = mutableListOf<IntRange>()
var i = 0
while (i < text.length) {
if (protected[i] || !text[i].isLetter()) {
i++
continue
}
val start = i
while (i < text.length && !protected[i] && text[i].isLetter()) {
i++
}
words += start until i
}
if (words.isEmpty()) {
return Translation(text, IntArray(text.length + 1) { it })
}
val stretched = words.map { word -> stretch(text.substring(word.first, word.last + 1), ceil(word.count() * wordGrowth).toInt()) }
val unprotectedLength = protected.count { !it }
val targetLength = ceil(unprotectedLength * expansion(unprotectedLength)).toInt()
var missing = targetLength - unprotectedLength - stretched.sumOf { it.length } + words.sumOf { it.count() }
val padding = mutableListOf<String>()
while (missing > 1) {
val word = stretched[padding.size % stretched.size].lowercase().take(missing - 1)
padding += word
missing -= word.length + 1
}
// Each word, keyed by where it starts, replaced by its translation and with any padding after the last
val replacements = mutableMapOf<Int, Pair<Int, String>>()
words.forEachIndexed { index, word -> replacements[word.first] = word.last + 1 to stretched[index] }
// Words with no whitespace between them, like "device's" or "auto-download", can't be broken apart, so they are
// one unit and only whole units are joined
val units = mutableListOf(mutableListOf(0))
for (index in 1 until words.size) {
val gap = text.substring(words[index - 1].last + 1, words[index].first)
if (gap.none { it.isWhitespace() }) {
units.last() += index
} else {
units += mutableListOf(index)
}
}
val unitLengths = units.map { unit -> words[unit.last()].last + 1 - words[unit.first()].first + unit.sumOf { stretched[it].length - words[it].count() } }
var runLength = unitLengths.first()
for (index in 1 until units.size) {
val previous = words[units[index - 1].last()]
val next = words[units[index].first()]
if (joins(runLength, unitLengths[index]) && next.first == previous.last + 2 && text[previous.last + 1] == ' ') {
replacements[previous.last + 1] = previous.last + 2 to ""
runLength += unitLengths[index]
} else {
runLength = unitLengths[index]
}
}
val last = words.last()
replacements[last.first] = last.last + 1 to buildString {
append(stretched.last())
for (word in padding) {
if (joins(runLength, word.length)) {
runLength += word.length
} else {
append(' ')
runLength = word.length
}
append(word)
}
}
val offsets = IntArray(text.length + 1)
val translated = StringBuilder()
var position = 0
while (position < text.length) {
offsets[position] = translated.length
val replacement = replacements[position]
if (replacement != null) {
val (end, value) = replacement
translated.append(value)
(position + 1 until end).forEach { offsets[it] = translated.length }
position = end
} else {
translated.append(text[position])
position++
}
}
offsets[text.length] = translated.length
return Translation(translated.toString(), offsets)
}
private fun joins(runLength: Int, nextLength: Int): Boolean {
return this == COMPOUND && runLength + nextLength <= MAX_WORD_LENGTH
}
/** [offsets] maps each position in the original text to where it ended up in [text]. */
private class Translation(val text: String, val offsets: IntArray)
companion object {
const val MAX_WORD_LENGTH = 20
private val STRING_RESOURCES = setOf("string", "plurals", "string-array")
private val PROTECTED = listOf(
// Format specifiers, e.g. %1$s, %d, %.1f, %%
Regex("""%(\d+\$)?[-#+ 0,(<]*\d*(\.\d+)?[a-zA-Z%]"""),
// Resource escapes, e.g. \n, \', …
Regex("""\\u[0-9a-fA-F]{4}|\\."""),
// Markup inside CDATA, e.g. <b> or <a href="...">, and entities
Regex("""<[^>]*>|&#?\w+;"""),
Regex("""[a-zA-Z][a-zA-Z0-9+.-]*://[^\s"'<>]+""")
)
private const val ACCENTED_UPPER = "ÅƁÇĐÉƑĜĤÎĴĶĹṀÑÖÞǪŔŠŢÛṼŴẊÝŽ"
private const val ACCENTED_LOWER = "åƀçđéƒĝĥîĵķĺɱñöþǫŕšţûṽŵẋýž"
private fun protectedMask(text: String): BooleanArray {
val mask = BooleanArray(text.length)
PROTECTED.forEach { regex -> regex.findAll(text).forEach { match -> mask.fill(true, match.range.first, match.range.last + 1) } }
return mask
}
/** How much longer translations of an English string of [length] characters get, at about the 99th percentile. */
private fun expansion(length: Int): Float {
return when {
length <= 10 -> 3.25f
length <= 20 -> 2.45f
length <= 40 -> 2f
length <= 80 -> 1.7f
else -> 1.56f
}
}
private fun stretch(word: String, length: Int): String {
val accented = word.map(::accent)
return buildString {
accented.forEach(::append)
while (this.length < min(length, MAX_WORD_LENGTH)) {
append(accented[(this.length - accented.size) % accented.size].lowercaseChar())
}
}
}
private fun accent(char: Char): Char {
return when (char) {
in 'A'..'Z' -> ACCENTED_UPPER[char - 'A']
in 'a'..'z' -> ACCENTED_LOWER[char - 'a']
else -> char
}
}
private fun org.w3c.dom.NodeList.toList(): List<Node> = List(length) { item(it) }
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.buildtools
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PseudoLanguageTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `short strings grow the most, accented`() {
assertEquals("Çåñçéĺçå çåñçéĺçå çå", PseudoLanguage.EXPANSIVE.translate("Cancel"))
}
@Test
fun `long strings grow less`() {
val english = "Messages and calls are end-to-end encrypted and nobody else can read or listen to them"
assertEquals(135, PseudoLanguage.EXPANSIVE.translate(english).length)
}
@Test
fun `expansive grows words a little and adds more of them`() {
assertEquals("Ĝöĝ ţöţ îţî ĝöĝ ţöţ îţî ĝö", PseudoLanguage.EXPANSIVE.translate("Go to it"))
}
@Test
fun `compound joins words up to the longest word length`() {
assertEquals("Ĝöĝţöţîţîĝöĝţöţîţîĝö", PseudoLanguage.COMPOUND.translate("Go to it"))
}
@Test
fun `compound counts words joined by an apostrophe or hyphen as one word`() {
listOf("Use your device\\'s biometrics or a security key", "Media auto-download").forEach { english ->
val translated = PseudoLanguage.COMPOUND.translate(english)
assertTrue(translated, translated.split(' ').all { it.replace("\\", "").length <= PseudoLanguage.MAX_WORD_LENGTH })
}
}
@Test
fun `no word grows past the longest word length`() {
val words = PseudoLanguage.COMPOUND.translate("Notifications").split(' ')
assertEquals(PseudoLanguage.MAX_WORD_LENGTH, words.first().length)
assertTrue(words.all { it.length <= PseudoLanguage.MAX_WORD_LENGTH })
}
@Test
fun `format specifiers, escapes and urls are left alone`() {
val translated = PseudoLanguage.COMPOUND.translate("""Delete %1${'$'}s and %2${'$'}d others?\nIt\'s 100%% at https://signal.org/donate \u2026""")
listOf("%1\$s", " %2\$d ", "\\n", "\\'", "100%%", "https://signal.org/donate", "\\u2026").forEach {
assertTrue("Expected $it in $translated", translated.contains(it))
}
}
@Test
fun `text without letters is unchanged`() {
assertEquals("%1\$s: %2\$d", PseudoLanguage.EXPANSIVE.translate("%1\$s: %2\$d"))
}
@Test
fun `strings xml keeps only translatable strings, plurals and arrays`() {
val translated = translateXml(
"""
<string name="a">Cancel</string>
<string name="b" translatable="false">Signal</string>
<dimen name="c">4dp</dimen>
<plurals name="d"><item quantity="one">%d minute</item><item quantity="other">%d minutes</item></plurals>
<string-array name="e"><item>Never</item></string-array>
"""
)
assertTrue(translated, translated.contains("""<string name="a">Çåñçéĺçå çåñçéĺçå çå</string>"""))
assertFalse(translated.contains("Signal"))
assertFalse(translated.contains("dimen"))
assertTrue(translated, translated.contains("""<item quantity="one">%d ɱîñûţéɱî ɱîñûţéɱî ɱîñû</item>"""))
assertTrue(translated, translated.contains("<item>Ñéṽéŕñé ñéṽéŕñé ñ</item>"))
}
@Test
fun `markup is kept around the text it covered`() {
val translated = translateXml("""<string name="a">Tap <b>here</b> now</string>""")
assertTrue(translated, translated.contains("""<string name="a">Ţåþţ <b>ĥéŕéĥ</b> ñöŵñ"""))
}
@Test
fun `xliff placeholders and references are not translated`() {
val translated = translateXml(
"""
<string name="a">Hi <xliff:g id="name">Alice</xliff:g></string>
<string name="b">@string/a</string>
"""
)
assertTrue(translated, translated.contains("""<xliff:g id="name">Alice</xliff:g>"""))
assertTrue(translated, translated.contains("""<string name="b">@string/a</string>"""))
}
@Test
fun `html inside cdata keeps its tags`() {
val translated = translateXml("""<string name="a"><![CDATA[Read the <a href="https://signal.org">terms</a>]]></string>""")
assertTrue(translated, translated.contains("""<a href="https://signal.org">ţéŕɱšţ"""))
assertTrue(translated, translated.contains("</a>"))
}
private fun translateXml(strings: String): String {
val file = temporaryFolder.newFile("strings.xml")
file.writeText("""<?xml version="1.0" encoding="utf-8"?><resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">$strings</resources>""")
return PseudoLanguage.EXPANSIVE.translateStringsXml(file)
}
}

View file

@ -10,20 +10,24 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@ -37,13 +41,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import org.signal.core.ui.R
@ -71,6 +80,8 @@ object Pickers {
const val TEST_TAG_DURATION_UNIT_WHEEL = "duration-unit-wheel"
private val WheelItemHeight = 48.dp
private val WheelItemPadding = 8.dp
private val MinWheelWidth = 48.dp
/**
* A scrolling wheel that snaps to one of [items], the Compose counterpart to a framework `NumberPicker`. The selected
@ -108,7 +119,7 @@ object Pickers {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.widthIn(min = 64.dp)
.widthIn(min = MinWheelWidth)
.height(WheelItemHeight * 3)
) {
LazyColumn(
@ -136,7 +147,14 @@ object Pickers {
style = MaterialTheme.typography.titleMedium,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp)
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = 12.sp,
maxFontSize = MaterialTheme.typography.titleMedium.fontSize
),
modifier = Modifier.padding(horizontal = WheelItemPadding)
)
}
}
@ -216,31 +234,54 @@ object Pickers {
) {
val unit = units[unitIndex]
val values = (unit.minValue..unit.maxValue).toList()
val labels = units.map { stringResource(it.label) }
val labelWidth = widestWheelItem(labels)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.heightIn(min = WheelItemHeight * 3)
) {
Wheel(
items = values.map { it.toString() },
selectedIndex = values.indexOf(value),
onSelectedIndexChange = { onValueChange(values[it]) },
modifier = Modifier
.weight(1f)
.testTag(TEST_TAG_DURATION_VALUE_WHEEL)
)
val spacing = 16.dp
val available = maxWidth - spacing
val unitWidth = maxOf(available / 2, minOf(labelWidth, available - MinWheelWidth))
Wheel(
items = units.map { stringResource(it.label) },
selectedIndex = unitIndex,
onSelectedIndexChange = onUnitIndexChange,
modifier = Modifier
.weight(1f)
.testTag(TEST_TAG_DURATION_UNIT_WHEEL)
)
Row(
horizontalArrangement = Arrangement.spacedBy(spacing, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Wheel(
items = values.map { it.toString() },
selectedIndex = values.indexOf(value),
onSelectedIndexChange = { onValueChange(values[it]) },
modifier = Modifier
.width(available - unitWidth)
.testTag(TEST_TAG_DURATION_VALUE_WHEEL)
)
Wheel(
items = labels,
selectedIndex = unitIndex,
onSelectedIndexChange = onUnitIndexChange,
modifier = Modifier
.width(unitWidth)
.testTag(TEST_TAG_DURATION_UNIT_WHEEL)
)
}
}
}
/** How wide a [Wheel] has to be to show the widest of [items] at full size. */
@Composable
private fun widestWheelItem(items: List<String>): Dp {
val textMeasurer = rememberTextMeasurer()
val style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium)
val density = LocalDensity.current
return remember(items, style, density) {
val widestPx = items.maxOfOrNull { textMeasurer.measure(it, style, maxLines = 1, softWrap = false).size.width } ?: 0
with(density) { widestPx.toDp() } + WheelItemPadding * 2
}
}
@ -268,12 +309,14 @@ object Pickers {
@PreviewWrapper(SignalPreviewWrapper::class)
@DayNightPreviews
@Composable
private fun DurationDialogPreview() {
Pickers.DurationDialog(
title = "Custom time",
initialDuration = 3.hours,
units = listOf(DurationPickerUnit.MINUTES, DurationPickerUnit.HOURS, DurationPickerUnit.DAYS),
onConfirm = {},
onDismiss = {}
)
private fun DurationWheelsPreview() {
Surface {
Pickers.DurationWheels(
units = listOf(DurationPickerUnit.MINUTES, DurationPickerUnit.HOURS, DurationPickerUnit.DAYS),
unitIndex = 1,
value = 3,
onUnitIndexChange = {},
onValueChange = {}
)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.ui.tooling.preview.Preview
/**
* Every config a layout stress test checks by default, as previews, so a failing one can be opened by name.
*
* The pseudo-languages ("qaa" and "qab") are generated into debug builds only, so the previews that use them render
* English in any other build. StressTestPreviewTest in :screener fails when these drift from the stress tests.
*/
@Preview(name = "phone", device = "spec:width=360dp,height=780dp", group = "stress test")
@Preview(name = "phone + font 2x", device = "spec:width=360dp,height=780dp", fontScale = 2f, group = "stress test")
@Preview(name = "landscape + display 1.3x + RTL + pseudo-expansive", device = "spec:width=600dp,height=277dp", locale = "b+qaa+Arab", group = "stress test")
@Preview(name = "tablet + font 2x + RTL + pseudo-compound", device = "spec:width=1280dp,height=800dp", fontScale = 2f, locale = "b+qab+Arab", group = "stress test")
@Preview(name = "landscape + display 1.3x + font 2x + pseudo-compound", device = "spec:width=600dp,height=277dp", fontScale = 2f, locale = "b+qab", group = "stress test")
@Preview(name = "tablet + pseudo-expansive", device = "spec:width=1280dp,height=800dp", locale = "b+qaa", group = "stress test")
@Preview(name = "phone + display 1.3x + RTL", device = "spec:width=277dp,height=600dp", locale = "b+en+Arab", group = "stress test")
@Preview(name = "phone + pseudo-compound", device = "spec:width=360dp,height=780dp", locale = "b+qab", group = "stress test")
@Preview(name = "phone + font 2x + pseudo-expansive", device = "spec:width=360dp,height=780dp", fontScale = 2f, locale = "b+qaa", group = "stress test")
@Preview(name = "landscape", device = "spec:width=780dp,height=360dp", group = "stress test")
@Preview(name = "tablet + display 1.3x", device = "spec:width=985dp,height=615dp", group = "stress test")
annotation class StressTestPreview

View file

@ -13,39 +13,53 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.ui.compose.ProvideIncognitoKeyboard
/**
* Headings and labels hyphenate a word too long for its line, rather than breaking it at an arbitrary letter. This also
* lets lines break at a hyphen already in the text, which they otherwise don't. Body text is left alone.
*/
private val typography = Typography().run {
copy(
displayLarge = displayLarge.copy(hyphens = Hyphens.Auto),
displayMedium = displayMedium.copy(hyphens = Hyphens.Auto),
displaySmall = displaySmall.copy(hyphens = Hyphens.Auto),
headlineLarge = headlineLarge.copy(
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
hyphens = Hyphens.Auto
),
headlineMedium = headlineMedium.copy(
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
hyphens = Hyphens.Auto
),
headlineSmall = headlineSmall.copy(hyphens = Hyphens.Auto),
titleLarge = titleLarge.copy(
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
hyphens = Hyphens.Auto
),
titleMedium = titleMedium.copy(
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.0125.sp,
fontFamily = FontFamily.SansSerif,
fontStyle = FontStyle.Normal
fontStyle = FontStyle.Normal,
hyphens = Hyphens.Auto
),
titleSmall = titleSmall.copy(
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.0125.sp
letterSpacing = 0.0125.sp,
hyphens = Hyphens.Auto
),
bodyLarge = bodyLarge.copy(
fontSize = 16.sp,
@ -65,17 +79,20 @@ private val typography = Typography().run {
labelLarge = labelLarge.copy(
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.0107.sp
letterSpacing = 0.0107.sp,
hyphens = Hyphens.Auto
),
labelMedium = labelMedium.copy(
fontSize = 13.sp,
lineHeight = 16.sp,
letterSpacing = 0.0192.sp
letterSpacing = 0.0192.sp,
hyphens = Hyphens.Auto
),
labelSmall = labelSmall.copy(
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.025.sp
letterSpacing = 0.025.sp,
hyphens = Hyphens.Auto
)
)
}

View file

@ -7,8 +7,6 @@ package org.signal.core.ui
import androidx.compose.runtime.Composable
import androidx.test.core.app.ApplicationProvider
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.screener.LayoutStressTest
@ -23,11 +21,6 @@ abstract class SignalLayoutStressTest : LayoutStressTest() {
override val previewAnnotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)")
@Before
fun bypassStressTests() {
Assume.assumeTrue("Layout stress tests are temporarily bypassed", false)
}
@Composable
override fun Wrap(content: @Composable () -> Unit) {
SignalTheme {

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -28,19 +29,24 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.appsettings.appSettingsScreen
@ -50,7 +56,10 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.SignalPreviewWrapper
import org.signal.core.ui.R as CoreUiR
private const val COLUMN_COUNT = 4
private const val MAX_COLUMN_COUNT = 4
private const val MIN_COLUMN_COUNT = 2
private val IconSize = 64.dp
private val LabelPadding = 4.dp
@VisibleForTesting
object AppIconSelectionTestTags {
@ -92,20 +101,13 @@ fun AppIconSelectionScreen(
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(
IconGrid(
selectedIcon = state.selectedIcon,
onEvent = onEvent,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppIconPreset.entries.chunked(COLUMN_COUNT).forEach { presets ->
IconRow(
presets = presets,
selectedIcon = state.selectedIcon,
onEvent = onEvent
)
}
}
.padding(horizontal = 18.dp)
)
}
}
@ -156,10 +158,53 @@ private fun ChangeIconDialog(pendingIcon: AppIconPreset, onEvent: (AppIconSelect
}
/**
* One row of icons that the user may choose from.
* The icons to choose from, in as many columns (up to [MAX_COLUMN_COUNT]) as leave room for the longest word of any
* label at the user's font size, so labels wrap between words rather than inside them. Fewer columns reflow the grid
* rather than shrinking the labels.
*/
@Composable
private fun IconRow(presets: List<AppIconPreset>, selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit) {
private fun IconGrid(selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit, modifier: Modifier = Modifier) {
val labelWidth = widestWord(AppIconPreset.entries.map { presetLabel(it) })
BoxWithConstraints(modifier = modifier) {
val cellWidth = maxOf(IconSize, labelWidth + LabelPadding * 2)
val columnCount = (maxWidth / cellWidth).toInt().coerceIn(MIN_COLUMN_COUNT, MAX_COLUMN_COUNT)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppIconPreset.entries.chunked(columnCount).forEach { presets ->
IconRow(
presets = presets,
columnCount = columnCount,
selectedIcon = selectedIcon,
onEvent = onEvent
)
}
}
}
}
@Composable
private fun widestWord(labels: List<String>): Dp {
val textMeasurer = rememberTextMeasurer()
val style = MaterialTheme.typography.bodySmall
val density = LocalDensity.current
return remember(labels, style, density) {
val words = labels.flatMap { it.split(' ') }
val widestPx = words.maxOfOrNull { textMeasurer.measure(it, style, maxLines = 1, softWrap = false).size.width } ?: 0
with(density) { widestPx.toDp() }
}
}
/**
* One row of icons that the user may choose from. A short last row keeps its icons in the same columns as the rows
* above it.
*/
@Composable
private fun IconRow(presets: List<AppIconPreset>, columnCount: Int, selectedIcon: AppIconPreset, onEvent: (AppIconSelectionEvent) -> Unit) {
Row(modifier = Modifier.fillMaxWidth()) {
presets.forEach { preset ->
IconGridElement(
@ -172,6 +217,10 @@ private fun IconRow(presets: List<AppIconPreset>, selectedIcon: AppIconPreset, o
.testTag(AppIconSelectionTestTags.icon(preset))
)
}
repeat(columnCount - presets.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
@ -184,7 +233,7 @@ private fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClick:
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
val boxModifier = Modifier.size(64.dp)
val boxModifier = Modifier.size(IconSize)
Box(
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
) {
@ -195,7 +244,8 @@ private fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClick:
text = presetLabel(preset),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall.copy(hyphens = Hyphens.Auto),
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LabelPadding)
)
}
}
@ -258,6 +308,10 @@ private fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
@Composable
private fun presetLabel(preset: AppIconPreset): String {
if (preset.labelResId == null && LocalInspectionMode.current) {
return "Signal"
}
return stringResource(preset.labelResId ?: LocalContext.current.applicationInfo.labelRes)
}

View file

@ -8,7 +8,7 @@ package org.signal.screener
/**
* Describes the preview function that a failure message suggests, so the problem can be reproduced in the IDE.
*
* [functionName] names the suggested preview function, [annotations] are added above its `@Preview`s, and
* [functionName] names the suggested preview function, [annotations] are added below its preview annotations, and
* [contentHint] goes in its body to say what content to render.
*/
data class PreviewTemplate(
@ -16,9 +16,35 @@ data class PreviewTemplate(
val annotations: List<String> = emptyList(),
val contentHint: String = "The content that failed"
) {
internal fun render(configs: List<StressConfig>): String {
companion object {
/** The multi-preview annotation in core/ui that previews every config in [StressConfig.Default]. */
const val STRESS_TEST_PREVIEW = "@StressTestPreview"
/** The preview group [STRESS_TEST_PREVIEW]'s previews are in. */
const val STRESS_TEST_PREVIEW_GROUP = "stress test"
}
/**
* A test that ran the default configs is reproduced with [STRESS_TEST_PREVIEW], pointing at the previews of
* [failingConfigs]. Any other test gets one preview per failing config.
*/
internal fun render(failingConfigs: List<StressConfig>, allConfigs: List<StressConfig>): String {
val usesStressTestPreview = allConfigs == StressConfig.Default
return buildString {
configs.forEach { appendLine(it.toPreviewAnnotation()) }
if (usesStressTestPreview) {
appendLine("Reproduce in the IDE with this preview, and look at its ${failingConfigs.joinToString { "\"${it.name}\"" }} previews:")
} else {
appendLine("Reproduce in the IDE with a preview like this:")
}
appendLine()
if (usesStressTestPreview) {
appendLine(STRESS_TEST_PREVIEW)
} else {
failingConfigs.forEach { appendLine(it.toPreviewAnnotation()) }
}
annotations.forEach { appendLine(it) }
appendLine("@Composable")
appendLine("private fun $functionName() {")

View file

@ -124,9 +124,7 @@ private fun failureMessage(problems: List<LayoutProblem>, configs: List<StressCo
appendLine("Layout checks failed, ${grouped.size} problem(s):")
grouped.forEach { appendLine(" - $it") }
appendLine()
appendLine("Reproduce in the IDE with a preview like this:")
appendLine()
append(previewTemplate.render(failingConfigs))
append(previewTemplate.render(failingConfigs, configs))
}
}

View file

@ -82,9 +82,9 @@ data class StressConfig(
/**
* `@Preview` has no layout direction, so RTL is expressed the way `@RtlPreview` does it: as the locale written in
* Arabic script.
* Arabic script. Three-letter languages, like the pseudo-languages, can only be written as BCP-47 qualifiers.
*/
internal fun toPreviewAnnotation(): String {
internal fun toPreviewAnnotation(group: String? = null): String {
val args = mutableListOf(
"name = \"$name\"",
"device = \"spec:width=${windowSize.width.value.toInt()}dp,height=${windowSize.height.value.toInt()}dp\""
@ -99,12 +99,16 @@ data class StressConfig(
args += "locale = \"$previewLocale\""
}
if (group != null) {
args += "group = \"$group\""
}
return "@Preview(${args.joinToString()})"
}
private fun previewLocale(): String? {
val parts = (locale ?: if (layoutDirection == LayoutDirection.Rtl) "en" else return null).split("-")
val subtags = if (layoutDirection == LayoutDirection.Rtl) listOf(parts.first(), "Arab") + parts.drop(1) else parts
return if (subtags.size == 1) subtags.single() else "b+" + subtags.joinToString("+")
return if (subtags.size == 1 && subtags.single().length == 2) subtags.single() else "b+" + subtags.joinToString("+")
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.screener
/**
* The language a [StressMatrix] renders string resources in, by BCP-47 tag in [locale].
*/
sealed interface StressLanguage {
val label: String
val locale: String
/**
* A real translation, such as "de". Layouts tested against one can start failing whenever new translations land,
* so prefer a [PseudoLanguage] for tests that gate merges.
*/
data class Translation(override val locale: String) : StressLanguage {
override val label: String = locale
}
}
/**
* Translations generated from the English strings into debug builds by the `pseudo-locales` build plugin, which
* describes how each one stretches them. A layout fails against one as soon as its code or English copy changes,
* rather than when real translations next land. Only strings from Signal modules are translated: framework and
* library strings stay English.
*/
enum class PseudoLanguage(override val label: String, override val locale: String) : StressLanguage {
/** More words, a little longer, with the same places to break between them, like French or Dutch. */
EXPANSIVE("pseudo-expansive", "qaa"),
/** Longer words joined into compounds with nowhere to break, like German or Finnish. */
COMPOUND("pseudo-compound", "qab")
}

View file

@ -12,14 +12,14 @@ private const val DEVICE = 0
private const val DISPLAY_SIZE = 1
private const val FONT_SCALE = 2
private const val LAYOUT_DIRECTION = 3
private const val LOCALE = 4
private const val LANGUAGE = 4
/**
* The dimensions a layout should pass its checks under, combined into [StressConfig]s.
*
* [displaySizes] are the system "Display size" setting's scale factors, which shrink a device's window in dp.
* The default [locales] are German, which has the longest unbreakable words of our Latin-script translations, and
* French, which has the longest short UI strings. The first value of each dimension is its everyday default.
* The default [languages] are generated from the English strings, so they don't change when translations land.
* The first value of each dimension is its everyday default, and a null language is English.
*/
data class StressMatrix(
val devices: Map<String, DpSize> = mapOf(
@ -30,7 +30,7 @@ data class StressMatrix(
val displaySizes: List<Float> = listOf(1f, LARGEST_DISPLAY_SIZE),
val fontScales: List<Float> = listOf(1f, 2f),
val layoutDirections: List<LayoutDirection> = listOf(LayoutDirection.Ltr, LayoutDirection.Rtl),
val locales: List<String?> = listOf(null, "de", "fr")
val languages: List<StressLanguage?> = listOf(null, PseudoLanguage.EXPANSIVE, PseudoLanguage.COMPOUND)
) {
companion object {
@ -39,7 +39,7 @@ data class StressMatrix(
}
private val dimensions: List<Int>
get() = listOf(devices.size, displaySizes.size, fontScales.size, layoutDirections.size, locales.size)
get() = listOf(devices.size, displaySizes.size, fontScales.size, layoutDirections.size, languages.size)
/** Every combination of every dimension. */
fun exhaustive(): List<StressConfig> {
@ -95,14 +95,14 @@ data class StressMatrix(
val displaySize = displaySizes[this[DISPLAY_SIZE]]
val fontScale = fontScales[this[FONT_SCALE]]
val layoutDirection = layoutDirections[this[LAYOUT_DIRECTION]]
val locale = locales[this[LOCALE]]
val language = languages[this[LANGUAGE]]
val name = buildList {
add(deviceName)
if (displaySize != 1f) add("display ${StressConfig.formatScale(displaySize)}x")
if (fontScale != 1f) add("font ${StressConfig.formatScale(fontScale)}x")
if (layoutDirection == LayoutDirection.Rtl) add("RTL")
if (locale != null) add(locale)
if (language != null) add(language.label)
}.joinToString(" + ")
return StressConfig(
@ -110,7 +110,7 @@ data class StressMatrix(
windowSize = StressConfig.WindowSizes.atDisplaySize(deviceSize, displaySize),
fontScale = fontScale,
layoutDirection = layoutDirection,
locale = locale
locale = language?.locale
)
}
}

View file

@ -50,8 +50,12 @@ class PreviewTemplateTest {
fun `template lists the failing configs above its function`() {
val template = PreviewTemplate(functionName = "MyScreenPreview", annotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)"), contentHint = "MyScreen()")
assertThat(template.render(listOf(StressConfig.Baseline, StressConfig.LargestFontLandscape))).isEqualTo(
val configs = listOf(StressConfig.Baseline, StressConfig.LargestFontLandscape)
assertThat(template.render(failingConfigs = configs, allConfigs = configs)).isEqualTo(
"""
Reproduce in the IDE with a preview like this:
@Preview(name = "baseline", device = "spec:width=360dp,height=780dp")
@Preview(name = "font 2x + landscape", device = "spec:width=780dp,height=360dp", fontScale = 2f)
@PreviewWrapper(SignalPreviewWrapper::class)
@ -62,4 +66,37 @@ class PreviewTemplateTest {
""".trimIndent()
)
}
@Test
fun `template for the default configs uses StressTestPreview and names the failing previews`() {
val template = PreviewTemplate(functionName = "MyScreenPreview", annotations = listOf("@PreviewWrapper(SignalPreviewWrapper::class)"), contentHint = "MyScreen()")
val failing = StressConfig.Default.take(2)
assertThat(template.render(failingConfigs = failing, allConfigs = StressConfig.Default)).isEqualTo(
"""
Reproduce in the IDE with this preview, and look at its "${failing[0].name}", "${failing[1].name}" previews:
@StressTestPreview
@PreviewWrapper(SignalPreviewWrapper::class)
@Composable
private fun MyScreenPreview() {
// MyScreen()
}
""".trimIndent()
)
}
@Test
fun `group is added last`() {
assertThat(StressConfig.Baseline.toPreviewAnnotation(group = "stress test"))
.isEqualTo("""@Preview(name = "baseline", device = "spec:width=360dp,height=780dp", group = "stress test")""")
}
@Test
fun `pseudo-languages become bcp-47 qualifiers`() {
val config = StressConfig(name = "pseudo-compound + RTL", layoutDirection = LayoutDirection.Rtl, locale = PseudoLanguage.COMPOUND.locale)
assertThat(config.toPreviewAnnotation())
.isEqualTo("""@Preview(name = "pseudo-compound + RTL", device = "spec:width=360dp,height=780dp", locale = "b+qab+Arab")""")
}
}

Some files were not shown because too many files have changed in this diff Show more