Skip to content

Commit ab12e11

Browse files
authored
Merge pull request #225 from tommybuonomo/add-compose-indicator-tests
test: add Compose indicator regression suite + fix AGP 9 build
2 parents 8cf17c9 + 1ff2f24 commit ab12e11

48 files changed

Lines changed: 609 additions & 35 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
name: verify-indicators
3+
description: Run the full regression suite for the Compose dots indicators (logic + behaviour + golden screenshots) and relay any screenshot diffs to the agent for a visual verdict. Use after any change to the library's Compose indicators (Shift, Balloon, Spring, Worm), their positioning math, or RTL handling.
4+
---
5+
6+
# Verify the Compose dots indicators
7+
8+
This is the automated replacement for the manual regression pass (swipe every page and
9+
eyeball each of the 4 indicator types, add/remove pages in edge situations, tap dots to
10+
navigate). It runs three deterministic test tiers on the JVM (Robolectric, no emulator)
11+
and, when screenshots change, **looks at the diff images itself** and gives a verdict.
12+
13+
The tests live in `viewpagerdotsindicator/src/test/kotlin/com/tbuonomo/viewpagerdotsindicator/compose/`:
14+
- `ComputationsTest` — pure positioning/sizing math.
15+
- `DotsIndicatorBehaviorTest` — dot-tap navigation, dot counts, add/remove page edge cases, LTR+RTL no-crash.
16+
- `DotsIndicatorScreenshotTest` — golden screenshots, 4 types × {LTR, RTL} × 4 states, committed under `src/test/screenshots/`.
17+
18+
## Steps
19+
20+
### 1. Run logic + behaviour tests
21+
```
22+
./gradlew :viewpagerdotsindicator:testDebugUnitTest --tests "com.tbuonomo.viewpagerdotsindicator.compose.ComputationsTest" --tests "com.tbuonomo.viewpagerdotsindicator.compose.DotsIndicatorBehaviorTest"
23+
```
24+
If anything fails, read the report at
25+
`viewpagerdotsindicator/build/reports/tests/testDebugUnitTest/index.html` and the stack
26+
trace, then report the failing test and likely cause. Do not continue to step 2 until the
27+
user decides.
28+
29+
### 2. Verify the golden screenshots
30+
```
31+
./gradlew :viewpagerdotsindicator:verifyRoborazziDebug
32+
```
33+
- **Passes** → all dots are pixel-identical to the committed goldens. Report success.
34+
- **Fails** → one or more renders drifted. Go to step 3.
35+
36+
> ⚠️ **Platform note:** the committed goldens are recorded on **Linux** (to match CI).
37+
> Native-graphics rendering is OS-specific, so `verifyRoborazziDebug` run on macOS/Windows
38+
> will report pixel diffs that are **not** real regressions. On a non-Linux machine, skip the
39+
> exact verify and instead do step 3 against freshly **recorded** images: run
40+
> `./gradlew :viewpagerdotsindicator:recordRoborazziDebug` and visually judge the regenerated
41+
> `src/test/screenshots/*.png` with the checklist below (do **not** commit those — they would
42+
> break CI). The authoritative exact check happens in CI on Linux.
43+
44+
### 3. AI relay — judge the diffs
45+
On failure, Roborazzi writes comparison images to
46+
`viewpagerdotsindicator/build/outputs/roborazzi/`. The relevant files end in `_compare.png`
47+
(side-by-side: expected | actual | diff). List them:
48+
```
49+
ls viewpagerdotsindicator/build/outputs/roborazzi/*_compare.png
50+
```
51+
**Open each failing `_compare.png` with the Read tool** (it renders the image) and judge the
52+
*actual* (middle/right) panel against this checklist:
53+
54+
- **Page 0**: selected dot at the **left** edge in LTR, at the **right** edge in RTL.
55+
- **Last page**: selected dot at the **right** edge in LTR, at the **left** edge in RTL.
56+
- **Mid page**: selected dot on the **center** dot.
57+
- **Selected dot advances one step per page**, in the reading direction (left→right LTR,
58+
right→left RTL).
59+
- **Worm** mid-transition (the `*_o05` frames): the worm is **elongated**, spanning the two
60+
dots it is moving between — toward the right in LTR, toward the left in RTL.
61+
- **Shift / Balloon**: the selected dot is the **largest**; others are base size.
62+
- No dot is clipped, off-screen, or missing; there are always 5 dots.
63+
64+
Then classify each diff:
65+
- **Intended improvement** (the new render is correct and better): tell the user to
66+
re-record and review, then commit the updated goldens:
67+
```
68+
./gradlew :viewpagerdotsindicator:recordRoborazziDebug
69+
```
70+
- **Regression** (the new render violates the checklist): report exactly which
71+
type / direction / state broke (the golden name, e.g. `worm_rtl_p0_o05`) and what is
72+
visually wrong, so it can be fixed before merging.
73+
74+
### 4. Summary
75+
Print a concise per-type PASS/FAIL table (Shift, Balloon, Spring, Worm), note any goldens
76+
that need re-recording, and state the overall verdict.
77+
78+
## (Re)recording the committed goldens
79+
80+
The committed goldens **must be recorded on Linux** so they match CI. Don't commit goldens
81+
recorded on macOS/Windows — they will fail CI's exact verify. To regenerate the trusted set:
82+
83+
1. Let CI record them: push a branch with the CI test step temporarily set to
84+
`recordRoborazziDebug` and an artifact upload of
85+
`viewpagerdotsindicator/src/test/screenshots/**`, download the artifact, commit those PNGs,
86+
then revert the CI step to `verifyRoborazziDebug`. (Or record inside a Linux container.)
87+
2. Before committing, open a representative sample (at minimum the LTR + RTL `*_p0_o0` and
88+
`worm_*_p0_o05` frames) with the Read tool and confirm against the checklist above —
89+
goldens are trusted only after a human/AI has eyeballed them once.
90+
91+
## Notes
92+
- Everything runs on the JVM via Robolectric — no emulator or device is required.
93+
- Requires JDK 17+ (the module toolchain is 21).
94+
- Goldens are recorded at `@Config(sdk = [34])`; they will not match if the SDK level in
95+
`DotsIndicatorScreenshotTest` changes.
96+
- To add a new scenario, add a row to the `states`/`types` matrix in
97+
`DotsIndicatorScreenshotTest`, then `recordRoborazziDebug` and review the new golden.

