Files
Githug-Android/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt
2026-05-18 19:52:50 -05:00

468 lines
17 KiB
Kotlin

package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class GitSandboxEngineTest {
@Test
fun statusShowsDeletedTrackedFiles() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)),
commits = listOf(CommitNode("0000001", "Added a temp file")),
branches = mapOf("master" to 1),
)
val (_, output) = GitSandboxEngine.execute(repo, "git status")
assertTrue(output.any { it.contains("deleted:") && it.contains("deleteme.rb") })
}
@Test
fun gitRmRemovesDeletedTrackedFileFromIndex() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)),
commits = listOf(CommitNode("0000001", "Added a temp file")),
branches = mapOf("master" to 1),
)
val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git rm deleteme.rb")
assertFalse(updatedRepo.files.any { it.name == "deleteme.rb" })
}
@Test
fun rmLevelIsNotSolvedByReadOnlyCommands() {
val level = rmLevel()
val repo = level.setup()
val (updatedRepo, _) = GitSandboxEngine.execute(repo, "ls")
assertFalse(level.validator(updatedRepo, "ls"))
}
@Test
fun gitMvExpandsWildcardSourcesIntoDestinationDirectory() {
val repo = RepoState(
initialized = true,
files = listOf(
GitFile("about.html", tracked = true),
GitFile("contact.html", tracked = true),
GitFile("index.html", tracked = true),
GitFile("README", tracked = true),
),
branches = mapOf("master" to 1),
)
val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git mv *.html src")
assertTrue(updatedRepo.files.any { it.name == "src/about.html" })
assertTrue(updatedRepo.files.any { it.name == "src/contact.html" })
assertTrue(updatedRepo.files.any { it.name == "src/index.html" })
assertTrue(updatedRepo.files.any { it.name == "README" })
}
@Test
fun singleQuotedWildcardIsNotExpanded() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("README"), GitFile("main.kt")),
)
val (_, output) = GitSandboxEngine.execute(repo, "echo '*'")
assertEquals(listOf("*"), output)
}
@Test
fun echoRedirectAcceptsNoSurroundingSpaces() {
val repo = RepoState(initialized = true)
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "echo '*.swp'>.gitignore")
assertTrue(output.isEmpty())
assertEquals("*.swp", updatedRepo.files.single { it.name == ".gitignore" }.content)
}
@Test
fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() {
val repo = RepoState(
initialized = true,
commits = listOf(CommitNode("abc1234", "Latest commit")),
branches = mapOf("master" to 1),
)
assertTrue(commitHashAnswer("0000001")(repo, "abc1234fedcba9876543210fedcba9876543210"))
}
@Test
fun mdIsAcceptedAsMkdirAlias() {
val repo = RepoState(initialized = true)
val (_, output) = GitSandboxEngine.execute(repo, "md src")
assertTrue(output.isEmpty())
}
@Test
fun dirIsAcceptedAsLsAlias() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("README"), GitFile("src/main.kt")),
)
val (_, output) = GitSandboxEngine.execute(repo, "dir")
assertTrue("README" in output)
assertTrue("src/main.kt" in output)
}
@Test
fun lsShowsDotfiles() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile(".gitignore"), GitFile("README")),
)
val (_, output) = GitSandboxEngine.execute(repo, "ls")
assertTrue(".gitignore" in output)
}
@Test
fun lsShowsGitDirectoryEntry() {
val repo = RepoState(initialized = true)
val (_, output) = GitSandboxEngine.execute(repo, "ls")
assertTrue(".git" in output)
}
@Test
fun fileCompletionUsesCurrentDirectory() {
val repo = RepoState(
initialized = true,
currentDir = "src",
files = listOf(
GitFile("README"),
GitFile("src/main.kt"),
GitFile("src/model/User.kt"),
),
)
assertEquals(listOf("main.kt", "model/User.kt"), fileCompletionCandidates(repo).sorted())
assertEquals(listOf("model/"), directoryCompletionCandidates(repo))
}
@Test
fun nativeCompletionUsesCurrentDirectoryIncludingGitMetadata() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-completion").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = level(
id = "completion-test",
title = "Completion Test",
description = "",
hints = emptyList(),
commandSuggestions = emptyList(),
setup = { RepoState(initialized = true) },
validator = { _, _ -> false },
)
val repo = runtime.prepareLevel(level)
val (gitDirRepo, _) = runtime.execute(level, repo, "cd .git")
assertTrue("HEAD" in runtime.completionCandidates(level, gitDirRepo, directoriesOnly = false))
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeCompletionIncludesExecutableShortcutPaths() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-script-completion").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = bisectLevel()
val repo = runtime.prepareLevel(level)
val candidates = runtime.completionCandidates(level, repo, directoriesOnly = false)
assertTrue("./test-balance.sh" in candidates)
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeGitExecAliasesRefreshWhenBinaryChanges() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-exec-refresh").toFile()
try {
val firstGit = File(root, "first-git").also { git.copyTo(it); it.setExecutable(true) }
val secondGit = File(root, "second-git").also { git.copyTo(it); it.setExecutable(true) }
val level = level(
id = "exec-refresh-test",
title = "Exec Refresh Test",
description = "",
hints = emptyList(),
commandSuggestions = emptyList(),
setup = { RepoState(initialized = true) },
validator = { _, _ -> false },
)
GitRepositoryRuntime(root, firstGit).prepareLevel(level)
val alias = File(root, "git-exec/git")
assertTrue(alias.exists())
assertTrue(File(root, "git-exec/.binary-fingerprint").readText().contains(firstGit.absolutePath))
GitRepositoryRuntime(root, secondGit).prepareLevel(level)
assertTrue(alias.exists())
assertTrue(File(root, "git-exec/.binary-fingerprint").readText().contains(secondGit.absolutePath))
} finally {
root.deleteRecursively()
}
}
@Test
fun nativePushLevelHasDivergedStatusAndRebasesCleanly() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-push-level").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = pushLevel()
val repo = runtime.prepareLevel(level)
val (_, statusOutput) = runtime.execute(level, repo, "git status")
assertTrue(statusOutput.any { it.contains("diverged", ignoreCase = true) })
val (_, diffOutput) = runtime.execute(level, repo, "git diff")
assertTrue(diffOutput.isNotEmpty())
val (rebasedRepo, pullOutput) = runtime.execute(level, repo, "git pull --rebase origin master")
assertFalse(pullOutput.any { it.contains("fatal:", ignoreCase = true) || it.contains("error:", ignoreCase = true) })
assertTrue(rebasedRepo.files.any { it.name == "file4" && it.tracked })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeDiffLevelShowsLine26Change() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-diff-level").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = diffLevel()
val repo = runtime.prepareLevel(level)
val appFile = repo.files.single { it.name == "app.rb" }
assertTrue(appFile.content.contains("server.json"))
val (_, diffOutput) = runtime.execute(level, repo, "git diff")
assertTrue(diffOutput.any { it.contains("- @message = get_response('data.json')") })
assertTrue(diffOutput.any { it.contains("+ @message = get_response('server.json')") })
assertTrue(diffOutput.any { it.contains("@@ -23,7 +23,7 @@") })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeBlameLevelShowsPasswordAuthor() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-blame-level").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = blameLevel()
val repo = runtime.prepareLevel(level)
assertTrue(repo.files.single { it.name == "config.rb" }.content.contains("password"))
val (_, blameOutput) = runtime.execute(level, repo, "git blame config.rb")
assertTrue(blameOutput.any { it.contains("Spider Man") && it.contains("password") })
assertTrue(level.validator(repo, "Spider Man"))
} finally {
root.deleteRecursively()
}
}
@Test
fun cdDotDotShortcutMovesToParentDirectory() {
val repo = RepoState(initialized = true, currentDir = "src/main")
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "cd..")
assertTrue(output.isEmpty())
assertEquals("src", updatedRepo.currentDir)
}
@Test
fun delIsAcceptedAsRmAlias() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("deleteme.txt")),
)
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "del deleteme.txt")
assertTrue(output.isEmpty())
assertFalse(updatedRepo.files.any { it.name == "deleteme.txt" })
}
@Test
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
assertFalse(output.any { it.contains("GitHug Android") })
}
@Test
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(updatedRepo.interactiveAddSession != null)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
}
@Test
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(updateOutput.any { it.contains("Update>>") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
assertTrue(quitRepo.interactiveAddSession == null)
assertTrue(quitOutput.any { it.contains("Bye.") })
}
@Test
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-interactive-add").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = level(
id = "interactive-add-test",
title = "Interactive Add Test",
description = "",
hints = emptyList(),
commandSuggestions = emptyList(),
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
validator = { _, _ -> false },
)
val repo = runtime.prepareLevel(level)
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(quitRepo.interactiveAddSession == null)
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeInteractiveRebaseUsesAppSequenceEditorContent() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-interactive-rebase-editor").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = reorderLevel()
val repo = runtime.prepareLevel(level)
val invocation = parseGitEditorInvocation("git rebase -i HEAD~3")
?: error("Expected interactive rebase editor invocation")
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
val reorderedTodo = todo.lines().toMutableList().also { lines ->
val secondPick = lines.indexOfFirst { it.contains("Third commit") }
val thirdPick = lines.indexOfFirst { it.contains("Second commit") }
val thirdLine = lines[thirdPick]
lines[thirdPick] = lines[secondPick]
lines[secondPick] = thirdLine
}.joinToString("\n")
val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, reorderedTodo)
assertFalse(output.any { it.contains("Terminal is dumb", ignoreCase = true) })
assertTrue(level.validator(updatedRepo, invocation.command))
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-script-shortcut").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = bisectLevel()
val repo = runtime.prepareLevel(level)
val (_, output) = runtime.execute(level, repo, "./test-balance.sh")
assertTrue(output.joinToString("\n"), output.any { it.contains("balance broken") })
} finally {
root.deleteRecursively()
}
}
private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() }
?.let { File(it) }
?.takeIf { it.exists() && it.canExecute() }
?.let { return it }
val repoHostGit = File(repoRoot(), "build/host-git/libgit.so")
if (repoHostGit.exists() && repoHostGit.canExecute()) return repoHostGit
return File("/usr/bin/git")
}
private fun repoRoot(): File {
val userDir = File(System.getProperty("user.dir") ?: ".")
return if (File(userDir, "app/build.gradle.kts").exists()) userDir else userDir.parentFile ?: userDir
}
}