diff --git a/LevelsCompare.md b/LevelsCompare.md index 37a7135..b04631a 100644 --- a/LevelsCompare.md +++ b/LevelsCompare.md @@ -124,7 +124,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro | `find_old_branch` | Current branch is `solve_world_hunger`. | Same. | Equivalent. | | `revert` | More than three commits and a revert of "Bad commit" exists. | A commit message starts with `Revert`. | Slightly looser; sufficient for current fixture. | | `restore` | `file3` exists. | `file3` is tracked. | Equivalent. | -| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires a merge action on `master`, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Known gap: Android does not expose merge-parent count in `RepoState`, so it tracks the merge command instead. | +| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires the latest commit on `master` to be a two-parent merge commit, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Equivalent. | | `submodule` | `githug-include-me` directory exists, has README, and is a gitlink/submodule. | `submodules` contains `githug-include-me` URL. | Equivalent state projection. | | `contribute` | Clones upstream and checks for a commit authored by configured user. | Any nonblank command. | Deliberate mobile/offline simplification for the final contribution prompt. | @@ -159,6 +159,5 @@ These are the remaining known non-parity items that need additional model suppor - `stage_lines`: model partial staged vs unstaged hunks. - `merge_squash`: verify the exact squashed file/content effects. -- `conflict`: expose merge parent count in `RepoState` instead of relying on tracked merge-command evidence. - `rebase_onto`: verify final commit count/content and removal of "Wrong changes". - `contribute`, `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation. diff --git a/README.md b/README.md index aec7e5c..c64b7ab 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ The Android port keeps the upstream GitHug level order, but some upstream fixtur | `contribute` | Expects cloning upstream and finding a commit authored by the configured user. | Treated as a mobile/offline final prompt with a nonblank response. | The original workflow leaves the sandbox and depends on external contribution infrastructure. | | `stage_lines` | Requires partial hunk staging: one feature line staged and another left unstaged. | Currently validates that `feature.rb` is staged. | Android does not yet expose enough index-vs-working-tree hunk detail in `RepoState` to validate partial staging precisely. | | `rebase_onto`, `merge_squash`, `repack` | Upstream validates detailed object graph, merge-parent, or object database details. | Android validates the relevant user-facing action or resulting state, but with less object-level detail in some cases. | The current `RepoState` projection does not expose every low-level Git object fact. These should be tightened when the state surface grows. | -| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates a merge action on `master`, no conflict markers, and the correct `Sat on a wall` line. | Android does not yet expose merge-parent count in `RepoState`, so it uses observed merge-command evidence plus file state. | +| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates the latest commit has two parents, no conflict markers remain, and the correct `Sat on a wall` line is present. | Equivalent. | ## Level Authoring diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1914a33..cc17df9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 162 - versionName = "0.1.161" + versionCode = 163 + versionName = "0.1.162" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index fd89455..00340d8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -18,6 +18,7 @@ data class CommitNode( val id: String, val message: String, val authorTimestampSeconds: Long? = null, + val parentCount: Int = 0, ) data class InteractiveAddSession( @@ -57,6 +58,8 @@ data class Level( val setup: () -> RepoState, val nativeSetup: (NativeLevelSetup.() -> Boolean)? = null, val testCases: List = emptyList(), + val negativeTestCases: List = emptyList(), + val setupChecks: List = emptyList(), ) data class LevelTestCase( @@ -64,4 +67,10 @@ data class LevelTestCase( val commands: List, ) +data class LevelSetupCheck( + val name: String, + val failureMessage: String, + val predicate: (RepoState) -> Boolean, +) + fun sampleLevels(): List = allGithugLevels() diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index d6432b4..c60e7a6 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -425,7 +425,7 @@ class GitRepositoryRuntime private constructor( } } - val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%s")) + val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%P\t%s")) val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list")) val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list")) val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list")) @@ -453,14 +453,16 @@ class GitRepositoryRuntime private constructor( ) val commits = if (logResult.exitCode == 0) { logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> - val parts = line.split('\t', limit = 3) + val parts = line.split('\t', limit = 4) if (parts.isEmpty()) { null } else { + val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() } CommitNode( id = parts[0], authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(), - message = parts.getOrElse(2) { "" }, + parentCount = parentHashes.size, + message = parts.getOrElse(3) { "" }, ) } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt index 9a6279d..2250bef 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt @@ -8,7 +8,14 @@ val RepoStateSaver = listSaver( state.initialized, state.headBranch, state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) }, - state.commits.flatMap { listOf(it.id, it.message, it.authorTimestampSeconds?.toString().orEmpty()) }, + state.commits.flatMap { + listOf( + it.id, + it.message, + it.authorTimestampSeconds?.toString().orEmpty(), + it.parentCount.toString(), + ) + }, state.branches.flatMap { listOf(it.key, it.value.toString()) }, state.currentDir, state.tags, @@ -86,11 +93,21 @@ val RepoStateSaver = listSaver( ) private fun List<*>.restoreCommitNodes(): List { - val hasTimestampColumn = size % 3 == 0 && chunked(3).all { chunk -> + val hasTimestampAndParentColumns = size % 4 == 0 && chunked(4).all { chunk -> + val timestamp = chunk.getOrNull(2) as? String + val parentCount = chunk.getOrNull(3) as? String + (timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true) && + parentCount?.toIntOrNull() != null + } + val hasTimestampColumn = !hasTimestampAndParentColumns && size % 3 == 0 && chunked(3).all { chunk -> val timestamp = chunk.getOrNull(2) as? String timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true } - val width = if (hasTimestampColumn) 3 else 2 + val width = when { + hasTimestampAndParentColumns -> 4 + hasTimestampColumn -> 3 + else -> 2 + } return chunked(width).mapNotNull { chunk -> val id = chunk.getOrNull(0) as? String ?: return@mapNotNull null @@ -98,7 +115,12 @@ private fun List<*>.restoreCommitNodes(): List { CommitNode( id = id, message = message, - authorTimestampSeconds = if (hasTimestampColumn) (chunk.getOrNull(2) as? String)?.toLongOrNull() else null, + authorTimestampSeconds = if (hasTimestampAndParentColumns || hasTimestampColumn) { + (chunk.getOrNull(2) as? String)?.toLongOrNull() + } else { + null + }, + parentCount = if (hasTimestampAndParentColumns) (chunk.getOrNull(3) as? String)?.toIntOrNull() ?: 0 else 0, ) } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt index f5aa3c1..b05a430 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt @@ -92,4 +92,13 @@ internal fun bisectLevel(): Level = level( "c8c7c00", ), ), + negativeTestCases = listOf( + levelTestCase( + "bisect run alone does not solve", + "git bisect start", + "git bisect bad HEAD", + "git bisect good known-good", + "git bisect run ./test-balance.sh", + ), + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt index f74f01e..cf8cfb8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt @@ -99,6 +99,14 @@ internal fun blameLevel(): Level = level( testCases = listOf( levelTestCase("answer author", "Spider Man"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream password line is present", + "blame should start with upstream config.rb password line.", + ) { repo -> + repo.files.firstOrNull { it.name == "config.rb" }?.content?.contains("@password = password || \"i<3evil\"") == true + }, + ), ) private fun blameLevelFinalConfigRb(): String = diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt index 5005d18..f5716d8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt @@ -61,6 +61,16 @@ internal fun cherryPickLevel(): Level = level( testCases = listOf( levelTestCase("cherry pick README commit", "git cherry-pick new-feature~2"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream cherry-pick files and branch are present", + "cherry-pick should start with upstream README.md, hardcore-math.js, and new-feature branch.", + ) { repo -> + repo.files.firstOrNull { it.name == "README.md" }?.content?.contains("I'll fill in the file some time later..") == true && + repo.files.firstOrNull { it.name == "hardcore-math.js" }?.content?.contains("console.log(42 * i);") == true && + repo.branches.containsKey("new-feature") + }, + ), ) private fun cherryPickLevelHardcoreMathJs(): String = diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/CommitInFutureLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/CommitInFutureLevel.kt index 3ea267a..b768c60 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/CommitInFutureLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/CommitInFutureLevel.kt @@ -23,4 +23,7 @@ internal fun commitInFutureLevel(): Level = level( testCases = listOf( levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""), ), + negativeTestCases = listOf( + levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""), + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt index 734dbb5..594f2c4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt @@ -41,7 +41,7 @@ internal fun conflictLevel(): Level = level( validator = repoPredicate { repo -> val poem = repo.files.find { it.name == "poem.txt" }?.content.orEmpty() repo.headBranch == "master" && - "merge" in repo.maintenanceActions && + repo.commits.firstOrNull()?.parentCount == 2 && "Sat on a wall" in poem && poem.none { it in "<>=|" } }, @@ -57,6 +57,27 @@ internal fun conflictLevel(): Level = level( "git commit --no-edit", ), ), + negativeTestCases = listOf( + levelTestCase( + "editing conflict text and rerunning merge does not solve", + "git merge mybranch", + "echo \"Humpty dumpty\" > poem.txt", + "echo \"Sat on a wall\" >> poem.txt", + "echo \"Humpty dumpty\" >> poem.txt", + "echo \"Had a great fall\" >> poem.txt", + "git merge mybranch", + ), + ), + setupChecks = listOf( + levelSetupCheck( + "upstream poem starts on master", + "conflict should start on master with upstream poem text and mybranch present.", + ) { repo -> + repo.headBranch == "master" && + repo.fileContent("poem.txt").contains("Categorized shoes by color") && + repo.branches.containsKey("mybranch") + }, + ), ) private fun conflictLevelInitialPoem(): String = @@ -88,3 +109,5 @@ private fun conflictLevelMasterPoem(): String = Humpty dumpty Had a great fall """.trimIndent() + "\n" + +private fun RepoState.fileContent(path: String): String = files.firstOrNull { it.name == path }?.content.orEmpty() diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt index b87b561..d5ac22d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt @@ -33,4 +33,13 @@ internal fun deleteBranchLevel(): Level = level( levelTestCase("delete branch", "git branch -d delete_me"), levelTestCase("force delete branch", "git branch -D delete_me"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream readme and delete branch are present", + "delete_branch should start with tracked readme and delete_me branch.", + ) { repo -> + repo.files.any { it.name == "readme" && it.tracked } && + repo.branches.containsKey("delete_me") + }, + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt index 6c27bf8..df7f086 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt @@ -32,6 +32,14 @@ internal fun diffLevel(): Level = level( testCases = listOf( levelTestCase("answer changed line", "26"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream changed line is present", + "diff should start with app.rb modified to use server.json on the changed line.", + ) { repo -> + repo.files.firstOrNull { it.name == "app.rb" }?.content?.contains("@message = get_response('server.json')") == true + }, + ), ) internal fun diffLevelBaselineAppRb(): String = diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt index f6c17d7..8b91268 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt @@ -49,4 +49,7 @@ internal fun fetchLevel(): Level = level( levelTestCase("fetch origin", "git fetch origin"), levelTestCase("fetch default", "git fetch"), ), + negativeTestCases = listOf( + levelTestCase("pulling does not solve fetch", "git pull"), + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt index 1ba18d7..d34b4e2 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt @@ -47,4 +47,13 @@ internal fun findOldBranchLevel(): Level = level( testCases = listOf( levelTestCase("checkout old branch", "git checkout solve_world_hunger"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream branch puzzle shape is present", + "find_old_branch should start on master with upstream distractor branches and solve_world_hunger.", + ) { repo -> + repo.headBranch == "master" && + repo.branches.keys == setOf("blowup_sun_for_ransom", "cure_common_cold", "master", "solve_world_hunger") + }, + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt index e7221e1..07e836d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt @@ -35,6 +35,15 @@ internal fun grepLevel(): Level = level( testCases = listOf( levelTestCase("answer todo count", "4"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream TODO files are present", + "grep should start with upstream app.rb and config.rb TODO entries.", + ) { repo -> + repo.files.firstOrNull { it.name == "app.rb" }?.content?.contains("# TODO Make site url variable.") == true && + repo.files.firstOrNull { it.name == "config.rb" }?.content?.contains("# TODO Move password to a configuration file.") == true + }, + ), ) private fun grepLevelAppRb(): String = diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt index c7c1def..9a475f2 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt @@ -68,6 +68,8 @@ internal fun level( setup: () -> RepoState, validator: (RepoState, String) -> Boolean, testCases: List = emptyList(), + negativeTestCases: List = emptyList(), + setupChecks: List = emptyList(), nativeSetup: (NativeLevelSetup.() -> Boolean)? = null, ): Level = Level( id = id, @@ -79,6 +81,8 @@ internal fun level( setup = setup, nativeSetup = nativeSetup, testCases = testCases, + negativeTestCases = negativeTestCases, + setupChecks = setupChecks, ) internal fun levelTestCase(name: String, vararg commands: String): LevelTestCase = LevelTestCase( @@ -86,6 +90,16 @@ internal fun levelTestCase(name: String, vararg commands: String): LevelTestCase commands = commands.toList(), ) +internal fun levelSetupCheck( + name: String, + failureMessage: String, + predicate: (RepoState) -> Boolean, +): LevelSetupCheck = LevelSetupCheck( + name = name, + failureMessage = failureMessage, + predicate = predicate, +) + internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boolean = { _, command -> val normalized = command.trim() val matched = answers.firstOrNull { it.equals(normalized, ignoreCase = true) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/MergeLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/MergeLevel.kt index f58ac1e..8c7127e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/MergeLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/MergeLevel.kt @@ -32,4 +32,7 @@ internal fun mergeLevel(): Level = level( testCases = listOf( levelTestCase("merge feature", "git merge feature"), ), + negativeTestCases = listOf( + levelTestCase("switching to feature does not solve", "git switch feature"), + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/ReorderLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/ReorderLevel.kt index dacfb84..7376c89 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/ReorderLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/ReorderLevel.kt @@ -33,4 +33,7 @@ internal fun reorderLevel(): Level = level( "GIT_SEQUENCE_EDITOR=\"sed -i '2{h;d};3{G}'\" git rebase -i HEAD~3", ), ), + negativeTestCases = listOf( + levelTestCase("bare interactive rebase does not solve", "git rebase -i"), + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt index f900b3e..77cc922 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt @@ -25,6 +25,14 @@ internal fun stashLevel(): Level = level( testCases = listOf( levelTestCase("stash changes", "git stash"), ), + setupChecks = listOf( + levelSetupCheck( + "upstream modified lyrics are present", + "stash should start with upstream lyrics plus the trailing Hey! modification.", + ) { repo -> + repo.files.firstOrNull { it.name == "lyrics.txt" }?.content?.contains("Hear them loudly cry:\nHey!") == true + }, + ), ) private fun stashLevelCommittedLyrics(): String = diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/StatusLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/StatusLevel.kt index 6a674d6..608ad3e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/StatusLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/StatusLevel.kt @@ -40,4 +40,12 @@ internal fun statusLevel(): Level = level( testCases = listOf( levelTestCase("answer untracked file", "database.yml"), ), + setupChecks = listOf( + levelSetupCheck( + "database.yml is the only untracked file", + "status should start with database.yml as the only untracked file.", + ) { repo -> + repo.files.filter { !it.staged && !it.tracked && !it.deleted }.map { it.name } == listOf("database.yml") + }, + ), ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index fe23f41..4168443 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -1,7 +1,6 @@ package solutions.tretter.githugandroid import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.File @@ -99,104 +98,54 @@ class LevelSolutionsTest { } @Test - fun upstreamFixtureBackedLevelsExposeSourceShapedSetup() { + fun embeddedSetupChecksPass() { val gitBinary = testGitBinary() val runtimeRoot = testSandboxRoot().apply { deleteRecursively() mkdirs() } - fun prepared(level: Level): RepoState = GitRepositoryRuntime(runtimeRoot, gitBinary).prepareLevel(level) - fun RepoState.fileContent(path: String): String = files.firstOrNull { it.name == path }?.content.orEmpty() + val failures = allGithugLevels().flatMap { level -> + if (level.setupChecks.isEmpty()) return@flatMap emptyList() + val repo = GitRepositoryRuntime(runtimeRoot, gitBinary).prepareLevel(level) + level.setupChecks + .filterNot { check -> check.predicate(repo) } + .map { check -> "${level.id} / ${check.name}: ${check.failureMessage}" } + } - val conflict = prepared(conflictLevel()) - assertEquals("master", conflict.headBranch) - assertTrue(conflict.fileContent("poem.txt").contains("Categorized shoes by color")) - assertTrue(conflict.branches.containsKey("mybranch")) - - val grep = prepared(grepLevel()) - assertTrue(grep.fileContent("app.rb").contains("# TODO Make site url variable.")) - assertTrue(grep.fileContent("config.rb").contains("# TODO Move password to a configuration file.")) - - val findOldBranch = prepared(findOldBranchLevel()) - assertEquals("master", findOldBranch.headBranch) - assertEquals( - setOf("blowup_sun_for_ransom", "cure_common_cold", "master", "solve_world_hunger"), - findOldBranch.branches.keys, + assertTrue( + "Expected every embedded setup check to pass. Failures:\n${failures.joinToString("\n")}", + failures.isEmpty(), ) - - val deleteBranch = prepared(deleteBranchLevel()) - assertTrue(deleteBranch.files.any { it.name == "readme" && it.tracked }) - assertTrue(deleteBranch.branches.containsKey("delete_me")) - - val diff = prepared(diffLevel()) - assertTrue(diff.fileContent("app.rb").contains("@message = get_response('server.json')")) - - val stash = prepared(stashLevel()) - assertTrue(stash.fileContent("lyrics.txt").contains("Hear them loudly cry:\nHey!")) - - val cherryPick = prepared(cherryPickLevel()) - assertTrue(cherryPick.fileContent("README.md").contains("I'll fill in the file some time later..")) - assertTrue(cherryPick.fileContent("hardcore-math.js").contains("console.log(42 * i);")) - assertTrue(cherryPick.branches.containsKey("new-feature")) - - val blame = prepared(blameLevel()) - assertTrue(blame.fileContent("config.rb").contains("@password = password || \"i<3evil\"")) } @Test - fun reportedRegressionCommandsDoNotSolveLevels() { - val statusRepo = statusLevel().setup() - val untrackedStatusFiles = statusRepo.files.filter { !it.staged && !it.tracked && !it.deleted }.map { it.name } - assertEquals(listOf("database.yml"), untrackedStatusFiles) - + fun embeddedNegativeScenariosDoNotSolveLevels() { val gitBinary = testGitBinary() val runtimeRoot = testSandboxRoot().apply { deleteRecursively() mkdirs() } - val mergeExercise = mergeLevel() - val mergeRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary) - val mergeRepo = mergeRuntime.prepareLevel(mergeExercise) - val (switchedRepo, _) = mergeRuntime.execute(mergeExercise, mergeRepo, "git switch feature") - assertFalse("Switching to feature must not solve the merge level.", mergeExercise.validator(switchedRepo, "git switch feature")) + val failures = allGithugLevels().flatMap { level -> + level.negativeTestCases.mapNotNull { testCase -> + val runtime = GitRepositoryRuntime(runtimeRoot, gitBinary) + var repo = runtime.prepareLevel(level) + var solved = false - val fetchExercise = fetchLevel() - val fetchRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary) - val fetchRepo = fetchRuntime.prepareLevel(fetchExercise) - val (pulledRepo, _) = fetchRuntime.execute(fetchExercise, fetchRepo, "git pull") - assertFalse("Pulling must not solve the fetch level.", fetchExercise.validator(pulledRepo, "git pull")) + testCase.commands.forEach { command -> + val (nextRepo, _) = runtime.execute(level, repo, command) + repo = nextRepo + solved = level.validator(repo, command) + } - val reorderExercise = reorderLevel() - val reorderRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary) - val reorderRepo = reorderRuntime.prepareLevel(reorderExercise) - val (bareInteractiveRebaseRepo, _) = reorderRuntime.execute(reorderExercise, reorderRepo, "git rebase -i") - assertFalse( - "Starting an interactive rebase without an upstream/range must not solve the reorder level.", - reorderExercise.validator(bareInteractiveRebaseRepo, "git rebase -i"), - ) - - val futureCommitExercise = commitInFutureLevel() - val futureCommitRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary) - val futureCommitRepo = futureCommitRuntime.prepareLevel(futureCommitExercise) - val (currentDateCommitRepo, _) = futureCommitRuntime.execute(futureCommitExercise, futureCommitRepo, "git commit -m \"Current date commit\"") - assertFalse( - "A normal commit using the current system date must not solve the commit_in_future level.", - futureCommitExercise.validator(currentDateCommitRepo, "git commit -m \"Current date commit\""), - ) - - val bisectExercise = bisectLevel() - val bisectRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary) - var bisectRepo = bisectRuntime.prepareLevel(bisectExercise) - listOf("git bisect start", "git bisect bad HEAD", "git bisect good known-good").forEach { command -> - val (nextRepo, _) = bisectRuntime.execute(bisectExercise, bisectRepo, command) - bisectRepo = nextRepo + if (solved) "${level.id} / ${testCase.name}: ${testCase.commands.joinToString(" && ")}" else null + } } - val (bisectRunRepo, _) = bisectRuntime.execute(bisectExercise, bisectRepo, "git bisect run ./test-balance.sh") - assertFalse( - "Running bisect should not solve the bisect level until the learner enters the last good commit hash.", - bisectExercise.validator(bisectRunRepo, "git bisect run ./test-balance.sh"), + + assertTrue( + "Expected every embedded negative scenario to remain unsolved. Failures:\n${failures.joinToString("\n")}", + failures.isEmpty(), ) } @@ -281,7 +230,7 @@ class LevelSolutionsTest { appendLine(" ") } else { commits.forEach { commit -> - appendLine(" ${commit.id} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}") + appendLine(" ${commit.id} parents=${commit.parentCount} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}") } } }