.github/workflows/ci.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ jobs:
1919
- name: Checkout
2020
uses: actions/checkout@v6
2121

22-
- name: Set up JDK 17
22+
- name: Set up JDK 21
2323
uses: actions/setup-java@v5
2424
with:
25-
java-version: '17'
25+
java-version: '21'
2626
distribution: 'temurin'
2727

2828
- name: Set up Gradle
@@ -42,6 +42,9 @@ jobs:
4242
- name: Run Android Lint
4343
run: ./gradlew :viewpagerdotsindicator:lint
4444

45+
- name: Run unit tests + golden screenshot verification
46+
run: ./gradlew :viewpagerdotsindicator:testDebugUnitTest :viewpagerdotsindicator:verifyRoborazziDebug
47+
4548
- name: Upload lint results
4649
if: always()
4750
uses: actions/upload-artifact@v7
@@ -50,6 +53,16 @@ jobs:
5053
path: viewpagerdotsindicator/build/reports/lint-results*.html
5154
retention-days: 7
5255

56+
- name: Upload test + screenshot results
57+
if: always()
58+
uses: actions/upload-artifact@v7
59+
with:
60+
name: test-results
61+
path: |
62+
viewpagerdotsindicator/build/reports/tests/**
63+
viewpagerdotsindicator/build/outputs/roborazzi/**
64+
retention-days: 7
65+
5366
dependency-review:
5467
name: Dependency Review
5568
runs-on: ubuntu-latest

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Top-level build file where you can add configuration options common to all sub-projects/modules.
22
plugins {
33
alias(libs.plugins.android.application) apply false
4-
alias(libs.plugins.jetbrains.kotlin.android) apply false
54
alias(libs.plugins.jetbrains.kotlin.compose) apply false
65
alias(libs.plugins.android.library) apply false
76
alias(libs.plugins.maven.publish) apply false
87
alias(libs.plugins.nmcp) apply false
8+
alias(libs.plugins.roborazzi) apply false
99
}

gradle/libs.versions.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ viewpager2 = "1.1.0"
1515
activityCompose = "1.13.0"
1616
mavenPublish = "0.36.0"
1717
nmcp = "1.5.0"
18+
roborazzi = "1.64.0"
19+
robolectric = "4.16.1"
1820

1921
[libraries]
2022
androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "cardview" }
@@ -34,6 +36,12 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
3436
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
3537
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
3638
androidx-dynamic-animation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicAnimation" }
39+
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
40+
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
41+
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
42+
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
43+
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
44+
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
3745

3846
[plugins]
3947
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -42,3 +50,4 @@ jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version
4250
android-library = { id = "com.android.library", version.ref = "agp" }
4351
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
4452
nmcp = { id = "com.gradleup.nmcp", version.ref = "nmcp" }
53+
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }

viewpagerdotsindicator-sample/build.gradle.kts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
plugins {
22
alias(libs.plugins.android.application)
3-
alias(libs.plugins.jetbrains.kotlin.android)
43
alias(libs.plugins.jetbrains.kotlin.compose)
54
}
65

76
android {
8-
compileSdk = 35
7+
compileSdk = 37
98
defaultConfig {
109
applicationId = "com.tbuonomo.dotsindicatorsample"
11-
minSdk = 21
10+
minSdk = 23
1211
targetSdk = 35
1312
versionCode = 1
1413
versionName = "1.0"

viewpagerdotsindicator/build.gradle.kts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,37 @@
22

33
plugins {
44
alias(libs.plugins.android.library)
5-
alias(libs.plugins.jetbrains.kotlin.android)
65
alias(libs.plugins.jetbrains.kotlin.compose)
76
alias(libs.plugins.maven.publish)
87
alias(libs.plugins.nmcp)
8+
alias(libs.plugins.roborazzi)
99
}
1010

1111
android {
12-
compileSdk = 35
12+
compileSdk = 37
1313

1414
defaultConfig {
15-
minSdk = 21
15+
minSdk = 23
1616
}
1717
buildTypes {
1818
release {
1919
isMinifyEnabled = false
20-
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
20+
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
2121
}
2222
}
2323

2424
buildFeatures {
2525
compose = true
2626
}
2727
namespace = "com.tbuonomo.viewpagerdotsindicator"
28+
29+
testOptions {
30+
unitTests {
31+
isIncludeAndroidResources = true // Robolectric needs merged resources/manifest
32+
isReturnDefaultValues = true
33+
all { it.maxHeapSize = "2g" } // native-graphics buffers for many PNGs
34+
}
35+
}
2836
}
2937

3038
kotlin {
@@ -44,4 +52,13 @@ dependencies {
4452
implementation(libs.androidx.material3)
4553
testImplementation(libs.junit)
4654
debugImplementation(libs.androidx.ui.tooling)
55+
56+
// JVM Compose UI tests + Roborazzi golden screenshots (Robolectric, no emulator)
57+
testImplementation(platform(libs.androidx.compose.bom))
58+
testImplementation(libs.androidx.ui.test.junit4)
59+
testImplementation(libs.robolectric)
60+
testImplementation(libs.roborazzi)
61+
testImplementation(libs.roborazzi.compose)
62+
testImplementation(libs.roborazzi.junit.rule)
63+
debugImplementation(libs.androidx.ui.test.manifest) // MUST be debug: backs createComposeRule()
4764
}

viewpagerdotsindicator/src/main/kotlin/com/tbuonomo/viewpagerdotsindicator/compose/Dot.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import androidx.compose.runtime.Composable
88
import androidx.compose.ui.Modifier
99
import com.tbuonomo.viewpagerdotsindicator.compose.model.DotGraphic
1010

11+
/**
12+
* Stable test tag applied to each clickable dot, indexed by its position.
13+
* Used by UI tests to locate and click a specific dot; inert in production.
14+
*/
15+
fun dotTestTag(index: Int): String = "dotsindicator_dot_$index"
16+
1117
@Composable
1218
internal fun Dot(
1319
graphic: DotGraphic,

viewpagerdotsindicator/src/main/kotlin/com/tbuonomo/viewpagerdotsindicator/compose/DotsIndicator.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.tbuonomo.viewpagerdotsindicator.compose
22

3+
import androidx.annotation.VisibleForTesting
34
import androidx.compose.foundation.ExperimentalFoundationApi
45
import androidx.compose.foundation.layout.*
56
import androidx.compose.foundation.pager.PagerState
@@ -53,7 +54,8 @@ fun DotsIndicator(
5354
type.IndicatorTypeComposable({ globalOffset }, modifier, dotCount, dotSpacing, onDotClicked)
5455
}
5556

56-
private fun computeGlobalScrollOffset(position: Int, positionOffset: Float, totalCount: Int): Float {
57+
@VisibleForTesting
58+
internal fun computeGlobalScrollOffset(position: Int, positionOffset: Float, totalCount: Int): Float {
5759
var offset = (position + positionOffset)
5860
val lastPageIndex = (totalCount - 1).toFloat()
5961
if (offset == lastPageIndex) {

viewpagerdotsindicator/src/main/kotlin/com/tbuonomo/viewpagerdotsindicator/compose/type/BalloonIndicatorType.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import androidx.compose.foundation.clickable
44
import androidx.compose.foundation.layout.*
55
import androidx.compose.foundation.lazy.LazyRow
66
import androidx.compose.runtime.*
7+
import androidx.annotation.VisibleForTesting
78
import androidx.compose.ui.Alignment
89
import androidx.compose.ui.Modifier
910
import androidx.compose.ui.draw.scale
11+
import androidx.compose.ui.platform.testTag
1012
import androidx.compose.ui.unit.Dp
1113
import androidx.compose.ui.unit.dp
1214
import androidx.compose.ui.unit.times
1315
import com.tbuonomo.viewpagerdotsindicator.compose.Dot
16+
import com.tbuonomo.viewpagerdotsindicator.compose.dotTestTag
1417
import com.tbuonomo.viewpagerdotsindicator.compose.model.DotGraphic
1518
import kotlin.math.absoluteValue
1619

@@ -39,6 +42,7 @@ class BalloonIndicatorType(
3942
val dotModifier by remember(dotSize) {
4043
mutableStateOf(
4144
Modifier
45+
.testTag(dotTestTag(dotIndex))
4246
.scale(dotSize)
4347
.clickable {
4448
onDotClicked?.invoke(dotIndex)
@@ -56,7 +60,8 @@ class BalloonIndicatorType(
5660
}
5761
}
5862

59-
private fun computeDotWidth(currentDotIndex: Int, globalOffset: Float): Float {
63+
@VisibleForTesting
64+
internal fun computeDotWidth(currentDotIndex: Int, globalOffset: Float): Float {
6065
val diffFactor = 1f - (currentDotIndex - globalOffset).absoluteValue.coerceAtMost(1f)
6166
val sizeToAdd = ((balloonSizeFactor - 1f).coerceAtLeast(0f) * dotsGraphic.size * diffFactor)
6267
return (dotsGraphic.size + sizeToAdd) / dotsGraphic.size

viewpagerdotsindicator/src/main/kotlin/com/tbuonomo/viewpagerdotsindicator/compose/type/ShiftIndicatorType.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import androidx.compose.foundation.clickable
44
import androidx.compose.foundation.layout.*
55
import androidx.compose.foundation.lazy.LazyRow
66
import androidx.compose.runtime.*
7+
import androidx.annotation.VisibleForTesting
78
import androidx.compose.ui.Alignment
89
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.platform.testTag
911
import androidx.compose.ui.unit.Dp
1012
import androidx.compose.ui.unit.times
1113
import com.tbuonomo.viewpagerdotsindicator.compose.Dot
14+
import com.tbuonomo.viewpagerdotsindicator.compose.dotTestTag
1215
import com.tbuonomo.viewpagerdotsindicator.compose.model.DotGraphic
1316
import kotlin.math.absoluteValue
1417

@@ -34,6 +37,7 @@ class ShiftIndicatorType(
3437
val dotModifier by remember(dotWidth) {
3538
mutableStateOf(
3639
Modifier
40+
.testTag(dotTestTag(dotIndex))
3741
.width(dotWidth)
3842
.clickable {
3943
onDotClicked?.invoke(dotIndex)
@@ -49,7 +53,8 @@ class ShiftIndicatorType(
4953
}
5054
}
5155

52-
private fun computeDotWidth(currentDotIndex: Int, globalOffset: Float): Dp {
56+
@VisibleForTesting
57+
internal fun computeDotWidth(currentDotIndex: Int, globalOffset: Float): Dp {
5358
val diffFactor = 1f - (currentDotIndex - globalOffset).absoluteValue.coerceAtMost(1f)
5459
val widthToAdd = ((shiftSizeFactor - 1f).coerceAtLeast(0f) * dotsGraphic.size * diffFactor)
5560
return dotsGraphic.size + widthToAdd

0 commit comments

Comments
 (0)