1 /*
<lambda>null2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.glance.appwidget.translators
18 
19 import android.content.Context
20 import android.content.res.Configuration
21 import android.graphics.Typeface
22 import android.os.Build
23 import android.text.Layout
24 import android.text.SpannedString
25 import android.text.style.AlignmentSpan
26 import android.text.style.StrikethroughSpan
27 import android.text.style.StyleSpan
28 import android.text.style.TextAppearanceSpan
29 import android.text.style.TypefaceSpan
30 import android.text.style.UnderlineSpan
31 import android.view.Gravity
32 import android.widget.LinearLayout
33 import android.widget.TextView
34 import androidx.compose.ui.graphics.Color
35 import androidx.compose.ui.unit.sp
36 import androidx.glance.GlanceModifier
37 import androidx.glance.appwidget.TextViewSubject.Companion.assertThat
38 import androidx.glance.appwidget.applyRemoteViews
39 import androidx.glance.appwidget.configurationContext
40 import androidx.glance.appwidget.nonGoneChildCount
41 import androidx.glance.appwidget.nonGoneChildren
42 import androidx.glance.appwidget.runAndTranslate
43 import androidx.glance.appwidget.runAndTranslateInRtl
44 import androidx.glance.appwidget.test.R
45 import androidx.glance.appwidget.toPixels
46 import androidx.glance.color.ColorProvider
47 import androidx.glance.layout.Column
48 import androidx.glance.layout.fillMaxWidth
49 import androidx.glance.semantics.contentDescription
50 import androidx.glance.semantics.semantics
51 import androidx.glance.text.FontFamily
52 import androidx.glance.text.FontStyle
53 import androidx.glance.text.FontWeight
54 import androidx.glance.text.Text
55 import androidx.glance.text.TextAlign
56 import androidx.glance.text.TextDecoration
57 import androidx.glance.text.TextStyle
58 import androidx.glance.unit.ColorProvider
59 import androidx.test.core.app.ApplicationProvider
60 import com.google.common.truth.Truth.assertThat
61 import kotlin.test.assertIs
62 import kotlinx.coroutines.ExperimentalCoroutinesApi
63 import kotlinx.coroutines.test.TestScope
64 import kotlinx.coroutines.test.runTest
65 import org.junit.Before
66 import org.junit.Test
67 import org.junit.runner.RunWith
68 import org.robolectric.RobolectricTestRunner
69 import org.robolectric.annotation.Config
70 
71 @OptIn(ExperimentalCoroutinesApi::class)
72 @RunWith(RobolectricTestRunner::class)
73 class TextTranslatorTest {
74 
75     private lateinit var fakeCoroutineScope: TestScope
76     private val context = ApplicationProvider.getApplicationContext<Context>()
77     private val lightContext = configurationContext { uiMode = Configuration.UI_MODE_NIGHT_NO }
78     private val darkContext = configurationContext { uiMode = Configuration.UI_MODE_NIGHT_YES }
79     private val displayMetrics = context.resources.displayMetrics
80 
81     @Before
82     fun setUp() {
83         fakeCoroutineScope = TestScope()
84     }
85 
86     @Test
87     fun canTranslateText() =
88         fakeCoroutineScope.runTest {
89             val rv = context.runAndTranslate { Text("test") }
90             val view = context.applyRemoteViews(rv)
91 
92             assertIs<TextView>(view)
93             assertThat(view.text.toString()).isEqualTo("test")
94         }
95 
96     @Test
97     @Config(sdk = [23, 29])
98     fun canTranslateText_withStyleWeightAndSize() =
99         fakeCoroutineScope.runTest {
100             val rv =
101                 context.runAndTranslate {
102                     Text(
103                         "test",
104                         style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp),
105                     )
106                 }
107             val view = context.applyRemoteViews(rv)
108 
109             assertIs<TextView>(view)
110             assertThat(view.textSize).isEqualTo(12.sp.toPixels(displayMetrics))
111             val content = view.text as SpannedString
112             assertThat(content.toString()).isEqualTo("test")
113             content.checkSingleSpan<TextAppearanceSpan> {
114                 if (Build.VERSION.SDK_INT >= 29) {
115                     assertThat(it.textFontWeight).isEqualTo(FontWeight.Medium.value)
116                     // Note: textStyle is always set, but to NORMAL if unspecified
117                     assertThat(it.textStyle).isEqualTo(Typeface.NORMAL)
118                 } else {
119                     assertThat(it.textStyle).isEqualTo(Typeface.BOLD)
120                 }
121             }
122         }
123 
124     @Test
125     fun canTranslateText_withMonoFontFamily() =
126         fakeCoroutineScope.runTest {
127             val rv =
128                 context.runAndTranslate {
129                     Text(
130                         "test",
131                         style = TextStyle(fontFamily = FontFamily.Monospace),
132                     )
133                 }
134             val view = context.applyRemoteViews(rv)
135 
136             assertIs<TextView>(view)
137             val content = view.text as SpannedString
138             assertThat(content.toString()).isEqualTo("test")
139             content.checkSingleSpan<TypefaceSpan> { span ->
140                 assertThat(span.family).isEqualTo("monospace")
141             }
142         }
143 
144     @Test
145     fun canTranslateText_withMonoSerifFamily() =
146         fakeCoroutineScope.runTest {
147             val rv =
148                 context.runAndTranslate {
149                     Text(
150                         "test",
151                         style = TextStyle(fontFamily = FontFamily.Serif),
152                     )
153                 }
154             val view = context.applyRemoteViews(rv)
155 
156             assertIs<TextView>(view)
157             val content = view.text as SpannedString
158             assertThat(content.toString()).isEqualTo("test")
159             content.checkSingleSpan<TypefaceSpan> { span ->
160                 assertThat(span.family).isEqualTo("serif")
161             }
162         }
163 
164     @Test
165     fun canTranslateText_withSansFontFamily() =
166         fakeCoroutineScope.runTest {
167             val rv =
168                 context.runAndTranslate {
169                     Text(
170                         "test",
171                         style = TextStyle(fontFamily = FontFamily.SansSerif),
172                     )
173                 }
174             val view = context.applyRemoteViews(rv)
175 
176             assertIs<TextView>(view)
177             val content = view.text as SpannedString
178             assertThat(content.toString()).isEqualTo("test")
179             content.checkSingleSpan<TypefaceSpan> { span ->
180                 assertThat(span.family).isEqualTo("sans-serif")
181             }
182         }
183 
184     @Test
185     fun canTranslateText_withCursiveFontFamily() =
186         fakeCoroutineScope.runTest {
187             val rv =
188                 context.runAndTranslate {
189                     Text(
190                         "test",
191                         style = TextStyle(fontFamily = FontFamily.Cursive),
192                     )
193                 }
194             val view = context.applyRemoteViews(rv)
195 
196             assertIs<TextView>(view)
197             val content = view.text as SpannedString
198             assertThat(content.toString()).isEqualTo("test")
199             content.checkSingleSpan<TypefaceSpan> { span ->
200                 assertThat(span.family).isEqualTo("cursive")
201             }
202         }
203 
204     @Test
205     fun canTranslateText_withCustomFontFamily() =
206         fakeCoroutineScope.runTest {
207             val rv =
208                 context.runAndTranslate {
209                     Text(
210                         "test",
211                         style = TextStyle(fontFamily = FontFamily("casual")),
212                     )
213                 }
214             val view = context.applyRemoteViews(rv)
215 
216             assertIs<TextView>(view)
217             val content = view.text as SpannedString
218             assertThat(content.toString()).isEqualTo("test")
219             content.checkSingleSpan<TypefaceSpan> { span ->
220                 assertThat(span.family).isEqualTo("casual")
221             }
222         }
223 
224     @Test
225     fun canTranslateText_withStyleStrikeThrough() =
226         fakeCoroutineScope.runTest {
227             val rv =
228                 context.runAndTranslate {
229                     Text("test", style = TextStyle(textDecoration = TextDecoration.LineThrough))
230                 }
231             val view = context.applyRemoteViews(rv)
232 
233             assertIs<TextView>(view)
234             val content = view.text as SpannedString
235             assertThat(content.toString()).isEqualTo("test")
236             content.checkSingleSpan<StrikethroughSpan> {}
237         }
238 
239     @Test
240     fun canTranslateText_withStyleUnderline() =
241         fakeCoroutineScope.runTest {
242             val rv =
243                 context.runAndTranslate {
244                     Text("test", style = TextStyle(textDecoration = TextDecoration.Underline))
245                 }
246             val view = context.applyRemoteViews(rv)
247 
248             assertIs<TextView>(view)
249             val content = view.text as SpannedString
250             assertThat(content.toString()).isEqualTo("test")
251             content.checkSingleSpan<UnderlineSpan> {}
252         }
253 
254     @Test
255     fun canTranslateText_withStyleItalic() =
256         fakeCoroutineScope.runTest {
257             val rv =
258                 context.runAndTranslate {
259                     Text("test", style = TextStyle(fontStyle = FontStyle.Italic))
260                 }
261             val view = context.applyRemoteViews(rv)
262 
263             assertIs<TextView>(view)
264             val content = view.text as SpannedString
265             assertThat(content.toString()).isEqualTo("test")
266             content.checkSingleSpan<StyleSpan> { assertThat(it.style).isEqualTo(Typeface.ITALIC) }
267         }
268 
269     @Test
270     @Config(sdk = [23, 29])
271     fun canTranslateText_withComplexStyle() =
272         fakeCoroutineScope.runTest {
273             val rv =
274                 context.runAndTranslate {
275                     Text(
276                         "test",
277                         style =
278                             TextStyle(
279                                 textDecoration =
280                                     TextDecoration.Underline + TextDecoration.LineThrough,
281                                 fontStyle = FontStyle.Italic,
282                                 fontWeight = FontWeight.Bold,
283                             ),
284                     )
285                 }
286             val view = context.applyRemoteViews(rv)
287 
288             assertIs<TextView>(view)
289             val content = view.text as SpannedString
290             assertThat(content.toString()).isEqualTo("test")
291             assertThat(content.getSpans(0, content.length, Any::class.java)).hasLength(4)
292             content.checkHasSingleTypedSpan<UnderlineSpan> {}
293             content.checkHasSingleTypedSpan<StrikethroughSpan> {}
294             content.checkHasSingleTypedSpan<StyleSpan> {
295                 assertThat(it.style).isEqualTo(Typeface.ITALIC)
296             }
297             content.checkHasSingleTypedSpan<TextAppearanceSpan> {
298                 if (Build.VERSION.SDK_INT >= 29) {
299                     assertThat(it.textFontWeight).isEqualTo(FontWeight.Bold.value)
300                     // Note: textStyle is always set, but to NORMAL if unspecified
301                     assertThat(it.textStyle).isEqualTo(Typeface.NORMAL)
302                 } else {
303                     assertThat(it.textStyle).isEqualTo(Typeface.BOLD)
304                 }
305             }
306         }
307 
308     @Test
309     fun canTranslateText_withAlignments() =
310         fakeCoroutineScope.runTest {
311             val rv =
312                 context.runAndTranslate {
313                     Column(modifier = GlanceModifier.fillMaxWidth()) {
314                         Text("Center", style = TextStyle(textAlign = TextAlign.Center))
315                         Text("Left", style = TextStyle(textAlign = TextAlign.Left))
316                         Text("Right", style = TextStyle(textAlign = TextAlign.Right))
317                         Text("Start", style = TextStyle(textAlign = TextAlign.Start))
318                         Text("End", style = TextStyle(textAlign = TextAlign.End))
319                     }
320                 }
321             val view = context.applyRemoteViews(rv)
322 
323             assertIs<LinearLayout>(view)
324             assertThat(view.nonGoneChildCount).isEqualTo(5)
325             val (center, left, right, start, end) = view.nonGoneChildren.toList()
326             assertIs<TextView>(center)
327             assertIs<TextView>(left)
328             assertIs<TextView>(right)
329             assertIs<TextView>(start)
330             assertIs<TextView>(end)
331 
332             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
333                 assertThat(center.horizontalGravity).isEqualTo(Gravity.CENTER_HORIZONTAL)
334                 assertThat(left.horizontalGravity).isEqualTo(Gravity.LEFT)
335                 assertThat(right.horizontalGravity).isEqualTo(Gravity.RIGHT)
336                 assertThat(start.horizontalGravity).isEqualTo(Gravity.START)
337                 assertThat(end.horizontalGravity).isEqualTo(Gravity.END)
338             } else {
339                 assertIs<SpannedString>(center.text).checkSingleSpan<AlignmentSpan.Standard> {
340                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_CENTER)
341                 }
342                 assertIs<SpannedString>(left.text).checkSingleSpan<AlignmentSpan.Standard> {
343                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL)
344                 }
345                 assertIs<SpannedString>(right.text).checkSingleSpan<AlignmentSpan.Standard> {
346                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE)
347                 }
348                 assertIs<SpannedString>(start.text).checkSingleSpan<AlignmentSpan.Standard> {
349                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL)
350                 }
351                 assertIs<SpannedString>(end.text).checkSingleSpan<AlignmentSpan.Standard> {
352                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE)
353                 }
354             }
355         }
356 
357     @Test
358     fun canTranslateText_withAlignmentsInRtl() =
359         fakeCoroutineScope.runTest {
360             val rv =
361                 context.runAndTranslateInRtl {
362                     Column(modifier = GlanceModifier.fillMaxWidth()) {
363                         Text("Center", style = TextStyle(textAlign = TextAlign.Center))
364                         Text("Left", style = TextStyle(textAlign = TextAlign.Left))
365                         Text("Right", style = TextStyle(textAlign = TextAlign.Right))
366                         Text("Start", style = TextStyle(textAlign = TextAlign.Start))
367                         Text("End", style = TextStyle(textAlign = TextAlign.End))
368                     }
369                 }
370             val view = context.applyRemoteViews(rv)
371 
372             assertIs<LinearLayout>(view)
373             assertThat(view.nonGoneChildCount).isEqualTo(5)
374             val (center, left, right, start, end) = view.nonGoneChildren.toList()
375             assertIs<TextView>(center)
376             assertIs<TextView>(left)
377             assertIs<TextView>(right)
378             assertIs<TextView>(start)
379             assertIs<TextView>(end)
380 
381             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
382                 assertThat(center.horizontalGravity).isEqualTo(Gravity.CENTER_HORIZONTAL)
383                 assertThat(left.horizontalGravity).isEqualTo(Gravity.LEFT)
384                 assertThat(right.horizontalGravity).isEqualTo(Gravity.RIGHT)
385                 assertThat(start.horizontalGravity).isEqualTo(Gravity.START)
386                 assertThat(end.horizontalGravity).isEqualTo(Gravity.END)
387             } else {
388                 assertIs<SpannedString>(center.text).checkSingleSpan<AlignmentSpan.Standard> {
389                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_CENTER)
390                 }
391                 assertIs<SpannedString>(left.text).checkSingleSpan<AlignmentSpan.Standard> {
392                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE)
393                 }
394                 assertIs<SpannedString>(right.text).checkSingleSpan<AlignmentSpan.Standard> {
395                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL)
396                 }
397                 assertIs<SpannedString>(start.text).checkSingleSpan<AlignmentSpan.Standard> {
398                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL)
399                 }
400                 assertIs<SpannedString>(end.text).checkSingleSpan<AlignmentSpan.Standard> {
401                     assertThat(it.alignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE)
402                 }
403             }
404         }
405 
406     @Test
407     fun canTranslateText_withColor_fixed() =
408         fakeCoroutineScope.runTest {
409             val rv =
410                 context.runAndTranslate {
411                     Column {
412                         Text("Blue", style = TextStyle(color = ColorProvider(Color.Blue)))
413                         Text("Red", style = TextStyle(color = ColorProvider(Color.Red)))
414                     }
415                 }
416             val view = context.applyRemoteViews(rv)
417 
418             assertIs<LinearLayout>(view)
419             assertThat(view.nonGoneChildCount).isEqualTo(2)
420 
421             val (blue, red) = view.nonGoneChildren.toList()
422             assertIs<TextView>(blue)
423             assertIs<TextView>(red)
424             assertThat(blue).hasTextColor(android.graphics.Color.BLUE)
425             assertThat(red).hasTextColor(android.graphics.Color.RED)
426         }
427 
428     @Config(minSdk = 29)
429     @Test
430     fun canTranslateText_withColor_resource_light() =
431         fakeCoroutineScope.runTest {
432             val rv =
433                 lightContext.runAndTranslate {
434                     Text("GrayResource", style = TextStyle(color = ColorProvider(R.color.my_color)))
435                 }
436             val view = lightContext.applyRemoteViews(rv)
437 
438             assertIs<TextView>(view)
439             assertThat(view).hasTextColor("#EEEEEE")
440         }
441 
442     @Config(minSdk = 29)
443     @Test
444     fun canTranslateText_withColor_resource_dark() =
445         fakeCoroutineScope.runTest {
446             val rv =
447                 darkContext.runAndTranslate {
448                     Text("GrayResource", style = TextStyle(color = ColorProvider(R.color.my_color)))
449                 }
450             val view = darkContext.applyRemoteViews(rv)
451 
452             assertIs<TextView>(view)
453             assertThat(view).hasTextColor("#111111")
454         }
455 
456     @Config(minSdk = 29)
457     @Test
458     fun canTranslateText_withColor_dayNight_light() =
459         fakeCoroutineScope.runTest {
460             val rv =
461                 lightContext.runAndTranslate {
462                     Text(
463                         "Green day / Magenta night",
464                         style =
465                             TextStyle(
466                                 color = ColorProvider(day = Color.Green, night = Color.Magenta)
467                             )
468                     )
469                 }
470             val view = lightContext.applyRemoteViews(rv)
471 
472             assertIs<TextView>(view)
473             assertThat(view).hasTextColor(android.graphics.Color.GREEN)
474         }
475 
476     @Config(minSdk = 29)
477     @Test
478     fun canTranslateText_withColor_dayNight_dark() =
479         fakeCoroutineScope.runTest {
480             val rv =
481                 darkContext.runAndTranslate {
482                     Text(
483                         "Green day / Magenta night",
484                         style =
485                             TextStyle(
486                                 color = ColorProvider(day = Color.Green, night = Color.Magenta)
487                             )
488                     )
489                 }
490             val view = darkContext.applyRemoteViews(rv)
491 
492             assertIs<TextView>(view)
493             assertThat(view).hasTextColor(android.graphics.Color.MAGENTA)
494         }
495 
496     @Test
497     fun canTranslateText_withMaxLines() =
498         fakeCoroutineScope.runTest {
499             val rv = context.runAndTranslate { Text("Max line is set", maxLines = 5) }
500             val view = context.applyRemoteViews(rv)
501 
502             assertIs<TextView>(view)
503             assertThat(view.maxLines).isEqualTo(5)
504         }
505 
506     @Test
507     fun canTranslateTextWithSemanticsModifier_contentDescription() =
508         fakeCoroutineScope.runTest {
509             val rv =
510                 context.runAndTranslate {
511                     Text(
512                         text = "Max line is set",
513                         maxLines = 5,
514                         modifier =
515                             GlanceModifier.semantics {
516                                 contentDescription = "Custom text description"
517                             },
518                     )
519                 }
520             val view = context.applyRemoteViews(rv)
521 
522             assertIs<TextView>(view)
523             assertThat(view.contentDescription).isEqualTo("Custom text description")
524         }
525 
526     private val TextView.horizontalGravity
527         get() = this.gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK
528 
529     // Check there is a single span, that it's of the correct type and passes the [check].
530     private inline fun <reified T> SpannedString.checkSingleSpan(check: (T) -> Unit) {
531         val spans = getSpans(0, length, Any::class.java)
532         assertThat(spans).hasLength(1)
533         checkInstance(spans[0], check)
534     }
535 
536     // Check there is a single span of the given type and that it passes the [check].
537     private inline fun <reified T> SpannedString.checkHasSingleTypedSpan(check: (T) -> Unit) {
538         val spans = getSpans(0, length, T::class.java)
539         assertThat(spans).hasLength(1)
540         check(spans[0])
541     }
542 
543     private inline fun <reified T> checkInstance(obj: Any, check: (T) -> Unit) {
544         assertIs<T>(obj)
545         check(obj)
546     }
547 }
548