Files
Githug-Android/app/src/androidTest/java/solutions/tretter/githugandroid/GitRepositoryRuntimeInstrumentedTest.kt
2026-06-26 16:24:45 -05:00

266 lines
9.9 KiB
Kotlin

package solutions.tretter.githugandroid
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class GitRepositoryRuntimeInstrumentedTest {
@get:Rule
val composeRule = createEmptyComposeRule()
@Test
fun nativeGitRuntimeCompletesInitLevelOnDevice() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val runtime = GitRepositoryRuntime(context)
val level = initLevel()
assertTrue(runtime.unavailableMessage(), runtime.isNativeGitAvailable())
val repo = runtime.prepareLevel(level)
val (updatedRepo, _) = runtime.execute(level, repo, "git init")
assertTrue(updatedRepo.initialized)
assertTrue(level.validator(updatedRepo, "git init"))
}
@Test
fun allLevelsCompleteThroughAppTerminalUi() {
launchFreshApp().use {
allGithugLevels().forEachIndexed { index, level ->
waitForExercise(level)
if (level.id == renameCommitLevel().id) {
completeRenameCommitWithEditor()
} else {
level.testCases.first().commands.forEach(::submitTerminalCommand)
}
waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1))
}
}
}
@Test
fun renameCommitLevelCompletesThroughUiEditor() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
completeRenameCommitWithEditor()
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun renameCommitLevelCompletesThroughMessageOptionAfterReword() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
startRenameCommitReword()
composeRule.onNodeWithTag("git-message-editor-dismiss").performClick()
submitTerminalCommand("git commit --amend -m \"First commit\"")
submitTerminalCommand("git rebase --continue")
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun squashLevelWaitsForFinalEditorBeforeCompleting() {
launchFreshApp().use {
selectLevel(squashLevel())
submitTerminalCommand("git rebase -i HEAD~4")
waitForGitEditorPath(".git/rebase-merge/git-rebase-todo")
replaceGitEditorContent(squashRebaseTodo(currentGitEditorContent()))
composeRule.onNodeWithTag("git-message-editor-save").performClick()
waitForGitMessageEditor()
composeRule.onNodeWithTag("exercise-title-${squashLevel().id}").assertIsDisplayed()
composeRule.onNodeWithTag("git-message-editor-save").performClick()
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun configLevelCompletesThroughUiWithArbitraryValues() {
launchFreshApp().use {
submitTerminalCommand("git init")
waitForExercise(configLevel())
submitTerminalCommand("git config user.name githug")
submitTerminalCommand("git config user.email xxx@yy.com")
waitForLevelCompletion(nextLevel = addLevel())
}
}
@Test
fun visualFileEditorStartsWithActionsVisible() {
launchFreshApp().use {
submitTerminalCommand("edit notes.txt")
waitForTextEditorActions()
}
}
@Test
fun selectingLevelFromLevelsPaneUpdatesExerciseImmediately() {
launchFreshApp().use {
composeRule.onNodeWithTag("level-submodule")
.performScrollTo()
.performClick()
composeRule.waitUntil(timeoutMillis = 1_500) {
composeRule.onAllNodes(hasText(submoduleLevel().title, substring = true))
.fetchSemanticsNodes()
.isNotEmpty()
}
}
}
private fun launchFreshApp(): ActivityScenario<MainActivity> {
val context = InstrumentationRegistry.getInstrumentation().targetContext
File(context.filesDir, "githug-sandboxes").deleteRecursively()
File(context.applicationInfo.dataDir, "cache").deleteRecursively()
runBlocking {
GameProgressStore(context.applicationContext).saveProgress(emptySet(), activeLevelId = null)
GameProgressStore(context.applicationContext).saveShowHelpOnStart(showHelpOnStart = true)
}
val scenario = ActivityScenario.launch(MainActivity::class.java)
waitForExercise(initLevel())
return scenario
}
private fun waitForExercise(level: Level) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(level.title, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("exercise-title-${level.id}").assertIsDisplayed()
}
private fun waitForLevelCompletion(nextLevel: Level?) {
if (nextLevel == null) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText("All Githug levels completed", substring = true)).fetchSemanticsNodes().isNotEmpty()
}
return
}
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(nextLevel.title, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed()
}
private fun selectLevel(level: Level) {
composeRule.onNodeWithTag("level-${level.id}")
.performScrollTo()
.performClick()
waitForExercise(level)
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText("Loaded level: ${level.title}", substring = true))
.fetchSemanticsNodes()
.isNotEmpty()
}
}
private fun completeRenameCommitWithEditor() {
startRenameCommitReword()
waitForGitEditorPath(".git/COMMIT_EDITMSG")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst("First coommit", "First commit"),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun startRenameCommitReword() {
submitTerminalCommand("git rebase -i HEAD~2")
waitForGitEditorPath(".git/rebase-merge/git-rebase-todo")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst(Regex("(?m)^pick "), "reword "),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun waitForGitEditorPath(path: String) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(path, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-path").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitMessageEditor() {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodesWithTag("git-message-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-content").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitEditorActions() {
composeRule.onNodeWithTag("git-message-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("git-message-editor-dismiss").assertIsDisplayed()
}
private fun waitForTextEditorActions() {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodesWithTag("text-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("text-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("text-editor-dismiss").assertIsDisplayed()
}
private fun currentGitEditorContent(): String {
val node = composeRule.onNodeWithTag("git-message-editor-content").fetchSemanticsNode()
return node.config.getOrNull(SemanticsProperties.EditableText)?.text
?: error("Expected Git editor content semantics")
}
private fun squashRebaseTodo(content: String): String {
return content
.lineSequence()
.mapIndexed { index, line ->
if (index > 0 && line.startsWith("pick ")) line.replaceFirst("pick ", "squash ") else line
}
.joinToString("\n")
}
private fun replaceGitEditorContent(content: String) {
composeRule.onNodeWithTag("git-message-editor-content")
.performSemanticsAction(SemanticsActions.SetText) { setText ->
setText(AnnotatedString(content))
}
}
private fun submitTerminalCommand(command: String) {
val input = composeRule.onNodeWithTag("terminal-command-input")
input.performClick()
input.performTextClearance()
input.performTextInput(command)
input.performImeAction()
}
}