diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/RobolectricDescribeSpecParameterBuilder.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/RobolectricDescribeSpecParameterBuilder.kt new file mode 100644 index 000000000..27465fcb0 --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/RobolectricDescribeSpecParameterBuilder.kt @@ -0,0 +1,150 @@ +package io.github.droidkaigi.confsched.testing + +inline fun describeTests(block: TestCaseTreeBuilder.() -> Unit): List> { + val builder = TestCaseTreeBuilder() + builder.block() + val root = builder.build() + return generateTestCases(root) +} + +fun DescribedTestCase.execute(robot: T) { + for ((index, step) in steps.withIndex()) { + println("Executing step: $index ($description)") + when (step) { + is TestNode.Run -> step.action(robot) + is TestNode.Check -> { + if (step.description == targetCheckDescription) { + step.action(robot) + } + } + + is TestNode.Describe -> {} + } + println("Step executed: $index") + } +} + +sealed class TestNode { + data class Describe(val description: String, val children: List>) : TestNode() + data class Run(val action: T.() -> Unit) : TestNode() + data class Check(val description: String, val action: T.() -> Unit) : TestNode() +} + +data class DescribedTestCase( + val description: String, + val steps: List>, + val targetCheckDescription: String, +) { + override fun toString(): String = description +} + +data class AncestryNode( + val node: TestNode, + val childIndex: Int, +) + +data class CheckNode( + val description: String, + val fullDescription: String, + val node: TestNode.Check, + val ancestry: List>, +) + +class TestCaseTreeBuilder { + private val children = mutableListOf>() + + fun describe(description: String, block: TestCaseTreeBuilder.() -> Unit) { + val builder = TestCaseTreeBuilder() + builder.block() + children.add(TestNode.Describe(description, builder.children)) + } + + fun run(action: T.() -> Unit) { + children.add(TestNode.Run { action() }) + } + + fun check(description: String, action: T.() -> Unit) { + children.add(TestNode.Check(description) { action() }) + } + + fun build(): TestNode.Describe = TestNode.Describe("", children) +} + +fun generateTestCases(root: TestNode.Describe): List> { + val checkNodes = collectCheckNodes(root) + return checkNodes.map { createTestCase(it) } +} + +/** + * Collect all check nodes from the test tree + * it will be O(N) + */ +private fun collectCheckNodes(root: TestNode.Describe): List> { + val checkNodes = mutableListOf>() + + fun traverse(node: TestNode, parentDescription: String, ancestry: List>) { + when (node) { + is TestNode.Describe -> { + val currentDescription = + if (parentDescription.isEmpty()) node.description else "$parentDescription - ${node.description}" + node.children.forEachIndexed { index, child -> + val currentAncestry = ancestry + AncestryNode(node, index) + traverse(child, currentDescription, currentAncestry) + } + } + + is TestNode.Check -> { + val fullDescription = if (parentDescription.isNotBlank()) { + "$parentDescription - ${node.description}" + } else { + node.description + } + checkNodes.add(CheckNode(node.description, fullDescription, node, ancestry)) + } + + is TestNode.Run -> {} + } + } + + traverse(root, "", emptyList()) + return checkNodes +} + +/** + * Create a test case from a check node + * We only run the steps that are necessary to reach the check node + * so the time complexity might be O(logN) + */ +private fun createTestCase(checkNode: CheckNode): DescribedTestCase { + val steps = mutableListOf>() + + fun processNode(node: TestNode, ancestry: List>, depth: Int) { + when (node) { + is TestNode.Describe -> { + for (child in node.children) { + if (depth + 1 < checkNode.ancestry.size && child == checkNode.ancestry[depth + 1].node) { + processNode(child, ancestry + node, depth + 1) + } else if (child is TestNode.Run) { + steps.add(child) + } else if (child == checkNode.node) { + steps.add(child) + } + } + } + + is TestNode.Run -> { + steps.add(node) + } + + is TestNode.Check -> { + if (node == checkNode.node) { + steps.add(node) + } + } + } + } + + processNode(checkNode.ancestry.first().node, emptyList(), 0) + + return DescribedTestCase(checkNode.fullDescription, steps, checkNode.description) +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt index 8acbd533b..dd1e33acf 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt @@ -12,6 +12,7 @@ import com.github.takahirom.roborazzi.Dump import com.github.takahirom.roborazzi.RoborazziOptions import com.github.takahirom.roborazzi.captureRoboImage import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.sessions.TimetableListItemBookmarkIconTestTag import io.github.droidkaigi.confsched.sessions.TimetableListItemTestTag import io.github.droidkaigi.confsched.sessions.TimetableScreen @@ -31,12 +32,16 @@ class TimetableScreenRobot @Inject constructor( private val timetableServerRobot: DefaultTimetableServerRobot, ) : ScreenRobot by screenRobot, TimetableServerRobot by timetableServerRobot { + val clickedItems = mutableSetOf() + fun setupTimetableScreenContent() { robotTestRule.setContent { CompositionLocalProvider(LocalClock provides FakeClock) { KaigiTheme { TimetableScreen( - onTimetableItemClick = { }, + onTimetableItemClick = { + clickedItems.add(it) + }, ) } } @@ -64,6 +69,7 @@ class TimetableScreenRobot @Inject constructor( composeTestRule .onNode(hasTestTag(TimetableUiTypeChangeButtonTestTag)) .performClick() + waitUntilIdle() } fun clickTimetableTab( @@ -86,6 +92,10 @@ class TimetableScreenRobot @Inject constructor( } } + fun checkClickedItemsExists() { + assert(clickedItems.isNotEmpty()) + } + fun checkTimetableItemsDisplayed() { composeTestRule .onAllNodes(hasTestTag(TimetableListItemTestTag)) diff --git a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt index ec959ebea..d87af33ae 100644 --- a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt +++ b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt @@ -1,22 +1,23 @@ package io.github.droidkaigi.confsched.sessions -import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import io.github.droidkaigi.confsched.testing.DescribedTestCase import io.github.droidkaigi.confsched.testing.RobotTestRule import io.github.droidkaigi.confsched.testing.TimetableServerRobot.ServerStatus +import io.github.droidkaigi.confsched.testing.describeTests +import io.github.droidkaigi.confsched.testing.execute import io.github.droidkaigi.confsched.testing.robot.TimetableScreenRobot import io.github.droidkaigi.confsched.testing.runRobot -import io.github.droidkaigi.confsched.testing.todoChecks import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.annotation.Config +import org.robolectric.ParameterizedRobolectricTestRunner import javax.inject.Inject -@RunWith(AndroidJUnit4::class) +@RunWith(ParameterizedRobolectricTestRunner::class) @HiltAndroidTest -class TimetableScreenTest { +class TimetableScreenTest(private val testCase: DescribedTestCase) { @get:Rule @BindValue val robotTestRule: RobotTestRule = RobotTestRule(this) @@ -25,101 +26,65 @@ class TimetableScreenTest { lateinit var timetableScreenRobot: TimetableScreenRobot @Test - fun checkLaunchShot() { + fun runTest() { runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!")) + testCase.execute(timetableScreenRobot) } } - @Test - fun checkLaunchServerErrorShot() { - runRobot(timetableScreenRobot) { - setupTimetableServer(ServerStatus.Error) - setupTimetableScreenContent() - captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!")) - } - } - - @Test - fun checkLaunch() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - checkTimetableItemsDisplayed() - } - } - - @Test - fun checkLaunchAccessibilityShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - checkAccessibilityCapture() - } - } - - @Test - fun checkBookmarkToggleShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - clickFirstSessionBookmark() - captureScreenWithChecks() - clickFirstSessionBookmark() - captureScreenWithChecks() - } - } - - @Test - fun checkScrollShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - scrollTimetable() - captureScreenWithChecks() - } - } - - @Test - fun checkGridShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - clickTimetableUiTypeChangeButton() - captureScreenWithChecks() - } - } - - @Test - fun checkGridScrollShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - clickTimetableUiTypeChangeButton() - scrollTimetable() - captureScreenWithChecks() - } - } - - @Test - @Config(fontScale = 0.5f) - fun checkSmallFontScaleShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!")) - } - } - - @Test - @Config(fontScale = 1.5f) - fun checkLargeFontScaleShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!")) - } - } - - @Test - @Config(fontScale = 2.0f) - fun checkHugeFontScaleShot() { - runRobot(timetableScreenRobot) { - setupTimetableScreenContent() - captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!")) + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun testCases(): List> { + return describeTests { + describe("when server is operational") { + run { + setupTimetableServer(ServerStatus.Operational) + setupTimetableScreenContent() + } + check("should show timetable items") { + captureScreenWithChecks(checks = { + checkTimetableItemsDisplayed() + }) + } + describe("click first session bookmark") { + run { + clickFirstSessionBookmark() + } + check("should show bookmarked session") { + // FIXME: Add check for bookmarked session + captureScreenWithChecks() + } + } + describe("click first session") { + run { + clickFirstSession() + } + check("should show session detail") { + checkClickedItemsExists() + } + } + describe("click timetable ui type change button") { + run { + clickTimetableUiTypeChangeButton() + } + check("should change timetable ui type") { + // FIXME: Add check for timetable ui type change + captureScreenWithChecks() + } + } + } + describe("when server is down") { + run { + setupTimetableServer(ServerStatus.Error) + setupTimetableScreenContent() + } + check("should show error message") { + // FIXME: Add check for error message + captureScreenWithChecks() + } + } + } } } } diff --git a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreen.kt b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreen.kt index 5f39617f6..2f575c692 100644 --- a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreen.kt +++ b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreen.kt @@ -180,10 +180,6 @@ private fun TimetableScreen( }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { -// TimetableTopArea( -// timetableUiType = uiState.timetableUiType, -// onTimetableUiChangeClick = onTimetableUiChangeClick, -// ) Row { Text(text = "UiType: ${uiState.timetableUiType}") Button(